Files
Momento/memento-note/components/note-editor/note-title-block.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

194 lines
7.6 KiB
TypeScript

'use client'
import { useLayoutEffect, useRef } from 'react'
import { useNoteEditorContext } from './note-editor-context'
import { TitleSuggestions } from '@/components/title-suggestions'
import { Loader2, Sparkles } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { resolveTitleDirection, resolveTitleLang } from '@/lib/clip/rtl-content'
import { InlinePaywall } from '@/components/settings/inline-paywall'
function autosizeTitle(el: HTMLTextAreaElement | null) {
if (!el) return
el.style.height = 'auto'
el.style.height = `${el.scrollHeight}px`
}
export function NoteTitleBlock() {
const { note, state, actions, readOnly, fullPage } = useNoteEditorContext()
const { t } = useLanguage()
const { requestAiConsent } = useAiConsent()
const titleRef = useRef<HTMLTextAreaElement>(null)
const titleDir = resolveTitleDirection(state.title || '', note.sourceUrl)
const titleLang = resolveTitleLang(state.title || '', note.sourceUrl)
const titleIsRtl = titleDir === 'rtl'
useLayoutEffect(() => {
autosizeTitle(titleRef.current)
}, [state.title, fullPage])
useLayoutEffect(() => {
const el = titleRef.current
if (!el) return
const onResize = () => autosizeTitle(el)
window.addEventListener('resize', onResize)
const parent = el.parentElement
const ro = typeof ResizeObserver !== 'undefined' && parent
? new ResizeObserver(onResize)
: null
if (ro && parent) ro.observe(parent)
return () => {
window.removeEventListener('resize', onResize)
ro?.disconnect()
}
}, [fullPage])
if (fullPage) {
// Adaptive font size: short = big editorial, long = smaller but still premium
const titleLen = (state.title || '').length
const titleSizeClass =
titleLen === 0 ? 'text-3xl sm:text-5xl md:text-6xl' :
titleLen < 40 ? 'text-3xl sm:text-5xl md:text-6xl' :
titleLen < 70 ? 'text-2xl sm:text-4xl md:text-5xl' :
titleLen < 100 ? 'text-xl sm:text-3xl md:text-4xl' :
'text-xl sm:text-2xl md:text-3xl'
return (
<div className="space-y-4 min-w-0">
{state.quotaExceededFeature === 'auto_title' && (
<InlinePaywall
feature="auto_title"
onDismiss={() => actions.setQuotaExceededFeature(null)}
/>
)}
{/* Title — auto-resizing textarea, adaptive size */}
<div className="group relative min-w-0">
<textarea
ref={titleRef}
dir={titleDir}
lang={titleLang}
rows={1}
placeholder={t('notes.titlePlaceholder') || 'Untitled…'}
value={state.title}
onChange={(e) => { actions.setTitle(e.target.value) }}
onInput={(e) => autosizeTitle(e.currentTarget)}
disabled={readOnly}
className={cn(
'w-full min-w-0 font-bold border-0 outline-none px-0 bg-transparent text-foreground',
titleIsRtl
? 'font-[family-name:var(--font-sans)] text-right'
: 'font-memento-serif text-left',
'leading-snug tracking-tight break-words [overflow-wrap:anywhere] whitespace-pre-wrap',
'placeholder:text-foreground/20 resize-none overflow-hidden',
titleSizeClass,
!readOnly && (titleIsRtl ? 'ps-12' : 'pe-12')
)}
/>
{/* AI title generation — visible on hover */}
{!readOnly && (
<button
type="button"
onClick={async () => {
const plain = state.content.replace(/<[^>]+>/g, ' ').trim()
const wordCount = plain.split(/\s+/).filter(Boolean).length
if (wordCount < 10) {
toast.error(t('ai.titleGenerationMinWords', { count: wordCount }))
return
}
const consented = await requestAiConsent()
if (!consented) return
actions.setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/title-suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: plain }),
})
if (res.status === 402) {
actions.setQuotaExceededFeature('auto_title')
return
}
if (res.ok) {
const data = await res.json()
const s = data.suggestions?.[0]?.title ?? ''
if (s) {
actions.setTitle(s)
toast.success(t('ai.titleApplied'))
} else {
toast.error(t('ai.titleGenerationFailed'))
}
} else {
toast.error(t('ai.titleGenerationError'))
}
} catch (e) {
toast.error(t('ai.networkErrorShort'))
} finally { actions.setIsProcessingAI(false) }
}}
disabled={state.isProcessingAI}
className={cn(
'absolute top-2 opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity rounded-lg p-2 text-foreground/50 hover:bg-black/5',
titleIsRtl ? 'left-0' : 'right-0',
)}
title={t('ai.generateTitlesTooltip')}
>
{state.isProcessingAI ? <Loader2 className="h-5 w-5 animate-spin" /> : <Sparkles className="h-5 w-5" />}
</button>
)}
</div>
{/* Auto title suggestions */}
{!state.title && !state.dismissedTitleSuggestions && state.titleSuggestions.length > 0 && (
<TitleSuggestions
suggestions={state.titleSuggestions}
onSelect={(s: string) => { actions.setTitle(s); actions.setDismissedTitleSuggestions(true) }}
onDismiss={() => actions.setDismissedTitleSuggestions(true)}
/>
)}
</div>
)
}
// Dialog mode title block
return (
<div className="space-y-2">
<div className="relative">
<input
dir={titleDir}
lang={titleLang}
placeholder={t('notes.titlePlaceholder')}
value={state.title}
onChange={(e) => actions.setTitle(e.target.value)}
disabled={readOnly}
className={cn(
'w-full text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent',
titleIsRtl ? 'text-right font-[family-name:var(--font-sans)] ps-10' : 'text-left pe-10',
readOnly && 'cursor-default',
)}
/>
<button
onClick={actions.handleGenerateTitles}
disabled={state.isGeneratingTitles || readOnly}
className="absolute right-0 top-1/2 -translate-y-1/2 p-1 hover:bg-purple-100 dark:hover:bg-purple-900 rounded transition-colors"
title={state.isGeneratingTitles ? t('ai.titleGenerating') : t('ai.titleGenerateWithAI')}
>
{state.isGeneratingTitles ? (
<div className="w-4 h-4 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
) : (
<Sparkles className="w-4 h-4 text-purple-600 hover:text-purple-700 dark:text-purple-400" />
)}
</button>
</div>
{state.quotaExceededFeature === 'auto_title' && (
<InlinePaywall
feature="auto_title"
onDismiss={() => actions.setQuotaExceededFeature(null)}
/>
)}
</div>
)
}