Files
Momento/memento-note/components/dashboard-action-strip.tsx
Antigravity 3bdbec575a 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.
2026-09-05 20:53:48 +00:00

231 lines
7.5 KiB
TypeScript

'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { motion } from 'motion/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
dueFlashcards: number
reminderCount: number
discoveryCount: number
themeCount: number
inboxPulse?: number
onInbox: () => void
onReview: () => void
onReminders: () => void
onDiscoveries: () => void
onThemes: () => void
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,
reminderCount,
discoveryCount,
themeCount,
inboxPulse = 0,
onInbox,
onReview,
onReminders,
onDiscoveries,
onThemes,
prefersReducedMotion,
}: DashboardActionStripProps) {
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 = [
{
key: 'inbox',
icon: Inbox,
label: t('homeDashboard.inbox'),
value: inboxCount,
onClick: onInbox,
accent: inboxCount > 0,
pulse: inboxPulse > 0,
},
{
key: 'review',
icon: GraduationCap,
label: t('homeDashboard.review'),
value: dueFlashcards,
onClick: onReview,
accent: dueFlashcards > 0,
},
{
key: 'reminders',
icon: Bell,
label: t('homeDashboard.reminders'),
value: reminderCount,
onClick: onReminders,
accent: reminderCount > 0,
},
{
key: 'discoveries',
icon: Sparkles,
label: t('homeDashboard.aiFound'),
value: discoveryCount,
onClick: onDiscoveries,
accent: discoveryCount > 0,
},
{
key: 'themes',
icon: Layers,
label: t('homeDashboard.themes'),
value: themeCount,
onClick: onThemes,
accent: themeCount > 0,
},
]
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="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'
}`}>
<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>
)
}