SPB Git
8.4 KB · 89 lines tsx
1"use client";2import { clsx } from "clsx";3import { useEffect, useRef, useState, type ReactNode, type ButtonHTMLAttributes, type InputHTMLAttributes, type TextareaHTMLAttributes } from "react";4import { X, Loader2, Check } from "lucide-react";56export const cn = clsx;78export function Button({ variant = "default", size = "md", loading, className, children, ...p }: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "default" | "primary" | "ghost" | "danger" | "outline"; size?: "sm" | "md" | "lg" | "icon"; loading?: boolean }) {9  return (10    <button {...p} disabled={p.disabled || loading} className={cn("inline-flex items-center justify-center gap-1.5 rounded-lg font-medium transition-all duration-150 select-none disabled:opacity-50 active:scale-[.98]",11      size === "sm" && "h-8 px-2.5 text-[13px]", size === "md" && "h-9 px-3.5 text-sm", size === "lg" && "h-11 px-5 text-[15px]", size === "icon" && "h-8 w-8 text-sm",12      variant === "default" && "bg-surface-2 border border-border hover:border-border-strong text-fg",13      variant === "outline" && "border border-border hover:bg-surface-2 text-fg-2 hover:text-fg",14      variant === "primary" && "bg-accent text-accent-ink hover:brightness-110 shadow-soft",15      variant === "ghost" && "hover:bg-surface-2 text-fg-2 hover:text-fg",16      variant === "danger" && "bg-danger/10 text-danger border border-danger/30 hover:bg-danger/20", className)}>17      {loading ? <Loader2 className="h-4 w-4 animate-spin" /> : children}18    </button>19  );20}2122export function IconButton({ label, className, children, ...p }: ButtonHTMLAttributes<HTMLButtonElement> & { label: string }) {23  return <button {...p} aria-label={label} title={label} className={cn("inline-flex h-8 w-8 items-center justify-center rounded-lg text-fg-2 hover:text-fg hover:bg-surface-2 transition-colors disabled:opacity-40", className)}>{children}</button>;24}2526export function Input(p: InputHTMLAttributes<HTMLInputElement>) { return <input {...p} className={cn("input", p.className)} />; }27export function Textarea(p: TextareaHTMLAttributes<HTMLTextAreaElement>) { return <textarea {...p} className={cn("input min-h-24", p.className)} />; }2829export function Badge({ children, tone = "neutral", className }: { children: ReactNode; tone?: "neutral" | "accent" | "ok" | "warn" | "danger" | "amber"; className?: string }) {30  return <span className={cn("inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-medium leading-4 border",31    tone === "neutral" && "bg-surface-2 border-border text-fg-2", tone === "accent" && "bg-accent/10 border-accent/30 text-accent", tone === "ok" && "bg-ok/10 border-ok/30 text-ok",32    tone === "warn" && "bg-warn/10 border-warn/30 text-warn", tone === "danger" && "bg-danger/10 border-danger/30 text-danger", tone === "amber" && "bg-accent-2/10 border-accent-2/30 text-accent-2", className)}>{children}</span>;33}3435export function Toggle({ checked, onChange, label, disabled }: { checked: boolean; onChange: (v: boolean) => void; label?: string; disabled?: boolean }) {36  return (37    <button type="button" role="switch" aria-checked={checked} aria-label={label} disabled={disabled} onClick={() => onChange(!checked)} className={cn("relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border transition-colors disabled:opacity-40", checked ? "bg-accent border-accent" : "bg-surface-2 border-border-strong")}>38      <span className={cn("inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform", checked ? "translate-x-[18px]" : "translate-x-[3px]")} />39    </button>40  );41}4243export function Dialog({ open, onClose, title, children, width = "max-w-lg", className }: { open: boolean; onClose: () => void; title?: ReactNode; children: ReactNode; width?: string; className?: string }) {44  const ref = useRef<HTMLDialogElement>(null);45  useEffect(() => { const d = ref.current; if (!d) return; if (open && !d.open) d.showModal(); if (!open && d.open) d.close(); }, [open]);46  useEffect(() => { const d = ref.current; if (!d) return; const onCancel = (e: Event) => { e.preventDefault(); onClose(); }; d.addEventListener("cancel", onCancel); return () => d.removeEventListener("cancel", onCancel); }, [onClose]);47  return (48    <dialog ref={ref} onClick={e => { if (e.target === ref.current) onClose(); }} className={cn("m-auto w-[calc(100%-2rem)] rounded-2xl border border-border bg-bg-elev p-0 shadow-soft backdrop:bg-black/60 open:anim-fade-up max-h-[90dvh]", width, className)}>49      <div className="flex max-h-[90dvh] flex-col">50        {title !== undefined && <div className="flex items-center justify-between border-b border-border px-5 py-3.5"><h2 className="text-[15px] font-semibold">{title}</h2><IconButton label="Fermer" onClick={onClose}><X className="h-4 w-4" /></IconButton></div>}51        <div className="overflow-y-auto">{children}</div>52      </div>53    </dialog>54  );55}5657export function Spinner({ className }: { className?: string }) { return <Loader2 className={cn("h-4 w-4 animate-spin text-muted", className)} />; }5859export function Empty({ icon, title, hint, action }: { icon?: ReactNode; title: string; hint?: string; action?: ReactNode }) {60  return <div className="flex flex-col items-center justify-center gap-2 py-16 text-center">{icon && <div className="text-muted">{icon}</div>}<div className="text-[15px] font-medium">{title}</div>{hint && <div className="max-w-sm text-sm text-muted">{hint}</div>}{action && <div className="mt-2">{action}</div>}</div>;61}6263export function CopyButton({ text, className, size = "sm" }: { text: string; className?: string; size?: "sm" | "xs" }) {64  const [ok, setOk] = useState(false);65  return <button type="button" onClick={async () => { await navigator.clipboard.writeText(text); setOk(true); setTimeout(() => setOk(false), 1200); }} className={cn("inline-flex items-center gap-1 rounded-md text-fg-2 hover:text-fg hover:bg-surface-2 transition-colors", size === "sm" ? "px-1.5 py-1 text-xs" : "px-1 py-0.5 text-[11px]", className)} title="Copier">{ok ? <Check className="h-3.5 w-3.5 text-ok" /> : <CopyIcon />}{ok ? "Copié" : "Copier"}</button>;66}67function CopyIcon() { return <svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></svg>; }6869// ---------------------------------------------------------------- toasts70type Toast = { id: number; message: string; tone?: "info" | "ok" | "error" };71let push: ((t: Omit<Toast, "id">) => void) | null = null;72export function toast(message: string, tone: Toast["tone"] = "info") { push?.({ message, tone }); }73export function Toaster() {74  const [items, setItems] = useState<Toast[]>([]);75  useEffect(() => { push = t => { const id = Date.now() + Math.random(); setItems(x => [...x, { ...t, id }]); setTimeout(() => setItems(x => x.filter(i => i.id !== id)), 4200); }; return () => { push = null; }; }, []);76  return <div className="pointer-events-none fixed bottom-4 left-1/2 z-[100] flex -translate-x-1/2 flex-col gap-2 safe-b">{items.map(t => <div key={t.id} className={cn("anim-fade-up pointer-events-auto rounded-xl border px-4 py-2.5 text-sm shadow-soft glass", t.tone === "error" ? "border-danger/40 text-danger" : t.tone === "ok" ? "border-ok/40 text-ok" : "border-border text-fg")}>{t.message}</div>)}</div>;77}7879export function Kbd({ children }: { children: ReactNode }) { return <kbd className="kbd">{children}</kbd>; }8081export function Tabs<T extends string>({ value, onChange, items, className }: { value: T; onChange: (v: T) => void; items: { value: T; label: ReactNode }[]; className?: string }) {82  return <div className={cn("inline-flex rounded-lg border border-border bg-surface p-0.5", className)}>{items.map(i => <button key={i.value} type="button" onClick={() => onChange(i.value)} className={cn("rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors", value === i.value ? "bg-surface-2 text-fg shadow-sm" : "text-muted hover:text-fg")}>{i.label}</button>)}</div>;83}8485export function Progress({ value, max = 100, tone = "accent" }: { value: number; max?: number; tone?: "accent" | "ok" | "warn" | "danger" }) {86  const pct = Math.max(0, Math.min(100, (value / max) * 100));87  return <div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-2"><div className={cn("h-full rounded-full transition-all", tone === "accent" && "bg-accent", tone === "ok" && "bg-ok", tone === "warn" && "bg-warn", tone === "danger" && "bg-danger")} style={{ width: `${pct}%` }} /></div>;88}89
Files · laptop-build-20261101
Menu

Theme