fix(ui): wave 2 from critique re-run — AA contrast, unified PageHeader, checkout confirm
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m46s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m46s
Nav: teams/settings/services back out of the nav until product-ready (user decision); teams page was already half-finished (UUID member display is a backend limitation). Payment: explicit confirmation dialog (plan, amount, billing period, Stripe note) before any redirect; ?plan= URL now pre-opens the dialog instead of triggering a silent checkout. Contrast AA sweep (71 replacements): functional micro-labels raised to >=60-65% opacity and >=10px across sidebar, header, pricing, landing; decorative all-caps 'interface' label removed. Design unification: new PageHeader component (accent pill + serif base/accent title) applied to settings, services, reviews, teams; pricing header returned to the editorial voice (serif + accent pill); dead GlossaryCard deleted. i18n residuals: suggestion chips, 'Standard' provider label, notification close, model-combobox strings extracted (+broken bg-surface/border-border- subtle tokens fixed); ~45 new keys EN+FR; 13 dead keys removed; zero missing keys verified. Reviews: icon-only row actions now carry visible text; 'Approve all' is two-step armed-confirm. Accelerators: Ctrl/Cmd+Enter submits; arrow-key navigation in the language combobox; recent-jobs history cap 8->20 with per-job download; source=target config rejected; empty 'Master Quality' badge removed. Cookie consent: reopenable via footer link, emoji replaced with drawn icon. Verified: build exit 0, vitest 9/9, eslint 64 errors = previous level, 0 missing i18n keys.
This commit is contained in:
@@ -106,7 +106,7 @@ const Hero = () => {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
>
|
||||
<div className="inline-flex items-center gap-2 px-4 py-1.5 mb-10 text-[10px] font-bold tracking-[0.3em] text-brand-dark/40 dark:text-white/40 uppercase bg-transparent border border-black/10 dark:border-white/10 rounded-full">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-1.5 mb-10 text-[10px] font-bold tracking-[0.3em] text-brand-dark/60 dark:text-white/60 uppercase bg-transparent border border-black/10 dark:border-white/10 rounded-full">
|
||||
<Languages size={10} /> {t('landing.hero.tag')}
|
||||
</div>
|
||||
<h1 className="text-6xl md:text-8xl mb-10 text-brand-dark dark:text-white font-serif font-medium tracking-tight leading-[1.1]">
|
||||
@@ -187,7 +187,7 @@ const Steps = () => {
|
||||
<div className="mb-24">
|
||||
<span className="accent-pill mb-6 block w-fit">{t('landing.steps.process')}</span>
|
||||
<h2 className="text-4xl md:text-5xl text-brand-dark dark:text-white font-serif font-medium tracking-tight mb-6">{t('landing.steps.title')}</h2>
|
||||
<p className="text-brand-dark/40 dark:text-white/40 text-lg max-w-xl font-light">{t('landing.steps.subtitle')}</p>
|
||||
<p className="text-brand-dark/60 dark:text-white/60 text-lg max-w-xl font-light">{t('landing.steps.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-16">
|
||||
@@ -197,7 +197,7 @@ const Steps = () => {
|
||||
{st.icon}
|
||||
</div>
|
||||
<h3 className="text-xl font-serif font-medium text-brand-dark dark:text-white mb-4">{st.title}</h3>
|
||||
<p className="text-brand-dark/40 dark:text-white/40 leading-relaxed font-light">{st.desc}</p>
|
||||
<p className="text-brand-dark/60 dark:text-white/60 leading-relaxed font-light">{st.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -222,9 +222,9 @@ const FeaturesContext = () => {
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{[
|
||||
{ icon: <Monitor className="text-brand-dark/40 dark:text-white/40" />, title: t('landing.features.context.title'), desc: t('landing.features.context.desc') },
|
||||
{ icon: <Layers className="text-brand-dark/40 dark:text-white/40" />, title: t('landing.features.glossary.title'), desc: t('landing.features.glossary.desc') },
|
||||
{ icon: <Eye className="text-brand-dark/40 dark:text-white/40" />, title: t('landing.features.vision.title'), desc: t('landing.features.vision.desc') },
|
||||
{ icon: <Monitor className="text-brand-dark/60 dark:text-white/60" />, title: t('landing.features.context.title'), desc: t('landing.features.context.desc') },
|
||||
{ icon: <Layers className="text-brand-dark/60 dark:text-white/60" />, title: t('landing.features.glossary.title'), desc: t('landing.features.glossary.desc') },
|
||||
{ icon: <Eye className="text-brand-dark/60 dark:text-white/60" />, title: t('landing.features.vision.title'), desc: t('landing.features.vision.desc') },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="editorial-card p-10 flex flex-col items-start gap-8">
|
||||
<div className="w-10 h-10 border border-black/5 dark:border-white/10 rounded-full flex items-center justify-center">
|
||||
@@ -232,7 +232,7 @@ const FeaturesContext = () => {
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xl font-serif font-medium mb-4 text-brand-dark dark:text-white">{item.title}</h4>
|
||||
<p className="text-brand-dark/40 dark:text-white/40 leading-relaxed font-light text-sm">{item.desc}</p>
|
||||
<p className="text-brand-dark/60 dark:text-white/60 leading-relaxed font-light text-sm">{item.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -283,10 +283,10 @@ const LayoutFeatures = () => {
|
||||
const FormatsSection = () => {
|
||||
const { t } = useI18n();
|
||||
const formats = [
|
||||
{ id: ".DOCX", name: t('landing.formats.word.name'), icon: <FileText className="text-brand-dark/40 dark:text-white/40" />, items: [t('landing.formats.word.i1'), t('landing.formats.word.i2'), t('landing.formats.word.i3'), t('landing.formats.word.i4')] },
|
||||
{ id: ".XLSX", name: t('landing.formats.excel.name'), icon: <FileSpreadsheet className="text-brand-dark/40 dark:text-white/40" />, items: [t('landing.formats.excel.i1'), t('landing.formats.excel.i2'), t('landing.formats.excel.i3'), t('landing.formats.excel.i4')] },
|
||||
{ id: ".PPTX", name: t('landing.formats.pptx.name'), icon: <Presentation className="text-brand-dark/40 dark:text-white/40" />, items: [t('landing.formats.pptx.i1'), t('landing.formats.pptx.i2'), t('landing.formats.pptx.i3'), t('landing.formats.pptx.i4')] },
|
||||
{ id: ".PDF", name: t('landing.formats.pdf.name'), icon: <FileText className="text-brand-dark/40 dark:text-white/40" />, items: [t('landing.formats.pdf.i1'), t('landing.formats.pdf.i2'), t('landing.formats.pdf.i3'), t('landing.formats.pdf.i4')] },
|
||||
{ id: ".DOCX", name: t('landing.formats.word.name'), icon: <FileText className="text-brand-dark/60 dark:text-white/60" />, items: [t('landing.formats.word.i1'), t('landing.formats.word.i2'), t('landing.formats.word.i3'), t('landing.formats.word.i4')] },
|
||||
{ id: ".XLSX", name: t('landing.formats.excel.name'), icon: <FileSpreadsheet className="text-brand-dark/60 dark:text-white/60" />, items: [t('landing.formats.excel.i1'), t('landing.formats.excel.i2'), t('landing.formats.excel.i3'), t('landing.formats.excel.i4')] },
|
||||
{ id: ".PPTX", name: t('landing.formats.pptx.name'), icon: <Presentation className="text-brand-dark/60 dark:text-white/60" />, items: [t('landing.formats.pptx.i1'), t('landing.formats.pptx.i2'), t('landing.formats.pptx.i3'), t('landing.formats.pptx.i4')] },
|
||||
{ id: ".PDF", name: t('landing.formats.pdf.name'), icon: <FileText className="text-brand-dark/60 dark:text-white/60" />, items: [t('landing.formats.pdf.i1'), t('landing.formats.pdf.i2'), t('landing.formats.pdf.i3'), t('landing.formats.pdf.i4')] },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -295,7 +295,7 @@ const FormatsSection = () => {
|
||||
<div className="mb-24">
|
||||
<span className="accent-pill mb-6 block w-fit">{t('landing.formats.pill')}</span>
|
||||
<h2 className="text-4xl md:text-5xl text-brand-dark dark:text-white mb-6 leading-tight font-serif font-medium tracking-tight">{t('landing.formats.title')} <span className="italic">{t('landing.formats.title2')}</span></h2>
|
||||
<p className="text-brand-dark/40 dark:text-white/40 max-w-xl text-lg font-light">{t('landing.formats.subtitle')}</p>
|
||||
<p className="text-brand-dark/60 dark:text-white/60 max-w-xl text-lg font-light">{t('landing.formats.subtitle')}</p>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{formats.map((f, i) => (
|
||||
@@ -304,12 +304,12 @@ const FormatsSection = () => {
|
||||
<div className="w-12 h-12 bg-brand-muted dark:bg-[#1f1f1f] rounded-xl flex items-center justify-center text-xl group-hover:bg-brand-dark dark:group-hover:bg-brand-accent group-hover:text-white dark:group-hover:text-brand-dark transition-all">
|
||||
{f.icon}
|
||||
</div>
|
||||
<span className="text-[9px] font-bold text-brand-dark/20 dark:text-white/20 uppercase tracking-[0.2em]">{f.id}</span>
|
||||
<span className="text-[9px] font-bold text-brand-dark/50 dark:text-white/50 uppercase tracking-[0.2em]">{f.id}</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-serif font-medium mb-6 text-brand-dark dark:text-white tracking-tight">{f.name}</h3>
|
||||
<ul className="space-y-4">
|
||||
{f.items.map((item, j) => (
|
||||
<li key={j} className="flex items-start gap-3 text-xs text-brand-dark/40 dark:text-white/40 font-medium">
|
||||
<li key={j} className="flex items-start gap-3 text-xs text-brand-dark/60 dark:text-white/60 font-medium">
|
||||
<div className="w-1 h-1 bg-brand-accent rounded-full mt-1.5 shrink-0 opacity-40"></div> {item}
|
||||
</li>
|
||||
))}
|
||||
@@ -337,14 +337,14 @@ const PricingSection = () => {
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-24">
|
||||
<h2 className="text-4xl md:text-5xl mb-8 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">{t('landing.pricing.title')}</h2>
|
||||
<p className="text-brand-dark/40 dark:text-white/40 text-xl font-light max-w-2xl mx-auto">{t('landing.pricing.subtitle')}</p>
|
||||
<p className="text-brand-dark/60 dark:text-white/60 text-xl font-light max-w-2xl mx-auto">{t('landing.pricing.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-12 mb-32">
|
||||
<button onClick={() => setIsAnnual(false)} className={`text-sm font-bold uppercase tracking-widest transition-all ${!isAnnual ? 'text-brand-dark dark:text-white underline underline-offset-8' : 'text-brand-dark/30 dark:text-white/30 hover:text-brand-dark dark:hover:text-white'}`}>
|
||||
<button onClick={() => setIsAnnual(false)} className={`text-sm font-bold uppercase tracking-widest transition-all ${!isAnnual ? 'text-brand-dark dark:text-white underline underline-offset-8' : 'text-brand-dark/55 dark:text-white/55 hover:text-brand-dark dark:hover:text-white'}`}>
|
||||
{t('landing.pricing.monthly')}
|
||||
</button>
|
||||
<button onClick={() => setIsAnnual(true)} className={`text-sm font-bold uppercase tracking-widest transition-all relative ${isAnnual ? 'text-brand-dark dark:text-white underline underline-offset-8' : 'text-brand-dark/30 dark:text-white/30 hover:text-brand-dark dark:hover:text-white'}`}>
|
||||
<button onClick={() => setIsAnnual(true)} className={`text-sm font-bold uppercase tracking-widest transition-all relative ${isAnnual ? 'text-brand-dark dark:text-white underline underline-offset-8' : 'text-brand-dark/55 dark:text-white/55 hover:text-brand-dark dark:hover:text-white'}`}>
|
||||
{t('landing.pricing.annual')}
|
||||
<span className="absolute -top-6 left-1/2 -translate-x-1/2 text-[9px] font-bold text-brand-accent whitespace-nowrap italic">
|
||||
(-20%)
|
||||
@@ -356,11 +356,11 @@ const PricingSection = () => {
|
||||
{plans.map((p, i) => (
|
||||
<div key={i} className="p-16 bg-white dark:bg-[#141414] flex flex-col text-left transition-all duration-500 hover:z-10 hover:shadow-2xl hover:scale-[1.01]">
|
||||
<h3 className="text-2xl font-serif font-medium mb-2 text-brand-dark dark:text-white tracking-tight">{p.name}</h3>
|
||||
<p className="text-brand-dark/40 dark:text-white/40 text-xs font-light mb-14 tracking-widest uppercase">{p.desc}</p>
|
||||
<p className="text-brand-dark/60 dark:text-white/60 text-xs font-light mb-14 tracking-widest uppercase">{p.desc}</p>
|
||||
|
||||
<div className="mb-14">
|
||||
<span className="text-5xl font-serif font-medium leading-none text-brand-dark dark:text-white">€{isAnnual ? p.annualPrice : p.monthlyPrice}</span>
|
||||
<span className="text-brand-dark/30 dark:text-white/30 font-light ml-2 uppercase text-[10px] tracking-[0.2em]">/ {t('landing.pricing.month')}</span>
|
||||
<span className="text-brand-dark/55 dark:text-white/55 font-light ml-2 uppercase text-[10px] tracking-[0.2em]">/ {t('landing.pricing.month')}</span>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-6 mb-16 flex-grow">
|
||||
@@ -381,7 +381,7 @@ const PricingSection = () => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-center mt-12 text-brand-dark/20 dark:text-white/20 text-xs font-bold uppercase tracking-widest">{t('landing.pricing.footer')}</p>
|
||||
<p className="text-center mt-12 text-brand-dark/50 dark:text-white/50 text-xs font-bold uppercase tracking-widest">{t('landing.pricing.footer')}</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -395,7 +395,7 @@ const CTASection = () => {
|
||||
<h2 className="text-5xl md:text-7xl text-brand-dark dark:text-white mb-10 leading-tight tracking-tight font-serif font-medium">
|
||||
{t('landing.cta.title')}
|
||||
</h2>
|
||||
<p className="text-lg text-brand-dark/40 dark:text-white/40 mb-16 font-light">
|
||||
<p className="text-lg text-brand-dark/60 dark:text-white/60 mb-16 font-light">
|
||||
{t('landing.cta.subtitle')}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-6 justify-center items-center">
|
||||
@@ -416,14 +416,14 @@ const Footer = () => {
|
||||
<div className="grid md:grid-cols-12 gap-20 mb-40">
|
||||
<div className="md:col-span-12 lg:col-span-6">
|
||||
<Logo />
|
||||
<p className="text-brand-dark/40 dark:text-white/40 text-lg font-medium mt-12 mb-12 max-w-sm leading-relaxed uppercase tracking-widest">
|
||||
<p className="text-brand-dark/60 dark:text-white/60 text-lg font-medium mt-12 mb-12 max-w-sm leading-relaxed uppercase tracking-widest">
|
||||
{t('landing.footer.desc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-4 lg:col-span-2">
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-[0.3em] text-brand-dark dark:text-white mb-10">{t('landing.footer.product')}</h4>
|
||||
<ul className="space-y-6 text-xs text-brand-dark/40 dark:text-white/40 font-bold uppercase tracking-widest leading-none">
|
||||
<ul className="space-y-6 text-xs text-brand-dark/60 dark:text-white/60 font-bold uppercase tracking-widest leading-none">
|
||||
<li><a href="#features" className="hover:text-brand-dark dark:hover:text-white transition-colors">{t('landing.nav.why')}</a></li>
|
||||
<li><a href="#formats" className="hover:text-brand-dark dark:hover:text-white transition-colors">{t('landing.nav.formats')}</a></li>
|
||||
<li><a href="#pricing" className="hover:text-brand-dark dark:hover:text-white transition-colors">{t('landing.nav.pricing')}</a></li>
|
||||
@@ -433,11 +433,20 @@ const Footer = () => {
|
||||
</div>
|
||||
|
||||
<div className="pt-10 border-t border-black/5 dark:border-white/5 flex flex-col md:flex-row justify-between items-center gap-10">
|
||||
<div className="text-[10px] font-bold text-brand-dark/20 dark:text-white/20 uppercase tracking-[0.4em]">
|
||||
<div className="text-[10px] font-bold text-brand-dark/50 dark:text-white/50 uppercase tracking-[0.4em]">
|
||||
{t('landing.footer.rights')}
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-brand-dark/30 dark:text-white/30 uppercase tracking-widest">
|
||||
wordly.art
|
||||
<div className="flex items-center gap-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => import('@/components/ui/cookie-consent').then(m => m.reopenCookieConsent())}
|
||||
className="text-[10px] font-bold text-brand-dark/50 dark:text-white/50 uppercase tracking-[0.2em] hover:text-brand-dark dark:hover:text-white transition-colors"
|
||||
>
|
||||
{t('cookieConsent.reopen')}
|
||||
</button>
|
||||
<div className="text-[10px] font-mono text-brand-dark/55 dark:text-white/55 uppercase tracking-widest">
|
||||
wordly.art
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
40
frontend/src/components/layout/page-header.tsx
Normal file
40
frontend/src/components/layout/page-header.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Single editorial page header: accent pill + serif title (base + italic accent)
|
||||
* + subtitle. Every dashboard surface uses this so the product speaks one voice.
|
||||
*/
|
||||
export function PageHeader({
|
||||
pill,
|
||||
titleBase,
|
||||
titleAccent,
|
||||
subtitle,
|
||||
action,
|
||||
className = '',
|
||||
}: {
|
||||
pill: string;
|
||||
titleBase: string;
|
||||
titleAccent: string;
|
||||
subtitle?: string;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`mb-10 flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between ${className}`}>
|
||||
<div>
|
||||
<span className="accent-pill mb-4 block w-fit">{pill}</span>
|
||||
<h1 className="mb-3 text-4xl font-serif font-medium leading-tight tracking-tight text-brand-dark dark:text-white md:text-5xl">
|
||||
{titleBase} <span className="italic">{titleAccent}</span>
|
||||
</h1>
|
||||
{subtitle && (
|
||||
<p className="max-w-xl text-sm font-light leading-relaxed text-brand-dark/60 dark:text-white/60">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,13 @@ import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const STORAGE_KEY = "cookie-consent";
|
||||
const REOPEN_EVENT = "wordly:reopen-cookie-consent";
|
||||
|
||||
/** Reopen the banner after a choice was made (footer link uses this). */
|
||||
export function reopenCookieConsent() {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
window.dispatchEvent(new Event(REOPEN_EVENT));
|
||||
}
|
||||
|
||||
export function CookieConsent() {
|
||||
const { t } = useI18n();
|
||||
@@ -13,6 +20,9 @@ export function CookieConsent() {
|
||||
|
||||
useEffect(() => {
|
||||
setVisible(!localStorage.getItem(STORAGE_KEY));
|
||||
const onReopen = () => setVisible(true);
|
||||
window.addEventListener(REOPEN_EVENT, onReopen);
|
||||
return () => window.removeEventListener(REOPEN_EVENT, onReopen);
|
||||
}, []);
|
||||
|
||||
function acceptAll() {
|
||||
@@ -38,7 +48,7 @@ export function CookieConsent() {
|
||||
<div className="mx-auto max-w-3xl rounded-t-2xl border border-border bg-background/95 backdrop-blur-md shadow-2xl p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl shrink-0" aria-hidden="true">
|
||||
🍪
|
||||
<CookieIcon />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-foreground mb-1">
|
||||
@@ -63,3 +73,16 @@ export function CookieConsent() {
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
/** Drawn icon instead of an emoji — keeps one consistent icon system. */
|
||||
function CookieIcon() {
|
||||
return (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-foreground/70" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<circle cx="9" cy="9" r="0.5" fill="currentColor" />
|
||||
<circle cx="14" cy="13" r="0.5" fill="currentColor" />
|
||||
<circle cx="10" cy="15" r="0.5" fill="currentColor" />
|
||||
<circle cx="16" cy="8" r="0.5" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { Search, List, Loader2, Check } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export interface ModelOption {
|
||||
id: string;
|
||||
@@ -31,6 +32,7 @@ export function ModelCombobox({
|
||||
providerLabel,
|
||||
placeholder = "nom-du-modele",
|
||||
}: ModelComboboxProps) {
|
||||
const { t } = useI18n();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [filter, setFilter] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -108,17 +110,17 @@ export function ModelCombobox({
|
||||
type="button"
|
||||
onClick={handleBrowse}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-1.5 px-3 h-10 rounded-lg border border-border-subtle bg-surface text-sm text-muted-foreground hover:bg-surface-elevated hover:text-foreground transition-colors disabled:opacity-50"
|
||||
className="flex items-center gap-1.5 px-3 h-10 rounded-lg border border-border bg-card text-sm text-muted-foreground hover:bg-card hover:text-foreground transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? <Loader2 className="size-4 animate-spin" /> : <List className="size-4" />}
|
||||
<span className="hidden sm:inline">Parcourir</span>
|
||||
<span className="hidden sm:inline">{t('common.browse')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border-subtle bg-card shadow-xl max-h-72 overflow-hidden flex flex-col">
|
||||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border bg-card shadow-xl max-h-72 overflow-hidden flex flex-col">
|
||||
{/* Filter */}
|
||||
<div className="p-2 border-b border-border-subtle">
|
||||
<div className="p-2 border-b border-border">
|
||||
<Input
|
||||
placeholder={`Filtrer...`}
|
||||
value={filter}
|
||||
@@ -132,7 +134,7 @@ export function ModelCombobox({
|
||||
<ul ref={listRef} className="overflow-y-auto flex-1">
|
||||
{filtered.length === 0 ? (
|
||||
<li className="px-3 py-4 text-xs text-muted-foreground text-center">
|
||||
{models.length === 0 ? "Aucun modele recupere" : "Aucun modele ne correspond"}
|
||||
{models.length === 0 ? t('common.noModels') : t('common.noModelMatch')}
|
||||
</li>
|
||||
) : (
|
||||
filtered.map((model, i) => (
|
||||
@@ -141,7 +143,7 @@ export function ModelCombobox({
|
||||
onClick={() => handleSelect(model)}
|
||||
onMouseEnter={() => setHighlightIndex(i)}
|
||||
className={`flex items-center justify-between px-3 py-2 text-xs cursor-pointer transition-colors ${
|
||||
i === highlightIndex ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-surface-elevated"
|
||||
i === highlightIndex ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-card"
|
||||
} ${value === model.id ? "bg-primary/5" : ""}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -160,7 +162,7 @@ export function ModelCombobox({
|
||||
</ul>
|
||||
|
||||
{models.length > 0 && (
|
||||
<div className="px-3 py-1.5 border-t border-border-subtle text-xs text-muted-foreground text-center bg-card">
|
||||
<div className="px-3 py-1.5 border-t border-border text-xs text-muted-foreground text-center bg-card">
|
||||
{filtered.length} modele{filtered.length !== 1 ? "s" : ""}
|
||||
{filter ? ` sur ${models.length}` : ""} — nom libre possible
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X, CheckCircle, AlertCircle, AlertTriangle, Info, Loader2 } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useI18n } from "@/lib/i18n"
|
||||
|
||||
const notificationVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-lg border p-4 pe-8 shadow-lg transition-all duration-300 ease-out",
|
||||
@@ -74,6 +75,7 @@ const Notification = React.forwardRef<HTMLDivElement, NotificationProps>(
|
||||
children,
|
||||
...props
|
||||
}, ref) => {
|
||||
const { t } = useI18n()
|
||||
const [visible, setVisible] = React.useState(true)
|
||||
const [progress, setProgress] = React.useState(100)
|
||||
|
||||
@@ -181,7 +183,7 @@ const Notification = React.forwardRef<HTMLDivElement, NotificationProps>(
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="absolute right-2 top-2 flex-shrink-0 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:opacity-100 focus-visible:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
aria-label="Close notification"
|
||||
aria-label={t("common.closeNotification")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user