feat: version 2.10.0 -- Notas en tarjetas, crear/editar en popup, compartir de solo lectura
Rediseno completo del modulo Notas (era lista inline, sin popup, sin compartir):
- Tarjetas con resumen del contenido en vez de lista vertical; la primera tarjeta siempre es
un "+" para crear.
- Crear y editar se hacen en un DetailPopup (antes: textarea fijo arriba + edicion inline por
tarjeta).
- Nueva tabla note_shares (migracion 019): compartir una nota con otro usuario ya registrado,
siempre de solo lectura -- el dueño sigue siendo el unico que edita/borra/comparte (mismo
patron que project_members pero sin rol ni invitacion por correo, ya que acá no hace falta).
Quien recibe la nota la ve con "Compartida por {nombre}", sin ningun control de edicion.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -39,7 +39,8 @@
|
|||||||
| `notifications` | notificaciones in-app por usuario |
|
| `notifications` | notificaciones in-app por usuario |
|
||||||
| `audit_log` | auditoría central (quién, qué, sobre qué, cuándo) |
|
| `audit_log` | auditoría central (quién, qué, sobre qué, cuándo) |
|
||||||
| `error_logs` | errores de backend (excepciones 500) y frontend (JS/promesas sin catch), panel "Errores del sistema" en Configuración |
|
| `error_logs` | errores de backend (excepciones 500) y frontend (JS/promesas sin catch), panel "Errores del sistema" en Configuración |
|
||||||
| `notes` | apuntes rápidos personales, siempre privados de cada usuario, menú propio "Notas" |
|
| `notes` | apuntes rápidos personales, menú propio "Notas"; editable solo por el dueño |
|
||||||
|
| `note_shares` | con quién está compartida (solo lectura) cada nota — `note_id, user_id` |
|
||||||
| `vault_entries` | repositorio de credenciales privado por usuario (menú "Contraseñas"), contraseña/notas cifradas |
|
| `vault_entries` | repositorio de credenciales privado por usuario (menú "Contraseñas"), contraseña/notas cifradas |
|
||||||
|
|
||||||
## Relaciones clave
|
## Relaciones clave
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# Changelog - project.linkedcorp.net
|
||||||
|
|
||||||
|
## [2.10.0] - 2026-10-12
|
||||||
|
### Added
|
||||||
|
- Rediseño del módulo "Notas": ahora se ven como tarjetas (resumen del contenido), con una
|
||||||
|
primera tarjeta fija de "+" para crear — crear y editar se hacen en un popup, ya no inline.
|
||||||
|
- Compartir una nota con otro usuario ya registrado, siempre de solo lectura: el dueño sigue
|
||||||
|
siendo el único que la edita, borra o administra a quién está compartida; quien la recibe la
|
||||||
|
ve con un aviso "Compartida por {nombre}" y sin ningún control de edición.
|
||||||
|
|
||||||
## [2.9.1] - 2026-10-07
|
## [2.9.1] - 2026-10-07
|
||||||
### Added
|
### Added
|
||||||
- Los bloques de código dentro de un .md visto en Documentos ahora tienen resaltado de sintaxis
|
- Los bloques de código dentro de un .md visto en Documentos ahora tienen resaltado de sintaxis
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Compartir una nota con otro usuario, siempre de solo lectura (el dueño sigue siendo el unico
|
||||||
|
-- que edita/borra/comparte). Analogo simplificado de project_members (sin columna de rol, sin
|
||||||
|
-- invitacion por correo). Idempotente.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS note_shares (
|
||||||
|
note_id INT NOT NULL,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
shared_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (note_id, user_id),
|
||||||
|
CONSTRAINT fk_note_shares_note FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_note_shares_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "2.9.1",
|
"version": "2.10.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
@@ -17,3 +17,15 @@ export const deleteNote = asyncHandler(async (req, res) => {
|
|||||||
await service.removeNote(Number(req.params.id), req.currentUser);
|
await service.removeNote(Number(req.params.id), req.currentUser);
|
||||||
ok(res, { deleted: true });
|
ok(res, { deleted: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getNoteShares = asyncHandler(async (req, res) => {
|
||||||
|
ok(res, await service.listNoteShares(Number(req.params.id), req.currentUser));
|
||||||
|
});
|
||||||
|
|
||||||
|
export const postNoteShare = asyncHandler(async (req, res) => {
|
||||||
|
ok(res, await service.shareNote(Number(req.params.id), Number(req.body.userId), req.currentUser), 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteNoteShare = asyncHandler(async (req, res) => {
|
||||||
|
ok(res, await service.unshareNote(Number(req.params.id), Number(req.params.userId), req.currentUser));
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { pool } from '../../config/db.js';
|
import { pool } from '../../config/db.js';
|
||||||
|
|
||||||
export async function findByUser(userId) {
|
// Notas propias MAS las compartidas conmigo. is_owner permite al frontend decidir si se puede
|
||||||
|
// editar/borrar/compartir (siempre false para una nota compartida: el dueno nunca cambia).
|
||||||
|
export async function findVisibleToUser(userId) {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
'SELECT id, user_id, content, created_at, updated_at FROM notes WHERE user_id = ? ORDER BY updated_at DESC',
|
`SELECT n.id, n.user_id, n.content, n.created_at, n.updated_at,
|
||||||
[userId]
|
(n.user_id = ?) AS is_owner, u.name AS owner_name
|
||||||
|
FROM notes n
|
||||||
|
JOIN users u ON u.id = n.user_id
|
||||||
|
WHERE n.user_id = ? OR n.id IN (SELECT note_id FROM note_shares WHERE user_id = ?)
|
||||||
|
ORDER BY n.updated_at DESC`,
|
||||||
|
[userId, userId, userId]
|
||||||
);
|
);
|
||||||
return rows;
|
return rows.map((r) => ({ ...r, is_owner: !!r.is_owner }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findById(id) {
|
export async function findById(id) {
|
||||||
@@ -26,3 +33,28 @@ export async function update(id, content) {
|
|||||||
export async function remove(id) {
|
export async function remove(id) {
|
||||||
await pool.query('DELETE FROM notes WHERE id = ?', [id]);
|
await pool.query('DELETE FROM notes WHERE id = ?', [id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listShares(noteId) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT ns.user_id, u.name, u.email, ns.shared_at
|
||||||
|
FROM note_shares ns JOIN users u ON u.id = ns.user_id
|
||||||
|
WHERE ns.note_id = ? ORDER BY u.name`,
|
||||||
|
[noteId]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isSharedWith(noteId, userId) {
|
||||||
|
const [rows] = await pool.query('SELECT 1 FROM note_shares WHERE note_id = ? AND user_id = ?', [noteId, userId]);
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addShare(noteId, userId) {
|
||||||
|
await pool.query('INSERT IGNORE INTO note_shares (note_id, user_id) VALUES (?, ?)', [noteId, userId]);
|
||||||
|
return listShares(noteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeShare(noteId, userId) {
|
||||||
|
await pool.query('DELETE FROM note_shares WHERE note_id = ? AND user_id = ?', [noteId, userId]);
|
||||||
|
return listShares(noteId);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { requireAuth } from '../../middleware/auth.js';
|
import { requireAuth } from '../../middleware/auth.js';
|
||||||
import { getNotes, postNote, putNote, deleteNote } from './controller.js';
|
import { getNotes, postNote, putNote, deleteNote, getNoteShares, postNoteShare, deleteNoteShare } from './controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Sin restriccion de rol: cualquier usuario autenticado tiene sus propias notas.
|
// Sin restriccion de rol: cualquier usuario autenticado tiene sus propias notas (y ve las que
|
||||||
|
// otros compartieron con el). Los permisos de compartir se resuelven en el service (dueño-only).
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
router.get('/', getNotes);
|
router.get('/', getNotes);
|
||||||
router.post('/', postNote);
|
router.post('/', postNote);
|
||||||
router.put('/:id', putNote);
|
router.put('/:id', putNote);
|
||||||
router.delete('/:id', deleteNote);
|
router.delete('/:id', deleteNote);
|
||||||
|
router.get('/:id/shares', getNoteShares);
|
||||||
|
router.post('/:id/shares', postNoteShare);
|
||||||
|
router.delete('/:id/shares/:userId', deleteNoteShare);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
|
import * as usersRepo from '../users/repository.js';
|
||||||
import { HttpError } from '../../utils/http.js';
|
import { HttpError } from '../../utils/http.js';
|
||||||
|
import { logAudit } from '../../utils/audit.js';
|
||||||
|
|
||||||
// Siempre privadas: cada quien ve y toca solo sus propias notas, sin importar el rol. Una nota
|
// Siempre privadas para editar/borrar/compartir: cada quien solo toca sus propias notas, sin
|
||||||
// de otro usuario responde 404 (no 403) para no revelar que existe.
|
// importar el rol. Una nota de otro usuario responde 404 (no 403) para no revelar que existe.
|
||||||
async function getOwnNoteOr404(id, currentUser) {
|
async function getOwnNoteOr404(id, currentUser) {
|
||||||
const note = await repo.findById(id);
|
const note = await repo.findById(id);
|
||||||
if (!note || note.user_id !== currentUser.id) throw new HttpError(404, 'Nota no encontrada');
|
if (!note || note.user_id !== currentUser.id) throw new HttpError(404, 'Nota no encontrada');
|
||||||
return note;
|
return note;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const listNotes = (currentUser) => repo.findByUser(currentUser.id);
|
export const listNotes = (currentUser) => repo.findVisibleToUser(currentUser.id);
|
||||||
|
|
||||||
export async function createNote(content, currentUser) {
|
export async function createNote(content, currentUser) {
|
||||||
const trimmed = (content || '').trim();
|
const trimmed = (content || '').trim();
|
||||||
@@ -28,3 +30,26 @@ export async function removeNote(id, currentUser) {
|
|||||||
await getOwnNoteOr404(id, currentUser);
|
await getOwnNoteOr404(id, currentUser);
|
||||||
await repo.remove(id);
|
await repo.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listNoteShares(id, currentUser) {
|
||||||
|
await getOwnNoteOr404(id, currentUser);
|
||||||
|
return repo.listShares(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shareNote(id, targetUserId, currentUser) {
|
||||||
|
await getOwnNoteOr404(id, currentUser);
|
||||||
|
if (!targetUserId) throw new HttpError(400, 'Indica un usuario');
|
||||||
|
if (targetUserId === currentUser.id) throw new HttpError(400, 'No puedes compartir una nota contigo mismo');
|
||||||
|
const targetUser = await usersRepo.findById(targetUserId);
|
||||||
|
if (!targetUser) throw new HttpError(404, 'Usuario no encontrado');
|
||||||
|
const shares = await repo.addShare(id, targetUserId);
|
||||||
|
await logAudit({ userId: currentUser.id, action: 'share', entityType: 'note', entityId: id, detail: { userId: targetUserId } });
|
||||||
|
return shares;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unshareNote(id, targetUserId, currentUser) {
|
||||||
|
await getOwnNoteOr404(id, currentUser);
|
||||||
|
const shares = await repo.removeShare(id, targetUserId);
|
||||||
|
await logAudit({ userId: currentUser.id, action: 'unshare', entityType: 'note', entityId: id, detail: { userId: targetUserId } });
|
||||||
|
return shares;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.9.1",
|
"version": "2.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -221,9 +221,14 @@ export const messages = {
|
|||||||
vault_auto_lock_notice: 'Saliste de Contraseñas por seguridad — ingresa tu PIN de nuevo para volver a entrar.',
|
vault_auto_lock_notice: 'Saliste de Contraseñas por seguridad — ingresa tu PIN de nuevo para volver a entrar.',
|
||||||
notes_title: 'Notas',
|
notes_title: 'Notas',
|
||||||
notes_placeholder: 'Escribe un apunte rápido...',
|
notes_placeholder: 'Escribe un apunte rápido...',
|
||||||
notes_add: 'Agregar',
|
|
||||||
notes_empty: 'No tienes notas todavía.',
|
notes_empty: 'No tienes notas todavía.',
|
||||||
notes_updated: 'Editado',
|
notes_updated: 'Editado',
|
||||||
|
notes_new: 'Nueva nota',
|
||||||
|
notes_share_title: 'Compartir',
|
||||||
|
notes_share_add: 'Compartir con',
|
||||||
|
notes_share_placeholder: 'Elegir usuario',
|
||||||
|
notes_share_empty: 'No compartida con nadie.',
|
||||||
|
notes_shared_by: 'Compartida por',
|
||||||
recurring_task_new: 'Nueva tarea recurrente',
|
recurring_task_new: 'Nueva tarea recurrente',
|
||||||
recurring_task_project: 'Proyecto de origen',
|
recurring_task_project: 'Proyecto de origen',
|
||||||
recurring_task_every: 'Cada',
|
recurring_task_every: 'Cada',
|
||||||
@@ -556,9 +561,14 @@ export const messages = {
|
|||||||
vault_auto_lock_notice: 'You left Passwords for security — enter your PIN again to come back in.',
|
vault_auto_lock_notice: 'You left Passwords for security — enter your PIN again to come back in.',
|
||||||
notes_title: 'Notes',
|
notes_title: 'Notes',
|
||||||
notes_placeholder: 'Write a quick note...',
|
notes_placeholder: 'Write a quick note...',
|
||||||
notes_add: 'Add',
|
|
||||||
notes_empty: 'You don\'t have any notes yet.',
|
notes_empty: 'You don\'t have any notes yet.',
|
||||||
notes_updated: 'Edited',
|
notes_updated: 'Edited',
|
||||||
|
notes_new: 'New note',
|
||||||
|
notes_share_title: 'Share',
|
||||||
|
notes_share_add: 'Share with',
|
||||||
|
notes_share_placeholder: 'Choose a user',
|
||||||
|
notes_share_empty: 'Not shared with anyone.',
|
||||||
|
notes_shared_by: 'Shared by',
|
||||||
recurring_task_new: 'New recurring task',
|
recurring_task_new: 'New recurring task',
|
||||||
recurring_task_project: 'Source project',
|
recurring_task_project: 'Source project',
|
||||||
recurring_task_every: 'Every',
|
recurring_task_every: 'Every',
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Plus, Pencil, Save, X, Trash2 } from 'lucide-react';
|
import { Plus, Save, Trash2, Share2, UserPlus } from 'lucide-react';
|
||||||
import { useUi } from '../../context/ui-context.jsx';
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||||
import { listNotes, createNote, updateNote, deleteNote } from '../../services/notes.js';
|
import {
|
||||||
|
listNotes, createNote, updateNote, deleteNote, listNoteShares, addNoteShare, removeNoteShare,
|
||||||
|
} from '../../services/notes.js';
|
||||||
|
import { listUsers } from '../../services/users.js';
|
||||||
import { Button } from '../../components/Button.jsx';
|
import { Button } from '../../components/Button.jsx';
|
||||||
|
import { Field } from '../../components/Field.jsx';
|
||||||
|
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||||
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
|
|
||||||
function formatDateTime(value) {
|
function formatDateTime(value) {
|
||||||
return String(value).slice(0, 16).replace('T', ' ');
|
return String(value).slice(0, 16).replace('T', ' ');
|
||||||
@@ -13,40 +19,49 @@ export default function NotasPage() {
|
|||||||
const { t, pushToast, openDialog } = useUi();
|
const { t, pushToast, openDialog } = useUi();
|
||||||
const runAction = useAction();
|
const runAction = useAction();
|
||||||
const { data: notes, reload } = useAsync(listNotes, []);
|
const { data: notes, reload } = useAsync(listNotes, []);
|
||||||
|
const { data: users } = useAsync(listUsers, []);
|
||||||
|
|
||||||
const [newContent, setNewContent] = useState('');
|
const [selected, setSelected] = useState(null); // nota abierta en el popup (null = cerrado)
|
||||||
const [editingId, setEditingId] = useState(null);
|
const [creating, setCreating] = useState(false);
|
||||||
const [editingContent, setEditingContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
|
const [shares, setShares] = useState([]);
|
||||||
|
const [sharingWith, setSharingWith] = useState('');
|
||||||
|
|
||||||
async function addNote() {
|
function openCreate() {
|
||||||
if (!newContent.trim()) return;
|
setSelected(null);
|
||||||
|
setCreating(true);
|
||||||
|
setContent('');
|
||||||
|
setShares([]);
|
||||||
|
setSharingWith('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openNote(note) {
|
||||||
|
setSelected(note);
|
||||||
|
setCreating(false);
|
||||||
|
setContent(note.content);
|
||||||
|
setSharingWith('');
|
||||||
|
setShares(note.is_owner ? await listNoteShares(note.id) : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePopup() {
|
||||||
|
setSelected(null);
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!content.trim()) return;
|
||||||
await runAction(async () => {
|
await runAction(async () => {
|
||||||
await createNote(newContent);
|
if (creating) {
|
||||||
setNewContent('');
|
await createNote(content);
|
||||||
|
} else {
|
||||||
|
await updateNote(selected.id, content);
|
||||||
|
}
|
||||||
|
closePopup();
|
||||||
await reload();
|
await reload();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEdit(note) {
|
function confirmDelete() {
|
||||||
setEditingId(note.id);
|
|
||||||
setEditingContent(note.content);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelEdit() {
|
|
||||||
setEditingId(null);
|
|
||||||
setEditingContent('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveEdit(id) {
|
|
||||||
if (!editingContent.trim()) return;
|
|
||||||
await runAction(async () => {
|
|
||||||
await updateNote(id, editingContent);
|
|
||||||
cancelEdit();
|
|
||||||
await reload();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmDelete(note) {
|
|
||||||
openDialog({
|
openDialog({
|
||||||
tone: 'warning',
|
tone: 'warning',
|
||||||
title: t('common_confirm_delete_title'),
|
title: t('common_confirm_delete_title'),
|
||||||
@@ -56,7 +71,8 @@ export default function NotasPage() {
|
|||||||
{
|
{
|
||||||
label: t('common_delete'), variant: 'danger',
|
label: t('common_delete'), variant: 'danger',
|
||||||
onClick: () => runAction(async () => {
|
onClick: () => runAction(async () => {
|
||||||
await deleteNote(note.id);
|
await deleteNote(selected.id);
|
||||||
|
closePopup();
|
||||||
await reload();
|
await reload();
|
||||||
pushToast({ tone: 'success', message: t('common_saved') });
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
}),
|
}),
|
||||||
@@ -65,61 +81,101 @@ export default function NotasPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userOptions = (users || [])
|
||||||
|
.filter((u) => !shares.some((s) => s.user_id === u.id))
|
||||||
|
.map((u) => ({ value: u.id, label: u.name }));
|
||||||
|
|
||||||
|
async function addShare() {
|
||||||
|
if (!sharingWith) return;
|
||||||
|
await runAction(async () => {
|
||||||
|
setShares(await addNoteShare(selected.id, Number(sharingWith)));
|
||||||
|
setSharingWith('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeShare(userId) {
|
||||||
|
runAction(async () => {
|
||||||
|
setShares(await removeNoteShare(selected.id, userId));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!notes) return <div className="text-muted">{t('common_loading')}</div>;
|
if (!notes) return <div className="text-muted">{t('common_loading')}</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="page-title mt-0" style={{ marginBottom: 16 }}>{t('notes_title')}</h2>
|
<h2 className="page-title mt-0" style={{ marginBottom: 16 }}>{t('notes_title')}</h2>
|
||||||
|
|
||||||
<div className="panel-card" style={{ marginBottom: 16 }}>
|
<div className="notes-grid">
|
||||||
<textarea
|
<button type="button" className="notes-card notes-card-new" onClick={openCreate}>
|
||||||
className="textarea"
|
<Plus size={28} />
|
||||||
rows={3}
|
<span>{t('notes_new')}</span>
|
||||||
placeholder={t('notes_placeholder')}
|
</button>
|
||||||
value={newContent}
|
|
||||||
onChange={(e) => setNewContent(e.target.value)}
|
|
||||||
/>
|
|
||||||
<div style={{ marginTop: 8 }}>
|
|
||||||
<Button variant="accent" icon={Plus} onClick={addNote} disabled={!newContent.trim()}>
|
|
||||||
{t('notes_add')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{notes.length === 0 && <div className="text-muted">{t('notes_empty')}</div>}
|
|
||||||
|
|
||||||
{notes.map((note) => (
|
{notes.map((note) => (
|
||||||
<div key={note.id} className="panel-card" style={{ marginBottom: 10 }}>
|
<div key={note.id} className="notes-card" onClick={() => openNote(note)}>
|
||||||
{editingId === note.id ? (
|
<div className="notes-card-preview">{note.content}</div>
|
||||||
<>
|
<div className="notes-card-meta">
|
||||||
<textarea
|
<span>
|
||||||
className="textarea" rows={3} autoFocus
|
|
||||||
value={editingContent}
|
|
||||||
onChange={(e) => setEditingContent(e.target.value)}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Escape') cancelEdit(); }}
|
|
||||||
/>
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
|
||||||
<Button variant="accent" icon={Save} onClick={() => saveEdit(note.id)}>{t('common_save')}</Button>
|
|
||||||
<Button variant="standard" icon={X} onClick={cancelEdit}>{t('common_cancel')}</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div style={{ fontSize: '.85rem', whiteSpace: 'pre-wrap', marginBottom: 8 }}>{note.content}</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
<span className="text-muted" style={{ fontSize: '.7rem' }}>
|
|
||||||
{note.created_at === note.updated_at
|
{note.created_at === note.updated_at
|
||||||
? formatDateTime(note.created_at)
|
? formatDateTime(note.created_at)
|
||||||
: `${t('notes_updated')} ${formatDateTime(note.updated_at)}`}
|
: `${t('notes_updated')} ${formatDateTime(note.updated_at)}`}
|
||||||
</span>
|
</span>
|
||||||
<div style={{ flex: 1 }} />
|
{!note.is_owner && <span className="notes-card-shared">{t('notes_shared_by')} {note.owner_name}</span>}
|
||||||
<button className="icon-btn" title={t('common_edit')} onClick={() => startEdit(note)}><Pencil size={15} /></button>
|
|
||||||
<button className="icon-btn" title={t('common_delete')} onClick={() => confirmDelete(note)}><Trash2 size={15} /></button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{notes.length === 0 && <div className="text-muted" style={{ marginTop: 12 }}>{t('notes_empty')}</div>}
|
||||||
|
|
||||||
|
<DetailPopup
|
||||||
|
open={creating || !!selected}
|
||||||
|
title={creating ? t('notes_new') : t('notes_title')}
|
||||||
|
onClose={closePopup}
|
||||||
|
width={560}
|
||||||
|
height={creating || selected?.is_owner ? 620 : 420}
|
||||||
|
>
|
||||||
|
{creating || selected?.is_owner ? (
|
||||||
|
<>
|
||||||
|
<textarea
|
||||||
|
className="textarea" rows={6} autoFocus
|
||||||
|
placeholder={t('notes_placeholder')}
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 8, marginBottom: creating ? 0 : 20 }}>
|
||||||
|
<Button variant="accent" icon={Save} onClick={save} disabled={!content.trim()}>{t('common_save')}</Button>
|
||||||
|
<Button variant="standard" onClick={closePopup}>{t('common_cancel')}</Button>
|
||||||
|
{!creating && <Button variant="danger" icon={Trash2} onClick={confirmDelete}>{t('common_delete')}</Button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!creating && (
|
||||||
|
<div>
|
||||||
|
<h4 style={{ display: 'flex', alignItems: 'center', gap: 6 }}><Share2 size={15} /> {t('notes_share_title')}</h4>
|
||||||
|
<Field label={t('notes_share_add')}>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<ComboBox options={userOptions} value={sharingWith} onChange={setSharingWith} placeholder={t('notes_share_placeholder')} />
|
||||||
|
</div>
|
||||||
|
<Button variant="standard" icon={UserPlus} onClick={addShare} disabled={!sharingWith}>{t('notes_share_add')}</Button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
{shares.length === 0 && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('notes_share_empty')}</div>}
|
||||||
|
{shares.map((s) => (
|
||||||
|
<div key={s.user_id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
|
||||||
|
<span style={{ flex: 1, fontSize: '.82rem' }}>{s.name} <span className="text-muted">· {s.email}</span></span>
|
||||||
|
<button className="icon-btn" onClick={() => removeShare(s.user_id)}><Trash2 size={15} /></button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-muted" style={{ fontSize: '.78rem', marginBottom: 10 }}>{t('notes_shared_by')} {selected?.owner_name}</div>
|
||||||
|
<div style={{ fontSize: '.85rem', whiteSpace: 'pre-wrap' }}>{content}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DetailPopup>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,3 +4,7 @@ export const listNotes = () => api.get('/notes');
|
|||||||
export const createNote = (content) => api.post('/notes', { content });
|
export const createNote = (content) => api.post('/notes', { content });
|
||||||
export const updateNote = (id, content) => api.put(`/notes/${id}`, { content });
|
export const updateNote = (id, content) => api.put(`/notes/${id}`, { content });
|
||||||
export const deleteNote = (id) => api.delete(`/notes/${id}`);
|
export const deleteNote = (id) => api.delete(`/notes/${id}`);
|
||||||
|
|
||||||
|
export const listNoteShares = (id) => api.get(`/notes/${id}/shares`);
|
||||||
|
export const addNoteShare = (id, userId) => api.post(`/notes/${id}/shares`, { userId });
|
||||||
|
export const removeNoteShare = (id, userId) => api.delete(`/notes/${id}/shares/${userId}`);
|
||||||
|
|||||||
@@ -361,6 +361,30 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
|
|||||||
.text-link { color: rgb(var(--accent)); text-decoration: none; }
|
.text-link { color: rgb(var(--accent)); text-decoration: none; }
|
||||||
.text-link:hover { text-decoration: underline; }
|
.text-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* --- Notas: tarjetas (ver NotasPage.jsx) --- */
|
||||||
|
.notes-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px;
|
||||||
|
}
|
||||||
|
.notes-card {
|
||||||
|
background: rgb(var(--surface)); border: 1px solid rgb(var(--line));
|
||||||
|
border-radius: var(--r-lg); padding: 16px; cursor: pointer; text-align: left;
|
||||||
|
display: flex; flex-direction: column; gap: 10px; min-height: 140px;
|
||||||
|
font: inherit; color: inherit;
|
||||||
|
transition: border-color .15s ease;
|
||||||
|
}
|
||||||
|
.notes-card:hover { border-color: rgb(var(--accent) / .5); }
|
||||||
|
.notes-card-preview {
|
||||||
|
font-size: .82rem; white-space: pre-wrap; flex: 1; margin: 0;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden;
|
||||||
|
}
|
||||||
|
.notes-card-meta { display: flex; flex-direction: column; gap: 2px; font-size: .68rem; color: rgb(var(--muted)); }
|
||||||
|
.notes-card-shared { color: rgb(var(--accent)); }
|
||||||
|
.notes-card-new {
|
||||||
|
border: 1px dashed rgb(var(--line)); background: transparent; color: rgb(var(--muted));
|
||||||
|
align-items: center; justify-content: center; gap: 8px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.notes-card-new:hover { border-color: rgb(var(--accent)); color: rgb(var(--accent)); }
|
||||||
|
|
||||||
/* --- Contraseñas: tarjetas en vez de tabla --- */
|
/* --- Contraseñas: tarjetas en vez de tabla --- */
|
||||||
.vault-grid {
|
.vault-grid {
|
||||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 14px;
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 14px;
|
||||||
|
|||||||
Reference in New Issue
Block a user