24169237e1
El icono de compartida de las tarjetas de Notas se mueve de junto a la fecha a la esquina inferior derecha, a la izquierda del icono de arrastre. De paso se corrige que no se actualizaba en la tarjeta tras compartir/dejar de compartir sin recargar la pagina (faltaba un reload() de la lista principal, que es de donde sale el share_count de cada tarjeta). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
241 lines
9.3 KiB
React
241 lines
9.3 KiB
React
import { useState } from 'react';
|
|
import { Plus, Save, Trash2, Share2, UserPlus, GripVertical } from 'lucide-react';
|
|
import { useUi } from '../../context/ui-context.jsx';
|
|
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
|
import { useDragReorder } from '../../hooks/useDragReorder.js';
|
|
import {
|
|
listNotes, createNote, updateNote, deleteNote, reorderNotes, listNoteShares, addNoteShare, removeNoteShare,
|
|
} from '../../services/notes.js';
|
|
import { listUsers } from '../../services/users.js';
|
|
import { Button } from '../../components/Button.jsx';
|
|
import { Field } from '../../components/Field.jsx';
|
|
import { ComboBox } from '../../components/ComboBox.jsx';
|
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
|
import { SearchInput } from '../../components/SearchInput.jsx';
|
|
|
|
function formatDateTime(value) {
|
|
return String(value).slice(0, 16).replace('T', ' ');
|
|
}
|
|
|
|
export default function NotasPage() {
|
|
const { t, pushToast, openDialog } = useUi();
|
|
const runAction = useAction();
|
|
const { data: notes, reload } = useAsync(listNotes, []);
|
|
const { data: users } = useAsync(listUsers, []);
|
|
|
|
const [selected, setSelected] = useState(null); // nota abierta en el popup (null = cerrado)
|
|
const [creating, setCreating] = useState(false);
|
|
const [content, setContent] = useState('');
|
|
const [shares, setShares] = useState([]);
|
|
const [sharingWith, setSharingWith] = useState('');
|
|
const [search, setSearch] = useState('');
|
|
|
|
function openCreate() {
|
|
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 () => {
|
|
if (creating) {
|
|
await createNote(content);
|
|
} else {
|
|
await updateNote(selected.id, content);
|
|
}
|
|
closePopup();
|
|
await reload();
|
|
});
|
|
}
|
|
|
|
function confirmDelete() {
|
|
openDialog({
|
|
tone: 'warning',
|
|
title: t('common_confirm_delete_title'),
|
|
message: t('common_confirm_delete_msg'),
|
|
actions: [
|
|
{ label: t('common_cancel'), variant: 'standard' },
|
|
{
|
|
label: t('common_delete'), variant: 'danger',
|
|
onClick: () => runAction(async () => {
|
|
await deleteNote(selected.id);
|
|
closePopup();
|
|
await reload();
|
|
pushToast({ tone: 'success', message: t('common_saved') });
|
|
}),
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
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('');
|
|
await reload(); // el ícono de compartida de la tarjeta usa share_count de la lista principal
|
|
});
|
|
}
|
|
|
|
function removeShare(userId) {
|
|
runAction(async () => {
|
|
setShares(await removeNoteShare(selected.id, userId));
|
|
await reload();
|
|
});
|
|
}
|
|
|
|
const query = search.trim().toLowerCase();
|
|
const filteredNotes = query ? (notes || []).filter((n) => n.content.toLowerCase().includes(query)) : (notes || []);
|
|
const ownNotes = filteredNotes.filter((n) => n.is_owner);
|
|
const sharedNotes = filteredNotes.filter((n) => !n.is_owner);
|
|
// Solo se puede arrastrar lo propio, y no mientras hay un filtro de busqueda activo (el orden
|
|
// de un subconjunto filtrado no tiene un significado claro que persistir).
|
|
const dragEnabled = !query;
|
|
const { order: orderedOwnNotes, getHandleProps, getRowProps, isDragging } = useDragReorder(ownNotes, async (ids) => {
|
|
await reorderNotes(ids);
|
|
await reload();
|
|
});
|
|
const displayedOwnNotes = dragEnabled ? orderedOwnNotes : ownNotes;
|
|
|
|
if (!notes) return <div className="text-muted">{t('common_loading')}</div>;
|
|
|
|
return (
|
|
<div>
|
|
<div className="toolbar" style={{ marginBottom: 16 }}>
|
|
<h2 className="page-title mt-0" style={{ marginBottom: 0 }}>{t('notes_title')}</h2>
|
|
<SearchInput value={search} onChange={setSearch} />
|
|
</div>
|
|
|
|
<div className="notes-grid">
|
|
<button type="button" className="notes-card notes-card-new" onClick={openCreate}>
|
|
<Plus size={28} />
|
|
<span>{t('notes_new')}</span>
|
|
</button>
|
|
{displayedOwnNotes.map((note) => (
|
|
<div
|
|
key={note.id}
|
|
className={`notes-card ${dragEnabled && isDragging(note.id) ? 'dragging' : ''}`}
|
|
onClick={() => openNote(note)}
|
|
{...(dragEnabled ? getRowProps(note) : {})}
|
|
>
|
|
<div className="notes-card-corner">
|
|
{note.share_count > 0 && (
|
|
<span className="notes-card-corner-share" title={t('notes_share_title')}>
|
|
<Share2 size={13} strokeWidth={2.5} />
|
|
</span>
|
|
)}
|
|
{dragEnabled && (
|
|
<span className="notes-card-handle" onClick={(e) => e.stopPropagation()} {...getHandleProps(note)}>
|
|
<GripVertical size={14} />
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="notes-card-preview">{note.content}</div>
|
|
<div className="notes-card-meta">
|
|
<span className="notes-card-meta-row">
|
|
{note.created_at === note.updated_at
|
|
? formatDateTime(note.created_at)
|
|
: `${t('notes_updated')} ${formatDateTime(note.updated_at)}`}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{sharedNotes.map((note) => (
|
|
<div key={note.id} className="notes-card" onClick={() => openNote(note)}>
|
|
<div className="notes-card-corner">
|
|
<span className="notes-card-corner-share" title={t('notes_share_title')}>
|
|
<Share2 size={13} strokeWidth={2.5} />
|
|
</span>
|
|
</div>
|
|
<div className="notes-card-preview">{note.content}</div>
|
|
<div className="notes-card-meta">
|
|
<span className="notes-card-meta-row">
|
|
{note.created_at === note.updated_at
|
|
? formatDateTime(note.created_at)
|
|
: `${t('notes_updated')} ${formatDateTime(note.updated_at)}`}
|
|
</span>
|
|
<span className="notes-card-shared">{t('notes_shared_by')} {note.owner_name}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{filteredNotes.length === 0 && (
|
|
<div className="text-muted" style={{ marginTop: 12 }}>
|
|
{query ? t('common_search_no_results') : 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>
|
|
);
|
|
}
|