import React, { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
import ICONS from './ui-icons.js';
import { __ } from './i18n.js';
export function I({ n, s = 16, className = '', stroke = 1.75, style }) {
const inner = ICONS[n] || ICONS['circle-help'] || '';
return (
);
}
export const cx = (...a) => a.filter(Boolean).join(' ');
/** Labels passed to shared components are translated here, so option lists and props need no wrapping. */
export const tr = (x) => (typeof x === 'string' && x ? __(x) : x);
export function Btn({ icon, children, kind = 'ghost', size = 'md', tip, tipPos, active, className, ...p }) {
return (
);
}
/** Close on outside pointerdown / Escape. */
export function useDismiss(ref, onClose, active = true) {
useEffect(() => {
if (!active) return;
const down = (e) => {
if (ref.current && !ref.current.contains(e.target) && !e.target.closest?.('.pop-keep')) onClose(e);
};
const key = (e) => {
if (e.key === 'Escape') {
e.stopPropagation();
onClose(e);
}
};
const t = setTimeout(() => {
document.addEventListener('pointerdown', down, true);
});
document.addEventListener('keydown', key, true);
return () => {
clearTimeout(t);
document.removeEventListener('pointerdown', down, true);
document.removeEventListener('keydown', key, true);
};
}, [active, onClose]);
}
/** Floating panel anchored to an element rect; flips/clamps into the viewport. */
export function Popover({ anchor, onClose, children, width = 280, className, place = 'bottom', offset = 6 }) {
const ref = useRef();
const [pos, setPos] = useState({ left: -9999, top: -9999 });
useDismiss(ref, onClose);
useLayoutEffect(() => {
if (!anchor || !ref.current) return;
const r = anchor.getBoundingClientRect ? anchor.getBoundingClientRect() : anchor;
const h = ref.current.offsetHeight,
w = ref.current.offsetWidth;
let left =
place === 'left' ? r.left - w - offset : place === 'right' ? r.right + offset : r.left + r.width / 2 - w / 2;
let top = place === 'left' || place === 'right' ? r.top : r.bottom + offset;
if (place === 'bottom' && top + h > innerHeight - 8) top = Math.max(8, r.top - h - offset);
left = Math.max(8, Math.min(left, innerWidth - w - 8));
top = Math.max(8, Math.min(top, innerHeight - h - 8));
setPos({ left, top });
}, [anchor, place]);
return createPortal(
e.stopPropagation()}
>
{children}
,
document.body,
);
}
export function Menu({ items, anchor, x, y, onClose, width = 230 }) {
const ref = useRef();
const [pos, setPos] = useState({ left: x ?? -9999, top: y ?? -9999 });
const [hi, setHi] = useState(-1);
useDismiss(ref, onClose);
useLayoutEffect(() => {
const h = ref.current.offsetHeight,
w = ref.current.offsetWidth;
let left = x,
top = y;
if (anchor) {
const r = anchor.getBoundingClientRect();
left = r.left;
top = r.bottom + 4;
if (left + w > innerWidth - 8) left = r.right - w;
}
if (top + h > innerHeight - 8) top = Math.max(8, (anchor ? anchor.getBoundingClientRect().top : top) - h - 4);
left = Math.max(8, Math.min(left, innerWidth - w - 8));
setPos({ left, top });
}, []);
const actionable = items.filter((i) => i && !i.sep && !i.label === false);
useEffect(() => {
const k = (e) => {
const list = items.map((it, i) => (it && !it.sep && !it.disabled && !it.heading ? i : -1)).filter((i) => i >= 0);
if (e.key === 'ArrowDown') {
e.preventDefault();
setHi((h) => list[(list.indexOf(h) + 1) % list.length]);
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setHi((h) => list[(list.indexOf(h) - 1 + list.length) % list.length]);
}
if (e.key === 'Enter' && hi >= 0) {
e.preventDefault();
items[hi].run();
onClose();
}
};
document.addEventListener('keydown', k);
return () => document.removeEventListener('keydown', k);
}, [hi, items]);
return createPortal(
e.preventDefault()}>
{items.filter(Boolean).map((it, i) =>
it.sep ? (
) : it.heading ? (
{it.heading}
) : (
),
)}
,
document.body,
);
}
export function Modal({ title, onClose, children, width = 560, footer, className, icon }) {
const ref = useRef();
useEffect(() => {
const k = (e) => {
if (e.key === 'Escape') onClose && onClose();
};
document.addEventListener('keydown', k);
const prev = document.activeElement;
setTimeout(() => ref.current?.querySelector('input,button,textarea,select,[tabindex]')?.focus(), 30);
return () => {
document.removeEventListener('keydown', k);
prev && prev.focus && prev.focus();
};
}, []);
return createPortal(
{
if (e.target === e.currentTarget && onClose) onClose();
}}
>
{title && (
{icon && (
)}
{tr(title)}
{onClose && }
)}
{children}
{footer &&
{footer}
}
,
document.body,
);
}
export function Seg({ value, onChange, options, size = 'md', full, label }) {
return (
{options.map((o) => {
const [v, raw, icon] = Array.isArray(o) ? o : [o, o];
const label = tr(raw);
return (
);
})}
);
}
export function Switch({ checked, onChange, label }) {
return (
);
}
/**
* Number/unit input with scrubbing: drag the handle (or Alt+drag the field) to change,
* ArrowUp/Down ±1 (Shift ±10, Alt ±0.1). Accepts free CSS values (auto, calc(), var()).
*/
export function UnitInput({
value,
onChange,
units = ['px', '%', 'em', 'rem', 'vw', 'vh'],
placeholder,
min,
max,
step = 1,
unitless,
scrubRef,
className,
onCommit,
allowKeywords = true,
}) {
const parse = (v) => {
const m = String(v ?? '')
.trim()
.match(/^(-?[\d.]+)\s*([a-z%]*)$/i);
return m ? { n: parseFloat(m[1]), u: m[2] || (unitless ? '' : units[0] || '') } : null;
};
const p = parse(value);
const [text, setText] = useState(p ? String(p.n) : (value ?? ''));
const [unit, setUnit] = useState(p ? p.u : unitless ? '' : units[0]);
const focused = useRef(false);
useEffect(() => {
if (focused.current) return;
const q = parse(value);
setText(q ? String(q.n) : (value ?? ''));
if (q) setUnit(q.u);
}, [value]);
const emit = (n, u = unit) => {
if (n === '' || n === null || n === undefined) return onChange('');
if (typeof n === 'number') {
if (min !== undefined) n = Math.max(min, n);
if (max !== undefined) n = Math.min(max, n);
n = Math.round(n * 1000) / 1000;
return onChange(unitless || !u ? n : n + u);
}
const q = parse(n);
if (q) return emit(q.n, String(n).match(/[a-z%]+$/i) ? q.u : u);
if (allowKeywords) onChange(String(n));
};
const commit = () => {
emit(text);
onCommit && onCommit();
};
const startScrub = (e) => {
if (e.button !== 0) return;
const start = e.clientX;
const base = parse(value)?.n ?? parseFloat(placeholder) ?? 0;
let moved = false;
const mv = (ev) => {
const d = ev.clientX - start;
if (Math.abs(d) > 2) moved = true;
if (!moved) return;
const mult = ev.shiftKey ? 10 : ev.altKey ? 0.1 : 1;
const n = base + Math.round(d / 2) * step * mult;
setText(String(Math.round(n * 1000) / 1000));
emit(n);
document.body.classList.add('is-scrubbing');
};
const up = () => {
removeEventListener('pointermove', mv);
removeEventListener('pointerup', up);
document.body.classList.remove('is-scrubbing');
};
addEventListener('pointermove', mv);
addEventListener('pointerup', up);
};
useEffect(() => {
if (scrubRef?.current) {
const el = scrubRef.current;
el.addEventListener('pointerdown', startScrub);
el.classList.add('is-scrub');
return () => el.removeEventListener('pointerdown', startScrub);
}
});
const isNum = text === '' || /^-?[\d.]*$/.test(text);
return (
{
focused.current = true;
e.target.select();
}}
onBlur={() => {
focused.current = false;
commit();
}}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.target.blur();
}
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
const cur = parse(text + (unit || ''))?.n ?? (parseFloat(placeholder) || 0);
const d = (e.key === 'ArrowUp' ? 1 : -1) * (e.shiftKey ? 10 : e.altKey ? 0.1 : 1) * step;
const n = Math.round((cur + d) * 1000) / 1000;
setText(String(n));
emit(n);
}
}}
/>
{!unitless && units.length > 0 && isNum && (
)}
);
}
export function Field({ label, children, resp, set, inherited, onReset, help, stack, labelRef, className, extra }) {
return (
{resp && (
)}
{set && (
)}
{!set && inherited && }
{extra}
{children}
{help && stack &&
{tr(help)}
}
);
}
export function Section({ title, children, open: initial = true, id, right }) {
const key = 'nx-sec-' + (id || title);
const [open, setOpen] = useState(() => {
try {
const v = localStorage.getItem(key);
return v === null ? initial : v === '1';
} catch (e) {
return initial;
}
});
const toggle = () => {
setOpen(!open);
try {
localStorage.setItem(key, open ? '0' : '1');
} catch (e) {}
};
return (
{open && {children}
}
);
}
export function Empty({ icon, title, children }) {
return (
{tr(title)}
{children &&
{tr(children)}
}
);
}
export function Spinner({ s = 16 }) {
return ;
}
export function useDebounced(fn, ms = 250) {
const t = useRef();
return useCallback(
(...a) => {
clearTimeout(t.current);
t.current = setTimeout(() => fn(...a), ms);
},
[fn, ms],
);
}
/** Relative time in the site's language (Intl), e.g. "5 minutes ago" / "vor 5 Minuten". */
export function timeAgo(t) {
const ms = typeof t === 'number' ? t : Date.parse(t);
if (!Number.isFinite(ms)) return '';
const s = Math.round((ms - Date.now()) / 1000);
const cfg = (typeof window !== 'undefined' && (window.AKRAM || window.AKRAM_ADMIN)) || {};
let rtf;
try {
rtf = new Intl.RelativeTimeFormat((cfg.locale || document.documentElement.lang || 'en').replace('_', '-'), {
numeric: 'auto',
style: 'short',
});
} catch (e) {
rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto', style: 'short' });
}
const a = Math.abs(s);
if (a < 45) return rtf.format(0, 'second');
if (a < 3600) return rtf.format(Math.round(s / 60), 'minute');
if (a < 86400) return rtf.format(Math.round(s / 3600), 'hour');
if (a < 86400 * 30) return rtf.format(Math.round(s / 86400), 'day');
return new Date(ms).toLocaleDateString((cfg.locale || 'en').replace('_', '-'), {
day: 'numeric',
month: 'short',
year: 'numeric',
});
}