beetask/frontend/src/pages/NotepadPage.tsx

766 lines
28 KiB
TypeScript

import React, { useState, useEffect, useRef, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import {
StickyNote,
Save,
Check,
AlertTriangle,
Bold,
Italic,
Underline,
Strikethrough,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Code,
Minus,
Highlighter,
AlignLeft,
AlignCenter,
AlignRight,
RemoveFormatting,
Undo,
Redo,
Copy,
Trash2,
Loader2,
X,
} from 'lucide-react'
import { noteService, type UserNote } from '../services/noteService'
export const NotepadPage: React.FC = () => {
const navigate = useNavigate()
const editorRef = useRef<HTMLDivElement>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [initialContent, setInitialContent] = useState('')
const [isDirty, setIsDirty] = useState(false)
const [lastSavedAt, setLastSavedAt] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [stats, setStats] = useState({ words: 0, characters: 0 })
// Leave warning modal state
const [showExitModal, setShowExitModal] = useState(false)
const [pendingNavigationUrl, setPendingNavigationUrl] = useState<string | null>(null)
const [showClearConfirm, setShowClearConfirm] = useState(false)
// Ref to always have the latest isDirty & editor content in callbacks
const isDirtyRef = useRef(isDirty)
isDirtyRef.current = isDirty
const initialContentRef = useRef(initialContent)
initialContentRef.current = initialContent
// Calculate stats from plain text
const updateStats = useCallback(() => {
if (!editorRef.current) return
const text = editorRef.current.innerText || ''
const cleanText = text.trim()
const words = cleanText ? cleanText.split(/\s+/).filter(Boolean).length : 0
const characters = text.length
setStats({ words, characters })
}, [])
// Load note from backend
useEffect(() => {
let mounted = true
const fetchNote = async () => {
try {
setLoading(true)
const note: UserNote = await noteService.getNote()
if (!mounted) return
const content = note?.content || ''
setInitialContent(content)
initialContentRef.current = content
if (note?.updatedAt) {
setLastSavedAt(new Date(note.updatedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }))
}
} catch (err) {
console.error('Erro ao carregar bloco de notas:', err)
} finally {
if (mounted) setLoading(false)
}
}
fetchNote()
return () => {
mounted = false
}
}, [])
// Sync loaded content into the editor DOM
useEffect(() => {
if (editorRef.current && !loading) {
editorRef.current.innerHTML = initialContent
const text = editorRef.current.innerText || ''
const isEmpty = text.trim() === '' && !editorRef.current.querySelector('img, hr')
editorRef.current.setAttribute('data-empty', isEmpty ? 'true' : 'false')
updateStats()
}
}, [loading, initialContent, updateStats])
// Handle editor input
const handleInput = () => {
if (!editorRef.current) return
const currentHtml = editorRef.current.innerHTML
// Check if HTML represents empty (e.g. <br>, <p><br></p>, etc.)
const text = editorRef.current.innerText || ''
const isEmpty = text.trim() === '' && !editorRef.current.querySelector('img, hr')
editorRef.current.setAttribute('data-empty', isEmpty ? 'true' : 'false')
const dirty = currentHtml !== initialContentRef.current
setIsDirty(dirty)
updateStats()
}
// Formatting commands via document.execCommand
const execCmd = (command: string, value: string | undefined = undefined) => {
if (!editorRef.current) return
editorRef.current.focus()
document.execCommand(command, false, value)
handleInput()
}
// Custom code block insertion
const handleFormatCodeBlock = () => {
if (!editorRef.current) return
editorRef.current.focus()
const selection = window.getSelection()
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
const selectedText = range.toString() || 'Código aqui...'
const pre = document.createElement('pre')
const code = document.createElement('code')
code.textContent = selectedText
pre.appendChild(code)
range.deleteContents()
range.insertNode(pre)
// Move cursor after pre
const newRange = document.createRange()
newRange.setStartAfter(pre)
newRange.collapse(true)
selection.removeAllRanges()
selection.addRange(newRange)
handleInput()
}
}
// Save note to database
const handleSave = async (showFeedback = true): Promise<boolean> => {
if (!editorRef.current) return false
const content = editorRef.current.innerHTML
try {
setSaving(true)
const saved = await noteService.saveNote(content)
setInitialContent(content)
initialContentRef.current = content
setIsDirty(false)
const timeStr = saved.updatedAt
? new Date(saved.updatedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
setLastSavedAt(timeStr)
return true
} catch (err) {
console.error('Falha ao salvar o bloco de notas:', err)
if (showFeedback) {
alert('Ocorreu um erro ao salvar o bloco de notas. Tente novamente.')
}
return false
} finally {
setSaving(false)
}
}
// Keyboard shortcut Ctrl+S / Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
handleSave()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
// 1. Browser unload / reload warning
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (isDirtyRef.current) {
e.preventDefault()
e.returnValue = ''
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [])
// 2. Browser Back / Forward warning (popstate)
useEffect(() => {
if (!isDirty) return
window.history.pushState(null, '', window.location.pathname)
const handlePopState = () => {
if (isDirtyRef.current) {
const confirmLeave = window.confirm(
'Você possui alterações não salvas no bloco de notas. Deseja realmente sair sem salvar?'
)
if (!confirmLeave) {
window.history.pushState(null, '', window.location.pathname)
} else {
setIsDirty(false)
window.history.back()
}
}
}
window.addEventListener('popstate', handlePopState)
return () => window.removeEventListener('popstate', handlePopState)
}, [isDirty])
// 3. In-App Navigation Interception
useEffect(() => {
const handleDocumentClick = (e: MouseEvent) => {
if (!isDirtyRef.current) return
const target = e.target as HTMLElement | null
const anchor = target?.closest('a')
if (!anchor) return
const href = anchor.getAttribute('href')
if (href && !href.startsWith('#') && href !== window.location.pathname) {
e.preventDefault()
e.stopPropagation()
setPendingNavigationUrl(href)
setShowExitModal(true)
}
}
// Capture phase intercepts before React Router
document.addEventListener('click', handleDocumentClick, true)
return () => {
document.removeEventListener('click', handleDocumentClick, true)
}
}, [])
// Exit Modal Actions
const handleStayOnPage = () => {
setShowExitModal(false)
setPendingNavigationUrl(null)
}
const handleDiscardAndExit = () => {
setIsDirty(false)
setShowExitModal(false)
if (pendingNavigationUrl) {
navigate(pendingNavigationUrl)
}
}
const handleSaveAndExit = async () => {
const success = await handleSave(false)
if (success) {
setShowExitModal(false)
if (pendingNavigationUrl) {
navigate(pendingNavigationUrl)
}
}
}
// Copy plain text
const handleCopyText = async () => {
if (!editorRef.current) return
const text = editorRef.current.innerText || ''
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// Fallback
}
}
// Clear note
const handleClear = () => {
if (editorRef.current) {
editorRef.current.innerHTML = ''
handleInput()
}
setShowClearConfirm(false)
}
return (
<div className="flex flex-col gap-5 min-h-[calc(100vh-6rem)]">
{/* Top Header Card */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 p-4 sm:p-5 rounded-2xl bg-surface border border-border-soft shadow-xs">
<div className="flex items-center gap-3.5">
<div className="w-11 h-11 rounded-2xl bg-gradient-to-br from-amber-500/20 via-brand/20 to-accent/20 border border-amber-500/30 flex items-center justify-center text-amber-500 shrink-0 shadow-xs">
<StickyNote className="w-6 h-6" />
</div>
<div>
<div className="flex items-center gap-2.5">
<h1 className="text-xl sm:text-2xl font-extrabold tracking-tight text-foreground">
Bloco de Notas
</h1>
{isDirty ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30 animate-pulse">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500" />
Não salvo
</span>
) : lastSavedAt ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30">
<Check className="w-3 h-3" />
Salvo às {lastSavedAt}
</span>
) : null}
</div>
<p className="text-xs text-muted mt-0.5">
Seu espaço livre para rascunhos, tarefas rápidas e anotações formatadas.
</p>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2 self-end sm:self-auto shrink-0">
<button
type="button"
onClick={handleCopyText}
title="Copiar texto puro"
className="flex items-center gap-1.5 px-3 py-2 rounded-xl border border-border-soft bg-surface-raised hover:bg-surface-sunken text-xs font-semibold text-foreground transition-all cursor-pointer"
>
{copied ? (
<>
<Check className="w-3.5 h-3.5 text-emerald-500" />
<span className="text-emerald-500">Copiado!</span>
</>
) : (
<>
<Copy className="w-3.5 h-3.5 text-muted" />
<span className="hidden sm:inline">Copiar</span>
</>
)}
</button>
<button
type="button"
onClick={() => setShowClearConfirm(true)}
title="Limpar bloco de notas"
className="p-2 rounded-xl border border-border-soft bg-surface-raised hover:bg-rose-500/10 text-muted hover:text-rose-500 transition-all cursor-pointer"
>
<Trash2 className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleSave()}
disabled={saving}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all shadow-md cursor-pointer ${
isDirty
? 'bg-brand hover:bg-brand-hover text-white shadow-brand/20 ring-2 ring-brand/30'
: 'bg-brand/90 hover:bg-brand text-white shadow-brand/10'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{saving ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>Salvando...</span>
</>
) : (
<>
<Save className="w-4 h-4" />
<span>Salvar</span>
<span className="hidden md:inline-block text-[10px] bg-white/20 px-1.5 py-0.2 rounded font-mono">
Ctrl+S
</span>
</>
)}
</button>
</div>
</div>
{/* Editor Main Card */}
<div className="flex-1 flex flex-col rounded-2xl bg-surface border border-border-soft shadow-xs overflow-hidden">
{/* Formatting Toolbar */}
<div className="flex flex-wrap items-center gap-1 p-2 sm:p-2.5 bg-surface-raised border-b border-border-soft select-none">
{/* Headings */}
<div className="flex items-center gap-0.5 pr-1.5 border-r border-border-soft">
<button
type="button"
onMouseDown={(e) => {
e.preventDefault()
execCmd('formatBlock', '<p>')
}}
className="p-1.5 rounded-lg hover:bg-surface-sunken text-muted hover:text-foreground text-xs font-semibold px-2 cursor-pointer"
title="Texto Normal"
>
Normal
</button>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault()
execCmd('formatBlock', '<h1>')
}}
className="p-1.5 rounded-lg hover:bg-surface-sunken text-muted hover:text-foreground cursor-pointer"
title="Título 1 (H1)"
>
<Heading1 className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault()
execCmd('formatBlock', '<h2>')
}}
className="p-1.5 rounded-lg hover:bg-surface-sunken text-muted hover:text-foreground cursor-pointer"
title="Título 2 (H2)"
>
<Heading2 className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault()
execCmd('formatBlock', '<h3>')
}}
className="p-1.5 rounded-lg hover:bg-surface-sunken text-muted hover:text-foreground cursor-pointer"
title="Título 3 (H3)"
>
<Heading3 className="w-4 h-4" />
</button>
</div>
{/* Inline Styles */}
<div className="flex items-center gap-0.5 px-1.5 border-r border-border-soft">
<button
type="button"
onMouseDown={(e) => {
e.preventDefault()
execCmd('bold')
}}
className="p-1.5 rounded-lg hover:bg-surface-sunken text-muted hover:text-foreground cursor-pointer"
title="Negrito (Ctrl+B)"
>
<Bold className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault()
execCmd('italic')
}}
className="p-1.5 rounded-lg hover:bg-surface-sunken text-muted hover:text-foreground cursor-pointer"
title="Itálico (Ctrl+I)"
>
<Italic className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault()
execCmd('underline')
}}
className="p-1.5 rounded-lg hover:bg-surface-sunken text-muted hover:text-foreground cursor-pointer"
title="Sublinhado (Ctrl+U)"
>
<Underline className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault()
execCmd('strikeThrough')
}}
className="p-1.5 rounded-lg hover:bg-surface-sunken text-muted hover:text-foreground cursor-pointer"
title="Tachado"
>
<Strikethrough className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault()
execCmd('hiliteColor', '#fef08a')
}}
className="p-1.5 rounded-lg hover:bg-surface-sunken text-muted hover:text-amber-500 cursor-pointer"
title="Destacar com marca-texto"
>
<Highlighter className="w-4 h-4" />
</button>
</div>
{/* Lists */}
<div className="flex items-center gap-0.5 px-1.5 border-r border-border-soft">
<button
type="button"
onMouseDown={(e) => {
e.preventDefault()
execCmd('insertUnorderedList')
}}
className="p-1.5 rounded-lg hover:bg-surface-sunken text-muted hover:text-foreground cursor-pointer"
title="Lista com marcadores"
>
<List className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault()
execCmd('insertOrderedList')
}}
className="p-1.5 rounded-lg hover:bg-surface-sunken text-muted hover:text-foreground cursor-pointer"
title="Lista numerada"
>
<ListOrdered className="w-4 h-4" />
</button>
</div>
{/* Blocks */}
<div className="flex items-center gap-0.5 px-1.5 border-r border-border-soft">
<button
type="button"
onMouseDown={(e) => {
e.preventDefault()
execCmd('formatBlock', '<blockquote>')
}}
className="p-1.5 rounded-lg hover:bg-surface-sunken text-muted hover:text-foreground cursor-pointer"
title="Citação"
>
<Quote className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault()
handleFormatCodeBlock()
}}
className="p-1.5 rounded-lg hover:bg-surface-sunken text-muted hover:text-foreground cursor-pointer"
title="Bloco de código"
>
<Code className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault()
execCmd('insertHorizontalRule')
}}
className="p-1.5 rounded-lg hover:bg-surface-sunken text-muted hover:text-foreground cursor-pointer"
title="Linha horizontal"
>
<Minus className="w-4 h-4" />
</button>
</div>
{/* Alignments */}
<div className="flex items-center gap-0.5 px-1.5 border-r border-border-soft">
<button
type="button"
onMouseDown={(e) => {
e.preventDefault()
execCmd('justifyLeft')
}}
className="p-1.5 rounded-lg hover:bg-surface-sunken text-muted hover:text-foreground cursor-pointer"
title="Alinhar à esquerda"
>
<AlignLeft className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault()
execCmd('justifyCenter')
}}
className="p-1.5 rounded-lg hover:bg-surface-sunken text-muted hover:text-foreground cursor-pointer"
title="Centralizar"
>
<AlignCenter className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault()
execCmd('justifyRight')
}}
className="p-1.5 rounded-lg hover:bg-surface-sunken text-muted hover:text-foreground cursor-pointer"
title="Alinhar à direita"
>
<AlignRight className="w-4 h-4" />
</button>
</div>
{/* Utility Tools */}
<div className="flex items-center gap-0.5 pl-1.5">
<button
type="button"
onMouseDown={(e) => {
e.preventDefault()
execCmd('removeFormat')
}}
className="p-1.5 rounded-lg hover:bg-surface-sunken text-muted hover:text-foreground cursor-pointer"
title="Limpar formatação"
>
<RemoveFormatting className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault()
execCmd('undo')
}}
className="p-1.5 rounded-lg hover:bg-surface-sunken text-muted hover:text-foreground cursor-pointer"
title="Desfazer (Ctrl+Z)"
>
<Undo className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault()
execCmd('redo')
}}
className="p-1.5 rounded-lg hover:bg-surface-sunken text-muted hover:text-foreground cursor-pointer"
title="Refazer (Ctrl+Y)"
>
<Redo className="w-4 h-4" />
</button>
</div>
</div>
{/* Editor Area */}
<div className="flex-1 p-5 sm:p-7 relative overflow-y-auto cursor-text">
{loading && (
<div className="absolute inset-0 bg-surface/80 backdrop-blur-xs z-10 flex flex-col items-center justify-center gap-3 text-muted">
<Loader2 className="w-6 h-6 animate-spin text-brand" />
<p className="text-xs font-semibold">Carregando suas anotações...</p>
</div>
)}
<div
ref={editorRef}
contentEditable={!loading}
suppressContentEditableWarning
onInput={handleInput}
data-placeholder="Escreva suas anotações aqui... Você pode usar a barra de ferramentas para formatar com títulos, negrito, listas e códigos."
className="notepad-editor min-h-[460px] text-base focus:outline-none"
/>
</div>
{/* Footer Statistics Bar */}
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-2.5 bg-surface-raised border-t border-border-soft text-xs text-muted">
<div className="flex items-center gap-3">
<span>{stats.words} palavras</span>
<span>•</span>
<span>{stats.characters} caracteres</span>
{isDirty && (
<>
<span>•</span>
<span className="text-amber-500 font-medium">Modificações pendentes de salvamento</span>
</>
)}
</div>
<div className="flex items-center gap-2 text-[11px]">
<span>Pressione <kbd className="px-1.5 py-0.5 rounded bg-surface-sunken border border-border-soft font-mono">Ctrl+S</kbd> para salvar a qualquer momento</span>
</div>
</div>
</div>
{/* Unsaved Changes Confirmation Modal */}
{showExitModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="fixed inset-0 bg-black/70 backdrop-blur-xs transition-opacity"
onClick={handleStayOnPage}
/>
<div className="relative w-full max-w-md rounded-2xl border border-border bg-surface-raised shadow-2xl p-5 sm:p-6 z-10 animate-in fade-in zoom-in-95 duration-150">
<div className="flex items-start gap-3.5">
<div className="w-10 h-10 rounded-xl bg-amber-500/15 border border-amber-500/30 text-amber-500 flex items-center justify-center shrink-0">
<AlertTriangle className="w-5 h-5" />
</div>
<div className="flex-1">
<h3 className="text-base font-bold text-foreground">
Alterações não salvas
</h3>
<p className="text-xs text-muted mt-1.5 leading-relaxed">
Você alterou o texto do bloco de notas e ainda não salvou. Se sair agora, as novas modificações serão perdidas. Deseja sair sem salvar?
</p>
</div>
</div>
<div className="mt-6 flex flex-col-reverse sm:flex-row items-center justify-end gap-2.5">
<button
type="button"
onClick={handleStayOnPage}
className="w-full sm:w-auto px-4 py-2 rounded-xl border border-border-soft bg-surface hover:bg-surface-sunken text-xs font-semibold text-foreground transition-colors cursor-pointer"
>
Continuar Editando
</button>
<button
type="button"
onClick={handleDiscardAndExit}
className="w-full sm:w-auto px-4 py-2 rounded-xl bg-rose-500/10 hover:bg-rose-500/20 text-rose-500 border border-rose-500/20 text-xs font-semibold transition-colors cursor-pointer"
>
Sair sem Salvar
</button>
<button
type="button"
onClick={handleSaveAndExit}
className="w-full sm:w-auto px-4 py-2 rounded-xl bg-brand hover:bg-brand-hover text-white text-xs font-bold transition-colors cursor-pointer shadow-sm"
>
Salvar e Sair
</button>
</div>
</div>
</div>
)}
{/* Clear Note Confirmation Modal */}
{showClearConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="fixed inset-0 bg-black/70 backdrop-blur-xs transition-opacity"
onClick={() => setShowClearConfirm(false)}
/>
<div className="relative w-full max-w-sm rounded-2xl border border-border bg-surface-raised shadow-2xl p-5 z-10 animate-in fade-in zoom-in-95 duration-150">
<div className="flex items-center justify-between pb-3 border-b border-border-soft">
<h3 className="text-sm font-bold text-foreground flex items-center gap-2">
<Trash2 className="w-4 h-4 text-rose-500" />
Limpar Bloco de Notas
</h3>
<button
type="button"
onClick={() => setShowClearConfirm(false)}
className="p-1 rounded-lg text-muted hover:text-foreground cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
<p className="text-xs text-muted my-4">
Tem certeza de que deseja apagar todo o conteúdo deste bloco de notas? Esta ação não poderá ser desfeita caso você salve em seguida.
</p>
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => setShowClearConfirm(false)}
className="px-3 py-1.5 rounded-xl border border-border-soft bg-surface hover:bg-surface-sunken text-xs font-semibold text-foreground cursor-pointer"
>
Cancelar
</button>
<button
type="button"
onClick={handleClear}
className="px-3 py-1.5 rounded-xl bg-rose-500 hover:bg-rose-600 text-white text-xs font-bold cursor-pointer"
>
Sim, Limpar
</button>
</div>
</div>
</div>
)}
</div>
)
}