/* ui.jsx — мелкие примитивы и хелперы. Экспортируются в window.
* Категории теперь динамические — берутся из window.Store (см. store.jsx).
* Здесь только catFrom() как безопасный геттер на случай раннего рендера. */
const catFrom = (k) => (window.catOf ? window.catOf(k) : { key: k || "other", label: "Прочее", color: "gray" });
const colorClassFrom = (c) => (window.colorClass ? window.colorClass(c) : "cat-c-gray");
/* ----------------------------------------------------------- даты (рус.) */
const MONTHS = ["янв.", "февр.", "марта", "апр.", "мая", "июня", "июля", "авг.", "сент.", "окт.", "нояб.", "дек."];
const MONTHS_FULL = ["января","февраля","марта","апреля","мая","июня","июля","августа","сентября","октября","ноября","декабря"];
function startOfDay(d) { const x = new Date(d); x.setHours(0,0,0,0); return x; }
function dayDiff(a, b) { return Math.round((startOfDay(a) - startOfDay(b)) / 86400e3); }
function hhmm(d) { return new Date(d).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); }
// «сегодня 14:30», «вчера», «3 июня»
function formatCreated(iso) {
const d = new Date(iso);
const diff = dayDiff(new Date(), d);
if (diff === 0) return "сегодня " + hhmm(d);
if (diff === 1) return "вчера";
if (diff > 1 && diff < 7) return diff + " дн. назад";
return d.getDate() + " " + MONTHS[d.getMonth()] + (d.getFullYear() !== new Date().getFullYear() ? " " + d.getFullYear() : "");
}
// для напоминаний: «сегодня 10:00», «завтра 09:00», «просрочено», «через 3 дня»
function formatRemind(iso) {
const d = new Date(iso);
const diff = dayDiff(d, new Date());
const past = d < new Date();
let when;
if (diff === 0) when = "сегодня " + hhmm(d);
else if (diff === 1) when = "завтра " + hhmm(d);
else if (diff === -1) when = "вчера " + hhmm(d);
else when = d.getDate() + " " + MONTHS_FULL[d.getMonth()] + ", " + hhmm(d);
let tone = "future";
if (past) tone = "overdue";
else if (diff === 0) tone = "today";
else if (diff === 1) tone = "tomorrow";
return { text: when, tone };
}
// значение для
function toLocalInput(iso) {
if (!iso) return "";
const d = new Date(iso);
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function fromLocalInput(v) { return v ? new Date(v).toISOString() : null; }
/* ----------------------------------------------------------- иконки (24px stroke) */
function Icon({ name, size = 20, className = "", strokeWidth = 1.75 }) {
const p = {
notes: <>>,
search: <>>,
bell: <>>,
plus: <>>,
close: <>>,
trash: <>>,
sun: <>>,
moon: <>>,
telegram: <>>,
web: <>>,
chevron: <>>,
logout: <>>,
sparkles: <>>,
tag: <>>,
check: <>>,
edit: <>>,
menu: <>>,
calendar: <>>,
arrowRight: <>>,
inbox: <>>,
lock: <>>,
key: <>>,
eye: <>>,
eyeOff: <>>,
copy: <>>,
settings: <>>,
paperclip: <>>,
image: <>>,
video: <>>,
link: <>>,
external: <>>,
refresh: <>>,
star: <>>,
grid: <>>,
rows: <>>,
mic: <>>,
}[name];
return (
);
}
/* ----------------------------------------------------------- бейдж категории */
function CategoryBadge({ category, size = "md" }) {
const c = catFrom(category);
const pad = size === "sm" ? "px-2 py-0.5 text-[11px]" : "px-2.5 py-1 text-xs";
return (
{c.label}
);
}
/* ----------------------------------------------------------- копирование */
function pluralRu(n, one, few, many) {
const m10 = n % 10, m100 = n % 100;
if (m10 === 1 && m100 !== 11) return one;
if (m10 >= 2 && m10 <= 4 && (m100 < 10 || m100 >= 20)) return few;
return many;
}
function copyToClipboard(text) { if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(text);
return new Promise((res) => {
const ta = document.createElement("textarea");
ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta); ta.select();
try { document.execCommand("copy"); } catch (e) {}
document.body.removeChild(ta); res();
});
}
/* ----------------------------------------------------------- генератор паролей */
function generatePassword({ length = 16, upper = true, digits = true, symbols = true } = {}) {
const lo = "abcdefghijkmnpqrstuvwxyz";
const up = "ABCDEFGHJKLMNPQRSTUVWXYZ";
const di = "23456789";
const sy = "!@#$%^&*-_=+?";
let pool = lo;
const must = [pick(lo)];
if (upper) { pool += up; must.push(pick(up)); }
if (digits) { pool += di; must.push(pick(di)); }
if (symbols) { pool += sy; must.push(pick(sy)); }
const out = [...must];
for (let i = out.length; i < length; i++) out.push(pick(pool));
// перемешать (Fisher–Yates с crypto)
for (let i = out.length - 1; i > 0; i--) {
const j = rnd(i + 1);
[out[i], out[j]] = [out[j], out[i]];
}
return out.join("");
function pick(s) { return s[rnd(s.length)]; }
function rnd(n) {
if (window.crypto && window.crypto.getRandomValues) {
const a = new Uint32Array(1); window.crypto.getRandomValues(a);
return a[0] % n;
}
return Math.floor(Math.random() * n);
}
}
// сила пароля → {score 0..4, label, tone}
function passwordStrength(pw) {
if (!pw) return { score: 0, label: "—", tone: "gray" };
let s = 0;
if (pw.length >= 8) s++;
if (pw.length >= 14) s++;
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) s++;
if (/\d/.test(pw)) s++;
if (/[^A-Za-z0-9]/.test(pw)) s++;
const score = Math.min(4, s);
const map = [
{ label: "Слабый", tone: "red" },
{ label: "Слабый", tone: "red" },
{ label: "Средний", tone: "amber" },
{ label: "Хороший", tone: "teal" },
{ label: "Надёжный", tone: "green" },
];
return { score, ...map[score] };
}
/* ----------------------------------------------------------- пилюля тега */
function TagPill({ children, onRemove }) {
return (
#{children}
{onRemove && (
)}
);
}
/* ----------------------------------------------------------- источник */
function SourceMark({ source }) {
const settings = window.Store ? Store.useSettings() : { showSource: true };
if (settings.showSource === false) return null;
const tg = source === "telegram";
return (
);
}
/* ----------------------------------------------------------- кнопки */
function Button({ children, variant = "primary", size = "md", className = "", ...rest }) {
const sizes = { sm: "h-8 px-3 text-[13px]", md: "h-10 px-4 text-sm", lg: "h-11 px-5 text-sm" };
const variants = {
primary: "btn-primary",
ghost: "btn-ghost",
soft: "btn-soft",
danger: "btn-danger",
};
return (
);
}
/* ----------------------------------------------------------- спиннер / скелетон */
function Spinner({ size = 18 }) {
return ;
}
function SkeletonCard() {
return (
);
}
Object.assign(window, {
catFrom, colorClassFrom,
formatCreated, formatRemind, toLocalInput, fromLocalInput, hhmm, MONTHS_FULL,
Icon, CategoryBadge, TagPill, SourceMark, Button, Spinner, SkeletonCard,
copyToClipboard, generatePassword, passwordStrength, pluralRu,
});