158 lines
6.3 KiB
TypeScript
158 lines
6.3 KiB
TypeScript
'use client'
|
|
|
|
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'
|
|
|
|
export function NoteTitleBlock() {
|
|
const { note, state, actions, readOnly, fullPage } = useNoteEditorContext()
|
|
const { t } = useLanguage()
|
|
const { requestAiConsent } = useAiConsent()
|
|
|
|
const titleDir = resolveTitleDirection(state.title || '', note.sourceUrl)
|
|
const titleLang = resolveTitleLang(state.title || '', note.sourceUrl)
|
|
const titleIsRtl = titleDir === 'rtl'
|
|
|
|
if (fullPage) {
|
|
// Adaptive font size: short = big editorial, long = smaller but still premium
|
|
const titleLen = (state.title || '').length
|
|
const titleSizeClass =
|
|
titleLen === 0 ? 'text-5xl md:text-6xl' :
|
|
titleLen < 40 ? 'text-5xl md:text-6xl' :
|
|
titleLen < 70 ? 'text-4xl md:text-5xl' :
|
|
titleLen < 100 ? 'text-3xl md:text-4xl' :
|
|
'text-2xl md:text-3xl'
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Title — auto-resizing textarea, adaptive size */}
|
|
<div className="group relative">
|
|
<textarea
|
|
dir={titleDir}
|
|
lang={titleLang}
|
|
rows={1}
|
|
placeholder={t('notes.titlePlaceholder') || 'Untitled…'}
|
|
value={state.title}
|
|
onChange={(e) => { actions.setTitle(e.target.value) }}
|
|
onInput={(e) => {
|
|
const el = e.currentTarget
|
|
el.style.height = 'auto'
|
|
el.style.height = el.scrollHeight + 'px'
|
|
}}
|
|
disabled={readOnly}
|
|
className={cn(
|
|
'w-full 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-[1.15] tracking-tight',
|
|
'placeholder:text-foreground/20 resize-none overflow-hidden',
|
|
titleSizeClass,
|
|
!readOnly && (titleIsRtl ? 'ps-12' : 'pe-12')
|
|
)}
|
|
style={{ height: 'auto' }}
|
|
ref={(el) => {
|
|
// Force correct initial height on mount
|
|
if (el) {
|
|
el.style.height = 'auto'
|
|
el.style.height = el.scrollHeight + 'px'
|
|
}
|
|
}}
|
|
/>
|
|
{/* 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.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="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>
|
|
)
|
|
} |