Files
Momento/memento-note/components/note-editor/note-title-block.tsx
Antigravity 3b2570d981
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled
chore(ci): correct Gitea runner to runs-on ubuntu-24.04 and feat(billing): implement US-3.7 billing/subscription UX
2026-05-28 21:39:08 +00:00

177 lines
7.0 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'
import { InlinePaywall } from '@/components/settings/inline-paywall'
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">
{state.quotaExceededFeature === 'auto_title' && (
<InlinePaywall
feature="auto_title"
onDismiss={() => actions.setQuotaExceededFeature(null)}
/>
)}
{/* 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.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>
)
}