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.
231 lines
7.5 KiB
TypeScript
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>
|
|
)
|
|
}
|