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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user