/* cards.jsx — карточки записей/напоминаний и модальные окна. */ /* ============================================================ КАРТОЧКА ЗАПИСИ */ // view: active | archived | trash; onAction(note, 'pin'|'archive'|'unarchive'|'trash'|'restore'|'purge') function NoteCard({ note, onOpen, onAction, view = "active", index = 0 }) { const body = note.summary || note.raw_text; const rem = note.reminder && note.reminder.status === "pending" ? formatRemind(note.reminder.remind_at) : null; const atts = note.attachments || []; const media = atts.filter((a) => a.type === "image" || a.type === "video"); const links = atts.filter((a) => a.type === "link"); // действия зависят от вида const actions = view === "trash" ? [{ k: "restore", icon: "refresh", label: "Вернуть", tone: "accent" }, { k: "purge", icon: "trash", label: "Навсегда", tone: "danger" }] : view === "archived" ? [{ k: "unarchive", icon: "inbox", label: "Из архива", tone: "accent" }, { k: "trash", icon: "trash", label: "В корзину", tone: "danger" }] : [{ k: "pin", icon: "star", label: note.pinned ? "Открепить" : "Закрепить", tone: "amber" }, { k: "archive", icon: "inbox", label: "В архив", tone: "muted" }, { k: "trash", icon: "trash", label: "Удалить", tone: "danger" }]; const [dx, setDx] = React.useState(0); const [open, setOpen] = React.useState(false); const start = React.useRef(null); const moved = React.useRef(false); const revealW = actions.length * 60; const onStart = (e) => { start.current = e.touches[0].clientX; moved.current = false; }; const onMove = (e) => { if (start.current == null) return; let d = e.touches[0].clientX - start.current + (open ? -revealW : 0); if (Math.abs(d - (open ? -revealW : 0)) > 6) moved.current = true; d = Math.max(-revealW - 20, Math.min(0, d)); setDx(d); }; const onEnd = () => { const shouldOpen = dx < -revealW / 2; setOpen(shouldOpen); setDx(shouldOpen ? -revealW : 0); start.current = null; }; const handleClick = () => { if (moved.current) return; if (open) { setOpen(false); setDx(0); return; } onOpen(note); }; const doAction = (k) => { setOpen(false); setDx(0); onAction && onAction(note, k); }; return (
{/* задний слой действий (свайп) — виден только во время свайпа */}
{actions.map((a) => ( ))}
{/* действия на ховере (десктоп) */}
{actions.map((a) => ( ))}
{note.pinned && view === "active" && }
{formatCreated(note.created_at)}

{body}

{note.summary && (

{note.raw_text}

)} {(note.tags.length > 0 || rem) && (
{rem && ( {rem.text} )} {note.tags.map((t) => {t})}
)} {atts.length > 0 && (
{media.slice(0, 3).map((a) => (
{a.type === "image" && a.url ? ( {a.name ) : ( )} {a.type === "video" && }
))} {links.slice(0, 2).map((a) => ( {a.name || hostOf(a.url)} ))} {atts.length > media.slice(0,3).length + links.slice(0,2).length && ( +{atts.length - media.slice(0,3).length - links.slice(0,2).length} )}
)}
); } function hostOf(url) { try { return new URL(url).hostname.replace(/^www\./, ""); } catch (e) { return url || "ссылка"; } } /* ===================================================== БАЗОВАЯ ОБЁРТКА МОДАЛКИ */ function Modal({ open, onClose, children, maxW = "max-w-lg" }) { React.useEffect(() => { if (!open) return; const onKey = (e) => e.key === "Escape" && onClose(); window.addEventListener("keydown", onKey); document.body.style.overflow = "hidden"; return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = ""; }; }, [open]); if (!open) return null; return (
{children}
); } /* ===================================================== ПОЛЕ-ВВОД ТЕГОВ */ function TagEditor({ tags, onChange }) { const [val, setVal] = React.useState(""); const add = () => { const t = val.trim().replace(/^#/, ""); if (t && !tags.includes(t)) onChange([...tags, t]); setVal(""); }; return (
{tags.map((t) => onChange(tags.filter((x) => x !== t))}>{t})} setVal(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } if (e.key === "Backspace" && !val && tags.length) onChange(tags.slice(0, -1)); }} onBlur={add} placeholder={tags.length ? "" : "добавить тег…"} className="flex-1 min-w-[80px] bg-transparent outline-none text-[13px] text-[var(--text)] placeholder:text-[var(--muted)]" />
); } /* ===================================================== РЕДАКТОР ВЛОЖЕНИЙ */ function AttachmentEditor({ attachments, onChange }) { const [linkUrl, setLinkUrl] = React.useState(""); const [adding, setAdding] = React.useState(false); const fileRef = React.useRef(null); const addLink = () => { let u = linkUrl.trim(); if (!u) return; if (!/^https?:\/\//i.test(u)) u = "https://" + u; onChange([...attachments, { id: api._uuid(), type: "link", url: u, name: hostOf(u) }]); setLinkUrl(""); setAdding(false); }; const [uploading, setUploading] = React.useState(false); const onFiles = async (e) => { const files = [...(e.target.files || [])]; e.target.value = ""; if (!files.length) return; setUploading(true); const next = []; for (const f of files) { try { const up = await api.uploadMedia(f); // → постоянный /media/... URL next.push({ id: api._uuid(), type: up.type, url: up.url, name: up.name || f.name, size: up.size }); } catch (err) { next.push({ id: api._uuid(), type: f.type.startsWith("video") ? "video" : "image", url: URL.createObjectURL(f), name: f.name }); } } setUploading(false); onChange([...attachments, ...next]); }; const remove = (id) => onChange(attachments.filter((a) => a.id !== id)); return (
{attachments.length > 0 && (
{attachments.map((a) => (
{a.type === "image" || a.type === "video" ? (
{a.type === "image" && a.url ? {a.name : }
) : ( e.stopPropagation()} className="flex items-center gap-1.5 max-w-[200px] h-16 px-3 rounded-xl att-thumb text-[13px] text-[var(--text)] hover:border-[var(--accent)] transition"> {a.name || hostOf(a.url)} )}
))}
)} {adding ? (
setLinkUrl(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addLink(); } if (e.key === "Escape") setAdding(false); }} placeholder="https://…" className="flex-1 rounded-xl border border-[var(--border)] bg-[var(--surface-2)] px-3 h-9 text-[13px] text-[var(--text)] outline-none focus:border-[var(--accent)] transition" />
) : (
)}
); } /* ===================================================== МОДАЛКА ЗАПИСИ (4.1) */ function NoteModal({ note, open, onClose, onSaved, onDeleted, onOpenRelated }) { const cats = Store.useCategories(); const [text, setText] = React.useState(""); const [category, setCategory] = React.useState("other"); const [tags, setTags] = React.useState([]); const [attachments, setAttachments] = React.useState([]); const [remindAt, setRemindAt] = React.useState(""); const [busy, setBusy] = React.useState(false); const [confirmDel, setConfirmDel] = React.useState(false); const [related, setRelated] = React.useState([]); React.useEffect(() => { if (note) { setText(note.raw_text); setCategory(note.category); setTags(note.tags || []); setAttachments(note.attachments || []); setRemindAt(note.reminder ? toLocalInput(note.reminder.remind_at) : ""); setConfirmDel(false); setRelated([]); api.getRelated(note.id).then(setRelated); } }, [note]); if (!note) return null; const save = async () => { setBusy(true); const updated = await api.updateNote(note.id, { raw_text: text, category, tags, attachments }); // напоминание const hadRem = !!note.reminder; const wantRem = !!remindAt; if (wantRem && !hadRem) { await api.createReminder({ note_id: note.id, remind_at: fromLocalInput(remindAt), text: (updated.summary || text).slice(0, 40) }); } else if (wantRem && hadRem) { await api.updateReminder(note.reminder.id, { remind_at: fromLocalInput(remindAt) }); } else if (!wantRem && hadRem) { await api.updateReminder(note.reminder.id, { status: "cancelled" }); } setBusy(false); onSaved && onSaved(); onClose(); }; const del = async () => { setBusy(true); await api.deleteNote(note.id); setBusy(false); onDeleted && onDeleted(); onClose(); }; const togglePin = async () => { await api.pinNote(note.id, !note.pinned); note.pinned = !note.pinned; onSaved && onSaved(); setRelated((r) => [...r]); }; const archive = async () => { await api.archiveNote(note.id, true); onSaved && onSaved(); onClose(); }; // быстрые действия по напоминанию-задаче const quickReminder = async (action) => { if (!note.reminder) return; setBusy(true); if (action === "done") await api.updateReminder(note.reminder.id, { status: "done" }); else if (action === "cancel") await api.updateReminder(note.reminder.id, { status: "cancelled" }); else { const base = new Date(note.reminder.remind_at); const now = new Date(); const from = base > now ? base : now; from.setDate(from.getDate() + action); await api.updateReminder(note.reminder.id, { remind_at: from.toISOString(), status: "pending" }); } setBusy(false); onSaved && onSaved(); onClose(); }; // экспорт записи как события календаря (.ics) const toCalendar = () => { const dt = remindAt ? new Date(fromLocalInput(remindAt)) : new Date(Date.now() + 86400e3); const pad = (n) => String(n).padStart(2, "0"); const fmt = (d) => d.getUTCFullYear() + pad(d.getUTCMonth() + 1) + pad(d.getUTCDate()) + "T" + pad(d.getUTCHours()) + pad(d.getUTCMinutes()) + "00Z"; const end = new Date(dt.getTime() + 3600e3); const title = (note.summary || text).slice(0, 60).replace(/\n/g, " "); const ics = [ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Umnyy Bloknot//RU", "BEGIN:VEVENT", "UID:" + note.id + "@bloknot", "DTSTAMP:" + fmt(new Date()), "DTSTART:" + fmt(dt), "DTEND:" + fmt(end), "SUMMARY:" + title, "DESCRIPTION:" + text.replace(/\n/g, "\\n"), "END:VEVENT", "END:VCALENDAR", ].join("\r\n"); const blob = new Blob([ics], { type: "text/calendar" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "event.ics"; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); }; return (
{formatCreated(note.created_at)}