feat(mobile): fondation design system DSFR (theme + composants UI) + hooks admin + route admin
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,7 @@ export default function RootLayout() {
|
|||||||
<Stack screenOptions={{ headerShown: false }}>
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
<Stack.Screen name="auth" />
|
<Stack.Screen name="auth" />
|
||||||
<Stack.Screen name="(tabs)" />
|
<Stack.Screen name="(tabs)" />
|
||||||
|
<Stack.Screen name="admin" />
|
||||||
</Stack>
|
</Stack>
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
<StatusBar style="auto" />
|
<StatusBar style="auto" />
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { Colors } from '@/lib/colors';
|
||||||
|
import { FontSize, FontWeight, Spacing } from '@/lib/theme';
|
||||||
|
|
||||||
|
interface AppBarProps {
|
||||||
|
/** Titre de la page, affiché sous le bandeau institutionnel. */
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
/** Élément optionnel aligné à droite du bandeau (ex. bouton). */
|
||||||
|
right?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bandeau institutionnel République Française réutilisable, à placer en haut
|
||||||
|
* de chaque écran principal. Reprend le bloc-marque de l'État (DSFR) + le nom
|
||||||
|
* du service, avec un titre/sous-titre de page optionnel.
|
||||||
|
*/
|
||||||
|
export default function AppBar({ title, subtitle, right }: AppBarProps) {
|
||||||
|
return (
|
||||||
|
<View style={styles.wrapper}>
|
||||||
|
<View style={styles.brandRow}>
|
||||||
|
<View style={styles.marianne}>
|
||||||
|
<Text style={styles.republique}>RÉPUBLIQUE</Text>
|
||||||
|
<Text style={styles.republique}>FRANÇAISE</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.serviceBlock}>
|
||||||
|
<Text style={styles.serviceName}>CESIZen</Text>
|
||||||
|
<Text style={styles.serviceTagline}>Santé mentale</Text>
|
||||||
|
</View>
|
||||||
|
{right ? <View style={styles.right}>{right}</View> : null}
|
||||||
|
</View>
|
||||||
|
{title ? (
|
||||||
|
<View style={styles.titleRow}>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
{subtitle ? <Text style={styles.subtitle}>{subtitle}</Text> : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrapper: {
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: Colors.gray[200],
|
||||||
|
},
|
||||||
|
brandRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: Spacing.lg,
|
||||||
|
paddingVertical: Spacing.md,
|
||||||
|
},
|
||||||
|
marianne: {
|
||||||
|
paddingRight: Spacing.md,
|
||||||
|
marginRight: Spacing.md,
|
||||||
|
borderRightWidth: 1,
|
||||||
|
borderRightColor: Colors.gray[200],
|
||||||
|
},
|
||||||
|
republique: {
|
||||||
|
fontSize: FontSize.tiny,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
lineHeight: 13,
|
||||||
|
},
|
||||||
|
serviceBlock: { flex: 1 },
|
||||||
|
serviceName: { fontSize: FontSize.lg, fontWeight: FontWeight.heavy, color: Colors.primary },
|
||||||
|
serviceTagline: { fontSize: FontSize.tiny, color: Colors.gray[500], marginTop: 1 },
|
||||||
|
right: { marginLeft: Spacing.sm },
|
||||||
|
titleRow: {
|
||||||
|
paddingHorizontal: Spacing.lg,
|
||||||
|
paddingBottom: Spacing.md,
|
||||||
|
paddingTop: Spacing.xs,
|
||||||
|
},
|
||||||
|
title: { fontSize: FontSize.xxl, fontWeight: FontWeight.bold, color: Colors.black },
|
||||||
|
subtitle: { fontSize: FontSize.sm, color: Colors.gray[500], marginTop: 2 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { Colors } from '@/lib/colors';
|
||||||
|
import { FontSize, FontWeight, Radius, Spacing } from '@/lib/theme';
|
||||||
|
|
||||||
|
type Tone = 'neutral' | 'success' | 'error' | 'info' | 'warning' | 'primary';
|
||||||
|
|
||||||
|
interface BadgeProps {
|
||||||
|
label: string;
|
||||||
|
tone?: Tone;
|
||||||
|
/** Couleur personnalisée (ex. couleur d'une émotion) — prioritaire sur `tone`. */
|
||||||
|
color?: string;
|
||||||
|
uppercase?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pastille d'état DSFR, taille homogène. */
|
||||||
|
export default function Badge({ label, tone = 'neutral', color, uppercase = false }: BadgeProps) {
|
||||||
|
const palette = color ? { bg: hexWithAlpha(color, 0.14), text: darken(color) } : TONES[tone];
|
||||||
|
return (
|
||||||
|
<View style={[styles.badge, { backgroundColor: palette.bg }]}>
|
||||||
|
<Text style={[styles.text, { color: palette.text }, uppercase && styles.uppercase]} numberOfLines={1}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TONES: Record<Tone, { bg: string; text: string }> = {
|
||||||
|
neutral: { bg: Colors.gray[100], text: Colors.gray[600] },
|
||||||
|
success: { bg: Colors.successBg, text: Colors.success },
|
||||||
|
error: { bg: Colors.errorBg, text: Colors.error },
|
||||||
|
info: { bg: '#E8EDFF', text: Colors.info },
|
||||||
|
warning: { bg: '#FFE9C7', text: Colors.warning },
|
||||||
|
primary: { bg: '#E3E3FD', text: Colors.primary },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ajoute un canal alpha (0–1) à une couleur hex #RRGGBB. */
|
||||||
|
function hexWithAlpha(hex: string, alpha: number): string {
|
||||||
|
const a = Math.round(alpha * 255).toString(16).padStart(2, '0');
|
||||||
|
return `${hex}${a}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pour le texte sur fond teinté, on garde la couleur d'origine (lisible sur 14% d'alpha). */
|
||||||
|
function darken(hex: string): string {
|
||||||
|
return hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
badge: {
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
paddingHorizontal: Spacing.sm,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: Radius.sm,
|
||||||
|
},
|
||||||
|
text: { fontSize: FontSize.tiny, fontWeight: FontWeight.bold },
|
||||||
|
uppercase: { textTransform: 'uppercase', letterSpacing: 0.5 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { ActivityIndicator, Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { Colors } from '@/lib/colors';
|
||||||
|
import { FontSize, FontWeight, MIN_TOUCH, Radius, Spacing } from '@/lib/theme';
|
||||||
|
|
||||||
|
type Variant = 'primary' | 'secondary' | 'danger' | 'tertiary';
|
||||||
|
type Size = 'md' | 'sm';
|
||||||
|
|
||||||
|
interface ButtonProps {
|
||||||
|
title: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
variant?: Variant;
|
||||||
|
size?: Size;
|
||||||
|
loading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Nom d'icône Ionicons affichée à gauche du libellé. */
|
||||||
|
icon?: keyof typeof Ionicons.glyphMap;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bouton DSFR. 4 variantes : primary (bleu plein), secondary (contour),
|
||||||
|
* danger (rouge plein), tertiary (texte seul). Hauteur ≥ 44px (accessibilité).
|
||||||
|
*/
|
||||||
|
export default function Button({
|
||||||
|
title,
|
||||||
|
onPress,
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
loading = false,
|
||||||
|
disabled = false,
|
||||||
|
icon,
|
||||||
|
fullWidth = false,
|
||||||
|
style,
|
||||||
|
}: ButtonProps) {
|
||||||
|
const isDisabled = disabled || loading;
|
||||||
|
const palette = VARIANTS[variant];
|
||||||
|
const minHeight = size === 'sm' ? 40 : MIN_TOUCH;
|
||||||
|
const fontSize = size === 'sm' ? FontSize.sm : FontSize.md;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={isDisabled}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ disabled: isDisabled, busy: loading }}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.base,
|
||||||
|
{
|
||||||
|
minHeight,
|
||||||
|
paddingVertical: size === 'sm' ? Spacing.sm : Spacing.md,
|
||||||
|
backgroundColor: palette.bg,
|
||||||
|
borderColor: palette.border,
|
||||||
|
borderWidth: palette.border === 'transparent' ? 0 : 1,
|
||||||
|
},
|
||||||
|
fullWidth && styles.fullWidth,
|
||||||
|
pressed && !isDisabled && { opacity: 0.85 },
|
||||||
|
isDisabled && styles.disabled,
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color={palette.text} size="small" />
|
||||||
|
) : (
|
||||||
|
<View style={styles.content}>
|
||||||
|
{icon && <Ionicons name={icon} size={fontSize + 3} color={palette.text} />}
|
||||||
|
<Text style={[styles.label, { color: palette.text, fontSize }]}>{title}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARIANTS: Record<Variant, { bg: string; text: string; border: string }> = {
|
||||||
|
primary: { bg: Colors.primary, text: Colors.white, border: 'transparent' },
|
||||||
|
secondary: { bg: Colors.white, text: Colors.primary, border: Colors.primary },
|
||||||
|
danger: { bg: Colors.error, text: Colors.white, border: 'transparent' },
|
||||||
|
tertiary: { bg: 'transparent', text: Colors.primary, border: 'transparent' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
base: {
|
||||||
|
borderRadius: Radius.sm,
|
||||||
|
paddingHorizontal: Spacing.xl,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
fullWidth: { alignSelf: 'stretch', width: '100%' },
|
||||||
|
disabled: { opacity: 0.45 },
|
||||||
|
content: { flexDirection: 'row', alignItems: 'center', gap: Spacing.sm },
|
||||||
|
label: { fontWeight: FontWeight.semibold },
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
|
||||||
|
import { Colors } from '@/lib/colors';
|
||||||
|
import { Radius, Shadow, Spacing } from '@/lib/theme';
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onPress?: () => void;
|
||||||
|
/** Padding interne (défaut lg = 16). Mettre 0 pour gérer soi-même. */
|
||||||
|
padding?: number;
|
||||||
|
/** Légère ombre portée. */
|
||||||
|
elevated?: boolean;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Conteneur DSFR : fond blanc, bordure grise fine, coins peu arrondis. */
|
||||||
|
export default function Card({ children, onPress, padding = Spacing.lg, elevated = false, style }: CardProps) {
|
||||||
|
const content = [styles.card, { padding }, elevated && Shadow.card, style];
|
||||||
|
|
||||||
|
if (onPress) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
accessibilityRole="button"
|
||||||
|
style={({ pressed }) => [content, pressed && styles.pressed]}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <View style={content}>{children}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: Colors.gray[200],
|
||||||
|
borderRadius: Radius.sm,
|
||||||
|
},
|
||||||
|
pressed: { backgroundColor: Colors.gray[50] },
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { Colors } from '@/lib/colors';
|
||||||
|
import { FontSize, FontWeight, Radius, Spacing } from '@/lib/theme';
|
||||||
|
import Button from './Button';
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
icon?: keyof typeof Ionicons.glyphMap;
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** État vide homogène : icône, titre, message et action optionnelle. */
|
||||||
|
export default function EmptyState({ icon = 'document-text-outline', title, message, actionLabel, onAction }: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.iconCircle}>
|
||||||
|
<Ionicons name={icon} size={32} color={Colors.primary} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
{message ? <Text style={styles.message}>{message}</Text> : null}
|
||||||
|
{actionLabel && onAction ? (
|
||||||
|
<View style={styles.action}>
|
||||||
|
<Button title={actionLabel} onPress={onAction} icon="add" />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { alignItems: 'center', justifyContent: 'center', paddingVertical: Spacing.xxxl, paddingHorizontal: Spacing.xl },
|
||||||
|
iconCircle: {
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
borderRadius: Radius.pill,
|
||||||
|
backgroundColor: '#E3E3FD',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: Spacing.lg,
|
||||||
|
},
|
||||||
|
title: { fontSize: FontSize.lg, fontWeight: FontWeight.bold, color: Colors.black, textAlign: 'center' },
|
||||||
|
message: { fontSize: FontSize.sm, color: Colors.gray[500], textAlign: 'center', marginTop: Spacing.sm, lineHeight: 20 },
|
||||||
|
action: { marginTop: Spacing.xl },
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { Colors } from '@/lib/colors';
|
||||||
|
import { FontSize, Spacing } from '@/lib/theme';
|
||||||
|
|
||||||
|
/** Indicateur de chargement centré et homogène, avec libellé optionnel. */
|
||||||
|
export default function Loader({ label }: { label?: string }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<ActivityIndicator size="large" color={Colors.primary} />
|
||||||
|
{label ? <Text style={styles.label}>{label}</Text> : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: Spacing.xl, backgroundColor: Colors.gray[50] },
|
||||||
|
label: { marginTop: Spacing.md, fontSize: FontSize.sm, color: Colors.gray[500] },
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { StyleSheet, Text } from 'react-native';
|
||||||
|
import { Colors } from '@/lib/colors';
|
||||||
|
import { FontSize, FontWeight, Spacing } from '@/lib/theme';
|
||||||
|
|
||||||
|
/** Intitulé de section (petites capitales DSFR) au-dessus d'une liste/groupe. */
|
||||||
|
export default function SectionTitle({ children }: { children: string }) {
|
||||||
|
return <Text style={styles.title}>{children}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
title: {
|
||||||
|
fontSize: FontSize.xs,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.gray[500],
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginBottom: Spacing.sm,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
type KeyboardTypeOptions,
|
||||||
|
type StyleProp,
|
||||||
|
type TextInputProps,
|
||||||
|
type ViewStyle,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { Colors } from '@/lib/colors';
|
||||||
|
import { FontSize, FontWeight, Radius, Spacing } from '@/lib/theme';
|
||||||
|
|
||||||
|
interface TextFieldProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChangeText: (text: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
error?: string;
|
||||||
|
helper?: string;
|
||||||
|
required?: boolean;
|
||||||
|
secureTextEntry?: boolean;
|
||||||
|
keyboardType?: KeyboardTypeOptions;
|
||||||
|
autoCapitalize?: TextInputProps['autoCapitalize'];
|
||||||
|
autoComplete?: TextInputProps['autoComplete'];
|
||||||
|
multiline?: boolean;
|
||||||
|
numberOfLines?: number;
|
||||||
|
editable?: boolean;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Champ de saisie DSFR : libellé au-dessus, fond gris clair, bordure basse
|
||||||
|
* accentuée (bleu au focus, rouge en erreur), message d'erreur sous le champ.
|
||||||
|
*/
|
||||||
|
export default function TextField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
placeholder,
|
||||||
|
error,
|
||||||
|
helper,
|
||||||
|
required = false,
|
||||||
|
secureTextEntry = false,
|
||||||
|
keyboardType,
|
||||||
|
autoCapitalize = 'none',
|
||||||
|
autoComplete,
|
||||||
|
multiline = false,
|
||||||
|
numberOfLines,
|
||||||
|
editable = true,
|
||||||
|
style,
|
||||||
|
}: TextFieldProps) {
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const borderColor = error ? Colors.error : focused ? Colors.primary : Colors.gray[600];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, style]}>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
{label}
|
||||||
|
{required && <Text style={styles.required}> *</Text>}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
onFocus={() => setFocused(true)}
|
||||||
|
onBlur={() => setFocused(false)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor={Colors.gray[400]}
|
||||||
|
secureTextEntry={secureTextEntry}
|
||||||
|
keyboardType={keyboardType}
|
||||||
|
autoCapitalize={autoCapitalize}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
multiline={multiline}
|
||||||
|
numberOfLines={numberOfLines}
|
||||||
|
editable={editable}
|
||||||
|
style={[
|
||||||
|
styles.input,
|
||||||
|
{ borderBottomColor: borderColor },
|
||||||
|
multiline && styles.multiline,
|
||||||
|
!editable && styles.readonly,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{error ? (
|
||||||
|
<View style={styles.messageRow}>
|
||||||
|
<Ionicons name="alert-circle" size={14} color={Colors.error} />
|
||||||
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
) : helper ? (
|
||||||
|
<Text style={styles.helperText}>{helper}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { marginBottom: Spacing.lg },
|
||||||
|
label: {
|
||||||
|
fontSize: FontSize.sm,
|
||||||
|
fontWeight: FontWeight.medium,
|
||||||
|
color: Colors.black,
|
||||||
|
marginBottom: Spacing.xs,
|
||||||
|
},
|
||||||
|
required: { color: Colors.error },
|
||||||
|
input: {
|
||||||
|
backgroundColor: Colors.gray[50],
|
||||||
|
borderTopLeftRadius: Radius.sm,
|
||||||
|
borderTopRightRadius: Radius.sm,
|
||||||
|
borderBottomWidth: 2,
|
||||||
|
paddingHorizontal: Spacing.md,
|
||||||
|
paddingVertical: Spacing.md,
|
||||||
|
fontSize: FontSize.body,
|
||||||
|
color: Colors.black,
|
||||||
|
minHeight: 44,
|
||||||
|
},
|
||||||
|
multiline: { minHeight: 110, textAlignVertical: 'top' },
|
||||||
|
readonly: { color: Colors.gray[500], backgroundColor: Colors.gray[100] },
|
||||||
|
messageRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.xs, marginTop: Spacing.xs },
|
||||||
|
errorText: { fontSize: FontSize.xs, color: Colors.error },
|
||||||
|
helperText: { fontSize: FontSize.xs, color: Colors.gray[500], marginTop: Spacing.xs },
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// Barrel des composants UI DSFR réutilisables.
|
||||||
|
export { default as AppBar } from './AppBar';
|
||||||
|
export { default as Badge } from './Badge';
|
||||||
|
export { default as Button } from './Button';
|
||||||
|
export { default as Card } from './Card';
|
||||||
|
export { default as EmptyState } from './EmptyState';
|
||||||
|
export { default as Loader } from './Loader';
|
||||||
|
export { default as SectionTitle } from './SectionTitle';
|
||||||
|
export { default as TextField } from './TextField';
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import type { Emotion, Feed, Utilisateur } from '@/types';
|
||||||
|
|
||||||
|
// Toutes les requêtes ci-dessous tapent les routes /admin/* du backend,
|
||||||
|
// protégées par le middleware role:administrateur.
|
||||||
|
|
||||||
|
/** Normalise une réponse paginée Laravel ({data:[...]}) ou un tableau brut. */
|
||||||
|
function unwrap<T>(payload: { data?: T } | T): T {
|
||||||
|
const body = payload as { data?: T };
|
||||||
|
return (body?.data ?? payload) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------- Utilisateurs --------------------------------
|
||||||
|
|
||||||
|
export function useAdminUtilisateurs() {
|
||||||
|
return useQuery<Utilisateur[]>({
|
||||||
|
queryKey: ['admin', 'utilisateurs'],
|
||||||
|
queryFn: async () => unwrap<Utilisateur[]>((await api.get('/admin/utilisateurs')).data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NouvelUtilisateur {
|
||||||
|
nom: string;
|
||||||
|
prenom: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
password_confirmation: string;
|
||||||
|
role_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateUtilisateur() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (data: NouvelUtilisateur) => (await api.post('/admin/utilisateurs', data)).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'utilisateurs'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToggleUtilisateurActif() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: number) => (await api.patch(`/admin/utilisateurs/${id}/toggle-active`)).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'utilisateurs'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteUtilisateur() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: number) => (await api.delete(`/admin/utilisateurs/${id}`)).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'utilisateurs'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------- Feeds ------------------------------------
|
||||||
|
|
||||||
|
export function useAdminFeeds() {
|
||||||
|
return useQuery<Feed[]>({
|
||||||
|
queryKey: ['admin', 'feeds'],
|
||||||
|
queryFn: async () => unwrap<Feed[]>((await api.get('/admin/feeds')).data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedPayload {
|
||||||
|
titre: string;
|
||||||
|
contenu: string;
|
||||||
|
image_url?: string | null;
|
||||||
|
est_publie: boolean;
|
||||||
|
ordre: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveFeed() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, ...data }: FeedPayload & { id?: number }) =>
|
||||||
|
id ? (await api.put(`/admin/feeds/${id}`, data)).data : (await api.post('/admin/feeds', data)).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'feeds'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteFeed() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: number) => (await api.delete(`/admin/feeds/${id}`)).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'feeds'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------- Émotions ----------------------------------
|
||||||
|
|
||||||
|
export function useAdminEmotions() {
|
||||||
|
return useQuery<Emotion[]>({
|
||||||
|
queryKey: ['admin', 'emotions'],
|
||||||
|
queryFn: async () => unwrap<Emotion[]>((await api.get('/admin/emotions')).data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmotionPayload {
|
||||||
|
nom: string;
|
||||||
|
couleur: string;
|
||||||
|
icone?: string | null;
|
||||||
|
niveau: number;
|
||||||
|
parent_id?: number | null;
|
||||||
|
est_actif: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveEmotion() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, ...data }: EmotionPayload & { id?: number }) =>
|
||||||
|
id ? (await api.put(`/admin/emotions/${id}`, data)).data : (await api.post('/admin/emotions', data)).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'emotions'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteEmotion() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: number) => (await api.delete(`/admin/emotions/${id}`)).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'emotions'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Design tokens centralisés — identité du Système de Design de l'État (DSFR).
|
||||||
|
// À utiliser partout au lieu de valeurs « magiques » inline, pour une UI cohérente.
|
||||||
|
|
||||||
|
/** Espacements (échelle 4px, comme le DSFR). */
|
||||||
|
export const Spacing = {
|
||||||
|
xs: 4,
|
||||||
|
sm: 8,
|
||||||
|
md: 12,
|
||||||
|
lg: 16,
|
||||||
|
xl: 24,
|
||||||
|
xxl: 32,
|
||||||
|
xxxl: 40,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Rayons de bordure. Le DSFR est volontairement « carré » (rayons faibles). */
|
||||||
|
export const Radius = {
|
||||||
|
sm: 4,
|
||||||
|
md: 8,
|
||||||
|
pill: 999,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Tailles de police. */
|
||||||
|
export const FontSize = {
|
||||||
|
tiny: 11,
|
||||||
|
xs: 13,
|
||||||
|
sm: 14,
|
||||||
|
body: 15,
|
||||||
|
md: 16,
|
||||||
|
lg: 18,
|
||||||
|
xl: 20,
|
||||||
|
xxl: 24,
|
||||||
|
display: 32,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Graisses (typées pour React Native). */
|
||||||
|
export const FontWeight = {
|
||||||
|
regular: '400',
|
||||||
|
medium: '500',
|
||||||
|
semibold: '600',
|
||||||
|
bold: '700',
|
||||||
|
heavy: '800',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Ombres douces, discrètes (le DSFR reste sobre). */
|
||||||
|
export const Shadow = {
|
||||||
|
card: {
|
||||||
|
shadowColor: '#161616',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.06,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 1,
|
||||||
|
},
|
||||||
|
raised: {
|
||||||
|
shadowColor: '#161616',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.12,
|
||||||
|
shadowRadius: 10,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Cible tactile minimale recommandée (accessibilité). */
|
||||||
|
export const MIN_TOUCH = 44;
|
||||||
|
|
||||||
|
/** hitSlop standard pour agrandir la zone cliquable des petites icônes. */
|
||||||
|
export const HIT_SLOP = { top: 10, bottom: 10, left: 10, right: 10 } as const;
|
||||||
Reference in New Issue
Block a user