feat: adicionar página de bloco de notas com salvamento no banco e confirmação ao sair

This commit is contained in:
Gabriel Menezes 2026-11-12 16:25:48 -04:00
parent 2ca14c8421
commit 51b70e567a
17 changed files with 1360 additions and 0 deletions

View file

@ -0,0 +1,62 @@
package net.beetechgroup.beetask.entities;
import java.time.LocalDateTime;
public class UserNote {
private Long id;
private Long userId;
private String content;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public UserNote() {
}
public UserNote(Long id, Long userId, String content, LocalDateTime createdAt, LocalDateTime updatedAt) {
this.id = id;
this.userId = userId;
this.content = content;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}

View file

@ -106,4 +106,20 @@ public class UserUseCaseConfig {
) { ) {
return new net.beetechgroup.beetask.usecase.user.signature.DeleteUserSignatureUseCase(userRepository, userSignatureRepository, storageRepository); return new net.beetechgroup.beetask.usecase.user.signature.DeleteUserSignatureUseCase(userRepository, userSignatureRepository, storageRepository);
} }
@Produces
public net.beetechgroup.beetask.usecase.user.note.GetUserNoteUseCase getUserNoteUseCase(
UserRepository userRepository,
net.beetechgroup.beetask.usecase.repository.UserNoteRepository userNoteRepository
) {
return new net.beetechgroup.beetask.usecase.user.note.GetUserNoteUseCase(userRepository, userNoteRepository);
}
@Produces
public net.beetechgroup.beetask.usecase.user.note.SaveUserNoteUseCase saveUserNoteUseCase(
UserRepository userRepository,
net.beetechgroup.beetask.usecase.repository.UserNoteRepository userNoteRepository
) {
return new net.beetechgroup.beetask.usecase.user.note.SaveUserNoteUseCase(userRepository, userNoteRepository);
}
} }

View file

@ -0,0 +1,77 @@
package net.beetechgroup.beetask.frameworks.persistence.entities;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "user_notes")
public class UserNoteEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false, unique = true)
private UserEntity user;
@Column(name = "content", columnDefinition = "TEXT", nullable = false)
private String content;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt = LocalDateTime.now();
public UserNoteEntity() {
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public UserEntity getUser() {
return user;
}
public void setUser(UserEntity user) {
this.user = user;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}

View file

@ -0,0 +1,34 @@
package net.beetechgroup.beetask.frameworks.persistence.mapper;
import net.beetechgroup.beetask.entities.UserNote;
import net.beetechgroup.beetask.frameworks.persistence.entities.UserEntity;
import net.beetechgroup.beetask.frameworks.persistence.entities.UserNoteEntity;
public class UserNoteEntityMapper {
public static UserNote toDomain(UserNoteEntity entity) {
if (entity == null) return null;
return new UserNote(
entity.getId(),
entity.getUser() != null ? entity.getUser().getId() : null,
entity.getContent(),
entity.getCreatedAt(),
entity.getUpdatedAt()
);
}
public static UserNoteEntity toEntity(UserNote domain, UserEntity userEntity) {
if (domain == null) return null;
UserNoteEntity entity = new UserNoteEntity();
entity.setId(domain.getId());
entity.setUser(userEntity);
entity.setContent(domain.getContent() != null ? domain.getContent() : "");
if (domain.getCreatedAt() != null) {
entity.setCreatedAt(domain.getCreatedAt());
}
if (domain.getUpdatedAt() != null) {
entity.setUpdatedAt(domain.getUpdatedAt());
}
return entity;
}
}

View file

@ -0,0 +1,53 @@
package net.beetechgroup.beetask.frameworks.persistence.repository;
import io.quarkus.hibernate.orm.panache.PanacheRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import jakarta.transaction.Transactional;
import net.beetechgroup.beetask.entities.UserNote;
import net.beetechgroup.beetask.frameworks.persistence.entities.UserEntity;
import net.beetechgroup.beetask.frameworks.persistence.entities.UserNoteEntity;
import net.beetechgroup.beetask.frameworks.persistence.mapper.UserNoteEntityMapper;
import net.beetechgroup.beetask.usecase.repository.UserNoteRepository;
import java.time.LocalDateTime;
import java.util.Optional;
@ApplicationScoped
public class UserNoteRepositoryImpl implements UserNoteRepository, PanacheRepository<UserNoteEntity> {
@Inject
EntityManager em;
@Override
@Transactional
public Optional<UserNote> findByUserId(Long userId) {
return find("user.id = ?1", userId)
.firstResultOptional()
.map(UserNoteEntityMapper::toDomain);
}
@Override
@Transactional
public UserNote save(UserNote note) {
UserEntity userEntity = em.getReference(UserEntity.class, note.getUserId());
Optional<UserNoteEntity> existingOpt = find("user.id = ?1", note.getUserId()).firstResultOptional();
UserNoteEntity entity;
if (existingOpt.isPresent()) {
entity = existingOpt.get();
entity.setContent(note.getContent() != null ? note.getContent() : "");
entity.setUpdatedAt(LocalDateTime.now());
} else {
entity = UserNoteEntityMapper.toEntity(note, userEntity);
if (entity.getCreatedAt() == null) {
entity.setCreatedAt(LocalDateTime.now());
}
entity.setUpdatedAt(LocalDateTime.now());
persist(entity);
}
return UserNoteEntityMapper.toDomain(entity);
}
}

View file

@ -0,0 +1,20 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.user;
public class SaveUserNoteRequest {
private String content;
public SaveUserNoteRequest() {
}
public SaveUserNoteRequest(String content) {
this.content = content;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
}

View file

@ -0,0 +1,51 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.user;
import io.quarkus.security.Authenticated;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import net.beetechgroup.beetask.entities.UserNote;
import net.beetechgroup.beetask.usecase.user.note.GetUserNoteUseCase;
import net.beetechgroup.beetask.usecase.user.note.SaveUserNoteUseCase;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
@Path("/notes")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Authenticated
@Tag(name = "Notes", description = "Bloco de notas pessoal do usuário")
public class UserNoteController {
@Inject
SecurityIdentity securityIdentity;
@Inject
GetUserNoteUseCase getUserNoteUseCase;
@Inject
SaveUserNoteUseCase saveUserNoteUseCase;
@GET
@Operation(summary = "Obter nota do usuário autenticado")
public Response getNote() {
String email = securityIdentity.getPrincipal().getName();
UserNote note = getUserNoteUseCase.execute(email);
return Response.ok(UserNoteResponse.from(note)).build();
}
@PUT
@Operation(summary = "Salvar ou atualizar nota do usuário autenticado")
public Response saveNote(SaveUserNoteRequest request) {
String email = securityIdentity.getPrincipal().getName();
String content = request != null && request.getContent() != null ? request.getContent() : "";
UserNote saved = saveUserNoteUseCase.execute(email, content);
return Response.ok(UserNoteResponse.from(saved)).build();
}
}

View file

@ -0,0 +1,74 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.user;
import net.beetechgroup.beetask.entities.UserNote;
import java.time.LocalDateTime;
public class UserNoteResponse {
private Long id;
private Long userId;
private String content;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public UserNoteResponse() {
}
public UserNoteResponse(Long id, Long userId, String content, LocalDateTime createdAt, LocalDateTime updatedAt) {
this.id = id;
this.userId = userId;
this.content = content;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
public static UserNoteResponse from(UserNote note) {
if (note == null) return null;
return new UserNoteResponse(
note.getId(),
note.getUserId(),
note.getContent() != null ? note.getContent() : "",
note.getCreatedAt(),
note.getUpdatedAt()
);
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}

View file

@ -0,0 +1,9 @@
package net.beetechgroup.beetask.usecase.repository;
import net.beetechgroup.beetask.entities.UserNote;
import java.util.Optional;
public interface UserNoteRepository {
Optional<UserNote> findByUserId(Long userId);
UserNote save(UserNote note);
}

View file

@ -0,0 +1,26 @@
package net.beetechgroup.beetask.usecase.user.note;
import net.beetechgroup.beetask.entities.User;
import net.beetechgroup.beetask.entities.UserNote;
import net.beetechgroup.beetask.usecase.exceptions.UserNotFoundException;
import net.beetechgroup.beetask.usecase.repository.UserNoteRepository;
import net.beetechgroup.beetask.usecase.repository.UserRepository;
public class GetUserNoteUseCase {
private final UserRepository userRepository;
private final UserNoteRepository userNoteRepository;
public GetUserNoteUseCase(UserRepository userRepository, UserNoteRepository userNoteRepository) {
this.userRepository = userRepository;
this.userNoteRepository = userNoteRepository;
}
public UserNote execute(String userEmail) {
User user = userRepository.findByEmail(userEmail)
.orElseThrow(() -> new UserNotFoundException("Usuário não encontrado: " + userEmail));
return userNoteRepository.findByUserId(user.getId())
.orElseGet(() -> new UserNote(null, user.getId(), "", null, null));
}
}

View file

@ -0,0 +1,29 @@
package net.beetechgroup.beetask.usecase.user.note;
import net.beetechgroup.beetask.entities.User;
import net.beetechgroup.beetask.entities.UserNote;
import net.beetechgroup.beetask.usecase.exceptions.UserNotFoundException;
import net.beetechgroup.beetask.usecase.repository.UserNoteRepository;
import net.beetechgroup.beetask.usecase.repository.UserRepository;
public class SaveUserNoteUseCase {
private final UserRepository userRepository;
private final UserNoteRepository userNoteRepository;
public SaveUserNoteUseCase(UserRepository userRepository, UserNoteRepository userNoteRepository) {
this.userRepository = userRepository;
this.userNoteRepository = userNoteRepository;
}
public UserNote execute(String userEmail, String content) {
User user = userRepository.findByEmail(userEmail)
.orElseThrow(() -> new UserNotFoundException("Usuário não encontrado: " + userEmail));
UserNote note = new UserNote();
note.setUserId(user.getId());
note.setContent(content != null ? content : "");
return userNoteRepository.save(note);
}
}

View file

@ -0,0 +1,10 @@
CREATE TABLE user_notes (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users_entity(id) ON DELETE CASCADE,
content TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_user_notes_user_id UNIQUE (user_id)
);
CREATE INDEX idx_user_notes_user_id ON user_notes (user_id);

View file

@ -18,6 +18,7 @@ import { OrganizationSettingsPage } from './pages/OrganizationSettingsPage'
import { AnalyticsPage } from './pages/AnalyticsPage' import { AnalyticsPage } from './pages/AnalyticsPage'
import { ProfilePage } from './pages/ProfilePage' import { ProfilePage } from './pages/ProfilePage'
import { ReportsPage } from './pages/ReportsPage' import { ReportsPage } from './pages/ReportsPage'
import { NotepadPage } from './pages/NotepadPage'
export function App() { export function App() {
return ( return (
@ -51,6 +52,7 @@ export function App() {
<Route path="/members" element={<MembersPage />} /> <Route path="/members" element={<MembersPage />} />
<Route path="/organization/settings" element={<OrganizationSettingsPage />} /> <Route path="/organization/settings" element={<OrganizationSettingsPage />} />
<Route path="/reports" element={<ReportsPage />} /> <Route path="/reports" element={<ReportsPage />} />
<Route path="/notepad" element={<NotepadPage />} />
<Route path="/profile" element={<ProfilePage />} /> <Route path="/profile" element={<ProfilePage />} />
</Route> </Route>

View file

@ -22,6 +22,7 @@ import {
FileText, FileText,
Lock, Lock,
CircleDot, CircleDot,
StickyNote,
} from 'lucide-react' } from 'lucide-react'
import { Chip, ChipLabel } from '@heroui/react' import { Chip, ChipLabel } from '@heroui/react'
import { useAuth } from '../../contexts/AuthContext' import { useAuth } from '../../contexts/AuthContext'
@ -89,6 +90,7 @@ export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
{ to: '/dashboard', label: 'Dashboard', icon: <LayoutDashboard className="w-4 h-4" /> }, { to: '/dashboard', label: 'Dashboard', icon: <LayoutDashboard className="w-4 h-4" /> },
{ to: '/dashboard-v2', label: 'BI', icon: <BarChart3 className="w-4 h-4" /> }, { to: '/dashboard-v2', label: 'BI', icon: <BarChart3 className="w-4 h-4" /> },
{ to: '/tasks', label: 'Tarefas', icon: <ListTodo className="w-4 h-4" /> }, { to: '/tasks', label: 'Tarefas', icon: <ListTodo className="w-4 h-4" /> },
{ to: '/notepad', label: 'Notas', icon: <StickyNote className="w-4 h-4" /> },
], ],
}, },
{ {

View file

@ -161,3 +161,118 @@ html:not(.dark) body {
background: rgba(148, 163, 184, 0.5); background: rgba(148, 163, 184, 0.5);
} }
/* Notepad contentEditable Editor Styles */
.notepad-editor {
outline: none;
word-break: break-word;
white-space: pre-wrap;
line-height: 1.65;
color: var(--color-foreground);
}
.notepad-editor:focus {
outline: none;
}
.notepad-editor[data-empty="true"]::before {
content: attr(data-placeholder);
color: var(--color-text-muted);
pointer-events: none;
position: absolute;
opacity: 0.6;
}
.notepad-editor h1 {
font-size: 1.75rem;
font-weight: 800;
line-height: 1.25;
margin-top: 1.25rem;
margin-bottom: 0.6rem;
color: var(--color-foreground);
}
.notepad-editor h2 {
font-size: 1.4rem;
font-weight: 700;
line-height: 1.3;
margin-top: 1rem;
margin-bottom: 0.5rem;
color: var(--color-foreground);
}
.notepad-editor h3 {
font-size: 1.15rem;
font-weight: 600;
line-height: 1.35;
margin-top: 0.85rem;
margin-bottom: 0.4rem;
color: var(--color-foreground);
}
.notepad-editor p {
margin-bottom: 0.5rem;
}
.notepad-editor ul {
list-style-type: disc;
padding-left: 1.5rem;
margin-bottom: 0.75rem;
}
.notepad-editor ol {
list-style-type: decimal;
padding-left: 1.5rem;
margin-bottom: 0.75rem;
}
.notepad-editor li {
margin-bottom: 0.25rem;
}
.notepad-editor blockquote {
border-left: 4px solid var(--color-brand);
padding-left: 1rem;
margin: 0.75rem 0;
font-style: italic;
color: var(--color-text-muted);
}
.notepad-editor pre {
background-color: var(--color-surface-sunken);
border: 1px solid var(--color-border-soft);
padding: 0.75rem 1rem;
border-radius: 0.5rem;
font-family: var(--font-mono);
font-size: 0.875rem;
margin: 0.75rem 0;
overflow-x: auto;
white-space: pre-wrap;
}
.notepad-editor code {
background-color: var(--color-surface-sunken);
border: 1px solid var(--color-border-soft);
padding: 0.15rem 0.35rem;
border-radius: 0.25rem;
font-family: var(--font-mono);
font-size: 0.875rem;
}
.notepad-editor hr {
border: 0;
border-top: 1px solid var(--color-border-soft);
margin: 1.25rem 0;
}
.notepad-editor mark {
background-color: #fef08a;
color: #1c1917;
padding: 0.1rem 0.25rem;
border-radius: 0.2rem;
}
.dark .notepad-editor mark {
background-color: #a16207;
color: #fef08a;
}

View file

@ -0,0 +1,758 @@
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)
if (note?.updatedAt) {
setLastSavedAt(new Date(note.updatedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }))
}
if (editorRef.current) {
editorRef.current.innerHTML = content
updateStats()
}
} catch (err) {
console.error('Erro ao carregar bloco de notas:', err)
} finally {
if (mounted) setLoading(false)
}
}
fetchNote()
return () => {
mounted = false
}
}, [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)
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="h-64 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
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>
)
}

View file

@ -0,0 +1,22 @@
import { apiFetch } from '../lib/api'
export interface UserNote {
id?: number | null
userId?: number | null
content: string
createdAt?: string | null
updatedAt?: string | null
}
export const noteService = {
async getNote(): Promise<UserNote> {
return apiFetch<UserNote>('/notes')
},
async saveNote(content: string): Promise<UserNote> {
return apiFetch<UserNote>('/notes', {
method: 'PUT',
body: { content },
})
},
}