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 && (
{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', }); }