feat: adicionar página de bloco de notas com salvamento no banco e confirmação ao sair
This commit is contained in:
parent
2ca14c8421
commit
51b70e567a
17 changed files with 1360 additions and 0 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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() {
|
|||
<Route path="/members" element={<MembersPage />} />
|
||||
<Route path="/organization/settings" element={<OrganizationSettingsPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/notepad" element={<NotepadPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
</Route>
|
||||
|
||||
|
|
|
|||
|
|
@ -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: <LayoutDashboard 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: '/notepad', label: 'Notas', icon: <StickyNote className="w-4 h-4" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
758
frontend/src/pages/NotepadPage.tsx
Normal file
758
frontend/src/pages/NotepadPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
frontend/src/services/noteService.ts
Normal file
22
frontend/src/services/noteService.ts
Normal 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 },
|
||||
})
|
||||
},
|
||||
}
|
||||
Loading…
Reference in a new issue