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.
194 lines
7.6 KiB
TypeScript
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>
|
|
)
|
|
} |