/* 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.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 (
);
}
/* ===================================================== ПОЛЕ-ВВОД ТЕГОВ */
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) => (
))}
)}
{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)}
{note.reminder && note.reminder.status === "pending" && (
Статус задачи
)}
{/* быстрые действия ИИ */}
{!remindAt && (
)}
{/* похожие записи */}
{related.length > 0 && (
Похожие записи
{related.map((r) => (
))}
)}
{!confirmDel ? (
) : (
Точно?
)}
);
}
/* ===================================================== МОДАЛКА СОЗДАНИЯ (4.2) */
function CreateModal({ open, onClose, onCreated }) {
const [text, setText] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [listening, setListening] = React.useState(false);
const ref = React.useRef(null);
const recog = React.useRef(null);
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
const voiceSupported = !!SR;
const toggleVoice = () => {
if (!SR) return;
if (listening) { recog.current && recog.current.stop(); return; }
const r = new SR();
r.lang = "ru-RU"; r.continuous = true; r.interimResults = false;
r.onresult = (e) => {
let add = "";
for (let i = e.resultIndex; i < e.results.length; i++) add += e.results[i][0].transcript;
setText((t) => (t ? t + " " : "") + add.trim());
};
r.onend = () => setListening(false);
r.onerror = () => setListening(false);
recog.current = r; r.start(); setListening(true);
};
React.useEffect(() => {
if (open) { setText(""); setTimeout(() => ref.current && ref.current.focus(), 60); }
return () => { if (recog.current) try { recog.current.stop(); } catch (e) {} };
}, [open]);
const create = async () => {
if (!text.trim()) return;
setBusy(true);
const note = await api.createNote({ raw_text: text.trim() });
setBusy(false);
onCreated && onCreated(note);
onClose();
};
return (
Новая запись
{listening ? "Слушаю… говори — текст появится сам" : "Категорию, теги и напоминание ИИ проставит автоматически"}
⌘↵ — сохранить
);
}
function Field({ label, children }) {
return (
);
}
Object.assign(window, { NoteCard, Modal, NoteModal, CreateModal, TagEditor, AttachmentEditor, Field, hostOf });