From 51b70e567aabc03ea6000243f824cee2e8ce0cab Mon Sep 17 00:00:00 2001 From: Gabriel Menezes Date: Thu, 12 Nov 2026 16:25:48 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20adicionar=20p=C3=A1gina=20de=20bloco=20?= =?UTF-8?q?de=20notas=20com=20salvamento=20no=20banco=20e=20confirma=C3=A7?= =?UTF-8?q?=C3=A3o=20ao=20sair?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../beetask/entities/UserNote.java | 62 ++ .../frameworks/config/UserUseCaseConfig.java | 16 + .../persistence/entities/UserNoteEntity.java | 77 ++ .../mapper/UserNoteEntityMapper.java | 34 + .../repository/UserNoteRepositoryImpl.java | 53 ++ .../controllers/user/SaveUserNoteRequest.java | 20 + .../controllers/user/UserNoteController.java | 51 ++ .../controllers/user/UserNoteResponse.java | 74 ++ .../repository/UserNoteRepository.java | 9 + .../usecase/user/note/GetUserNoteUseCase.java | 26 + .../user/note/SaveUserNoteUseCase.java | 29 + .../V19__create_user_notes_table.sql | 10 + frontend/src/App.tsx | 2 + frontend/src/components/layout/Sidebar.tsx | 2 + frontend/src/index.css | 115 +++ frontend/src/pages/NotepadPage.tsx | 758 ++++++++++++++++++ frontend/src/services/noteService.ts | 22 + 17 files changed, 1360 insertions(+) create mode 100644 backend/src/main/java/net/beetechgroup/beetask/entities/UserNote.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/UserNoteEntity.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/UserNoteEntityMapper.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/UserNoteRepositoryImpl.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/user/SaveUserNoteRequest.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/user/UserNoteController.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/user/UserNoteResponse.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/usecase/repository/UserNoteRepository.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/usecase/user/note/GetUserNoteUseCase.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/usecase/user/note/SaveUserNoteUseCase.java create mode 100644 backend/src/main/resources/db/migration/V19__create_user_notes_table.sql create mode 100644 frontend/src/pages/NotepadPage.tsx create mode 100644 frontend/src/services/noteService.ts diff --git a/backend/src/main/java/net/beetechgroup/beetask/entities/UserNote.java b/backend/src/main/java/net/beetechgroup/beetask/entities/UserNote.java new file mode 100644 index 0000000..ad49dc5 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/entities/UserNote.java @@ -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; + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/UserUseCaseConfig.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/UserUseCaseConfig.java index bfbe09b..adb810f 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/UserUseCaseConfig.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/UserUseCaseConfig.java @@ -106,4 +106,20 @@ public class UserUseCaseConfig { ) { 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); + } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/UserNoteEntity.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/UserNoteEntity.java new file mode 100644 index 0000000..20413c5 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/UserNoteEntity.java @@ -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; + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/UserNoteEntityMapper.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/UserNoteEntityMapper.java new file mode 100644 index 0000000..383785e --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/UserNoteEntityMapper.java @@ -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; + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/UserNoteRepositoryImpl.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/UserNoteRepositoryImpl.java new file mode 100644 index 0000000..8919f9f --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/UserNoteRepositoryImpl.java @@ -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 { + + @Inject + EntityManager em; + + @Override + @Transactional + public Optional 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 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); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/user/SaveUserNoteRequest.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/user/SaveUserNoteRequest.java new file mode 100644 index 0000000..4c3707b --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/user/SaveUserNoteRequest.java @@ -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; + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/user/UserNoteController.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/user/UserNoteController.java new file mode 100644 index 0000000..cd21c0b --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/user/UserNoteController.java @@ -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(); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/user/UserNoteResponse.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/user/UserNoteResponse.java new file mode 100644 index 0000000..e0abf1c --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/user/UserNoteResponse.java @@ -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; + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/UserNoteRepository.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/UserNoteRepository.java new file mode 100644 index 0000000..ad01987 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/UserNoteRepository.java @@ -0,0 +1,9 @@ +package net.beetechgroup.beetask.usecase.repository; + +import net.beetechgroup.beetask.entities.UserNote; +import java.util.Optional; + +public interface UserNoteRepository { + Optional findByUserId(Long userId); + UserNote save(UserNote note); +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/user/note/GetUserNoteUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/note/GetUserNoteUseCase.java new file mode 100644 index 0000000..13bf066 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/note/GetUserNoteUseCase.java @@ -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)); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/user/note/SaveUserNoteUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/note/SaveUserNoteUseCase.java new file mode 100644 index 0000000..ee6a76f --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/note/SaveUserNoteUseCase.java @@ -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); + } +} diff --git a/backend/src/main/resources/db/migration/V19__create_user_notes_table.sql b/backend/src/main/resources/db/migration/V19__create_user_notes_table.sql new file mode 100644 index 0000000..7d9d2d0 --- /dev/null +++ b/backend/src/main/resources/db/migration/V19__create_user_notes_table.sql @@ -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); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f4850f3..5d8be77 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,7 @@ import { OrganizationSettingsPage } from './pages/OrganizationSettingsPage' import { AnalyticsPage } from './pages/AnalyticsPage' import { ProfilePage } from './pages/ProfilePage' import { ReportsPage } from './pages/ReportsPage' +import { NotepadPage } from './pages/NotepadPage' export function App() { return ( @@ -51,6 +52,7 @@ export function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 459c15c..2610a46 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -22,6 +22,7 @@ import { FileText, Lock, CircleDot, + StickyNote, } from 'lucide-react' import { Chip, ChipLabel } from '@heroui/react' import { useAuth } from '../../contexts/AuthContext' @@ -89,6 +90,7 @@ export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => { { to: '/dashboard', label: 'Dashboard', icon: }, { to: '/dashboard-v2', label: 'BI', icon: }, { to: '/tasks', label: 'Tarefas', icon: }, + { to: '/notepad', label: 'Notas', icon: }, ], }, { diff --git a/frontend/src/index.css b/frontend/src/index.css index 65082eb..7b2791a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -161,3 +161,118 @@ html:not(.dark) body { 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; +} + diff --git a/frontend/src/pages/NotepadPage.tsx b/frontend/src/pages/NotepadPage.tsx new file mode 100644 index 0000000..00d8f18 --- /dev/null +++ b/frontend/src/pages/NotepadPage.tsx @@ -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(null) + + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [initialContent, setInitialContent] = useState('') + const [isDirty, setIsDirty] = useState(false) + const [lastSavedAt, setLastSavedAt] = useState(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(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.
,


, 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 => { + 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 ( +
+ {/* Top Header Card */} +
+
+
+ +
+
+
+

+ Bloco de Notas +

+ {isDirty ? ( + + + Não salvo + + ) : lastSavedAt ? ( + + + Salvo às {lastSavedAt} + + ) : null} +
+

+ Seu espaço livre para rascunhos, tarefas rápidas e anotações formatadas. +

+
+
+ + {/* Action Buttons */} +
+ + + + + +
+
+ + {/* Editor Main Card */} +
+ {/* Formatting Toolbar */} +
+ {/* Headings */} +
+ + + + +
+ + {/* Inline Styles */} +
+ + + + + +
+ + {/* Lists */} +
+ + +
+ + {/* Blocks */} +
+ + + +
+ + {/* Alignments */} +
+ + + +
+ + {/* Utility Tools */} +
+ + + +
+
+ + {/* Editor Area */} +
+ {loading ? ( +
+ +

Carregando suas anotações...

+
+ ) : ( +
+ )} +
+ + {/* Footer Statistics Bar */} +
+
+ {stats.words} palavras + • + {stats.characters} caracteres + {isDirty && ( + <> + • + Modificações pendentes de salvamento + + )} +
+
+ Pressione Ctrl+S para salvar a qualquer momento +
+
+
+ + {/* Unsaved Changes Confirmation Modal */} + {showExitModal && ( +
+
+
+
+
+ +
+
+

+ Alterações não salvas +

+

+ 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? +

+
+
+ +
+ + + +
+
+
+ )} + + {/* Clear Note Confirmation Modal */} + {showClearConfirm && ( +
+
setShowClearConfirm(false)} + /> +
+
+

+ + Limpar Bloco de Notas +

+ +
+

+ 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. +

+
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/services/noteService.ts b/frontend/src/services/noteService.ts new file mode 100644 index 0000000..14aa464 --- /dev/null +++ b/frontend/src/services/noteService.ts @@ -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 { + return apiFetch('/notes') + }, + + async saveNote(content: string): Promise { + return apiFetch('/notes', { + method: 'PUT', + body: { content }, + }) + }, +}