SPB Git forge

spb/immbot-ai

Public
1commits 1branches 0releases
1.5 MBsize
maindefault branch
20 days agolast push
TypeScript 98.3% CSS 0.9% Shell 0.7%
4.7 KB · 130 lines tsx
Raw Blame History
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