TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1"use client";2// Utilitaires partagés du centre d'apprentissage : fetch typé, niveaux de maîtrise,3// formats de dates, bannière d'erreur, anneau de progression.4import type { ReactNode } from "react";5import { cn } from "@/components/ui";67// ---------- Fetch JSON avec erreurs françaises ----------8export async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {9 const res = await fetch(url, init);10 const data = await res.json().catch(() => ({}));11 if (!res.ok) {12 throw new Error((data as { error?: string }).error ?? `Erreur ${res.status}`);13 }14 return data as T;15}1617export function postJson<T>(url: string, body: unknown): Promise<T> {18 return fetchJson<T>(url, {19 method: "POST",20 headers: { "Content-Type": "application/json" },21 body: JSON.stringify(body),22 });23}2425// ---------- Niveaux de maîtrise ----------26export type MasteryLevel = "a-decouvrir" | "en-construction" | "solide" | "maitrise";2728export const LEVELS: Record<29 MasteryLevel,30 { label: string; tone: "neutral" | "amber" | "brand" | "green"; hex: string; text: string; bg: string }31> = {32 maitrise: { label: "Maîtrisé", tone: "green", hex: "#10b981", text: "text-emerald-600 dark:text-emerald-400", bg: "bg-emerald-500" },33 solide: { label: "Solide", tone: "brand", hex: "#1d64b0", text: "text-brand-600 dark:text-brand-300", bg: "bg-brand-500" },34 "en-construction": { label: "En construction", tone: "amber", hex: "#f59e0b", text: "text-amber-600 dark:text-amber-400", bg: "bg-amber-500" },35 "a-decouvrir": { label: "À découvrir", tone: "neutral", hex: "#94a3b8", text: "text-muted", bg: "bg-slate-400" },36};3738export function levelInfo(level: string) {39 return LEVELS[(level as MasteryLevel) in LEVELS ? (level as MasteryLevel) : "a-decouvrir"];40}4142// ---------- Dates (SQLite UTC → local fr-CA) ----------43export function parseDbDate(s: string): Date {44 return new Date(s.includes("T") || s.endsWith("Z") ? s : s.replace(" ", "T") + "Z");45}46export function fmtDate(s: string): string {47 return parseDbDate(s).toLocaleDateString("fr-CA", { day: "numeric", month: "long", year: "numeric" });48}49export function fmtDateTime(s: string): string {50 return parseDbDate(s).toLocaleString("fr-CA", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });51}5253// ---------- Bannière d'erreur ----------54export function ErrorBanner({ message, className }: { message: string; className?: string }) {55 return (56 <div57 role="alert"58 className={cn("text-sm text-red-600 dark:text-red-400 bg-red-500/10 border border-red-500/25 rounded-xl px-4 py-3", className)}59 >60 {message}61 </div>62 );63}6465// ---------- Anneau de progression ----------66export function ProgressRing({67 value,68 size = 120,69 stroke = 10,70 label,71 className,72}: {73 value: number; // 0..174 size?: number;75 stroke?: number;76 label?: ReactNode;77 className?: string;78}) {79 const v = Math.min(1, Math.max(0, value));80 const r = (size - stroke) / 2;81 const c = 2 * Math.PI * r;82 return (83 <div className={cn("relative inline-flex items-center justify-center", className)} style={{ width: size, height: size }}>84 <svg width={size} height={size} className="-rotate-90" aria-hidden>85 <circle cx={size / 2} cy={size / 2} r={r} fill="none" strokeWidth={stroke} className="stroke-surface-3 dark:stroke-brand-900" />86 <circle87 cx={size / 2}88 cy={size / 2}89 r={r}90 fill="none"91 strokeWidth={stroke}92 strokeLinecap="round"93 strokeDasharray={c}94 strokeDashoffset={c * (1 - v)}95 className="stroke-brand-500 transition-[stroke-dashoffset] duration-700"96 />97 </svg>98 <div className="absolute inset-0 flex flex-col items-center justify-center">99 {label ?? (100 <>101 <span className="text-2xl font-bold text-fg">{Math.round(v * 100)}%</span>102 <span className="text-[11px] text-muted">maîtrise</span>103 </>104 )}105 </div>106 </div>107 );108}109110// ---------- Points de difficulté (1-5) ----------111export function DifficultyDots({ value }: { value: number }) {112 return (113 <span className="inline-flex items-center gap-1" title={`Difficulté ${value}/5`} aria-label={`Difficulté ${value} sur 5`}>114 {[1, 2, 3, 4, 5].map((i) => (115 <span key={i} className={cn("w-1.5 h-1.5 rounded-full", i <= value ? "bg-brand-500" : "bg-surface-3 dark:bg-brand-900")} />116 ))}117 </span>118 );119}120121// ---------- Téléchargement d'un fichier texte ----------122export function downloadText(filename: string, content: string, mime = "text/markdown") {123 const blob = new Blob([content], { type: mime });124 const a = document.createElement("a");125 a.href = URL.createObjectURL(blob);126 a.download = filename;127 a.click();128 URL.revokeObjectURL(a.href);129}130