feat(ui): panneau repliable, carnets plus lisibles et accueil plus clair

Harmonise la liste des notes et l’éditeur (titres, dates, retour),
rend la saisie rapide et les raccourcis de l’accueil utilisables
sur téléphone, et conserve le panneau latéral repliable.
This commit is contained in:
Antigravity
2026-09-05 20:53:48 +00:00
parent a7b16870a4
commit 3bdbec575a
37 changed files with 1138 additions and 482 deletions

View File

@@ -1,8 +1,10 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { motion } from 'motion/react'
import { Inbox, GraduationCap, Bell, Sparkles, Layers } from 'lucide-react'
import { Inbox, GraduationCap, Bell, Sparkles, Layers, ChevronLeft, ChevronRight } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
export interface DashboardActionStripProps {
inboxCount: number
@@ -19,6 +21,16 @@ export interface DashboardActionStripProps {
prefersReducedMotion?: boolean
}
function updateOverflow(el: HTMLDivElement) {
const max = el.scrollWidth - el.clientWidth
if (max <= 4) return { canPrev: false, canNext: false }
const pos = Math.abs(el.scrollLeft)
return {
canPrev: pos > 6,
canNext: pos < max - 6,
}
}
export function DashboardActionStrip({
inboxCount,
dueFlashcards,
@@ -33,7 +45,43 @@ export function DashboardActionStrip({
onThemes,
prefersReducedMotion,
}: DashboardActionStripProps) {
const { t } = useLanguage()
const { t, language } = useLanguage()
const scrollerRef = useRef<HTMLDivElement>(null)
const [canPrev, setCanPrev] = useState(false)
const [canNext, setCanNext] = useState(false)
const isRtl = language === 'fa' || language === 'ar'
const numberLocale = language === 'fa' ? 'fa-IR' : language === 'zh' ? 'zh-CN' : language
const refreshOverflow = useCallback(() => {
const el = scrollerRef.current
if (!el) return
const next = updateOverflow(el)
setCanPrev(next.canPrev)
setCanNext(next.canNext)
}, [])
useEffect(() => {
const el = scrollerRef.current
if (!el) return
refreshOverflow()
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(refreshOverflow) : null
ro?.observe(el)
el.addEventListener('scroll', refreshOverflow, { passive: true })
window.addEventListener('resize', refreshOverflow)
return () => {
ro?.disconnect()
el.removeEventListener('scroll', refreshOverflow)
window.removeEventListener('resize', refreshOverflow)
}
}, [refreshOverflow, inboxCount, dueFlashcards, reminderCount, discoveryCount, themeCount])
const scrollByPage = (direction: 1 | -1) => {
const el = scrollerRef.current
if (!el) return
const delta = Math.max(el.clientWidth * 0.7, 140) * direction
const left = isRtl ? -delta : delta
el.scrollBy({ left, behavior: prefersReducedMotion ? 'auto' : 'smooth' })
}
const items = [
{
@@ -79,47 +127,104 @@ export function DashboardActionStrip({
},
]
const chevronBtn =
'absolute top-1/2 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full border border-border/40 bg-[#F9F8F6]/95 dark:bg-[#0D0D0D]/95 text-foreground shadow-sm hover:bg-brand-accent/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-accent'
return (
<div className="flex gap-2 overflow-x-auto custom-scrollbar pb-0.5 -mx-0.5 px-0.5">
{items.map(item => {
const Icon = item.icon
const Wrapper = item.pulse && !prefersReducedMotion ? motion.button : 'button'
const motionProps = item.pulse && !prefersReducedMotion
? {
animate: { scale: [1, 1.03, 1] },
transition: { duration: 0.45 },
}
: {}
return (
<Wrapper
key={item.pulse && !prefersReducedMotion ? `inbox-pulse-${inboxPulse}` : item.key}
type="button"
onClick={item.onClick}
{...motionProps}
className={`shrink-0 flex items-center gap-2.5 px-3.5 py-2.5 rounded-xl border transition-all text-start min-w-[128px] ${
item.accent
? 'border-brand-accent/30 bg-brand-accent/[0.06] hover:border-brand-accent/50 hover:bg-brand-accent/10 shadow-sm'
: 'border-border/25 bg-white/60 dark:bg-zinc-900/40 hover:border-border/50'
}`}
>
<div className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 ${
item.accent ? 'bg-brand-accent/15 text-brand-accent' : 'bg-stone-100 dark:bg-zinc-800 text-concrete'
}`}>
<Icon size={13} strokeWidth={2} />
</div>
<div className="min-w-0">
<p className={`text-lg font-serif font-bold leading-none ${
item.accent ? 'text-ink dark:text-dark-ink' : 'text-concrete/80'
<div className="relative">
{canPrev && (
<button
type="button"
className={cn(chevronBtn, 'start-0')}
aria-label={t('homeDashboard.actionStripPrev')}
onClick={() => scrollByPage(-1)}
>
<ChevronLeft size={18} className="rtl:rotate-180" />
</button>
)}
{canNext && (
<button
type="button"
className={cn(chevronBtn, 'end-0')}
aria-label={t('homeDashboard.actionStripNext')}
onClick={() => scrollByPage(1)}
>
<ChevronRight size={18} className="rtl:rotate-180" />
</button>
)}
<div
aria-hidden={!canPrev}
className={cn(
'pointer-events-none absolute inset-y-0 start-0 z-[1] w-8 bg-gradient-to-r from-[#F9F8F6] to-transparent dark:from-[#0D0D0D] transition-opacity',
canPrev ? 'opacity-100' : 'opacity-0',
)}
/>
<div
aria-hidden={!canNext}
className={cn(
'pointer-events-none absolute inset-y-0 end-0 z-[1] w-8 bg-gradient-to-l from-[#F9F8F6] to-transparent dark:from-[#0D0D0D] transition-opacity',
canNext ? 'opacity-100' : 'opacity-0',
)}
/>
<div
ref={scrollerRef}
role="navigation"
aria-label={t('homeDashboard.actionStripLabel')}
className={cn(
'dashboard-action-strip flex gap-2 overflow-x-auto overscroll-x-contain pb-2 -mx-0.5 px-0.5 snap-x snap-mandatory',
canPrev && 'ps-8',
canNext && 'pe-8',
)}
>
{items.map(item => {
const Icon = item.icon
const Wrapper = item.pulse && !prefersReducedMotion ? motion.button : 'button'
const motionProps = item.pulse && !prefersReducedMotion
? {
animate: { scale: [1, 1.03, 1] },
transition: { duration: 0.45 },
}
: {}
return (
<Wrapper
key={item.pulse && !prefersReducedMotion ? `inbox-pulse-${inboxPulse}` : item.key}
type="button"
onClick={item.onClick}
onFocus={(e: { currentTarget: HTMLElement }) => {
e.currentTarget.scrollIntoView({
inline: 'nearest',
block: 'nearest',
behavior: prefersReducedMotion ? 'auto' : 'smooth',
})
}}
{...motionProps}
className={`snap-start shrink-0 flex min-h-11 min-w-[128px] items-center gap-2.5 px-3.5 py-2.5 rounded-xl border transition-colors text-start ${
item.accent
? 'border-brand-accent/30 bg-brand-accent/[0.06] hover:border-brand-accent/50 hover:bg-brand-accent/10 shadow-sm'
: 'border-border/25 bg-white/60 dark:bg-zinc-900/40 hover:border-border/50'
}`}
>
<div className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 ${
item.accent ? 'bg-brand-accent/15 text-brand-accent' : 'bg-stone-100 dark:bg-zinc-800 text-concrete'
}`}>
{item.value}
</p>
<p className="text-[13px] font-medium text-concrete truncate mt-0.5">
{item.label}
</p>
</div>
</Wrapper>
)
})}
<Icon size={13} strokeWidth={2} />
</div>
<div className="min-w-0">
<p className={`text-lg font-serif font-bold leading-none tabular-nums ${
item.accent ? 'text-ink dark:text-dark-ink' : 'text-concrete/80'
}`}>
{item.value.toLocaleString(numberLocale)}
</p>
<p className="text-[13px] font-medium text-concrete truncate mt-0.5">
{item.label}
</p>
</div>
</Wrapper>
)
})}
</div>
</div>
)
}