"use strict";
/**********************************************************************
* The main namespace for all webpage related functions. This has been
* in use for years, so there's quite a lot of legacy code inside.
* I might go back and rework some of it whenever I get some free time.
**********************************************************************/
// ********************************************************************
/** Namespace object for transient global variables. Cleared at every
* call to P.LoadURL()
*/
if (!window.G)
{
window.G = {};
}
// ********************************************************************
/** Persistent global variables. You must clear explicitly.
*/
if (!window.H)
{
window.H = {};
}
// ********************************************************************
/** Master namespace for all Pafera webpage functions.
*
* Note that there are quite a few additions to array and string in
* paferalib.js that JSDoc doesn't pick up, so be sure to check the
* source if you don't know where a function is coming from.
*/
class Pafera
{
// ------------------------------------------------------------------
/** Initializes all namespace variables.
*/
constructor()
{
this.LBUTTON = 0;
this.RBUTTON = 1;
this.MBUTTON = 2;
// Keycodes
this.LEFT = 37;
this.RIGHT = 39;
this.UP = 38;
this.DOWN = 40;
this.PAGE_UP = 33;
this.PAGE_DOWN = 34;
// User permission constants in P.userflags
this.USER_MUST_CHANGE_PASSWORD = 0x01;
this.USER_DISABLED = 0x02;
this.USER_NEED_APPROVAL = 0x04;
this.USER_REJECTED = 0x08;
this.USER_CAN_MANAGE_SELF = 0x10;
this.USER_IS_ADMIN = 0x20;
this.USER_CAN_UPLOAD = 0x40;
this.USER_CAN_UPLOAD_ORIGINALS = 0x80;
this.USER_CAN_POST_MESSAGES = 0x100;
this.USER_REQUIRE_PASSWORD = 0x200;
this.USER_REQUIRE_TOTP = 0x400;
this.USER_REQUIRE_PASSKEY = 0x800;
// Common language names for the language picker popup. These will
// only show up if the language codes are enabled in the control panel.
this.LANGNAMES = {
'ar': 'العربية',
'bn': 'বাংলা',
'de': 'Deutsch',
'en': 'English',
'es': 'español',
'fr': 'français',
'hi': 'हिन्दी',
'ja': '日本語',
'pt': 'Português',
'ru': 'ру́сский язы́к',
'zh': '中文'
}
this.lang = 'en';
this.userid = 0;
this.wallpaper = 'blue';
this.texttheme = 'dark';
this.media = {};
this.mediatoload = [];
this.onmedialoaded = [];
this.currentmediaid = 0;
this.events = {};
this.delayedevents = {};
this.messageboxids = [];
this.features = [];
this.istouch = 0;
this.mousex = 0;
this.mousey = 0;
this.currenty = 0;
this.holdpos = 0;
this.ignoreup = 0;
this.lastcenterx = 0;
this.lastcentery = 0;
this.lastclicktime = 0;
this.emsize = 0;
this.firstview = 1;
this.screensize = 'small';
this.baseurl = '';
this.currenturl = '';
this.viewportwidth = 0;
this.viewportheight = 0;
this.timeoffset = new Date().getTimezoneOffset();
this.ischrome = /\bChrome\b/.test(navigator.userAgent);
this.isfirefox = /\bFirefox\b/.test(navigator.userAgent);
this.issafari = /\bSafari\b/.test(navigator.userAgent);
this.isedge = /\bEdge\b/.test(navigator.userAgent);
this.isandroid = /\bAndroid\b/.test(navigator.userAgent);
this.useadvanced = this.ischrome || this.isfirefox || this.issafari || this.isedge;
this.preloads = {};
this.screenorientation = window.innerWidth > window.innerHeight ? 'landscape' : 'portrait';
this.currentpermissions = '';
this.inphoneapp = (typeof JSBridge != "undefined");
this.onuploadfinished = 0;
this.layerstack = [];
this.ajaxrefreshlimit = 5;
this.ajaxrefreshes = 0;
this.pageloadedtime = {};
this.pages = [];
this.pageloadhandlers = {};
this.pageobjs = {};
this.timeoutids = [];
this.lazyloads = ['.LazyLoad'];
this.fullscreennum = 0;
this.topbarhidden = 0;
this.contenttoppos = 0;
this.topbarsize = 0;
this.bottombarsize = 0;
this.autohidebottombar = 0;
this.numcanvasundos = 10;
this.previouscanvases = [];
this.nextcanvases = [];
this.TIMEZONES = {};
for (let i = -12; i < 14; i++)
{
this.TIMEZONES[i] = `GMT ${i > 0 ? '+' + i : i}`;
}
this.savedlayout = {};
this.ClearHandlers();
}
// --------------------------------------------------------------------
/** Runs the pageload handler after everything has finished loading
*/
OnReadyStateChange(e)
{
if (document.readyState != 'complete')
{
return;
}
// Some browsers fire the ready state event before everything has
// actually fuly loaded, so we delay for a bit
setTimeout(
function()
{
P.RunHandlers('pageload');
},
200
);
}
// --------------------------------------------------------------------
/** Turns a HTML text fragment into DOM objects
*
* @param {string} html - HTML text fragment
*/
HTMLToDOM(html)
{
if (IsArray(html))
{
html = html.join('\n');
}
return document.createRange().createContextualFragment(html);
}
// --------------------------------------------------------------------
/** Returns a string where HTML entities are converted.
*
* @param {string} html - HTML text fragment
*/
EscapeHTML(html)
{
if (IsArray(html))
{
html = html.join('\n');
}
return document
.createElement("div")
.appendChild(
document.createTextNode(html)
).parentNode.innerHTML;
}
// --------------------------------------------------------------------
/** Appends the HTML string as the last child of selector
*
* @param {string} selector - CSS selector
* @param {string} html - HTML code fragment to append
*/
Add(selector, html)
{
return E(selector).appendChild(P.HTMLToDOM(html));
}
// --------------------------------------------------------------------
/** Adds an event handler to selector
*
* @param {string} selector - CSS selector
* @param {string} events - event names separated by spaces
* @param {function} func - event handler
* @param {dict} options - options to pass to addEventListener()
*/
On(selector, events, func, options)
{
let elements = IsString(selector) ? Q(selector) : [selector];
for (let r of elements)
{
if (!r)
{
continue;
}
let eventnames = events.split(' ');
for (let s of eventnames)
{
r.addEventListener(s, func, options);
}
}
return this;
}
// --------------------------------------------------------------------
/** The inverse of On(), removing event handlers from a selector
*
* @param {string} selector - CSS selector
* @param {string} events - event names separated by spaces
* @param {function} func - event handler
* @param {dict} options - options to pass to addEventListener()
*/
Off(selector, events, func, options)
{
let elements = Q(selector);
for (let r of elements)
{
if (!r)
{
continue;
}
let eventnames = events.split(' ');
for (let s of eventnames)
{
r.removeEventListener(s, func, options);
}
}
return this;
}
// --------------------------------------------------------------------
/** Restores a hidden selector to its previous display mode or
* specified display.
*
* @param {string} selector - CSS selector
* @param {string} display - Manually set the display CSS property
*/
Show(selector, display = 'block')
{
let elements = Q(selector);
for (let r of elements)
{
if (!r)
{
continue;
}
if (r.olddisplay && r.olddisplay != 'none')
{
r.style.display = r.olddisplay;
} else
{
r.style.display = display;
}
}
return this;
}
// --------------------------------------------------------------------
/** Removes a selector from view
*
* @param {string} selector - CSS selector
*/
Hide(selector)
{
let elements = Q(selector);
for (let r of elements)
{
if (!r || r.style.display == 'none')
{
continue;
}
r.olddisplay = getComputedStyle(r).display;
r.style.display = 'none';
}
return this;
}
// --------------------------------------------------------------------
/** Stores an object into the global registry. Used by EditPopup()
* to save the original object for editing later.
*
* @param {object} obj - The value to store
* @param {string} name - The name to identify the object. If name is
* not specified, then this function will generate a random integer
* ID and return it.
*/
Store(obj, name = 0)
{
if (name)
{
P.pageobjs[name] = obj;
return name;
}
for (let i = 0; i < 1000; i++)
{
let k = RandInt(0, 99999999);
if (!(k in P.pageobjs))
{
P.pageobjs[k] = obj;
return k;
}
}
return this;
}
// --------------------------------------------------------------------
/** Retrieves an object from the global registry, or returns an empty
* object otherwise.
*
* @param {string} name - name of an object which was previously stored
*/
Retrieve(name)
{
if (P.pageobjs[name])
{
return P.pageobjs[name];
}
let obj = P.pageobjs[parseInt(name)];
return obj ? obj : {};
}
// --------------------------------------------------------------------
/** Explicitly deletes an object from the global registry.
*
* @param {string} name - name of an object which was previously stored
*/
Remove(name)
{
if (P.pageobjs[name])
{
delete P.pageobjs[name];
}
return this;
}
// --------------------------------------------------------------------
/** Shortcut to get the target element from an event for all browsers.
*/
Target(e)
{
e = e || window.event;
return e.target || e.srcElement;
}
// --------------------------------------------------------------------
/** Retrieves the parent element of el, or a parent element that matches
* the selector specified in searchselector to do multilevel searching.
*
* @param {string} el - CSS selector or HTML element
* @param {string} searchselector - name of a particular class that
* you're looking for, or a function with the signature func(element)
* that returns true when the proper parent is found.
*/
Parent(el, searchselector)
{
let parent = E(el);
for (;;)
{
if (!parent)
break;
if (IsString(searchselector))
{
switch (searchselector[0])
{
case '.':
if (!parent.classList)
{
return 0;
}
if (parent.classList.contains(searchselector.substr(1)))
{
return parent;
}
break;
case '#':
if (parent.id == searchselector.substr(1))
{
return parent;
}
break;
default:
if (!parent.tagName || !parent.tagName.tolowerCase)
{
return 0;
}
if (parent.tagName.tolowerCase() == searchselector)
{
return parent;
}
}
} else if (searchselector && searchselector(parent))
{
return parent;
}
parent = parent.parentNode;
}
return 0;
}
// --------------------------------------------------------------------
/** Returns a parent of the event target that has the specified selector
*
* @param {event} e - DOM event
* @param {string} searchselector - CSS selector that you want to search for.
*/
TargetClass(e, searchselector)
{
return P.Parent(P.Target(e), searchselector);
}
// --------------------------------------------------------------------
/** Returns the option element that is currently chosen in a select.
*
* @param {string} selector - CSS selector that you want to search for.
*/
Selected(selector)
{
let item = E(selector);
if (item)
{
return item.options[item.selectedIndex];
}
return '';
}
// --------------------------------------------------------------------
/** Old fashioned printf() style debugging at the bottom of the page.
*
* @param {string} message - message to print.
*/
Debug(message)
{
E('.DebugMessages').innerHTML += message + "\n";
return this;
}
// --------------------------------------------------------------------
/** Stops the default behavior for the current event.
*
* @param {event} e - event to cancel
*/
CancelBubble(e)
{
e = e || window.event;
if (!e)
{
return;
}
if (e.stopPropagation)
{
e.preventDefault();
e.stopPropagation();
} else
{
e.cancelBubble = true;
}
return this;
}
// --------------------------------------------------------------------
/** Fills the selectors' innerText with the text or list in ls.
*
* @param {string} selector - CSS selector.
* @param {string} ls - string or list of strings to replace innerText
* with.
*/
Fill(selector, ls)
{
if (!IsArray(ls))
{
ls = [ls];
}
ls = ls.join('\n');
let elements = Q(selector);
for (let r of elements)
{
r.innerText = ls;
}
return this;
}
// --------------------------------------------------------------------
/** Fills the selectors' innerHTML with the text or list in ls.
*
* @param {string} selector - CSS selector.
* @param {string} ls - string or list of strings to replace innerText
* with.
*/
HTML(selector, ls)
{
if (!IsArray(ls))
{
ls = [ls];
}
ls = ls.join('\n');
let elements = Q(selector);
for (let r of elements)
{
r.innerHTML = ls;
}
return this;
}
// --------------------------------------------------------------------
/** Separates a short code into segments of three character directory
* names. Useful for not having millions of files in a single directory.
*
* @param {shortcode} code
*/
CodeDir(code)
{
let ls = [];
for (let i = 0, l = code.length; i < l; i += 3)
{
ls.push(code.substr(i, 3))
}
return ls.join('/');
}
// --------------------------------------------------------------------
/** Calls func with the selector element timeout milliseconds after the
* last input event. Useful for confirming user actions without having
* them explicitly pushing a confirm button.
*
* @param {string} selector - The CSS selector to bind to
* @param {function} func - The function to run with the signature
* func(element, event)
* @param {int} timeout - How long to delay before triggering the
* function.
*/
DoneTyping(selector, func, timeout)
{
timeout = timeout || 1000;
P.On(
selector,
'keydown paste input',
function(e)
{
let el = P.Target(e);
if (el.typingtimeoutid)
{
clearTimeout(el.typingtimeoutid);
}
el.typingtimeoutid = setTimeout(
function()
{
el.typingtimeoutid = 0;
func(el, e);
},
timeout
);
}
);
return this;
}
// --------------------------------------------------------------------
/** An advanced click function using hammer.js as a backend that supports
* single click, double click, and hold functions along with touch
* support and multiple button mice.
*
* @param {string} selector - The CSS selector to bind to
* @param {int} button - One of the Pafera button constants such as
* P.LBUTTON
* @param {function} clickfunc - Handler for single clicks with the
* signature func(event)
* @param {function} doubleclickfunc - Handler for double clicks with the
* signature func(event)
* @param {function} holdfunc - Handler for tap and hold with the
* signature func(event)
*/
OnClick(
selector,
button,
clickfunc,
doubleclickfunc,
holdfunc
)
{
let elements = Q(selector);
for (let element of elements)
{
if (element.hammer)
{
continue;
}
element.hammer = new Hammer.Manager(
element,
{
cssProps: {
userSelect: 'auto'
}
}
);
let hammer = element.hammer;
if (clickfunc)
{
hammer.add(new Hammer.Tap());
hammer.on(
'tap',
function(e)
{
let now = Date.now();
// Prevent ghost double clicks
if (e.center)
{
if (P.lastcenterx == e.center.x
&& P.lastcentery == e.center.y
&& (now < P.lastclicktime + 200)
)
{
return;
}
P.lastcenterx = e.center.x;
P.lastcentery = e.center.y;
}
if (clickfunc && (e.button == button || e.button == undefined))
{
P.lastclicktime = Date.now();
clickfunc(e);
}
}
);
}
if (doubleclickfunc)
{
hammer.add(new Hammer.Tap({event: 'doubletap', taps: 2 }));
hammer.on(
'doubletap',
function(e)
{
if (doubleclickfunc
&& (e.button == button || e.button == undefined)
)
{
doubleclickfunc(e);
}
}
);
}
if (holdfunc)
{
hammer.add(new Hammer.Press());
hammer.on(
'press',
function(e)
{
if (holdfunc && (e.button == button || e.button == undefined))
{
holdfunc(e);
}
}
);
}
}
return this;
}
// --------------------------------------------------------------------
/** Fires when the user switches tabs. Useful for detecting
* turning off music or low intelligence cheaters on tests. ;)
*
* @param {function} onleave - Handler when the user leaves the tab
* with signature func(event)
* @param {function} onreturn - Handler when the user returns to the tab
* with signature func(event)
*/
OnUserSwitch(onleave, onreturn)
{
document.addEventListener('blur', onleave);
document.addEventListener('focus', onreturn);
}
// --------------------------------------------------------------------
/** Adds a handler for custom events such as pageload or orientationchange
*
* @param {string} eventname - The custom event to listen for
* @param {function} func - The handler with signature func(event)
* @param {int} priority - An int indicating the order in which this
* handler should be called relative to other handlers.
*/
AddHandler(eventname, func, priority = 0)
{
if (!this.events[eventname])
{
this.events[eventname] = [];
}
if (IsEmpty(this.events[eventname][priority]))
{
this.events[eventname][priority] = [];
}
this.events[eventname][priority].push(func);
return this;
}
// --------------------------------------------------------------------
/** Manually run handlers for an event. Used for AJAX pages.
*
* @param {string} eventname - The name of the event
* @param {event} e - The event to pass to the handler
*/
RunHandlers(eventname, e)
{
if (eventname == 'pageload')
{
P.LazyLoad();
let flexitems = Q('.FlexItem');
for (let r of flexitems)
{
r.style.flex = 1;
}
P.SetWallpaper();
}
if (IsEmpty(P.events[eventname]))
{
return;
}
// Sort events by priority
let keys = Keys(P.events[eventname]);
keys.sort();
for (let key of keys)
{
if (!P.events[eventname])
{
break;
}
let funcs = P.events[eventname][key];
for (let func of funcs)
{
if (IsFunc(func))
{
func(e);
}
}
}
if (eventname == 'pageleave')
{
while (P.fullscreennum)
{
P.RemoveFullScreen();
}
P.lazyloads = ['img', '.LazyLoad'];
P.events = {};
P.pageobjs = {};
P.layerstack = {};
for (let timeoutid of P.timeoutids)
{
clearTimeout(timeoutid);
}
P.timeoutids = [];
G = {};
}
return this;
}
// --------------------------------------------------------------------
/** Clear all handlers for eventname, or leave eventname blank to
* clear every registered handler on the page.
*
* @param {string} eventname
*/
ClearHandlers(eventname)
{
if (eventname)
{
this.events[eventname] = {}
} else
{
this.events = {};
this.AddHandler('scroll', this.LazyLoad);
this.AddHandler('scroll', this.HideTopBar);
}
return this;
}
// --------------------------------------------------------------------
/** Timing functions used in animations
*
* @param {int} t - The time to calculate.
*/
LinearTiming(t)
{
return t;
}
// --------------------------------------------------------------------
/** Timing functions used in animations
*
* @param {int} t - The time to calculate.
*/
SinTiming(t)
{
return Math.sin(t);
}
// --------------------------------------------------------------------
/** Timing functions used in animations
*
* @param {int} t - The time to calculate.
*/
CosTiming(t)
{
return Math.cos(t);
}
// --------------------------------------------------------------------
/** Timing functions used in animations
*
* @param {int} t - The time to calculate.
*/
PowTiming(t)
{
return Math.pow(t, 5);
}
// --------------------------------------------------------------------
/** Timing functions used in animations
*
* @param {int} t - The time to calculate.
*/
InversePowTiming(t)
{
return 1 - Math.pow(1 - t, 5);
}
// --------------------------------------------------------------------
/** Timing functions used in animations
*
* @param {int} t - The time to calculate.
*/
ArcTiming(t)
{
return 1 - Math.sin(Math.acos(t));
}
// --------------------------------------------------------------------
/** Timing functions used in animations
*
* @param {int} t - The time to calculate.
*/
InverseArcTiming(t)
{
return Math.sin(Math.acos(1 - t));
}
// --------------------------------------------------------------------
/** Timing functions used in animations
*
* @param {int} t - The time to calculate.
*/
BounceTiming(t)
{
for (let a = 0, b = 1; 1; a += b, b /= 2)
{
if (t >= (7 - 4 * a) / 11)
{
return -Math.pow((11 - 6 * a - 11 * t) / 4, 2) + Math.pow(b, 2)
}
}
}
// --------------------------------------------------------------------
/** Uses requestAnimationFrame() to smoothly animate an element.
*
* Thanks to javascript.info for the basic idea.
*
* @param {function} updatefunc - The function to call to do the animation
* @param {int} duration - How long the animation will last in milliseconds
* @param {function} timing - One of the Pafera timing functions such as
* P.LinearTiming
*/
Animate(updatefunc, duration, timing)
{
let start = performance.now();
if (!timing)
{
timing = P.LinearTiming;
}
requestAnimationFrame(
function animate(time)
{
// timeFraction goes from 0 to 1
let fraction = (time - start) / duration;
if (fraction > 1)
{
fraction = 1;
}
// calculate the current animation state
let progress = timing(fraction);
updatefunc(progress); // draw it
if (fraction < 1)
{
requestAnimationFrame(animate);
}
}
);
return this;
}
// --------------------------------------------------------------------
/** Helper function to automatically hide the topbar when the user
* scrolls down, then show it again when the user scrolls up like how
* Chrome on Android works.
*
* Unfortunately, it seems that certain browsers don't play well with
* this code, so we leave it disabled for now.
*
* @param {event} e - The scroll event
*/
HideTopBar(e)
{
if (G.disablehidetopbar)
{
return;
}
let toppos = E('.PageBodyGrid').scrollTop;
let topbar = E('.TopBarGrid');
let bottombar = E('.BottomBarGrid');
if (toppos > P.contenttoppos)
{
if (!P.topbarhidden)
{
P.topbarhidden = 1;
P.topbarsize = topbar.clientHeight;
P.Animate(
function(fraction)
{
topbar.style.height = (P.topbarsize * (1 - fraction)) + 'px';
},
300
);
if (P.autohidebottombar)
{
P.bottombarsize = bottombar.clientHeight;
P.Animate(
function(fraction)
{
bottombar.style.height = (P.bottombarsize * (1 - fraction)) + 'px';
},
300
);
}
}
} else if (toppos < 64 || (toppos < P.contenttoppos && Math.abs(toppos - P.contenttoppos) > 64))
{
if (P.topbarhidden)
{
P.topbarhidden = 0;
P.Animate(
function(fraction)
{
topbar.style.height = (P.topbarsize * fraction) + 'px';
},
300
);
if (P.autohidebottombar)
{
P.Animate(
function(fraction)
{
bottombar.style.height = (P.bottombarsize * fraction) + 'px';
},
300
);
}
}
}
P.contenttoppos = top;
return this;
}
// --------------------------------------------------------------------
/** Adds an explicitly named timer. Handy if you need a bunch of
* simultaneous timers.
*
* @param {string} name - The name for the timer
* @param {int} timeout - The timeout in milliseconds
* @param {function} func - The function to call
*/
AddTimer(func, timeout, name)
{
let timerid = setTimeout(func, timeout);
P.timeoutids[name] = timerid;
return timerid;
}
// --------------------------------------------------------------------
/** Removes the timer created by P.AddTimer()
*
* @param {string} name - The name for the timer
*/
ClearTimer(name)
{
if (!name || !P.timeoutids[name])
{
return;
}
clearTimeout(P.timeoutids[name]);
delete P.timeoutids[name];
return this;
}
// --------------------------------------------------------------------
/** Returns true if the selector is currently visible
*
* @param {string} selector - CSS selector
*/
IsVisible(selector)
{
return E(selector).style.display != 'none';
}
// --------------------------------------------------------------------
/** Shows a centered loading image in the selector to indicate that the user
* should wait for an operation to complete.
*
* @param {string} selectr - CSS selector
* @param {string} text - Optional text to display along with the loading GIF
*/
Loading(selector, text = '', scrolltoelement = 0)
{
let el = E(selector);
if (el)
{
el.innerHTML = `<div class="Center Flex FlexCenter">
<div class=FlexItem></div>
<div class="MaxWidth2000 MarginAuto Flex FlexCenter FlexVertical">
<img loading="lazy" alt="Loading" class="FlexItem" src="${P.baseurl}/system/loading.gif">
${text
? '<div class="FlexItem">' + text + '</div>'
: ''}
</div>
<div class=FlexItem></div>
</div>`;
if (scrolltoelement)
{
P.ScrollTo(el);
}
}
return this;
}
// --------------------------------------------------------------------
/** An advanced form of the scrollIntoView() function which scrolls
* smoothly and uses the minimum amount of scrolling needed.
*
* @param {string} selector - CSS selector
*/
ScrollTo(selector)
{
setTimeout(
function()
{
let el = E(selector);
if (el)
{
let rt = P.AbsoluteRect(el);
let above = rt.top < window.pageYOffset;
if (above || rt.bottom > window.pageYOffset + window.innerHeight)
{
el.scrollIntoView({
behavior: 'smooth',
block: above ? 'start' : 'end'
});
}
}
},
200
);
return this;
}
// --------------------------------------------------------------------
/** Gets the absolute coordiates rect for selector with page offsets
*
* @param {string} selector - CSS selector
*/
AbsoluteRect(selector)
{
let el = E(selector);
if (!el)
{
throw Error(`"${selector}" is not a valid selector`);
}
let rt = el.getBoundingClientRect();
return {
left: rt.left + window.pageXOffset,
top: rt.top + window.pageYOffset,
right: rt.right + window.pageXOffset,
bottom: rt.bottom + window.pageYOffset,
width: rt.width,
height: rt.height
};
}
// --------------------------------------------------------------------
/** Gets the size of an em for sizing calculations.
*
* Note that this function will not work until the page has fully
* loaded, so use it only after the pageload event.
*
* @param {string} selector - An optional CSS selector to examine
* instead of the page body.
*/
EMSize(selector)
{
selector = selector || document.body;
return parseFloat(
getComputedStyle(selector).fontSize
);
}
// --------------------------------------------------------------------
/** Create a popup window that animates into view with a variety of
* options, automatically sized to the current screen width. The
* window will position itself at the center of the screen by default,
* or next to an selector if options.parent is specified.
*
* Set options.closeonmouseout to 1 if you want the popup
* to disappear when the mouse cursor leaves the popup or if the user
* taps somewhere else on the screen.
*
* Set options.noanimation to 1 if you want the popup to instantly appear.
*
* @param {string} content - The contents for the popup
* @param {object} options - The standard options dict. This function
* has a multitude of options, so please look at the source to see
* all of them.
*/
async Popup(content, options)
{
let defaults = {
position: 'absolute',
left: '',
top: '',
width: '16em',
height: 'auto',
color: 'black',
backgroundColor: 'white',
zIndex: 20000,
overflowX: 'hidden',
overflowY: 'auto',
maxHeight: '80vh',
animationtime: 300,
extraclasses: ''
};
switch (P.screensize)
{
case 'medium':
defaults.width = '22em';
break;
case 'large':
defaults.width = '30em';
break;
};
options = Merge(defaults, options);
// Generate an ID for this popup
let popupclass = options.popupclass ? options.popupclass : 0;
// Remove leading period from class
if (popupclass && popupclass[0] == '.')
{
popupclass = popupclass.substr(1);
}
if (!popupclass)
{
for (;;)
{
popupclass = 'Popup' + RandInt(1, 9999);
if (!Q('.' + popupclass).length)
{
break;
}
}
}
let popup = document.createElement('div');
popup.className = "Popup " + popupclass + ' ' + options.extraclasses;
CopyValues(options, popup.style);
if (IsArray(content))
{
content = content.join("\n");
}
popup.innerHTML = content;
document.body.appendChild(popup);
let viewportrect = P.ViewPortRect();
let popuprect = popup.getBoundingClientRect();
let parentrect = {};
let left = 0;
let top = 0;
let emsize = P.EMSize();
if (options.parent)
{
parentrect = P.AbsoluteRect(options.parent);
switch (options.position)
{
case 'left':
left = parentrect.left - popuprect.width;
top = parentrect.top;
break;
case 'top':
left = parentrect.left;
top = parentrect.top - popuprect.height;
break;
case 'right':
left = parentrect.right;
top = parentrect.top;
break;
case 'bottom':
left = parentrect.left;
top = parentrect.bottom;
default:
// Find the side with the most space and put the popup there
let leftspace = Math.abs(parentrect.left - viewportrect.left);
let rightspace = Math.abs(viewportrect.right - parentrect.right);
let topspace = Math.abs(parentrect.top - viewportrect.top);
let bottomspace = Math.abs(viewportrect.bottom - parentrect.bottom);
if (leftspace > rightspace && leftspace > topspace && leftspace > bottomspace)
{
left = parentrect.left - popuprect.width;
top = parentrect.top;
} else if (rightspace > leftspace && rightspace > topspace && rightspace > bottomspace)
{
left = parentrect.right;
top = parentrect.top;
} else if (topspace > leftspace && topspace > rightspace && topspace > bottomspace)
{
left = parentrect.left;
top = parentrect.top - popuprect.height;
} else
{
left = parentrect.left;
top = parentrect.bottom;
}
};
} else
{
parentrect = {
left: P.mousex,
top: P.mousey,
width: 0,
height: 0
};
left = options.left
? parseInt(options.left)
: viewportrect.width / 2 - popuprect.width / 2 + viewportrect.left;
top = options.top
? parseInt(options.top)
: viewportrect.height / 2 - popuprect.height / 2 + viewportrect.top;
}
if (left + popuprect.width > viewportrect.right)
{
left = viewportrect.right - popuprect.width - P.emsize;
}
if (top + popuprect.height > viewportrect.bottom)
{
top = viewportrect.bottom - popuprect.height;
}
if (left < viewportrect.left)
{
left = viewportrect.left;
}
if (top < viewportrect.top)
{
top = viewportrect.top;
}
if (!options.noanimation)
{
CopyValues(
{
left: parentrect.left + 'px',
top: parentrect.top + 'px',
width: parentrect.width + 'px',
height: parentrect.height + 'px'
},
popup.style
);
let finalwidth = parseInt(options.width);
let widthunit = options.width.substr(finalwidth.length);
let xdiff = parentrect.left - left;
let ydiff = parentrect.top - top;
P.Animate(
function(percent)
{
CopyValues(
{
left: (parentrect.left - (xdiff * percent)) + 'px',
top: (parentrect.top - (ydiff * percent)) + 'px',
width: (percent == 1)
? options.width
: (finalwidth * percent) + widthunit,
height: options.height
},
popup.style
);
},
options.animationtime
);
} else
{
CopyValues(
{
left: left + 'px',
top: top + 'px'
},
popup.style
);
}
// Ensure that the bottom of the popup stays within screen
// boundaries
setTimeout(
function()
{
let rt = popup.getBoundingClientRect();
let bottombarrt = E('.BottomBarGrid').getBoundingClientRect();
let maxheight = window.innerHeight - bottombarrt.height;
if (rt.bottom > maxheight)
{
popup.style.height = (rt.height - (rt.bottom - maxheight)) + 'px';
}
},
options.animationtime + 200
);
if (options.closeonmouseout)
{
setTimeout(
function()
{
// Make sure that the popup doesn't immediately close in the short term
P.mousex = left + 1;
P.mousey = top + 1;
P.ClosePopupOnMouseOut([options.parent, '.' + popupclass], options.closefunc);
},
500
);
}
P.AddLayer('.' + popupclass);
return '.' + popupclass;
}
// --------------------------------------------------------------------
/** Helper function to automatically close the popup in elements
* when the mouse cursor moves out of its boundaries or the user taps
* somewhere else.
*
* @param {list} elements - A list of DOM elements to examine
* @param {function} closefunc - An option handler that is triggered
* when an element is closed with the signature func(element)
*/
ClosePopupOnMouseOut(elements, closefunc)
{
let inrects = 0;
for (let element of elements)
{
try
{
let rt = P.AbsoluteRect(element);
if (InRect(P.mousex, P.mousey, rt))
{
inrects = 1;
break;
}
} catch (error)
{
// No action for missing elements
}
}
if (!inrects)
{
let selector = Last(elements);
P.RemoveLayer(selector);
let el = E(selector);
if (el)
{
if (closefunc)
{
closefunc(el);
}
el.remove();
}
return;
}
setTimeout(
function()
{
P.ClosePopupOnMouseOut(elements, closefunc);
},
100
);
return this;
}
// --------------------------------------------------------------------
/** Closes the popup containing selector
*
* @param {string} selector - CSS selector of the popup or an element
* inside the popup
*/
CloseThisPopup(selector)
{
let el = E(selector);
if (!el)
{
//P.ErrorPopup('Cannot close popup ' + selector + ': no element found.');
return this;
}
if (el.classList.contains('Popup'))
{
P.ClosePopup(el);
} else
{
P.ClosePopup(P.Parent(el, '.Popup'));
}
return this;
}
// --------------------------------------------------------------------
/** A generic popup for getting one single input from the user.
*
* @param {string} title - Title for the popup's title bar
* @param {function} onfinished - Function to be called when the user
* clicks on "Finished" with signature func(value). If the user
* cancels, then this function is never called.
* @param {string} inputelement - HTML code for the input element, or
* blank for the default text input. The input element must have the
* CSS class "InputPopupInput".
* @param {object} options - Extra options to pass to the Popup()
* function
*/
async InputPopup(title, onfinished, inputelement, options)
{
inputelement = inputelement || '<input type="text" class="InputPopupInput" style="width: 100%">';
options = options || {};
let popupclass = await P.Popup(
`<div class="whiteb Raised Rounded Bordered">
<div class="TitleBar">${title}</div>
<div class="Pad50">
${inputelement}
<div class="ButtonBar">
<a class="Color3 FinishedButton">${T.finished}</a>
<a class="Color1" onclick="P.CloseThisPopup(this)">${T.back}</a>
</div>
</div>
</div>`,
options
);
P.On(
popupclass + ' .FinishedButton',
'click',
function(e)
{
onfinished(E('.InputPopupInput').value);
P.ClosePopup(popupclass);
}
);
P.OnEnter(
'.InputPopupInput',
function(e)
{
onfinished(E('.InputPopupInput').value);
P.ClosePopup(popupclass);
}
);
E('.InputPopupInput').focus();
}
// --------------------------------------------------------------------
/** A generic messagebox for informing the user that something has
* happened.
*
* @param {string} content - The contents of the messagebox
* @param {list} buttons - A list of buttons in the format
* [buttontext, onclickfunc]
* @param {object} options - Set popupclass to explicitly set the
* class for the popup.
*/
async MessageBox(content, buttons, options = {})
{
if (!options.popupclass)
{
let id = RandInt(1, 99999999);
while (P.messageboxids.has(id))
{
id = RandInt(1, 99999999);
}
options.popupclass = 'MsgBox' + id;
}
if (options.popupclass && options.popupclass[0] == '.')
{
options.popupclass = options.popupclass.substr(1);
}
let ls = [
'<div class="whiteb Rounded Raised Border20 OverflowAuto">',
];
if (!IsArray(content))
{
content = [content];
}
ls.push(content);
ls.push('<div class="MessageBoxBar"></div></div>');
await P.Popup(ls.join('\n'), options);
if (!buttons)
{
buttons = [
[T.back, function() { P.CloseThisPopup('.' + options.popupclass + ' .MessageBoxBar') }]
];
}
P.MakeButtonBar('.' + options.popupclass + ' .MessageBoxBar', buttons);
return options.popupclass;
}
// --------------------------------------------------------------------
/** A popup with a nice error icon.
*
* @param {string} content - The contents of the popup
* @param {object} options - Options to pass to P.MessageBox(), or an
* empty object by default.
*/
ErrorPopup(content, options = {})
{
P.MessageBox(
`<table class="Border0">
<tr>
<td class="Size300 Pad50">⚠️</td>
<td class="ErrorPopupMsg Pad50">${content}</td>
</tr>
</table>
<br class="Cleared">`,
[
[T.back, function() { P.CloseThisPopup('.ErrorPopupMsg') }]
],
Merge(
{
popupclass: 'ErrorPopup'
},
options
)
);
CopyValues(
{
maxWidth: '20em',
maxHeight: '20em',
overflow: 'auto',
overflowWrap: 'break-word',
wordWrap: 'break-word'
},
E('.ErrorPopupMsg').style
);
return this;
}
// --------------------------------------------------------------------
/** Convenience function to show an error message inside of a selector.
*
* @param {string} selector - A CSS selector to use
* @param {error} error - The error to display
*/
ShowError(selector, error)
{
P.HTML(
selector,
`<div class="yellowb Pad50">
⚠️ ${error}
</div>`
);
return this;
}
// --------------------------------------------------------------------
/** Closes the popup with a nice animation
*
* @param {string} selector - CSS selector of the popup
*/
ClosePopup(selector)
{
if (!selector)
{
return;
}
selector = selector || '.Popup';
let el = E(selector);
if (!el)
{
return;
}
let rt = P.AbsoluteRect(el);
P.Animate(
function (fraction)
{
CopyValues(
{
left: (rt.left * (1 - fraction)) + 'px',
top: (rt.top * (1 - fraction)) + 'px',
width: (rt.width * (1 - fraction)) + 'px',
height: (rt.height * (1 - fraction)) + 'px'
},
el.style
);
},
300
);
setTimeout(
function()
{
let popups = Q(selector);
if (popups.length)
popups[0].remove();
P.layerstack.remove(selector);
},
300
);
return this;
}
// --------------------------------------------------------------------
/** Helper function for the custom combobox control
*
* @param {string} selector - CSS selector
* @param {string} comboboxname - The name for the combobox
*/
OnComboBoxChange(selector, comboboxname)
{
let dict = P.Retrieve(comboboxname);
if (!dict)
{
return;
}
let el = E(selector);
let input = el.next();
let prevkey = el.dataset.previouskey;
dict[prevkey] = input.value;
let key = el.value;
let value = dict[key];
el.dataset.previouskey = key;
if (!value)
{
value = '';
}
input.value = value;
}
// --------------------------------------------------------------------
/** Creates a progressbar widget in selector.
*
* Use P.SetProgressBarValue() to change the value after creation.
*
* @param {string} selector - CSS selector
* @param {object} options - Set title to give the progressbar a label.
* Set showvalue to show a numerical value in addition to the bar.
*/
MakeProgressBar(selector, options)
{
options = options || {};
let cls = options.cls || 'ProgressBar';
let min = options.min || 0;
let max = options.max || 100;
let value = options.value || max;
let percent = (value - min) / (max - min) * 100;
let el = E(selector);
el.dataset.min = min;
el.dataset.max = max;
el.dataset.value = value;
el.dataset.percent = percent;
el.classList.add('ProgressBar')
el.classList.add('Flex');
P.HTML(
el,
`
${options.title
? '<div class=Title>' + EncodeEntities(options.title) + '</div>'
: ''
}
<div class="BackgroundBar">
<div class="ForegroundBar" style="width: ${percent}%"></div>
</div>
${options.showvalue
? '<div class="Value">' + value + '</div>'
: ''
}
`
);
}
// --------------------------------------------------------------------
/** Sets the value of a progressbar widget created by P.MakeProgressBar()
*
* @param {string} selector - CSS selector
* @param {number} value - The new value for the bar
*/
SetProgressBarValue(selector, value)
{
let el = E(selector);
let min = parseInt(el.dataset.min);
let max = parseInt(el.dataset.max);
let percent = (value - min) / (max - min) * 100;
el.dataset.percent = percent;
let bar = E(selector + ' .ForegroundBar');
bar.style.width = percent + '%';
let valueel = E(selector + ' .Value');
if (valueel)
{
valueel.innerText = value;
}
}
// --------------------------------------------------------------------
/** Creates a combobox widget in selector, which is basically a text
* control with a select beside it allowing the user to choose the
* value from a list of options or manually type in the value.
*
* @param {string} selector - CSS selector
* @param {string} name - name for the input element
* @param {object} dict - A set of value: displayname pairs to populate the
* select with.
* @param {string} defaultkey - The default value for the select
*/
ComboBox(selector, name, dict, defaultkey)
{
if (IsEmpty(dict))
{
return;
}
let uid = P.Store(dict);
let keys = Keys(dict);
keys.sort();
if (!defaultkey)
{
defaultkey = keys[0];
}
let ls = [
`<div class=ComboBox data-name="${EncodeEntities(name)}" data-uid="${uid}">',
'<select onchange="P.OnComboBoxChange(this, '${uid}')" data-prevvalue="${defaultkey}">`
];
for (let key of keys)
{
if (!key)
{
continue;
}
ls.push(
`<option ${key == defaultkey ? 'selected=1' : ''}>${dict[key]}</option>`
);
}
ls.push('<input type=text value=""></div>');
return ls;
}
// --------------------------------------------------------------------
/** Retrieves the object with an ID stored in the element's dataset.objid
* property. Used by EditPopup() to simplify saving object data that is
* not easily stuffed in input elements.
*
* @param {string} selector - CSS selector
*/
GetSelectorObj(selector)
{
return P.Retrieve(E(selector).dataset.objid);
}
// --------------------------------------------------------------------
/** The main function to interface with the database API, EditPopup()
* has a variety of options to faciliate entering data through a
* web interface.
*
* This will also automatically convert DATETIME fields from UTC time
* to local time when displaying, and local time to UTC time when
* sending data, so be aware of this automatic conversion.
*
* @param {list} fields - fields are a list of arrays in the format
*
* <pre>
* [
* fieldname,
* fieldtype,
* displayname,
* value,
* placeholdertext,
* additionalattributes
* ]
* </pre>
*
* <p>
* fieldtype can be one of the following
* </p>
*
* <dl>
* <dt>text</dt>
* <dd>Your garden variety single line text input</dd>
* <dt>multitext</dt>
* <dd>A textarea</dd>
* <dt>int</dt>
* <dd>Integer values</dd>
* <dt>float</dt>
* <dd>Real number values</dd>
* <dt>select</dt>
* <dd>Choose values from a list</dd>
* <dt>combobox</dt>
* <dd>Select/text combination</dd>
* <dt>datetime</dt>
* <dd>Local date/time: the only datetime control left in HTML 5</dd>
* <dt>newlinelist</dt>
* <dd>A list where every line is a different value</dd>
* <dt>json</dt>
* <dd>Standard JSON format in a textbox</dd>
* <dt>rating</dt>
* <dd>Choose how many stars to give</dd>
* <dt>file</dt>
* <dd>Choose a file from the filesystem</dd>
* <dt>imagefile</dt>
* <dd>Choose from uploaded images or upload your own</dd>
* <dt>soundfile</dt>
* <dd>Choose from uploaded sounds or upload your own</dd>
* <dt>videofile</dt>
* <dd>Choose from uploaded video or upload your own</dd>
* <dt>filelist</dt>
* <dd>Choose multiple files to include</dd>
* <dt>translation</dt>
* <dd>Write different values for each language with a handy
* select control</dd>
* <dt>bitflags</dt>
* <dd>Turn flags on or off with toggle buttons</dd>
* <dt>radioflags</dt>
* <dd>Turn flags on or off with toggle buttons where only one value
* is valid</dd>
* <dt>custom</dt>
* <dd>Make your own field after the form is displayed.
* There will be a selector with the class
* fieldname + 'Div' for you to use.
* </dd>
* </dl>
*
* @param {function} onsuccess - A handler when the user presses
* the finished button with the signature
* async func(formelement, formdata, resultsdiv, event, saveandcontinue)
*
* onsuccess should return true if it was successful so that this function
* may automatically close any popups or fullscreen layers.
*
* @param {object} options - The most commonly used ones are
*
* <dl>
* <dt>enabletranslation</dt>
* <dd>Set to true to enable translation fields</dd>
* <dt>formdiv</dt>
* <dd>Places the form inside of this selector instead of
* creating a new popup</dd>
* <dt>classes</dt>
* <dd>Extra CSS classes for the form element</dd>
* <dt>fullscreen</dt>
* <dd>Enable to use a full screen form instead of a regular popup</dd>
* <dt>cancelfunc</dt>
* <dd>Handler for when the user cancels the form</dd>
* <dt>afterdisplayfunc</dt>
* <dd>Place your custom field logic here</dd>
* <dt>beforesavefunc</dt>
* <dd>A handler to alter the data before it's sent to the server</dd>
* <dt>extrabuttons</dt>
* <dd>Extra buttons to place alongside the normal Finished, Reset,
* and Back buttons</dd>
* <dt>headertext</dt>
* <dd>A header bar for the form</dd>
* </dl>
*
* Please look at the code to see what else is available.
*/
async EditPopup(fields, onsuccess, options)
{
options = options ? Clone(options) : {};
let defaults = {
backgroundColor: '#006',
background: 'dblueg',
popupclass: 'EditPopup'
};
let formdiv = options.formdiv;
let customform = 0;
if (formdiv)
{
if (IsString(formdiv) && formdiv[0] == '.')
{
formdiv = formdiv.substr(1);
}
customform = 1;
} else
{
let id = RandInt(1, 99999999);
while (P.messageboxids.has(id))
{
id = RandInt(1, 99999999);
}
formdiv = 'EditPopup' + id;
}
let obj = options.obj || {};
let objid = options.objid || '';
if (!objid)
{
objid = P.Store(obj);
}
let afterdisplayfunc = options.afterdisplayfunc;
let beforesavefunc = options.beforesavefunc;
let cancelfunc = options.cancelfunc;
let fullscreen = options.fullscreen;
let saveandcontinue = options.saveandcontinue;
let gobuttontext = options.gobuttontext;
let cancelbuttontext = options.cancelbuttontext;
let extrabuttons = options.extrabuttons || '';
let enabletranslation = options.enabletranslation;
let headertext = options.headertext || '';
let footertext = options.footertext || '';
let classes = options.classes || '';
let title = options.title || '';
let resultsdiv = formdiv + 'Results';
options = Merge(defaults, options);
let ls = [
`${title
? `<div class="TitleBar">${title}</div>`
: ''
}
<div class="Pad50">
<form class="EditPopupForm ${formdiv}Form" data-objid="${objid}">
${headertext}
`
];
if (enabletranslation)
{
ls.push(`<div class="TranslationDiv Pad50">
<select name="TranslationLanguage" class="TranslationLanguage">
`);
let langcodes = P.languages;
langcodes.sort();
for (let langcode of langcodes)
{
ls.push(
`<option ${P.lang == langcode ? 'selected' : ''}>
${langcode}
</option>`
);
}
ls.push(`</select>
</div>
`);
}
for (let field of fields)
{
if (!field)
{
continue;
}
let name = field[0];
let valuetype = field[1];
let displayname = field[2];
let value = field[3];
let placeholder = field[4]; // options for selects
let attrs = field[5]; // Attributes for input element such as required
let editfield = '';
if (!attrs)
{
attrs = '';
}
if (!value)
{
if (obj)
{
value = obj[name] ? obj[name] : '';
} else
{
// Check for undefined
value = value ? value : '';
}
}
if (!placeholder)
{
placeholder = '';
}
switch (valuetype.toLowerCase())
{
case 'select':
let opts = [];
if (IsArray(placeholder))
{
for (let v of placeholder)
{
opts.push(`<option ${(value == v) ? 'selected' : ''}>${v}</option>`);
}
} else
{
for (let k in placeholder)
{
let v = placeholder[k];
opts.push(`<option value="${k}" ${(value == k) ? 'selected' : ''} >${v}</option>`);
}
}
editfield = `<select
name="${name}"
class="${name}"
${attrs}>
${opts.join('\n')}
</select>`;
break;
case 'newlinelist':
case 'multitext':
if (valuetype == 'newlinelist')
{
if (IsEmpty(value))
{
value = '';
} else if (Array.isArray(value))
{
value = value.join("\n");
}
}
editfield = `<div
class=FlexInput
data-name="${name}"
class="Width100P ${name}"
data-value="${EncodeEntities(value.toString())}"
placeholder="${EncodeEntities(placeholder)}"
data-attrs="${EncodeEntities(attrs)}">
</div>`;
break;
case 'list':
case 'dict':
if (IsEmpty(value))
{
value = '';
}
editfield = `<div
class=FlexInput
data-name="${name}"
class="Width100P ${name}"
data-value="${EncodeEntities(JSON.stringify(value, null, 2))}"
placeholder="${EncodeEntities(placeholder)}"
data-attrs="${EncodeEntities(`type="${valuetype}" ` + attrs)}">
</div>`;
break;
case 'rating':
editfield = P.Rating(name, {value: value});
break;
case 'int':
editfield = `<input type=number
name="${name}"
class="${name}"
value="${value ? value : 0}"
placeholder="${EncodeEntities(placeholder)}"
${attrs}>`;
break;
case 'float':
editfield = `<input
type=number
step=any
name="${name}"
class="${name}"
value="${value ? value : 0}"
placeholder="${EncodeEntities(placeholder)}"
${attrs}>`;
break;
case 'datetime':
case 'timestamp':
if (!value)
{
value = UTCToLocal();
} else
{
value = UTCToLocal(value * 1000);
}
editfield = `<input
type="datetime-local"
name="${name}"
class="${name}"
value="${value}"
placeholder="${EncodeEntities(placeholder)}"
${attrs}>`;
break;
case 'timezone':
editfield = [`<select
name="${name}"
class="${name}"
value="${
value
? value
: 0
}"
placeholder="${EncodeEntities(placeholder)}"
${attrs}>`];
for (let k in P.TIMEZONES)
{
editfield.push(`
<option value="${k}" ${k == value ? `selected` : ''}>${P.TIMEZONES[k]}</option>
`);
}
editfield.push('</select>');
editfield = editfield.join('\n');
break;
case 'combobox':
editfield = P.ComboBox(dict);
break;
case 'file':
editfield = `<input
type="file"
name="${name}"
class="${name}"
value=""
${attrs}>`
break;
case 'custom':
editfield = `<input
type=hidden
name="${name}"
class="${name}"
value="${EncodeEntities(value.toString())}"
${attrs}>
<div class="FormRow ${name}Div ${placeholder}" data-field="${name}"></div>`;
break;
case 'imagefile':
case 'soundfile':
case 'videofile':
case 'filelist':
if (valuetype.toLowerCase() == 'filelist')
{
if (IsArray(value))
{
value = value.join('\n')
}
}
editfield = `<input
type=hidden
name="${name}"
class="${name}"
value="${EncodeEntities(value.toString())}" >
<div class="FormRow ${placeholder} Center FileField"
data-name="${name}"
data-value="${EncodeEntities(value.toString())}"
data-filetype="${valuetype.substr(0, valuetype.length - 4)}"
data-attrs="${EncodeEntities(attrs.toString())}">
</div>`;
break;
case 'translation':
let translationtext = value[P.lang];
if (!translationtext)
translationtext = '';
editfield = `<div class="Translation FlexInput"
data-name="${name}"
data-value="${EncodeEntities(translationtext.toString())}">
</div>`;
break;
case 'bitflags':
editfield = `<div class="BitFlags ${name}Flags"
data-name="${name}"
data-value="${value}">
</div>`;
break;
case 'radioflags':
editfield = `<div class="RadioFlags ${name}Flags"
data-name="${name}"
data-value="${value}">
</div>`;
break;
case 'captcha':
editfield = `<br><div class="Center CaptchaDiv ${name}"></div>`;
break;
default:
editfield = `<input
type=${valuetype}
name="${name}"
class="Width100P ${name}"
value="${EncodeEntities(value.toString())}"
placeholder="${EncodeEntities(placeholder)}"
${attrs}>`;
};
if (valuetype == 'hidden')
{
ls.push(editfield);
} else
{
ls = ls.concat([
`<div class="EditDiv ${name}Row">
<label>${displayname}</label>
${editfield}
</div>`
]);
}
}
ls.push(
`<br class=Cleared>
<div class=ButtonBar>
<a class="ConfirmButton Color3"><img loading="lazy" class="LineHeight BusyIcon">${gobuttontext ? gobuttontext : T.finished}</a>
${options.saveandcontinue
? `<a class="SaveAndContinueButton Color4"><img loading="lazy" alt="" class="LineHeight BusyIcon">${T.saveandcontinue}</a>`
: ''
}
<a class="Spacer Width2"></a>
${formdiv
? `<a class="ResetButton Color2"><img loading="lazy" alt="" class="LineHeight BusyIcon">${T.reset}</a>`
: ''
}
<a class="CancelButton Color1"><img loading="lazy" alt="" class="LineHeight BusyIcon">
${cancelbuttontext ? cancelbuttontext : T.back}
</a>
${extrabuttons}
</div>
</form>
<div class="${resultsdiv}"></div>
<div class=Cleared></div>
${footertext}
</div>`
);
if (customform && !fullscreen)
{
P.HTML('.' + formdiv, ls);
let resetbutton = '.' + formdiv + 'Form .ResetButton';
P.On(
resetbutton,
'click',
P.Busy(
resetbutton,
function()
{
let formselector = '.' + formdiv + ' form';
E(formselector).reset();
let firstinput = E(formselector + ' input');
if (firstinput)
{
firstinput.focus();
} else
{
firstinput = E(formselector + ' textarea');
if (firstinput)
{
firstinput.focus();
}
}
}
)
);
if (cancelfunc)
{
let cancelbutton = '.' + formdiv + 'Form .CancelButton';
P.On(
cancelbutton,
'click',
P.Busy(
cancelbutton,
function()
{
cancelfunc('.' + formdiv);
}
)
);
}
} else if (fullscreen || P.screensize == 'small')
{
ls = ['<div class="FullScreen ' + formdiv + '">', ls.join('\n'),'</div>'];
let selector = P.AddFullScreen(options['background'], '', ls);
let cancelbutton = '.' + formdiv + 'Form .CancelButton';
P.On(
cancelbutton,
'click',
P.Busy(
cancelbutton,
function()
{
P.RemoveFullScreen();
if (cancelfunc)
{
cancelfunc(selector);
}
}
)
);
} else
{
let popupclass = await P.Popup('<div class="whiteb Bordered Raised Rounded ' + formdiv + ' ' + classes + '">' + ls.join('\n') + '</div>', options);
let cancelbutton = '.' + formdiv + 'Form .CancelButton';
P.On(
cancelbutton,
'click',
P.Busy(
cancelbutton,
function()
{
P.ClosePopup(popupclass);
if (cancelfunc)
{
cancelfunc('.' + formdiv);
}
}
)
);
if (title)
{
P.MakeDraggable(popupclass);
}
}
let confirmbutton = '.' + formdiv + 'Form .ConfirmButton, .' + formdiv + 'Form .SaveAndContinueButton';
P.OnClick(
confirmbutton,
P.LBUTTON,
async function(e)
{
let dataobj = P.GetSelectorObj('.' + formdiv + 'Form');
let formdata = '';
let saveandcontinue = e.target.classList.contains('SaveAndContinueButton');
try
{
formdata = P.FormToArray('.' + formdiv + 'Form', 1);
// Special processing for some browsers that mess up newlinelists
for (let field of fields)
{
if (!field)
{
continue;
}
let name = field[0];
let valuetype = field[1].toLowerCase();
if (valuetype == 'newlinelist')
{
if (formdata[name] && formdata[name].has('\n'))
{
formdata[name] = formdata[name].split('\n').filter(Boolean);
}
}
}
} catch (e)
{
P.ShowError('.' + resultsdiv, e);
}
if (!formdata)
{
return;
}
// Convert local time to UTC time
for (let field of fields)
{
if (!field)
{
continue;
}
let name = field[0];
let valuetype = field[1];
switch (valuetype.toLowerCase())
{
case 'datetime':
if (formdata[name])
{
formdata[name] = LocalToUTC(formdata[name]);
}
default:
};
}
let data = Merge(dataobj, formdata);
if (enabletranslation)
{
let translations = Q('.' + formdiv + 'Form .Translation');
let fieldlangcode = P.Selected('.' + formdiv + 'Form .TranslationLanguage').value;
for (let translation of translations)
{
let fieldname = translation.dataset.name;
let textarea = translation.querySelector('textarea');
if (!IsObject(dataobj[fieldname]))
{
dataobj[fieldname] = {};
}
dataobj[fieldname][fieldlangcode] = textarea.value;
data[fieldname] = dataobj[fieldname];
}
}
if (beforesavefunc)
{
beforesavefunc(data);
}
if (await onsuccess('.' + formdiv, data, E('.' + resultsdiv), e, saveandcontinue))
{
if (!e.target.classList.contains('SaveAndContinueButton'))
{
if (fullscreen)
{
P.RemoveFullScreen();
} else
{
P.CloseThisPopup('.' + formdiv);
}
} else
{
setTimeout(
function()
{
P.HTML('.' + resultsdiv, '');
},
3000
);
}
}
}
);
P.On(
'.' + formdiv + 'Form',
'submit',
function(e)
{
P.CancelBubble(e);
}
);
P.FlexInput('.' + formdiv + 'Form .FlexInput');
P.MakeFileFields('.' + formdiv + 'Form .FileField');
P.MakeCaptcha('.' + formdiv + 'Form .CaptchaDiv');
let inputs = Q('.' + formdiv + 'Form input');
if (inputs.length)
{
inputs[0].focus();
} else
{
inputs = Q('.' + formdiv + 'Form textarea');
if (inputs.length)
{
inputs[0].focus();
}
}
if (enabletranslation)
{
let translationselect = E('.' + formdiv + 'Form .TranslationLanguage');
translationselect.oldvalue = P.Selected(translationselect).value;
P.On(
translationselect,
'change',
function(e)
{
let oldvalue = translationselect.oldvalue;
let newvalue = P.Selected(translationselect).value;
let dataobj = P.GetSelectorObj('.' + formdiv + 'Form');
translationselect.oldvalue = newvalue;
let translations = Q('.' + formdiv + 'Form .Translation');
for (let translation of translations)
{
let fieldname = translation.dataset.name;
let textarea = translation.querySelector('textarea');
if (!IsObject(dataobj[fieldname]))
{
dataobj[fieldname] = {};
}
dataobj[fieldname][oldvalue] = textarea.value;
let newtext = dataobj[fieldname][newvalue];
if (!newtext)
{
newtext = '';
}
textarea.value = newtext;
}
}
);
}
// Create bitflags and radioflags toggle buttons
for (let field of fields)
{
if (!field)
{
continue;
}
let valuetype = field[1];
if (valuetype != 'bitflags' && valuetype != 'radioflags')
{
continue;
}
let name = field[0];
let value = field[3];
let placeholder = field[4]; // options for selects
if (!value)
{
value = obj ? obj[name] : 0;
// Check for undefined
value = value ? value : 0;
}
switch (valuetype)
{
case 'bitflags':
P.MakeToggleButtons(
'.' + formdiv + 'Form .' + name + 'Flags',
placeholder,
{
flags: value
}
);
break;
case 'radioflags':
P.MakeRadioButtons(
'.' + formdiv + 'Form .' + name + 'Flags',
placeholder,
function(el, newvalue)
{
let flagsel = E('.' + formdiv + 'Form .' + name + 'Flags');
flagsel.dataset.value = newvalue;
}
);
break;
default:
L('Unsupported field type', valuetype);
}
}
if (afterdisplayfunc)
{
afterdisplayfunc(E('.' + formdiv), fields, options);
}
return '.' + formdiv;
}
// --------------------------------------------------------------------
/** A convenience function to close a popup or remove a full screen
* layer depending on the selector class.
*
* @param {string} selector - CSS selector of the element.
*/
ClosePopupOrFullScreen(selector)
{
let el = E(selector);
if (!el)
{
return;
}
let parentpopup = P.Parent(el, '.Popup');
if (parentpopup)
{
parentpopup.remove();
} else
{
P.RemoveFullScreen();
}
}
// --------------------------------------------------------------------
/** A convenience function to show a popup to change the innerText of
* a selector. Useful for spreadsheet interfaces.
*
* @param {string} selector - CSS selector
* @param {function} onsuccess - handler when the user confirms the
* change with the signature func(element, newvalue)
*/
EditThis(selector, onsuccess)
{
let el = E(selector);
let text = el.innerText;
P.EditPopup(
[
['EditThisValue', 'multitext', 'Change text']
],
async function()
{
let newvalue = E('.EditThisValue').value;
el.innerText = newvalue;
if (onsuccess)
{
onsuccess(el, newvalue);
}
return 1;
},
{
parent: el,
popupclass: 'EditThisPopup'
}
);
return this;
}
// --------------------------------------------------------------------
/** Utility function used for bitflag datasets.
*
* @param {value} value - The value of the field
* @param {value} onflag - The bit value considered on
* @param {value} offflag - The bit value considered off
*/
FlagToToggle(value, onflag, offflag)
{
let base = ' data-toggled=';
if (value & onflag)
{
return base + 'on';
}
if (value & offflag)
{
return base + 'off';
}
return base + 'unset';
}
// --------------------------------------------------------------------
/** Simple function to display a tooltip above selector after delay
* milliseconds have passed. func(el) should return the text of the
* tooltip when called.
*
* @param {string} selector - CSS selector
* @param {function} func - function to return the tooltip text with
* the signature func(element)
* @param {int} delay - Number of milliseconds to delay before
* showing the tooltip. Defaults to 1000 milliseconds.
*/
async Tooltip(selector, func, delay = 1000)
{
P.On(
selector,
'mouseenter mouseleave',
function(e)
{
let self = this;
if (e.type == 'mouseenter')
{
if (self.dataset.tooltipid)
return 0;
self.dataset.tooltiptimer = setTimeout(
async function()
{
let popupid = await P.Popup(
func(self),
{
parent: self,
position: 'top',
closeonmouseout: 1
}
);
E(popupid).classList.add('Tooltip');
self.dataset.tooltipid = popupid;
},
delay
);
} else
{
let timerid = self.dataset.tooltiptimer;
if (timerid)
{
clearTimeout(timerid);
self.dataset.tooltiptimer = 0;
}
}
}
);
return this;
}
// --------------------------------------------------------------------
/** Creates a select with all of the languages enabled in the system.
* Used by the enabletranslation option in P.EditPopup()
*
* @param {string} name - Name of the input
* @param {string} value - The current value of the field
* @param {string} classes - CSS classes to add to the select
*/
LanguageSelect(name, value, classes)
{
name = name || 'language';
value = value || '';
classes = classes || '';
let ls = [`<select class="Language ${classes}" name="${name}">`];
for (let language of P.languages)
{
ls.push(`<option value="${language[2][0]}"
${language[2][0] == value ? 'selected=1' : ''}>
${language[0]}
</option>`
);
}
ls.push('</select>');
return ls.join('\n');
}
// --------------------------------------------------------------------
/** Creates a CKEDITOR instance in selector
*
* @param {string} selector - The CSS selector to use
* @param {object} options - Options to pass to the CKEDITOR instance
*/
MakeFullEditor(selector, options)
{
options = options || {};
let defaults = {
name: selector,
extraPlugins: 'autogrow,button,clipboard,dialog,dialogui,filetools,image2,lineutils,notification,notificationaggregator,toolbar,uploadimage,uploadwidget,widget,widgetselection',
autoGrow_onStartup: true,
allowedContent: true
};
options = Merge(defaults, options);
CKEDITOR.replace(
E(selector),
options
);
}
// --------------------------------------------------------------------
/** Gets an existing CKEDITOR instance by name
*
* @param {string} name - The name of the CKEDITOR instance
*/
GetEditor(name)
{
for (let k in CKEDITOR.instances)
{
let editor = CKEDITOR.instances[k];
if (editor.config['name'] == name)
{
return editor;
}
}
}
// --------------------------------------------------------------------
/** An alternative way to create the standard Pafera toolbar by
* placing the controls into selector.
*
* @param {string} selector - CSS selector
* @param {list} buttons - a list of arrays in the format
* [buttontext, buttontype, buttoncolor, buttonclasses]
*/
MakeButtonBar(selector, buttons)
{
let ls = [];
let color = 1;
let item = 0;
let cls = '';
for (let i = 0, l = buttons.length; i < l; i++)
{
let button = buttons[i];
if (button[1] == 'custom')
{
ls.push(button[0]);
continue;
}
if (button.length > 2 && button[2])
{
color = button[2];
}
cls = (button.length > 3) ? button[3] : '';
ls.push(`<a class="Color${color} Button${i} ${cls}">
<img loading="lazy" alt="" class="LineHeight BusyIcon">
${button[0]}
</a>`);
color++;
if (color > 6)
{
color = 1;
}
}
let bar = E(selector);
bar.classList.add('ButtonBar');
bar.innerHTML = ls.join('\n');
for (let i = 0, l = buttons.length; i < l; i++)
{
let item = selector + ' .Button' + i;
P.OnClick(item, P.LBUTTON, P.Busy(item, buttons[i][1]));
}
}
// --------------------------------------------------------------------
/** Helper function to create draggable elements
*
* @param {event} e
*/
MoveOnDrag(e)
{
let target = P.TargetClass(e, '.Draggable');
if (!target)
return;
// keep the dragged position in the data-x/data-y attributes
let x = Bound(target.dataset.dragx, -999999, 999999);
let y = Bound(target.dataset.dragy, -999999, 999999);
let deltax = Bound(target.dataset.deltax, -999999, 999999);
let deltay = Bound(target.dataset.deltay, -999999, 999999);
x += (e.deltaX - deltax);
y += (e.deltaY - deltay);
let translate = 'translate(' + x + 'px, ' + y + 'px)';
CopyValues(
{
webkitTransform: 'translate',
transform: 'translate',
},
target.style
);
CopyValues(
{
dragx: x,
dragy: y,
deltax: e.deltaX,
deltay: e.deltaY,
},
target.dataset
);
}
// --------------------------------------------------------------------
/** Creates a button that floats above the normal page and can be
* moved around by the user. Handy for quick action access.
*
* @param {string} selector - CSS selector
* @param {string} content - The contents of the button
* @param {function} onclick - A handler with the signature
* func(event)
*/
async MakeFloatingButton(selector, content, onclick)
{
let button = E(selector);
if (!button.length)
{
let popupid = await P.Popup(content);
button = E(popupid);
}
button.classlist.add('Draggable');
P.Hide(button);
setTimeout(
function()
{
button.classList.add(selector.substr(1));
CopyValues(
{
zIndex: '1100',
position: 'fixed',
left: '110%',
top: '80%',
cursor: 'nwse-resize'
},
button.style
);
P.Show(button);
P.Animate(
function(fraction)
{
CopyValues(
{
left: (fraction * 100) + '%',
top: (fraction * 100) + '%'
},
button.style
);
},
300
);
},
500
);
let hammer = new Hammer(button[0]);
if (onclick)
{
hammer.on("tap", onclick);
}
hammer.get('pan').set({ direction: Hammer.DIRECTION_ALL });
hammer.on("panmove", P.MoveOnDrag);
hammer.on(
"panend",
function(e)
{
let el = P.TargetClass(e, '.Draggable');
el.dataset.deltax = 0;
el.dataset.deltay = 0;
}
);
return selector;
}
// --------------------------------------------------------------------
/** A utilty function to show a busy icon when a selector is clicked.
* Useful for long operations when you want to tell the user that
* the operation has started but might take some time to finish.
*
* You must have a blank image with the class BusyIcon within the
* selector.
*
* @param {string} selector - CSS selector
* @param {function} func - The click handler with the signature
* func(event, element)
*/
Busy(selector, func)
{
return function(evt, target)
{
let img = E(selector + ' .BusyIcon');
let origsrc = img.getAttribute('src');
img.setAttribute('src', P.baseurl + '/system/busy.webp');
P.Show(img);
func(evt, target);
setTimeout(
function()
{
if (origsrc)
{
img.setAttribute('src', origsrc);
} else
{
P.Hide(img);
}
},
2000
);
}
}
// --------------------------------------------------------------------
/** Creates a clickable range widget in selector.
*
* @param {string} selector - CSS selector to use
* @param {string} classes - Extra classes for the meter element
* @param {number} min - The minimum value
* @param {number} max - The maximum value
* @param {number} value - The current value
* @param {string} title - An optional title for the control
* @param {function} onchange - A handler when the user clicks on the
* widget with the signature func(element, newvalue)
*/
MakeRange(selector, classes, min, max, value, title = '', onchange = 0)
{
value = value ? value : min;
title = title || '';
let el = E(selector);
el.innerHTML = `
<div class=ButtonBar>
${title ? `<span class=RangeTitle>${title}</span>` : ''}
<meter class="Range ${classes}" min=${min} max=${max} value=${value}></meter>
<span class=RangeDisplay>${value}</span>
</div>`;
P.OnClick(
selector + ' .Range',
P.LBUTTON,
function(e)
{
let self = this;
let min = parseInt(self.getAttribute('min'));
let max = parseInt(self.getAttribute('max'));
let value = self.value;
let rt = P.AbsoluteRect(self);
let newvalue = Math.round(((P.mousex - rt.left) / rt.width) * (max - min)) + min;
if (value == newvalue)
return;
self.value = newvalue;
P.Fill(selector + ' .RangeDisplay', newvalue);
if (onchange)
{
onchange(self, newvalue);
}
}
);
}
// --------------------------------------------------------------------
/** Moves the child element up one position. Useful for rearranging
* table rows or cards.
*
* @param {DOMElement} el - The element to be moved
* @param {string} selector - An optional parent class to be moved
* instead of el like a tr or card
* @param {function} onfinished - An optional handler when the move
* is complete with the signature func(element)
*/
MoveUp(el, selector, onfinished)
{
if (selector)
{
el = P.Parent(el, selector);
}
if (!el)
{
return;
}
if (el.previousNode)
{
el.previousNode.before(el);
}
if (onfinished)
{
onfinished(el);
}
}
// --------------------------------------------------------------------
/** Moves the child element down one position. Useful for rearranging
* table rows or cards.
*
* @param {DOMElement} el - The element to be moved
* @param {string} selector - An optional parent class to be moved
* instead of el like a tr or card
* @param {function} onfinished - An optional handler when the move
* is complete with the signature func(element)
*/
MoveDown(el, selector, onfinished)
{
if (selector)
{
el = P.Parent(el, selector);
}
if (!el)
{
return;
}
if (el.nextNode)
{
el.nextNode.after(el);
}
if (onfinished)
{
onfinished(el);
}
}
// --------------------------------------------------------------------
/** Adds an event handler to a table where you can click buttons to
* move rows up or down.
*
* You should add and style the up or down tds yourself such as
* <td data-action="up">Move Up</td>
* <td data-action="down">Move Down</td>
*
* @param {string} selector - CSS selector
* @param {object} onchangefunc - An optional handler when the
* change has finished with the signature func(element, event)
*/
MakeSortableTable(selector, onchangefunc = 0)
{
let elements = Q(selector);
for (let element of elements)
{
if (element.madesortabletable)
{
continue;
}
element.madesortabletable = 1;
P.OnClick(
element,
P.LBUTTON,
function(e)
{
let target = P.TargetClass(e, 'td');
if (!target)
return;
let thistr = target.parentNode;
let objid = thistr.dataset.objid;
let action = target.dataset.action
switch (action)
{
case 'up':
P.MoveUp(thistr);
break;
case 'down':
P.MoveDown(thistr);
break;
default:
if (options && options[action])
{
options[action](objid, thistr);
}
}
if (onchangefunc)
{
onchangefunc(thistr, e);
}
}
);
}
}
// --------------------------------------------------------------------
/** Your typical "Do you *really* want to delete this?" confirmation
* dialog.
*
* @param {string} objname - The name of the object to delete
* @param {function} deletefunc - The function called to do the actual
* deletion with a signature func(resultsdiv)
*/
async ConfirmDeletePopup(objname, deletefunc)
{
await P.Popup(
`<div class="DeletePopup yellowb Pad50">
<p>
${T.confirmdelete + objname + '?'}
</p>
<div class="DeleteButtonBar"></div>
<div class="Cleared"></div>
<div class="ConfirmDeleteResults"></div>
<div class="Cleared"></div>
</div>`,
{
popupclass: 'ConfirmDeletePopup'
}
);
P.MakeButtonBar(
'.DeleteButtonBar',
[
[T.delete, function() { deletefunc(E('.ConfirmDeleteResults')); P.CloseThisPopup('.ConfirmDeletePopup');}],
[T.back, function() { P.CloseThisPopup('.ConfirmDeletePopup'); }]
]
);
}
// =====================================================================
/** Returns the actual viewport size.
*
* Thanks to http://stackoverflow.com/questions/16894603/javascript-incorrect-viewport-size
*/
ViewPortRect()
{
let w = window,
d = document,
e = d.documentElement,
g = d.getElementsByTagName('body')[0];
/*
let width = Math.max(w.innerWidth || e.clientWidth || g.clientWidth);
let height = Math.max(w.innerHeight|| e.clientHeight|| g.clientHeight);
*/
let width = w.innerWidth;
let height = w.innerHeight;
return {
left: w.pageXOffset,
top: w.pageYOffset,
right: w.pageXOffset + width,
bottom: w.pageYOffset + height,
width: width,
height: height
};
}
// =====================================================================
/** Utility function to emit the resize event whenever the window has
* finished resizing.
*
* @param {event} e
*/
OnResize(e)
{
// Don't run before page is fully loaded
if (!P.emsize)
{
return;
}
let numems = window.innerWidth / P.emsize;
if (numems > 50)
{
P.screensize = 'large';
} else if (numems > 25)
{
P.screensize = 'medium';
} else
{
P.screensize = 'small';
}
P.AddDelayedEvent('delayedresize', e);
}
// =====================================================================
/** Converts dotted name and value pairs such as animals.lions.number = 5
* into animals['lions']['number'] = 5. Useful for form elements when
* you want to convert them to a JSON array.
*
* @param {object} container - The object to store the value in
* @param {string} name - The dotted name
* @param {value} value - The value to store
* @param {bool} overwrite - If enabled, the value to store will be added
* to a list containing all previous values. Otherwise, it will
* overwrite the previous value.
*/
StoreInArray(container, name, value, overwrite = false)
{
let nameparts = name.split('.');
let l = nameparts.length;
for (let i = 0; i < l; i++)
{
let key = nameparts[i];
if (i != l - 1)
{
if (!IsObject(container[key]))
{
container[key] = {};
}
} else
{
if (container[key] && !overwrite)
{
if (!IsArray(container[key]))
{
container[key] = [container[key]];
}
container[key].push(value);
} else
{
container[key] = value;
}
}
container = container[nameparts[i]];
}
return container;
}
// =====================================================================
/** Converts all input elements in a form to a JavaScript object.
*
* Will even convert dotted multilevel names such as
* <input name="animals.felines.numcats" value="5"> into
* obj['animals']['felines']['numcats'] = 5
*
* @param {string} selector - CSS selector
* @param {bool} overwrite - If enabled, the value to store will be added
* to a list containing all previous values. Otherwise, it will
* overwrite the previous value.
*/
FormToArray(selector, overwrite = 0)
{
let form = E(selector);
let uid = form.dataset.objid;
let data = {};
let elements = form.querySelectorAll('input, textarea, .FormInput');
for (let element of elements)
{
if (element.classList.contains('translatedTextArea'))
continue;
let name = element.getAttribute('name');
let value = element.value;
let type = element.getAttribute('type');
let required = element.hasAttribute('required');
if (!name)
{
P.ErrorPopup('input ' + element.classList + ' is missing a name attribute!');
return;
}
if (required)
{
if (!value)
{
P.ErrorPopup(T.missingfields + ': ' + name);
element.origborder = element.style.border;
element.style.border = '0.5em solid red';
element.focus();
return;
} else
{
element.style.border = element.origborder ? element.origborder : '';
element.origborder = '';
}
}
switch (type)
{
case 'number':
value = parseFloat(value);
break;
case 'json':
if (value)
{
value = JSON.parse(value);
} else
{
value = "";
}
break;
case 'newlinelist':
case 'filelist':
if (value)
{
value = value.trim().split("\n");
} else
{
value = [];
}
break;
}
P.StoreInArray(data, name, value, overwrite);
}
elements = form.querySelectorAll('select');
for (let element of elements)
{
let name = element.getAttribute('name');
let value = P.Selected(element).value;
if (!name)
{
P.ErrorPopup('select ' + element.classList + ' is missing a name attribute!');
return;
}
P.StoreInArray(data, name, value, overwrite);
}
elements = form.querySelectorAll('.ComboBox');
for (let element of elements)
{
let name = element.dataset.name;
let uid = element.dataset.uid;
let orig = P.Retrieve(uid);
let key = P.Selected(element.querySelector('select')).value;
let value = element.querySelector('input').value;
orig[key] = value;
if (!name)
{
P.ErrorPopup('combobox ' + element.classList + ' is missing a name attribute!');
return;
}
P.StoreInArray(data, name, orig, overwrite);
}
elements = form.querySelectorAll('.ToggleButton');
for (let element of elements)
{
let name = element.dataset.name;
let toggled = element.dataset.toggled;
if (name)
{
P.StoreInArray(data, name, toggled, overwrite);
}
}
elements = form.querySelectorAll('.BitFlags, .RadioFlags');
for (let element of elements)
{
let name = element.dataset.name;
let value = element.dataset.value;
if (name)
{
P.StoreInArray(data, name, value, 1);
}
}
if (uid && P.pageobjs[uid])
{
data = Merge(P.pageobjs[uid], data);
}
return data;
}
// ====================================================================
/** Accordions are widgets that hide or show their contents when clicked
* like Wikipedia uses. This is the Pafera version.
*
* @param {string} selector - CSS selector
*/
MakeAccordion(selector)
{
let elements = Q(selector);
for (let element of elements)
{
let next = self.nextNode;
// Only create toggles on high elements that require scrolling
/*if (next.clientHeight < 600)
return;*/
P.Hide(next);
element.classList.add('HoverHighlight', 'Accordion');
element.appendChild(P.Icon('Down', 'h', 'ToggleIcon', 2.5));
next.add(
P.HTMLToDOM(
`<a class="Button MiniButton Color4" onclick="P.HideThisDiv(event)">
${T.expand + P.Icon('Down', 'h', 'FlipV ToggleIcon', 2.5)}
</a>`
)
);
}
P.OnClick(selector, P.LBUTTON, P.OnAccordion);
}
// ====================================================================
/** Helper function for MakeAccordion()
*
* @param {event} e
*/
HideThisDiv(e)
{
let div = P.TargetClass(e, 'div');
P.Hide(div);
div.previousNode.querySelector('.ToggleIcon').classList.remove('FlipV');
}
// ====================================================================
/** Helper function for MakeAccordion()
*
* @param {event} e
*/
OnAccordion(e)
{
let el = P.TargetClass(e, '.Accordion');
let next = el.nextNode;
let toggleicon = el.querySelector('.ToggleIcon');
let isvisible = toggleicon.classList.contains('FlipV');
if (isvisible)
{
P.Hide(next);
toggleicon.classList.remove('FlipV');
} else
{
P.Show(next);
toggleicon.classList.add('FlipV');
}
}
// ====================================================================
/** Rearranges selector's children by their text values. childclass1
* and childclass2 can be specified to only sort those children.
*
* Useful for resorting table rows.
*
* @param {string} selector - The parent element
* @param {string} childclass1 - A selector specifying the level 1
* child element
* @param {string} childclass2 - A selector specifying the level 2
* child element
*/
SortChildrenByText(selector, childclass1, childclass2)
{
childclass1 = childclass1 || '';
childclass2 = childclass2 || '';
let parent = E(selector);
let ls = [];
let children = parent.querySelectorAll(childclass1);
for (let child of children)
{
ls.push(parent.removeChild(child));
}
ls.sort(
function(a, b)
{
let texta = a.querySelector(childclass2).innerText.toLowerCase();
let textb = b.querySelector(childclass2).innerText.toLowerCase();
return (texta < textb) ? -1 : (texta > textb) ? 1 : 0;
}
);
for (let child of children)
{
parent.appendChild(child);
}
}
// ====================================================================
/** Creates a set of toggle buttons inside selector.
*
* @param {string} selector - The CSS selector to use
* @param {list} buttons - An array such as
* <pre>
* [
* [displaytext, buttonname, bitflagvalue, state, extraclasses]
* ]</pre>
*
* Value is the bit for this name used in normal bitflags.
*
* For advanced use, state can be used to manually set 'on', 'off',
* or 'unset'.
*
* extraclasses are CSS classes to add to the button.
*
* @param {object} options - A dict containing optional settings
*
* <dl>
* <dt>tritoggle</td>
* <dd>Normally, toggle buttons are either on or unset. Tritoggle
* buttons have an additional off state.</dd>
* <dt>ontoggle</dt>
* <dd>A handler called whenever a button is togged with the
* signature func(element, buttonname, newstate)</dd>
* <dt>noicon</dt>
* <dd>Disable the default icons showing the button state</dd>
* <dt>classes</dt>
* <dd>Extra CSS classes for all buttons</dd>
* </dl>
*/
MakeToggleButtons(selector, buttons, options = {})
{
let el = E(selector);
if (!el)
{
return;
}
let ls = [];
let l = buttons.length;
let tritoggle = options.tritoggle;
let ontoggle = options.ontoggle;
let noicon = options.noicon;
let classes = options.classes || '';
let flags = options.flags || 0;
el.classList.add('Flex', 'FlexWrap');
el.dataset.value = flags;
for (let button of buttons)
{
let background = 'dredg';
let icon = '⛒';
let display = button[0];
let name = button[1];
let value = button.length > 2 ? button[2] : 0;
let state = button.length > 3 ? button[3] : 0;
let classes = (button.length > 4) ? button[4] : '';
if (!value)
{
value = 0;
}
if (!state)
{
state = flags & value ? 'on' : 'unset';
}
switch (state)
{
case 'on':
background = 'lgreeng';
icon = '✓';
break;
case 'unset':
background = 'lgrayg';
icon = '□';
break;
};
ls.push(
`<div class="ToggleButton HoverHighlight ${classes} ${background} ${classes}"
data-name="${name}"
data-value="${value}"
data-toggled="${state}">
${noicon
? ''
: `<span class=CheckBoxIcon>${icon}</span>`
}
${display}
</div>`
);
}
P.HTML(el, ls);
P.OnClick(
selector,
P.LBUTTON,
function(e)
{
let toggledbutton = P.TargetClass(e, '.ToggleButton');
if (!toggledbutton)
{
return;
}
let newstate = 'on';
let icon = toggledbutton.querySelector('.CheckBoxIcon');
switch (toggledbutton.dataset.toggled)
{
case 'on':
if (tritoggle)
{
newstate = 'off';
} else
{
newstate = 'unset';
}
el.dataset.value = parseInt(el.dataset.value) & (~parseInt(toggledbutton.dataset.value));
break;
case 'off':
newstate = 'unset';
el.dataset.value = parseInt(el.dataset.value) & (~parseInt(toggledbutton.dataset.value));
break;
default:
newstate = 'on';
el.dataset.value = parseInt(el.dataset.value) | parseInt(toggledbutton.dataset.value);
};
if (newstate == 'on')
{
toggledbutton.classList.remove('dredg', 'lgrayg');
toggledbutton.classList.add('lgreeng');
if (icon)
{
icon.innerText = '✓';
}
} else if (newstate == 'off')
{
toggledbutton.classList.remove('lgreeng', 'lgrayg');
toggledbutton.classList.add('dredg');
if (icon)
{
icon.innerText = '⛒';
}
} else
{
toggledbutton.classList.remove('lgreeng', 'dredg');
toggledbutton.classList.add('lgrayg');
if (icon)
{
icon.innerText = '□';
}
}
toggledbutton.dataset.toggled = newstate;
if (ontoggle)
{
ontoggle(el, toggledbutton.dataset.name, newstate);
}
}
);
}
// ====================================================================
/** Returns the state of a set of toggled buttons in selector as an
* object containing keys of 'on', 'off', and 'unset' and values of
* lists. The list will contain the names of each button that is in
* that state.
*
* @param {string} selector - CSS selector containing the buttons.
*/
ToggledButtons(selector)
{
let ls = {
on: [],
off: [],
unset: []
};
let elements = Q(selector + ' .ToggleButton')
for (let element of elements)
{
ls[element.dataset.toggled].push(element.dataset.name);
}
return ls;
}
// ====================================================================
/** Creates a group of radio buttons, which is a set of buttons where
* clicking on one turns all of the other buttons off.
*
* When more than eight buttons are used, this will automatically turn
* into a select control instead.
*
* @param {string} selector - CSS selector
* @param {list} buttons - A list in the format
* [title, value, selected, classes]
* @param {function} onchangefunc - An optional handler with the
* signature func(element, newvalue)
*/
MakeRadioButtons(selector, buttons, onchangefunc = 0)
{
let el = E(selector);
if (!el)
{
return;
}
let ls = [];
let l = buttons.length;
let enableselect = l > 8;
if (enableselect)
{
ls.push('<select>');
}
for (let button of buttons)
{
if (enableselect)
{
ls.push(`<option value="${EncodeEntities(button[1])}"
${button[2] ? 'selected' : ''}>
${button[0]}
</option>`);
} else
{
ls.push(
`<div class="RadioButton HoverHighlight Center FlexItem
${button[3] ? ' ' + button[3] : ''}
${button[2] ? ' lgreeng' : ' lgrayg'}"
data-value="${EncodeEntities(button[1].toString())}"
data-state="${button[2] ? 'on' : 'unset'}">
<span class="RadioIcon">
${button[2] ? '✅' : '❌'}
</span>
${button[0]}
</div>`
);
}
if (button[2])
{
el.dataset.value = button[1];
}
}
if (enableselect)
{
ls.push('</select>');
} else
{
el.classList.add('RadioGroup', 'Flex', 'FlexWrap');
}
P.HTML(el, ls);
if (!enableselect)
{
let children = Q(selector + ' .RadioButton');
/*children[0].style.borderRadius = '1em 0em 0em 1em';
children[children.length - 1].style.borderRadius = '0em 1em 1em 0em';*/
P.OnClick(
selector + ' .RadioButton',
P.LBUTTON,
function(e)
{
let thisbutton = P.TargetClass(e, '.RadioButton');
let value = thisbutton.dataset.value;
let parent = P.Parent(thisbutton, '.RadioGroup');
let buttons = parent.querySelectorAll('.RadioButton');
for (let button of buttons)
{
if (button.dataset.value == value)
{
button.classList.remove('lgrayg');
button.classList.add('lgreeng');
button.dataset.state = 'on';
button.querySelector('.RadioIcon').innerText = '✅';
} else
{
button.classList.remove('lgreeng');
button.classList.add('lgrayg');
button.dataset.state = 'unset';
button.querySelector('.RadioIcon').innerText = '❌';
}
}
if (onchangefunc)
{
onchangefunc(parent, value);
}
}
);
} else
{
if (onchange)
{
P.On(
selector + ' select',
'change',
function(e)
{
let target = P.Target(e);
onchange(P.Parent(target, 'select'), P.Selected(target).value);
}
);
}
}
}
// ====================================================================
/** Creates a group of radio buttons, which is a set of buttons where
* clicking on one turns all of the other buttons off.
*
* When more than eight buttons are used, this will automatically turn
* into a select control instead.
*
* @param {string} selector - CSS selector
* @param {list} buttons - A list in the format
* [title, value, selected, classes]
* @param {function} onchangefunc - An optional handler with the
* signature func(element, newvalue)
*/
GetSelectedRadioButton(selector)
{
let el = E(selector);
let select = el.querySelector('select');
if (select)
{
return select.value;
}
return el.querySelector('[data-state="on"]').dataset.value;
}
// ====================================================================
/** The underlying function for all API calls, which is basically a
* wrapper around XMLHttpRequest().
*
* @param {string} url - The API url
* @param {object} data - The data to send to the server
* @param {object} settings - An optional dict containing
*
* <dl>
* <dt>method</dt>
* <dd>get or post</dd>
* <dt>timeout</dt>
* <dd>The number of seconds to wait for a reply before failing</dd>
* </dl>
*/
AJAX(url, data, settings = {})
{
let defaults = {
method: 'get',
timeout: 30,
};
settings = Merge(defaults, settings);
if (settings.method == 'get' && data)
{
url += '?' + ToParams(data);
}
return new Promise(
function(resolve, reject)
{
let r = new XMLHttpRequest();
r.withCredentials = true;
r.open(settings.method, url);
// Convert to seconds
r.timeout = settings.timeout * 1000;
r.addEventListener("load", function() { resolve(r) });
r.addEventListener("error", function() { reject(r); });
r.addEventListener("abort", function() { reject(r); });
r.addEventListener("timeout", function() { reject(r); });
if (settings.method == 'post' && data)
{
r.send(data);
} else
{
r.send();
}
}
);
}
// =====================================================================
/** Sends a request to url with data as the payload. Will return
* a JSON dict while checking for connection timeout and HTTP status
* error codes. If something doesn't work, then this will throw an
* Error object.
*
* By default, this will use POST, but if you set options.method to
* GET, then this function will automatically append anything in data
* as a query string to the end of url.
*
* The returned data will automatically be converted to a JSON dict,
* and the 'error' key will be checked to see if an error needs to
* be thrown.
*
* This version has no visual indication at all, so you should use
* P.LoadingAPI() or P.DialogAPI() if you want the user to know that
* something is happening.
*
* @param {string} url - The API url
* @param {object} data - The data to send to the server
* @param {object} options - An optional dict containing settings to
* pass to the underlying fetch() API, and the additional settings
*
* <dl>
* <dt>timeout</dt>
* <dd>The number of seconds to wait for a reply before failing</dd>
* </dl>
*/
async API(url, data = {}, options = {})
{
let defaults = {
method: 'POST',
timeout: 30,
cache: 'no-cache',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
};
options = Merge(defaults, options);
if (options.method == 'GET' && !IsEmpty(data))
{
if (url.has('?'))
{
url += '&' + ToParams(data);
} else
{
url += '?' + ToParams(data);
}
}
const controller = new AbortController();
const timeoutid = setTimeout(() =>
{
controller.abort();
},
options.timeout * 1000
);
options.signal = controller.signal;
const response = await fetch(url, options);
if (!response.ok)
{
throw new Error('HTTP Status ' + response.status);
}
clearTimeout(timeoutid)
const json = await response.json();
if (json.error)
{
throw new Error(json.error)
}
return json;
}
// =====================================================================
/** A version of API which will show a loading indicator in selector,
* and displays any errors inside of the same selector.
*
* In case of error, this will return a failed promise.
*
* @param {string} resultsdiv - A selector to show the loading indicator
* and results
* @param {string} apiurl - The server URL to send the data
* @param {object} data - The data to send
* @param {object} options - An options object to pass to P.API().
* Set the key dontshowsuccess if you want to handle your own
* visual effects.
*/
async LoadingAPI(resultsdiv, apiurl, data = {}, options = {})
{
options.toastduration = options.toastduration || 1000;
resultsdiv = E(resultsdiv);
P.Loading(resultsdiv, T.waitingforserver, options.scrolltoelement);
try {
const json = await P.API(apiurl, data, options);
if (!options.dontshowsuccess)
{
P.Toast(
`<div class="greenb Pad50">${T.allgood}</div>`,
resultsdiv,
options.toastduration
);
}
return new Promise(
function(accept, reject)
{
accept({
json: json,
resultsdiv: resultsdiv
});
}
);
} catch (error)
{
P.HTML(resultsdiv, `<div class=Error>${error}</div>`);
throw error;
}
return new Promise(
function(accept, reject)
{
reject();
}
);
}
// =====================================================================
/** Displays a dialog with a loading indicator. Will return the
* CSS selector of the popup so that you can call P.ClosePopup() on it
* when you are done with your operation.
*
* @param {string} extraclasses - CSS classes for the loading dialog
*/
async LoadingDialog(extraclasses = '')
{
return P.Popup(
`<div class="LoadingDialog Pad50 whiteb Center Bordered Raised Rounded Flex FlexCenter FlexVertical ${extraclasses}">
<div class="LoadingResults FlexItem">
<img loading="lazy" alt="Loading" src="${P.baseurl}/system/loading.gif">
${T.working}
</div>
</div>`
);
}
// =====================================================================
/** A version of P.API() which will show a loading indicator in a popup
* and displays any errors inside of the same popup.
*
* In case of error, this will return undefined instead of throwing an
* error.
*
* @param {string} apiurl - The server URL to send the data
* @param {object} data - The data to send
* @param {object} options - An options object to pass to P.API().
* Set the key dontshowsuccess if you want to handle your own
* visual effects.
*/
async DialogAPI(apiurl, data = {}, options = {})
{
options.toastduration = options.toastduration || 1000;
let popupclass = await P.LoadingDialog(options.selectorclass);
let resultsdiv = E(popupclass + ' .LoadingResults')
P.Loading(resultsdiv, T.waitingforserver, options.scrolltoelement);
try {
let json = await P.API(apiurl, data, options);
if (!options.dontshowsuccess)
{
P.Toast(
`<div class="greenb Pad50">${T.allgood}</div>`,
resultsdiv,
options.toastduration,
{
onfinished: function()
{
P.CloseThisPopup(resultsdiv);
}
}
);
}
return new Promise(
function(accept, reject)
{
accept({
json: json,
resultsdiv: resultsdiv,
popupclass: popupclass
});
}
);
} catch (error)
{
P.HTML(
resultsdiv,
`<div class="whiteb Rounded Pad50">
<div class=Error>${error}</div>
<br>
<div>
<a class="Button Color1 Width93"
onclick="P.CloseThisPopup(this)">
${T.close}
</a>
</div>
</div>`
);
throw error;
}
return new Promise(
function(accept, reject)
{
reject();
}
);
}
// ====================================================================
/** Sets the wallpaper for the current user. The user must have the
* wallpaper enabled in their settings.
*
* @param {string} filename - The name of the wallpaper file
*/
async SetWallpaper(filename)
{
if (filename && P.wallpaper != filename)
{
P.wallpaper = filename;
await P.DialogAPI(
'/system/userapi',
{
command: 'setwallpaper',
data: filename
}
);
let el = E('.PageBodyGrid');
if (el)
{
el.style.backgroundImage = `url('/system/wallpapers/${filename}.webp')`;
}
}
}
// ====================================================================
/** Return the best possible translation from a dict of translations.
* If nothing is found, then returns double hyphens to indicate that
* the text has not been translated yet.
*
* @param {object} translations - An object containing language codes
* as the keys and the translation texts as the values
* @param {string} langcode - An explicit langcode to look for
*/
BestTranslation(translations, langcode = 0)
{
if (!translations)
{
return '--';
}
if (langcode)
{
return translations[langcode]
? translations[langcode]
: '--';
}
// First try to get the current langcode
let t = translations[P.lang];
if (t)
{
return t;
}
if (P.lang.indexOf('-'))
{
let parts = P.lang.split('-');
t = translations[parts[0]];
if (t)
{
return t;
}
}
for (let lang of P.langcodes)
{
let translation = translations[lang];
if (translation)
{
return translation;
}
}
if (translations['en'])
{
return translations['en'];
}
if (translations['en-us'])
{
return translations['en-us'];
}
for (let k in translations)
{
if (translations[k] && IsString(translations[k]))
{
return translations[k];
}
}
return '';
}
// ====================================================================
/** Shows a popup with the most dominant flags for each language. Yes,
* I know that languages and flags aren't the same thing, but that is
* the most common way for visitors to notice a language, so that's
* what we're sticking with.
*
* @param {event} e - An event to show the popup next to the target,
* or false to show the popup in the center of the screen like
* normal.
*/
async ChooseLanguagePopup(e = 0)
{
let ls = ['<div class="ContextMenu Language">'];
P.languages.forEach(
function(r)
{
ls.push(`<a onclick="P.ChooseLanguage('${r}')">
<div class="Flex FlexCenter">
<div><img loading="lazy" alt="" class="Height150" src="/flags/${r}.webp"></div>
<div>${P.LANGNAMES[r]}</div>
</div>
</a>`);
}
)
ls.push('</div>');
return P.Popup(
ls,
{
parent: e ? e.target : '',
closeonmouseout: 1,
width: '12em'
}
);
}
// ====================================================================
/** Sets the current language and reloads the page with the right
* language code.
*
* @param {string} langcode - A language code enabled in the system.
*/
async ChooseLanguage(langcode)
{
await P.DialogAPI(
'/system/userapi',
{
command: 'setlanguage',
data: langcode
}
);
let parts = window.location.pathname.split('/');
for (let i = parts.length - 1; i >= 0; i--)
{
let r = parts[i];
if (r.endsWith('.html'))
{
let pagenameparts = parts[i].split('.')
pagenameparts[pagenameparts.length - 2] = langcode;
parts[i] = pagenameparts.join('.')
window.location.pathname = parts.join('/')
}
}
}
// ====================================================================
/** Check if the current user is part of the current group.
*
* @param {string} group - The game of the group to test for
*/
HasGroup(group)
{
return P.groups.has(group);
}
// ====================================================================
/** Stores variables in the global window object using dot notation.
* Handy for passing complicated structures from server side.
*
* @param {object} vars - An object containing the names and values to
* store into window.
*/
StoreGlobals(vars)
{
if (IsObject(vars))
{
for (let k in vars)
{
if (!vars.hasOwnProperty(k))
{
continue;
}
if (k.has('.'))
{
let parts = k.split('.');
switch (parts.length)
{
case 2:
if (!IsObject(window[parts[0]]))
{
window[parts[0]] = {};
}
window[parts[0]][parts[1]] = vars[k];
break;
case 3:
if (!IsObject(window[parts[0]]))
{
window[parts[0]] = {};
}
if (!IsObject(window[parts[0]][parts[1]]))
{
window[parts[0]][parts[1]] = {};
}
window[parts[0]][parts[1]][parts[2]] = vars[k];
break;
case 4:
if (!IsObject(window[parts[0]]))
{
window[parts[0]] = {};
}
if (!IsObject(window[parts[0]][parts[1]]))
{
window[parts[0]][parts[1]] = {};
}
if (!IsObject(window[parts[0]][parts[1]][parts[2]]))
{
window[parts[0]][parts[1]][parts[2]] = {};
}
window[parts[0]][parts[1]][parts[2]][parts[3]] = vars[k];
break;
}
} else
{
window[k] = vars[k];
}
}
}
}
// ====================================================================
/** Used for AJAX pages, this function resets all handlers, shows or
* hides sidebars, and loads new scripts, essentially creating a new
* page without completely reloading.
*
* @param {object} d - An object containing the new page
*/
EvalScripts(d)
{
d = d || {};
_loader.Clear();
P.ClearHandlers();
let bar = E('.TopBarGrid');
if (d.topbar)
{
bar.innerHTML = d.topbar;
P.Show(bar);
} else
{
P.Hide(bar);
}
bar = E('.LeftBarGrid');
if (d.leftbar)
{
bar.innerHTML = d.leftbar;
P.Show(bar);
} else
{
P.Hide(bar);
}
bar = E('.RightBarGrid');
if (d.rightbar)
{
bar.innerHTML = d.rightbar;
P.Show(bar);
} else
{
P.Hide(bar);
}
bar = E('.BottomBarGrid');
if (d.bottombar)
{
bar.innerHTML = d.bottombar;
P.Show(bar);
} else
{
P.Hide(bar);
}
P.StoreGlobals(d.jsglobals);
if (d.jsfiles)
{
_loader.Load(d.jsfiles);
}
_loader.OnFinished(
function()
{
P.RunHandlers('pageload');
}
);
}
// ====================================================================
/** Used for AJAX pages, this function will load only what is needed to
* change the page to its new state.
*
* @param {string} url - The URl to get information for the new page
* @param {bool} contentonly - To refresh the whole page or only new
* content
* @param {object} options - An optional dict containing
*
* <dl>
* <dt>title</dt>
* <dd>The new page title</dd>
* <dt>content</dt>
* <dd>The main content for the new page</dd>
* <dt>contentfunc</dt>
* <dd>A function to call to get the content for the new page with
* signature func(data, contentdiv)</dd>
* <dt>data</dt>
* <dd>An object containing page information</dd>
* <dt>contentdiv</dt>
* <dd>A custom selector to fill with page information rather than
* the standard .PageBodyGrid</dd>
* <dt>nopushstate</dt>
* <dd>Enable to keep the URL the same instead of changing it with
* history.pushState()</dd>
* </dl>
*/
async LoadURL(url, contentonly, options = {})
{
let title = options.title || '';
let content = options.content || '';
let contentfunc = options.contentfunc || '';
let data = options.data || {};
let contentdiv = options.contentdiv || '.PageBodyGrid';
let nopushstate = options.nopushstate || 0;
// Avoid multiple page loads on a single click event
if (url == P.currenturl)
{
return;
}
P.currenturl = url;
contentdiv = E(contentdiv);
if (!contentonly
|| !P.useadvanced
|| P.ajaxrefreshes >= P.ajaxrefreshlimit
)
{
P.LoadingDialog();
window.location = url;
} else
{
P.ajaxfreshes++;
P.RunHandlers('pageleave');
if (title && document)
{
document.title = title;
}
if (content || contentfunc)
{
if (!nopushstate)
{
history.pushState(
{
title: title,
content: content,
contentdiv: contentdiv,
contentfunc: contentfunc,
data: data
},
title,
url
);
}
if (contentfunc)
{
window[contentfunc](data, contentdiv);
} else
{
contentdiv.innerHTML = content;
}
P.EvalScripts(data);
} else
{
let popupid = await P.LoadingDialog();
P.Fill('.LoadingResults', T.waitingforserver);
P.AJAX(
url,
{contentonly: 1},
{timeout: 10000}
).then(
function(r)
{
P.Fill('.LoadingResults', T.working);
let d = {};
try
{
d = JSON.parse(r.response);
} catch (e)
{
d.error = e;
}
if (!d)
{
P.HTML(
contentdiv,
'<div class=Error>Problem getting content... Please try again in a little bit.</div>'
);
} else if (d.error)
{
P.HTML(
contentdiv,
'<div class=Error>' + d.error + '</div>'
);
} else
{
d.contentdiv = '.PageBody';
if (!nopushstate)
{
history.pushState(d, 'Testing', url);
}
if (d.title && document)
{
document.title = d.title;
}
contentdiv.innerHTML = d.content;
P.EvalScripts(d);
window.scrollTo(0, 0);
P.RunHandlers('pageload');
setTimeout(
function()
{
P.ClosePopup(popupid);
},
500
);
}
},
function(r)
{
P.ClosePopup(popupid);
P.HTML(
contentdiv,
`<div class=Error>
${r.reason}<br>
${r.responseText}
</div>`
);
}
);
}
}
}
// ====================================================================
/** Helper function for AJAX pages to return to the previous state.
*
* @param {event} e
*/
async OnPopState(e)
{
if (P.CloseTopLayer())
{
return;
}
if (e.state)
{
let popupid = await P.LoadingDialog();
if (e.state.contentfunc)
{
window[e.state.contentfunc](e.state.data);
} else
{
let contentdiv = e.state.contentdiv
? e.state.contentdiv
: E('.PageBody');
if (!contentdiv)
{
contentdiv = E('.PageBody');
}
P.HTML(contentdiv, e.state.content);
P.EvalScripts(e.state);
}
setTimeout(
function()
{
P.ClosePopup(popupid);
},
500
);
} else
{
P.LoadURL(
window.location.href,
1,
{
nopushstate: 1
}
);
}
}
// ====================================================================
/** A common Pafera utility, this displays a set of buttons for the
* user to select what chunk out of the total set of items he or she
* wishes to see.
*
* @param {int} count - The total number of items
* @param {int} start - The current position
* @param {int} limit - items per page
* @param {int} numonpage - how many items are on the current page
* @param {string} listfunc - a function name with the signature
* func(newstart) that will be called whenever the user chooses
* a new chunk.
*/
PageBar(count, start, limit, numonpage, listfunc)
{
count = parseInt(count);
start = parseInt(start);
numonpage = parseInt(numonpage);
listfunc = listfunc || 'ListObjects';
limit = parseInt(limit) || 100;
let numpages = Math.ceil(count / limit);
let pagestart = (count ? (start + 1) : 0);
let pageend = (start + numonpage);
if (pagestart && !pageend)
{
pageend = pagestart;
}
let ls = [
`<div class="Cleared"></div>
<div class="Pad50 PageBarNumbers">
${pagestart} - ${pageend} / ~${count}
</div>
<div class="ButtonBar PageBar">`
];
if (count > limit)
{
if (start)
{
ls.push(`<a class="FirstPage" onclick="${listfunc}(0)"><<</a>`);
if (start >= limit)
{
ls.push(`<a class="PreviousPage"
onclick="${listfunc}(${start - limit})"><
</a>`
);
}
}
let currentpage = Math.floor(start / limit);
let startpage = 0;
let endpage = numpages;
if (numpages > 16)
{
startpage = currentpage > 8 ? currentpage - 8 : 0;
endpage = currentpage + 8 < numpages ? currentpage + 8 : numpages;
}
for (let i = startpage; i < endpage; i++)
{
let startnum = i * limit;
if (startnum != start)
{
ls.push(`<a onclick="${listfunc}(${startnum})">${startnum}</a>`);
}
}
if (start + limit < count)
{
if (start + limit < count - limit)
{
ls.push(`<a class="NextPage" onclick="${listfunc}(${start + limit})">></a>`);
}
ls.push(`<a class="LastPage" onclick="${listfunc}(${Math.ceil((count - limit) / limit) * limit})">>></a>`);
}
}
if (numpages > 16)
ls.push(
`<a class=ChoosePage
onclick="P.ChoosePage(${start}, ${limit}, ${count}, '${listfunc}')">
#
</a>`
);
ls.push(`</div>
<div class=Cleared></div>`
);
return ls;
}
// ====================================================================
/** A helper function for P.PageBar() that shows a popup for manual
* page entry.
*
* @param {int} start - The current position
* @param {int} limit - items per page
* @param {int} count - The total number of items
* @param {string} listfunc - a function name with the signature
* func(newstart) that will be called whenever the user chooses
* a new chunk.
*/
ChoosePage(start, limit, count, listfunc)
{
let currentpage = Math.floor(start / limit) + 1;
let numpages = Math.ceil(count / limit);
P.EditPopup(
[
['pagenum', 'int', `${T.whichpage} (1 - ${numpages})`, currentpage, '', `min="1" max="${numpages}"`],
],
async function()
{
P.GotoPage(limit, listfunc);
return 1;
}
);
P.OnEnter(
'.pagenum',
function()
{
P.GotoPage(limit, listfunc);
}
);
setTimeout(
function()
{
let e = E('.pagenum');
e.focus();
e.select();
},
500
);
}
// ====================================================================
/** Helper function for P.ChoosePage() which goes straight to the page
* requested in the popup
*
* @param {int} limit - The number of items per page
* @param {string} listfunc - The name of a function with signature
* listfunc(newstart) that is called to load the next page.
*/
GotoPage(limit, listfunc)
{
let start = limit * (parseInt(E('.pagenum').value) - 1);
// This code is to let a nested object have the proper this keyword
let obj = GetNestedObject(window, listfunc.substr(0, listfunc.lastIndexOf('.')));
let func = GetNestedObject(window, listfunc).bind(obj);
func(start, limit);
let el = E('.pagenum');
if (P.Parent(el, '.Popup'))
{
P.CloseThisPopup('.pagenum');
} else
{
P.RemoveFullScreen();
}
}
// ====================================================================
/** A helper function for play/pause functionality on custom media controls.
*
* @param {string} medianame - The name of an already loaded sound file
* through P.Play()
*/
OnPauseButton(medianame)
{
let s = P.media[medianame];
if (s)
{
if (s.playing())
{
E('.PlayBar .PauseButton').setAttribute('src', P.FileURL('icons/pause.webp', 'system'));
s.pause();
} else
{
E('.PlayBar .PauseButton').setAttribute('src', P.FileURL('icons/play.webp', 'system'));
s.play(P.currentmediaid);
}
}
}
// ====================================================================
/** Helper function for playing the current audio position
*
* @param {string} medianame - The name of an already loaded sound file
* through P.Play()
*/
OnPlayTimer(url)
{
if (P.media[url] && P.media[url].playing())
{
let pos = P.media[url].seek();
P.Fill('.PlayBar .CurrentPos', SecondsToTime(pos, 1));
E('.PlayBar .Position').value = pos;
setTimeout(
function()
{
P.OnPlayTimer(url);
},
300
);
}
}
// ====================================================================
/** Turns a selector into an audio control.
*
* @param {string} selector - CSS selector
*/
async MakePlayButtons(selector)
{
let elements = Q(selector);
for (let element of elements)
{
element.classList.add('HoverHighlight');
P.HTML(`<img loading="lazy" alt="Play" class="DoubleLineHeight" src="/system/icons/play.png"/>`);
}
P.OnClick(
selector,
P.LBUTTON,
async function(e)
{
let el = P.Target(e);
let url = el.dataset.url;
let img = el.querySelector('img');
let src = img.getAttribute('src');
img.setAttribute('src', P.FileURL('busy.webp', 'system'));
await P.Popup(
`<div class="whiteb Pad50 Raised Rounded PlayBar">
<img loading="lazy" alt="Loading" src="${P.baseurl}/system/loading.gif"/>
</div>`,
{
closeonmouseout: 1,
parent: el
}
)
P.Play(
url,
url,
{
autoplay: 1,
onplay()
{
let s = P.media[url];
let d = s.duration();
P.HTML(
'.PlayBar',
[
`<img loading="lazy" alt="Pause" class="DoubleLineHeight PauseButton HoverHighlight" src="${P.FileURL('icons/play.png', 'system')}"/>
<span class=CurrentPos>${SecondsToTime(0, 1)}</span> /
<span class=Duration>${SecondsToTime(d, 1)}</span>
<meter class="Position Width100" min=0 max=${d.toFixed(0)}></meter>`
]
);
P.OnClick(
'.PlayBar .PauseButton',
P.LBUTTON,
function()
{
P.OnPauseButton(url);
}
);
P.OnClick(
'.PlayBar .Position',
P.LBUTTON,
function(e)
{
let self = P.Target(e);
let min = parseInt(self.get('@min'));
let max = parseInt(self.get('@max'));
let value = self[0].value;
let rt = P.AbsoluteRect(self);
let newvalue = Math.round(((P.mousex - rt.left) / rt.width) * (max - min)) + min;
if (value == newvalue)
{
return;
}
self[0].value = newvalue;
if (P.media[url])
{
P.media[url].seek(newvalue);
}
}
);
img.set('@src', src);
setTimeout(
function()
{
P.OnPlayTimer(url);
},
300
);
},
onloaderror(id, msg)
{
P.HTML('.PlayBar', '<div class=Error>' + msg + '</div>');
img.setAttribute('src', src);
}
}
);
}
);
}
// ====================================================================
/** Plays the audio file specified by url. Supports queuing and
* autoplay
*
* @param {string} filename - The name to refer to this sound by
* @param {string} url - The URL to load the sound from
* @param {object} options - An optional object with
*
* <dl>
* <dt>onloadfunc</dt>
* <dd>A handler called when the file has finished downloading
* with signature func(filename, howl)</dd>
* <dt>autoplay</dt>
* <dd>Set to true to start playing the file immediately after
* download.</dd>
* </dl>
*/
Play(filename, url, options)
{
options = options || {};
options.src = [url];
if (!P.media[filename])
{
P.mediatoload.push(url);
let prevonload = options.onloadfunc;
options.onload = function()
{
P.mediatoload.remove(url);
if (!P.mediatoload.length)
{
for (let func of P.onmedialoaded)
{
func(filename);
}
P.onmedialoaded = [];
}
if (options.onloadfunc)
{
options.onloadfunc(filename, this);
}
};
P.media[filename] = new Howl(options);
} else
{
if (options.autoplay != 0)
{
P.currentmediaid = P.media[filename].play();
if (options.onfinished)
{
P.media[filename].on('end', options.onfinished);
}
}
}
}
// ====================================================================
/** Helper function for the audio loader queue, this adds a function
* to be called when the file has finished downloading.
*
* @param {function} func - The handler with signature func(filename)
*/
OnMediaLoaded(func)
{
P.onmedialoaded.push(func);
}
// ====================================================================
/** Helper functions for stack control of popups and fullscreen layers.
*
* @param {string} selector - Adds the selector to the top of the stack
*/
AddLayer(selector)
{
P.layerstack.push(selector);
//history.pushState(null, '', '#' + selector);
}
// ====================================================================
/** Helper functions for stack control of popups and fullscreen layers.
*
* @param {string} selector - Removes the selector from the stack
*/
RemoveLayer(selector)
{
let index = P.layerstack.indexOf(selector);
if (index > -1)
{
P.layerstack.remove(index);
}
}
// ====================================================================
/** Closes the topmost layer. Normally bound to the escape key.
*
* Helper functions for stack control of popups and fullscreen layers
*
* @param {bool} closeall - Set to true to close all layers instead of
* just the topmost one.
*/
CloseTopLayer(closeall = 0)
{
// Close popup windows and fullscreens before going to previous page
if (P.layerstack.length)
{
if (closeall)
{
for (;;)
{
if (!P.layerstack.length)
{
break;
}
let selector = P.layerstack.pop();
let el = E(selector);
if (el)
{
break;
}
if (selector.has('Popup'))
{
el.remove();
} else
{
P.RemoveFullScreen();
}
P.Back();
return 1;
}
} else {
let el = E(P.layerstack.pop());
if (el)
{
if (el.classList.contains('Popup'))
{
P.ClosePopup(el);
} else
{
P.RemoveFullScreen();
}
}
}
}
return 0;
}
// ====================================================================
/** Calculates the dimensions of a fullscreen layer, taking into account
* the different sidebars present.
*/
FullScreenRect()
{
let viewport = P.ViewPortRect();
let leftbarsize = parseInt(E('.LeftBarGrid').clientWidth);
let topbarsize = parseInt(E('.TopBarGrid').clientHeight);
let rightbarsize = parseInt(E('.RightBarGrid').clientWidth);
let bottombarsize = parseInt(E('.BottomBarGrid').clientHeight);
if (!leftbarsize)
{
leftbarsize = 0;
}
if (!topbarsize)
{
topbarsize = 0;
}
if (!rightbarsize)
{
rightbarsize = 0;
}
if (!bottombarsize)
{
bottombarsize = 0;
}
return {
left: leftbarsize + 'px',
top: topbarsize + 'px',
width: (viewport.width - leftbarsize - rightbarsize) + 'px',
height: (viewport.height - topbarsize - bottombarsize) + 'px'
}
}
// ====================================================================
/** Adds an animated layer that covers the entire viewport: the web browser
* equivalent of a modal dialog. Note that this will not cover up any
* sidebars. If you don't want sidebars visible, hide them with P.SetLayout()
* before calling this function.
*
* Consistently used by EditPopup(), since many forms take up the
* whole screen.
*
* @param {string} classes - Additional CSS classes for the layer
* @param {string} styles - Additional CSS styles for the layer
* @param {string} contentlist - A string or list of strings containing the
* content of the layer
* @param {object} options - An optional object which may contain
*
* <dl>
* <dt>noanimation</dt>
* <dd>Set to true to have the layer appear instantly rather than
* slide in from the left</dd>
* </dl>
*/
AddFullScreen(classes, styles, contentlist, options = {})
{
let noanimation = options.noanimation || 0;
P.fullscreennum++;
document.body.appendChild(
P.HTMLToDOM(
`<div class="FullScreen FullScreen${P.fullscreennum} ${classes}"
style="z-index: ${P.fullscreennum * 100 + 10000} ${styles}">
</div>`
)
);
let selector = '.FullScreen' + P.fullscreennum;
P.AddLayer(selector);
let el = E(selector);
P.HTML(el, contentlist);
if (noanimation)
{
CopyValues(
P.FullScreenRect(),
el.style
);
} else
{
let startingleft = -window.outerWidth;
let finalrect = P.FullScreenRect();
let leftbarsize = parseInt(E('.LeftBarGrid').clientWidth);
CopyValues(
{
left: startingleft + 'px',
top: finalrect.top,
width: finalrect.width,
height: finalrect.height
},
el.style
);
P.Show(el);
P.Animate(
function(fraction)
{
CopyValues(
{
left: ((leftbarsize + -startingleft) * fraction + startingleft) + 'px'
},
el.style
);
if (fraction == 1)
{
el.scrollTo(0, 0);
}
},
300
);
}
return selector;
}
// ====================================================================
/** Removes the topmost fullscreen layer with animation.
*/
RemoveFullScreen()
{
let selector = 'FullScreen' + P.fullscreennum;
P.RemoveLayer('.' + selector);
let el = E('.' + selector);
if (!el)
{
return;
}
let leftbarsize = parseInt(E('.LeftBarGrid').clientWidth);
let startingleft = -window.outerWidth;
P.Animate(
function(fraction)
{
CopyValues(
{
left: ((leftbarsize + -startingleft) * (1 - fraction) + startingleft) + 'px'
},
el.style
);
if (fraction == 1)
el.remove();
},
300
)
P.fullscreennum--;
}
// ====================================================================
/** Force reload of the page
*/
ForceReloadPage()
{
window.location.reload(1);
}
// ====================================================================
/** A handy function to disable all non-typing input events. Very useful
* when you're trying to stop a student from copying and pasting his or
* her way to looking smart.
*
* @param {string} selector - CSS selector for the input element
*/
NoPaste(selector)
{
P.On(
selector,
'copy paste drag drop selectstart mousedown',
function(e)
{
e.preventDefault();
}
);
}
// ====================================================================
/** Helper function used by the popup handlers for the closeonmouseout
* functionality
*
* @param {event} e
*/
CaptureMousePos(e)
{
P.mousex = e.changedTouches ? e.changedTouches[0].pageX : e.pageX;
P.mousey = e.changedTouches ? e.changedTouches[0].pageY : e.pageY;
P.istouch = e.type.indexOf('touch') != -1;
}
// ====================================================================
/** Returns the last position of the mouse as recorded by the system
*/
MousePos()
{
return [P.mousex, P.mousey];
}
// ====================================================================
/** Creates a textarea which automatically resizes itself as you type.
* Handy for minimal forms which can expand for more complex content.
*
* @param {string} selector - The CSS selector to place the textarea in
*/
FlexInput(selector)
{
selector = selector || '.FlexInput';
let elements = Q(selector);
for (let element of elements)
{
let name = element.dataset.name;
let value = element.dataset.value;
let attrs = element.dataset.attrs;
if (!value)
{
value = '';
}
if (!attrs)
{
attrs = '';
}
if (!name)
{
P.HTML(element, `<div class=Error>No name provided</div>`);
return;
}
element.classList.add(name);
P.HTML(
element,
`<textarea class="Width100P ${element.classList.contains('Translation') ? 'translatedTextArea' : ''} ${name}TextArea" name="${name}" ${attrs}></textarea>`
);
let area = element.querySelector('textarea');
P.On(
area,
'input',
function()
{
if (this.scrollHeight > this.clientHeight)
{
this.style.height = this.clientHeight + (P.emsize * 2.6) + 'px';
}
}
);
area.value = value;
area.style.height = area.scrollHeight + (P.emsize * 2.6) + 'px';
}
}
// ====================================================================
/** Directly set the text inside of a flexinput
*
* @param {string} selector - CSS selector of the flex input
* @param {string} newtext
*/
SetFlexInputText(selector, newtext)
{
let elements = Q(selector + ' textarea');
for (let element of elements)
{
element.value = newtext;
}
}
// ====================================================================
/** Pafera captchas consist of clicking a button that says "I'm human."
* The magic is getting a randomized prompt from the server to send
* to the JavaScript eval() function for verification.
*
* @param {string} selector - The CSS selector to place the captcha.
*/
async MakeCaptcha(selector)
{
selector = selector || '.CaptchaDiv';
let elements = Q(selector);
for (let element of elements)
{
P.LoadingAPI(
element,
'/system/captchaapi',
{
command: 'get'
},
{
dontshowsuccess: 1
}
).then(
function(returned)
{
G.captchaprompt = returned.json.data;
P.HTML(
returned.resultsdiv,
`<label class="Pad50 lblueb Raised Rounded HoverHighlight">
<input type="checkbox" name="CaptchaCheckbox" class="CaptchaCheckbox" style="margin-top: 1em;">
${T.iamhuman}
</label>
<input type="hidden" name="captchavalue" class="captchavalue">
`
);
P.On(
'.CaptchaCheckbox',
'click',
function(e)
{
E('.captchavalue').value = eval(G.captchaprompt);
}
);
}
);
}
}
// ====================================================================
/** Helper function for EditPopup() which handles the imagefile,
* soundfile, videofile, and filelist fields
*
* @param {string} selector - The selector containing the file field
*/
async MakeFileFields(selector)
{
selector = selector || '.FileField';
for (let element of Q(selector))
{
let name = element.dataset.name;
let filetype = element.dataset.filetype;
let value = element.dataset.value;
let attrs = element.dataset.attrs;
if (!value)
{
value = '';
}
if (!attrs)
{
attrs = '';
}
if (!name)
{
P.HTML(element, `<div class=Error>No name provided</div>`);
return;
}
element.classList.add(name);
let ls = [];
switch (filetype)
{
case "image":
ls.push(`<img loading="lazy" alt="File icon" class="FileIcon FileSource"
src="${value
? (value[0] == '$'
? '/system/thumbnailer/' + value.substr(1) + '.webp'
: '/system/files/' + P.CodeDir(value) + '.webp'
)
: ''}"><br>
<div class="Center FileID Pad50">${value ? value : ''}</div>`
);
break;
case "sound":
ls.push(`<audio class="FileIcon" controls=1>
<source class="FileSource" src="${value
? (value[0] == '$'
? '/system/thumbnailer/' + value.substr(1) + '.mp3'
: '/system/files/' + P.CodeDir(value) + '.mp3'
)
: ''}"">
</audio><br>
<div class="Center FileID Pad50">${value ? value : ''}</div>`
);
break;
case "video":
ls.push(`<video class="FileIcon" controls=1>
<source class="FileSource" src="${value
? (value[0] == '$'
? '/system/thumbnailer/' + value.substr(1) + '.mp4'
: '/system/files/' + P.CodeDir(value) + '.mp4'
)
: ''}"">
</video><br>
<div class="Center FileID Pad50">${value ? value : ''}</div>`
);
break;
case "file":
ls.push(`<div class="Center FlexGrid16 FileList FileList${name}"></div>`);
break;
default:
ls.push(`
<div class="Center FileID FileIcon FileSource">${value ? value : ''}</div>`
);
}
ls.push(
`<div class="ButtonBar">
<a class="Color3" onclick="P.ChooseFileField(this)">${T.search}</a>
${
filetype == 'file'
? ''
: `<a class="Color2" onclick="P.UploadFileField(this, 'front')">${T.frontcamera}</a>
<a class="Color2" onclick="P.UploadFileField(this, 'back')">${T.backcamera}</a>`
}
<a class="Color2" onclick="P.UploadFileField(this, 0)">${T.upload}</a>
<a class="Color1" onclick="P.ClearFileField('${name}')">${T.delete}</a>
</div>`
);
P.HTML(element, ls);
if (filetype == 'file' && value)
{
let returned = await P.LoadingAPI(
'.FileList' + name,
'/system/fileapi',
{
command: 'search',
fileids: value.split('\n')
},
{
dontshowsuccess: 1
}
);
G.filefieldobjs = returned.json.data;
P.RenderFileObjects(returned.resultsdiv, returned.json.data);
}
}
}
// ====================================================================
/** Helper function for displaying a system file from returned JSON
* results.
*
* @param {object} r - The information for the file
*/
RenderFileInfo(r)
{
return `
<div class="FileImg Center">
<img loading="lazy" alt="File icon" src="${r.thumbnail}" class="Square800">
</div>
<div>
<div class="FileName Center">${r.filename}</div>
<div class="FileDescription Center">${r.description}</div>
<div class="FileSize Center Size80">${(r.size / 1024).toFixed(2)}k</div>
</div>
`;
}
// ====================================================================
/** Used by the file list to permit the user to easily download
* chosen files
*
* @param {string} selector - The selector for the file list
* @param {list} fileobjs - A list of objects containing the information
* for each file.
*/
RenderFileObjects(selector, fileobjs)
{
let ls = [];
fileobjs.forEach(
function(r)
{
ls.push('<div class="whiteb Rounded Pad50 Margin25">');
ls.push(P.RenderFileInfo(r));
let filenameparts = r.filename.split('.');
let downloadlink = r.url;
ls.push(`
<div class="ButtonBar">
<a class="Color4" href="${downloadlink}" target="_blank">
${T.download}
</a>
</div>
</div>
`);
}
)
P.HTML(selector, ls);
}
// ====================================================================
/** Helper function for the EditPopup() file fields to show a newly
* chosen file.
*
* @param {string} fieldname - The name for the file field
* @param {string} newvalue - The short code for the new file
* @param {list} additionalids - A list of short codes for multiple
* select file lists
* @param {list} fileobjs - A list of objects containing the information
* for each file.
*/
async UpdateFileList(fieldname, newvalue, additionalids, fileobjs)
{
newvalue = newvalue || '';
additionalids = additionalids || '';
fileobjs = fileobjs || [];
let inputel = E('input.' + fieldname);
let filedisplay = '.FileList' + fieldname;
if (newvalue)
{
inputel.value = newvalue;
}
if (additionalids)
{
if (inputel.value)
{
inputel.value += '\n' + additionalids.join('\n');
} else
{
inputel.value = additionalids.join('\n');
}
}
if (!IsEmpty(fileobjs))
{
let displayel = E(filedisplay);
P.RenderFileObjects(displayel, fileobjs);
let fileids = [];
fileobjs.forEach(
function(r)
{
fileids.push(r.idcode);
}
);
inputel.value = fileids.join('\n');
} else
{
if (inputel.value)
{
let d = (await P.DialogAPI(
'/system/fileapi',
{
command: 'search',
fileids: inputel.value.split('\n')
}
)).json.data;
if (d)
{
let ls = [];
P.RenderFileObjects(filedisplay, d);
} else
{
P.HTML(filedisplay, T.nothingfound);
}
} else
{
P.HTML(filedisplay, T.nothingfound);
}
}
}
// ====================================================================
/** Helper function for the EditPopup() file fields to show a newly
* chosen file.
*
* @param {string} fieldname - The name for the file field
* @param {string} newvalue - The short code for the new file
* @param {list} additionalids - A list of short codes for multiple
* select file lists
* @param {list} fileobjs - A list of objects containing the information
* for each file.
*/
ClearFileField(fieldname)
{
let inputel = E('input.' + fieldname);
let filedisplay = '.FileList' + fieldname;
inputel.value = '';
P.HTML(filedisplay, T.nothingfound);
let imgdisplay = inputel.parentElement.querySelector('img');
if (imgdisplay)
{
imgdisplay.src = '';
}
let fileid = inputel.parentElement.querySelector('.FileID');
if (fileid)
{
fileid.innerText = '';
}
}
// ====================================================================
/** Updates a file field with the chosen file's name and thumbnail
*
* @param {DOMElement} el - The file field
* @param {string} thumbnail - The URL for the file's thumbnail image
* @param {string} idcode - The short code for the file
* @param {string} filename - The name of the file
*/
UpdateFileField(el, thumbnail, idcode, filename)
{
let filediv = P.Parent(el, '.FileField');
let fieldname = filediv.dataset.name;
let filetype = filediv.dataset.filetype;
let fileid = filediv.querySelector('.FileID');
let fileicon = filediv.querySelector('.FileIcon');
let filesource = filediv.querySelector('.FileSource');
if (filetype == 'filelist')
{
P.UpdateFileList(fieldname, '', [idcode]);
return;
}
filesource.setAttribute('src', thumbnail);
if (filetype == 'sound' || filetype == 'video')
{
if (idcode)
{
if (filetype == 'sound')
{
fileicon.outerHTML = P.SoundFile(idcode, 'FileIcon', 'controls=1');
} else
{
fileicon.outerHTML = P.VideoFile(idcode, 'FileIcon', 'controls=1');
}
} else
{
filesource.setAttribute('src', '');
}
}
fileid.innerText = idcode + ': ' + filename;
// Public files are in /system/files, while private files must use
// the /system/thumbnailer API
E('input.' + fieldname).value = thumbnail.indexOf('thumbnailer') == -1 ? idcode : '$' + idcode;
}
// ====================================================================
/** Helper function to show a chooser for the file field. Requires
* /system/paferachooser.js to be loaded.
*
* @param {DOMElement} el - The file field element
*/
ChooseFileField(el)
{
let filediv = P.Parent(el, '.FileField');
let fieldname = filediv.dataset.name;
let filetype = filediv.dataset.filetype;
G.filefieldchooser = new PaferaFileChooser({
div: 'FileFieldChooser',
reference: 'G.filefieldchooser',
filetype: filetype
});
if (filetype == 'file')
{
G.filefieldchooser.multipleselect = 1;
if (G.filefieldobjs)
{
G.filefieldchooser.chosen = G.filefieldobjs;
}
}
G.filefieldchooser.OnFinished = function()
{
let self = this;
if (filetype == 'file')
{
P.UpdateFileList(fieldname, '', '', self.chosen);
P.RemoveFullScreen();
} else
{
let f = self.chosen[0];
P.UpdateFileField(el, f.thumbnail, f.idcode, f.filename);
}
}
G.filefieldchooser.Display();
}
// ====================================================================
/** Handles the user manually uploading a new file into a file field
*
* @param {DOMElement} el - The file field element
* @param {string} camera - Can be blank, "front", or "back" to let
* the user use the camera to take a photo or video.
*/
UploadFileField(el, camera = '')
{
let filediv = P.Parent(el, '.FileField');
let fieldname = filediv.dataset.name;
let filetype = filediv.dataset.filetype;
let fileid = filediv.querySelector('.FileID');
let fileicon = filediv.querySelector('.FileIcon');
let filesource = filediv.querySelector('.FileSource');
let options = {
onfinished(e, xhr, filename)
{
let self = this;
let fileinfo = JSON.parse(xhr.response);
if (fileinfo.error)
{
P.HTML(
'.UploadedFileResults',
`<div class="Error Pad50">${fileinfo.error}</div>`
);
return;
}
if (filetype == 'file')
{
P.UpdateFileList(fieldname, '', [fileinfo.id]);
} else
{
P.UpdateFileField(
el,
fileinfo.thumbnail,
fileinfo.id,
filename
);
}
}
};
switch (camera)
{
case 'front':
options.attrs = 'capture="user"';
break;
case 'back':
options.attrs = 'capture="environment"';
break;
}
switch (filetype)
{
case 'image':
options.accept = `image/*${camera ? '; capture=camera' : ''}`;
options.allowedextensions = [
'jpg',
'jpeg',
'gif',
'png',
'tif',
'tiff',
'svg',
'psd',
'bmp',
'pcd',
'pcx',
'pct',
'pgm',
'ppm',
'tga',
'img',
'raw',
'webp',
'wbmp',
'eps',
'cdr',
'ai',
'dwg',
'indd',
'dss',
'fla',
'ss'
];
break;
case 'audio':
options.accept = `audio/*${camera ? '; capture=camera' : ''}`;
options.allowedextensions = [
'mp3',
'aac',
'wma',
'oga',
'ogg',
'm4a',
'wav',
'aif',
'aiff',
'dvf',
'm4b',
'm4p',
'mid',
'midi',
'ram',
'mp2'
];
break;
case 'video':
options.accept = `video/*${camera ? '; capture=camera' : ''}`;
options.allowedextensions = [
'mp4',
'mpg',
'avi',
'wmv',
'rm',
'rmvb',
'ogv',
'ogm',
'm4v',
'mov',
'flv',
'f4v',
'3gp',
'3gpp',
'vob',
'asf',
'divx',
'mswmm',
'asx',
'amr',
'mkv',
'vp8',
'webm'
];
break;
}
P.UploadFilePopup('/system/uploadapi', options);
}
// ====================================================================
/** Creates a clickable, changeable calendar widget
*
* @param {string} selector - The selector to create the calendar in
* @param {string} datestring - A string containing the date to display
* @param {function} onchangemonthfunc - A handler for when the user
* chooses another month with signature func(selector, monthnumber)
* @param {function} ondayclickfunc - A handler for when the user
* clicks on a day with signature func(element, datenumber, event)
*/
MakeCalendar(selector, datestring, onchangemonthfunc, ondayclickfunc)
{
let d = datestring ? Date.parse(datestring) : new Date();
if (isNaN(d.getTime()))
{
P.HTML(selector, `<div class=Error>Invalid date: ${datestring}</div>`);
return;
}
let currentdate = d.getDate();
let currentday = d.getDay();
let currentyear = d.getFullYear();
let currentmonth = d.getMonth();
let monthtostop = currentmonth == 11 ? 0 : currentmonth + 1;
let nextmonth = (currentmonth == 11)
? (currentyear + 1) + '-01'
: currentyear + '-' + (currentmonth < 8 ? '0' + (currentmonth + 2) : (currentmonth + 2));
let previousmonth = (currentmonth == 0)
? (currentyear - 1) + '-12'
: currentyear + '-' + (currentmonth < 10 ? '0' + currentmonth : currentmonth);
let ls = [`<table class="Styled Calendar Width100 Center">
<tr>
${switchmonthfunc
? '<td class="Color1 PreviousMonthButton"><<</td>'
: '<th></th>'
}
<td colspan=5 class=ThisMonth>${currentyear}-${currentmonth < 9 ? '0' + (currentmonth + 1) : (currentmonth + 1)}</td>
${switchmonthfunc ? '<td class="Color2 NextMonthButton">>></td>' : '<th></th>'}
</tr>
<tr>
<th>${T_CALENDAR[9]}</th>
<th>${T_CALENDAR[10]}</th>
<th>${T_CALENDAR[11]}</th>
<th>${T_CALENDAR[12]}</th>
<th>${T_CALENDAR[13]}</th>
<th>${T_CALENDAR[14]}</th>
<th>${T_CALENDAR[15]}</th>
</tr>`
];
// Set date to starting square of calendar
d.setDate(1);
d.setDate(d.getDate() - d.getDay() + 1);
let i = 0;
let now = new Date();
let thismonth = now.getMonth();
let thisday = now.getDate();
for (;;)
{
ls.push('<tr>');
for (let i = 0; i < 7; i++)
{
let loopdate = d.getDate();
let loopmonth = d.getMonth();
let zerodate = (loopdate < 10 ? '0' + loopdate : loopdate);
ls.push(
`<td class="
${(loopmonth == thismonth && loopdate == thisday) ? 'Today ' : ''}
${(loopmonth == currentmonth)
? 'Day Day' + zerodate + '" data-date="' + zerodate
: 'OtherMonth'}
">${loopdate}</td>`
);
d.setDate(loopdate + 1);
}
ls.push('</tr>');
if (d.getMonth() == monthtostop || i > 60)
{
break;
}
i++;
}
ls.push('</table>');
P.HTML(selector, ls);
if (onchangemonthfunc)
{
P.On(
selector + ' .NextMonthButton',
'click',
function() {onchangemonthfunc(selector, nextmonth); }
);
P.On(
selector + ' .PreviousMonthButton',
'click',
function() {onchangemonthfunc(selector, previousmonth); }
);
}
if (ondayclickfunc)
{
P.OnClick(
selector + ' .Calendar',
P.LBUTTON,
function(e)
{
let el = P.TargetClass(e, '.Day');
ondayclickfunc(el, el.get('%date'))
}
);
}
}
// ====================================================================
/** Convenience function to call func when the user pushes enter inside
* of selector
*
* @param {string} selector - CSS selector of the input element
* @param {function} func - The handler with signature func(event)
*/
OnEnter(selector, func)
{
P.On(
selector,
'keypress',
function(e)
{
if (e.keyCode == 13)
{
func(e);
return false;
}
return true;
}
);
}
// ====================================================================
/** Creates a DOM image to ensure the image requested is already being
* downloaded and the user won't have to wait too long to use it.
*
* @param {string} urls - The URL of the image or a list of URLs
*/
PreloadImage(urls)
{
if (!IsArray(urls))
{
urls = [urls];
}
for (let url of urls)
{
for (let preloadurl in P.preloads)
{
if (preloadurl == url)
{
return P.preloads[k];
}
}
let img = new Image();
img.src = url;
P.preloads[url] = img;
}
}
// ====================================================================
/** Similar to MakeRadioButtons(), this function will create a tab
* widget on medium or large screens, and a select element on small screens.
*
* You'll need to have a set of elements each with the class value + 'Tab'
* for this widget to show and hide depending on the user input.
*
* @param {string} selector - CSS selector to put the tabs in
* @param {list} tabs - A list of [value, display] arrays. When the user
* selects a tab, every element with the '.Tab' class will be hidden,
* and the element with class value + 'Tab' will be shown.
*/
MakeTabs(selector, tabs, onclickfunc)
{
let el = E(selector);
let ls = [];
let useselect = (P.screensize == 'small');
if (useselect)
{
ls.push('<select class="TabSelect">');
} else
{
for (let k of 'TabBar Flex FlexCenter FlexWrap'.split(' '))
{
el.classList.add(k);
}
}
let firsttab = 1;
for (let tab of tabs)
{
if (useselect)
{
ls.push(`
<option value="${tab[0]}">
${tab[1]}
</option>`
);
} else
{
ls.push(`
<a class="TabItem ${firsttab ? 'Selected' : ''}" data-name="${tab[0]}">
${tab[1]}
</a>`
);
}
let tabel = E('.' + tab[0] + 'Tab');
tabel.classList.add('Tab');
if (!firsttab)
{
P.Hide(tabel);
}
firsttab = 0;
}
if (useselect)
{
ls.push('</select>');
} else
{
ls.push('<br class=Cleared>');
}
P.HTML(el, ls);
P.Hide('.Tab');
P.Show('.' + tabs[0][0] + 'Tab');
if (useselect)
{
P.On(
el,
'change',
function(e)
{
P.Hide('.Tab');
P.Show('.' + E(selector + ' .TabSelect').value + 'Tab');
L('Showing', '.' + E(selector + ' .TabSelect').value + 'Tab')
}
);
} else
{
P.OnClick(
el,
P.LBUTTON,
function(e)
{
let tabitem = P.TargetClass(e, '.TabItem');
if (!tabitem)
{
return;
}
for (let child of el.children)
{
child.classList.remove('Selected');
}
tabitem.classList.add('Selected');
P.Hide('.Tab');
P.Show('.' + tabitem.dataset.name + 'Tab');
}
);
}
}
// ====================================================================
/** System level tagging functions. Usable for any object.
*
* @param {string} selector - CSS selector for the tags
* @param {list} taglist - Existing tags for the object
* @param {function} savefunc - Handler for saving the updated tags
* with signature func(selector, taglist)
*/
MakeEditTags(selector, taglist, savefunc)
{
let el = E(selector);
el.classList.add('EditTags');
self.origtaglist = taglist.slice();
P.HTML(
el,
`<p class=EditTagsList></p>
<div class=ButtonBar>
<a class="Color1 AddTagButton">+</a>
<a class="Color2 ResetTagsButton">${T.reset}</a>
<a class="Color3 SaveTagsButton">${T.finished}</a>
</div>
<div class=SaveTagsResults></div>`
);
P.MakeEditTagsContent(selector, taglist);
P.On(
'.EditTags .Tag',
'click',
function()
{
this.remove();
}
);
P.On(
selector + ' .AddTagButton',
'click',
function()
{
P.EditPopup(
[
['newtag', 'text', T.new]
],
async function()
{
let text = E('.newtag').value;
if (!taglist.contains(text))
{
taglist.push(text);
P.MakeEditTagsContent(selector, taglist);
}
return 1;
},
{
parent: this
}
);
}
);
P.On(
selector + ' .ResetTagsButton',
'click',
function()
{
P.MakeEditTagsContent(selector, self.origtaglist);
}
);
P.OnClick('.SaveTagButton', P.LBUTTON, function() { onsavefunc(selector, taglist) });
}
// ====================================================================
/** Helper function for MakeEditTags to show more tags on click
*
* @param {string} selector - CSS selector of the tags widget
* @param {list} taglist - A list of strings depicting the new list of
* tags to render.
*/
MakeEditTagsContent(selector, taglist)
{
taglist.sort();
let ls = [];
for (let tag of taglist)
{
ls.push(
`<a class=Tag>
<img loading="lazy" alt="" src="${P.baseurl}/system/svg/tag.svg">
${tag}
</a>`
);
}
P.HTML(selector + ' .EditTagsList', ls);
}
// ====================================================================
/** Overrides all normal links in favor of AJAX page handling.
*
* If you want a link to proceed normally, set data-nointercept on the
* a tag.
*/
InterceptHREF()
{
P.On(
document.body,
'click',
function(e)
{
let target = P.TargetClass(e, 'a');
if (!target)
{
return 1;
}
let href = target.getAttribute('href');
if (!target.dataset.nointercept
&& href
&& href.indexOf('://') == -1
&& href.indexOf('login') == -1
)
{
P.CancelBubble(e);
P.LoadURL(href, 1);
return 0;
}
return 1;
}
);
}
// ====================================================================
/** Convenience function for simplifying CSS transforms.
*
* @param {string} selector - CSS selector of the image
* @param {int} deg - The number of degrees to rotate
*/
RotateImage(selector, deg)
{
let img = E(selector);
let angle = parseInt(img.dataset.angle);
if (!angle)
{
angle = 0;
}
angle += deg;
if (angle < 0)
{
angle = 360 - (angle % 360);
}
if (angle >= 360)
{
angle %= 360;
}
img.style.angle = angle;
img.style.transform = 'rotate(' + angle + 'deg)';
}
// ====================================================================
/** Emulates an event and dispatches it on element.
*
* Thanks to http://stackoverflow.com/questions/6157929/how-to-simulate-a-mouse-click-using-javascript
*
* @param {DOMElement} element - The element to receive the event
* @param {string} eventName - The event to emulate. Look at the source
* for all of the possible names.
* @param {object} options - All of the normal event properties such as
* x and y position, keys, buttons, and so forth. See the source for
* a comprehensive list.
*/
Emit(element, eventName, options)
{
options = Merge(
{
pointerX: 0,
pointerY: 0,
button: 0,
ctrlKey: false,
altKey: false,
shiftKey: false,
metaKey: false,
bubbles: true,
cancelable: true
},
options || {}
);
let oEvent, eventType = null;
for (let name in {
'HTMLEvents': /^(?:load|unload|abort|error|select|change|submit|reset|focus|blur|resize|scroll)$/,
'MouseEvents': /^(?:click|dblclick|mouse(?:down|up|over|move|out))$/
}
)
{
if (eventMatchers[name].test(eventName))
{
eventType = name; break;
}
}
if (!eventType)
{
throw new SyntaxError('Only HTMLEvents and MouseEvents interfaces are supported');
}
if (document.createEvent)
{
oEvent = document.createEvent(eventType);
if (eventType == 'HTMLEvents')
{
oEvent.initEvent(eventName, options.bubbles, options.cancelable);
}
else
{
oEvent.initMouseEvent(
eventName,
options.bubbles,
options.cancelable,
document.defaultView,
options.button,
options.pointerX,
options.pointerY,
options.pointerX,
options.pointerY,
options.ctrlKey,
options.altKey,
options.shiftKey,
options.metaKey,
options.button,
element
);
}
element.dispatchEvent(oEvent);
}
else
{
options.clientX = options.pointerX;
options.clientY = options.pointerY;
let evt = document.createEventObject();
oEvent = extend(evt, options);
element.fireEvent('on' + eventName, oEvent);
}
return element;
}
// ====================================================================
/** Helper function for navigation bar tabs which automatically
* highlight the current URL
*
* @param {string} selector - CSS selector of the navbar
*/
HighlightTab(selector)
{
selector = selector || '.Tabs';
let elements = Q(selector + ' a');
for (let element of elements)
{
let href = element.getAttribute('href');
if (href)
{
(window.location.href.indexOf(href) > -1)
? element.classList.add('Selected')
: element.classList.remove('Selected');
}
}
}
// ====================================================================
/** Shows the system standard upload dialog for the user to choose a
* file to upload. Note that by default, only users in the uploaders
* group can upload files, so make sure that your user has been added
* to the group before using this.
*
* @param {string} uploadurl - The URL to upload to, by default
* /system/upload
* @param {object} options - An object containing optional attributes
*
* <dl>
* <dt>accept</dt>
* <dt>accept attribute for the file input</dt>
* <dt>maxsize</dt>
* <dt>Size limit for the file, default 128MiB.</dt>
* <dt>filetype</dt>
* <dt>MIME type to look for</dt>
* <dt>fileid</dt>
* <dt>existing short code when replacing an already uploaded file</dt>
* <dt>attrs</dt>
* <dt>extra attributes for the file input</dt>
* <dt>title</dt>
* <dt>a title for the popup</dt>
* <dt>onfinished</dt>
* <dd>a handler called when upload has finished with signature
* func(event, xmlhttprequest, filename)</dd>
* <dt>resizeimage</dt>
* <dd>int value for the largest dimension of an image. Before upload,
* Pafera will use HTML canvas to resize the image to the largest dimension.
* </dd>
* </dl>
*/
UploadFilePopup(uploadurl, options = {})
{
if (!P.IsAdmin() && !(P.userflags & 0x40))
{
P.ErrorPopup(T.nopermission);
return;
}
options.accept = options.accept || 'image/*; capture=camera';
options.maxsize = options.maxsize || (128 * 1024 * 1024);
options.filetype = options.filetype || '';
options.fileid = options.fileid || '';
options.attrs = options.attrs || '';
options.title = options.title || '';
options.onfinished = options.onfinished || 0;
options.resizeimage = options.resizeimage || 0;
options.allowedextensions = options.allowedextensions || 0;
if (!options.closeonuploaded)
{
let onfinished = options.onfinished;
options.onfinished = function(event, xhr, filename)
{
try
{
let json = JSON.parse(xhr.response);
if (json.error)
{
P.ShowError('.UploadedFileResults', json.error);
return;
}
} catch (error)
{
}
P.CloseThisPopup('.UploadFileForm');
if (onfinished)
{
onfinished(event, xhr, filename);
}
}
}
P.MessageBox(
[
`<div class="dgreeng Pad50 HoverHighlight TitleBar">
${options.headertext
? options.headertext
: T.upload
}
</div>
<form class="Pad50 UploadFileForm" method="post" enctype="multipart/form-data">
${options.disabletitle
? ''
: `<div>
<label>${T.title}</label><br>
<input type="text" class="UploadFileTitle">
</div>`
}
${options.disablesecurity
? ''
: `<div>
<label>${T.security}</label><br>
<select class="UploadFileSecurity">
<option value="public">${T.public}</option>
<option value="protected">${T.protected}</option>
<option value="private">${T.private}</option>
</select>
<a class="FileSecurityInfo Color4 Pad50 Raised Rounded">
?
</a>
</div>`
}
<div>
<input type="file" class="UploadFile"
accept="${options.accept}"
data-maxsize="${options.maxsize}"
data-fileid="${options.fileid}"
data-filetype="${options.filetype}"
data-uploadurl="${EncodeEntities(uploadurl)}"
multiple
style="cursor: pointer;"
${options.attrs}>
</div>
<div class=UploadedFiles></div>
<div class="UploadedFileResults"></div>
</form>
<br>`
],
[
// The new popup automatically uploads the file as soon as the user
// finishes choosing. Uncomment this line if you disable
// automatic uploading
//
// [T.finished, function() { P.UploadFile(0, options) }, 3],
[
T.back,
function()
{
P.CloseThisPopup('.UploadFileForm');
},
1
]
],
{
popupclass: '.UploadFilePopup'
}
);
P.On(
'.FileSecurityInfo',
'click',
function(el)
{
P.MessageBox(`
<dl>
<dt>${T.public}</dt>
<dd>${T.publicfile}</dd>
<dt>${T.protected}</dt>
<dd>${T.protectedfile}</dd>
<dt>${T.private}</dt>
<dd>${T.privatefile}</dd>
</dl>
`);
}
);
P.On(
'.UploadFile',
'change',
function()
{
if (options.allowedextensions)
{
let extension = GetFileExtension(E('.UploadFile').files[0].name);
if (options.allowedextensions.indexOf(extension) == -1)
{
P.ErrorPopup(T.wrongfileformat);
return;
}
}
P.UploadFile('.UploadFile', options);
}
);
P.MakeDraggable('.UploadFilePopup');
}
// ====================================================================
/** Helper functions for UploadFilePopup() to show progress
*
* @param {int} filenum - The number of the file to update
*/
MakeFileProgressHandler(filenum)
{
return function(e)
{
P.UploadFileProgress(
e,
E('.UploadedFiles .Upload' + filenum + ' .Right')
);
}
}
// ====================================================================
/** Uploads a file to the Pafera system. If you want to upload a file
* manually, send a POST request to /system/upload in the format
*
* /system/upload/fileid/filename/filetitle/filesecurity
*
* @param {string} fileselector - CSS class of the file input. By
* default .UploadFile
* @param {object} options - An optional object which may contain
*
* <dl>
* <dt>title</dt>
* <dd>The title for the system, which is a description of the
* file separate from the file name.</dd>
* <dt>security</dt>
* <dd>public, protected, or private. The default is public.</dd>
* <dt>onfinished</dt>
* <dd>a function with the signature onfinished(event, xhr, filename)</dd>
* </dl>
*/
UploadFile(fileselector = '.UploadFile', options = {})
{
let el = E(fileselector);
let files = el.files;
let l = files.length;
let titleel = E('.UploadFileTitle');
let title = titleel ? titleel.value : '_';
if (options.title)
{
title = options.title;
}
let securityel = E('.UploadFileSecurity');
let security = securityel ? securityel.value : 'public';
if (options.security)
{
title = options.security;
}
if (!l)
{
P.ErrorPopup(T.needfile);
return;
}
if (!title)
{
title = '_';
}
let uploadurl = options.uploadurl ? options.uploadurl : el.dataset.uploadurl;
let fileid = el.dataset.fileid;
let filetype = el.dataset.filetype;
let maxsize = el.dataset.maxsize;
fileid = fileid || '_';
for (let i = 0; i < l; i++)
{
let file = files[i];
if (filetype && !file.type.match(filetype))
{
P.ErrorPopup(file.name + ' is the wrong type');
continue;
}
P.Add(
'.UploadedFiles',
`<table class="Width100P Border0 Upload${i}">
<tr>
<td>${EncodeEntities(file.name)}</td>
<td class=Right>0%</td>
</tr>
</table>`
);
let url = (
uploadurl
+ '/' + fileid
+ '/' + encodeURIComponent(file.name)
+ '/' + encodeURIComponent(title)
+ '/' + encodeURIComponent(security)
);
//if (options.resizeimage && file.type.match(/image.*/))
/*{
let img = document.createElement('img');
img.src = window.URL.createObjectURL(file);
let canvas = document.createElement('canvas');
canvas.width = options.resizeimage[0];
canvas.height = options.resizeimage[1];
pica.resize(
img,
canvas,
{
unsharpAmount: 50,
unsharpRadius: 1,
unsharpThreshold: 70
}
).then(
function(err)
{
P.SendFile(i, url, file, options);
}
);
} else
{
P.SendFile(i, url, file, options);
}*/
P.SendFile(i, url, file, options);
}
}
// ====================================================================
/** Sends file to URL as a POST request, automatically updating the
* progress in P.UploadFilePopup()
*
* @param {int} filenum - The number of the file in the current popup
* @param {string} url - The URL to upload the file to
* @param {File} file - The file input to read from
* @param {object} options - An optional object which may contain
*
* <dl>
* <dt>onfinished</dt>
* <dd>a function with the signature onfinished(event, xhr, filename)</dd>
* </dl>
*/
SendFile(filenum, url, file, options)
{
options = options || {};
options.timeout = options.timeout || 600;
let xhr = new XMLHttpRequest();
xhr.open('post', url, true);
xhr.timeout = options.timeout * 1000;
xhr.upload.addEventListener(
"progress",
P.MakeFileProgressHandler(filenum, file.name)
);
if (options.onfinished)
{
xhr.onload = function(e)
{
options.onfinished(e, xhr, file.name);
}
}
xhr.send(file);
}
// ====================================================================
/** Sets the percentage indicator in P.UploadFilePopup()
*
* @param {event} e - An upload progress event
* @param {DOMElement} el - The element to update
*/
UploadFileProgress(e, el)
{
let percent = Math.round(e.loaded / e.total * 100);
el.innerText = percent + '%';
}
// ====================================================================
/** Convenience function to automatically hide a popup after
* option.duration seconds
*
* @param {string} message - The text to display
* @param {string} selector - The selector to display the toast in, or
* blank to display in a popup.
* @param {int} duration - The number of milliseconds before the toast
* disappears
* @param {object} options - Options to pass to the popup. You can also
* add an onfinished function to call when the toast disappears.
*/
async Toast(message, selector = '', duration = 2000, options = {})
{
if (selector)
{
P.HTML(selector, message);
setTimeout(
function()
{
if (options.onfinished)
{
options.onfinished();
}
P.HTML(selector, '');
},
duration
)
return;
}
let popupid = await P.Popup(message, options);
setTimeout(
function()
{
if (options.onfinished)
{
options.onfinished();
}
P.ClosePopup(popupid);
},
duration
);
}
// ====================================================================
/** For use on mobile devices, returns the current position of the user.
*
* @param {function} callback - A handler which receives the coordinates
* with signature func(coordinates)
*/
GetLocation(callback)
{
navigator.geolocation.getCurrentPosition(
function(p)
{
callback(p.coords);
},
function()
{
P.ErrorPopup(T.cantgps);
},
{
enableHighAccuracy: true,
maximumAge : 30000,
timeout : 20000
}
);
}
// ====================================================================
/** Creates a standard gray context menu function which accepts an
* event and popups up a context menu at its location.
*
* @param {list} items - A list of text strings to display or a function
* that returns a list of a elements with the "ContextMenuItem"
* class and has signature func(element, event)
* @param {object} options - An optional object which may contain
*
* <dl>
* <dt>topclass</dt>
* <dd>The topmost CSS class of the popup</dd>
* <dt>width</dt>
* <dd>the width of the popup</dd>
* </dl>
*/
MakeContextMenu(items, options)
{
options = options || {};
options.width = options.width || '12em';
return function(e)
{
let el = P.Target(e);
if (options.topclass
&& !el.classList.contains(options.topclass.substr(1))
)
{
el = P.Parent(el, options.topclass);
}
if (!el)
{
return;
}
P.CancelBubble(e);
let ls = [
`<div class=ContextMenu>`
];
let menuitems = IsFunc(items) ? items(el, e) : items;
for (let i = 0, l = menuitems.length; i < l; i++)
{
let item = menuitems[i];
ls.push(`<a class="ContextMenuItem" data-pos=${i}>
${item[0]}
</a>`);
}
ls.push('</div>');
P.Popup(
ls.join('\n'),
{
left: P.mousex + 'px',
top: P.mousey + 'px',
width: options.width,
closeonmouseout: 1
}
).then((returned) =>
P.OnClick(
'.ContextMenu',
P.LBUTTON,
function(e)
{
let item = P.TargetClass(e, '.ContextMenuItem');
if (!item)
{
return;
}
let func = menuitems[parseInt(item.dataset.pos)][1];
func(e, el);
P.CloseThisPopup('.ContextMenu');
}
)
)
}
}
// ====================================================================
/** An advanced form of OnEnter() that requires the user to press
* Alt+Enter instead.
*
* Thanks to http://stackoverflow.com/questions/30467263/handling-alt-enter-key-press-in-javascript
*
* @param {string} selector - CSS selector for the input
* @param {function} func - The handler with signature func(selector, event)
*/
OnAltEnter(selector, func)
{
P.On(
selector,
'keydown',
function(e)
{
if (e.defaultPrevented)
{
return;
}
if (e.altKey
&& (e.key === 'Enter'
|| e.keyIdentifier === "Enter"
|| e.keyCode === 13)
)
{
P.CancelBubble(e);
func(selector, e);
}
}
);
}
// ------------------------------------------------------------------
/** A simple function to see if this element is visible within the
* selector's on-screen region. For speed, this checks only the
* vertical offset.
*
* @param {DOMElement} el - The child element
* @param {string} selector - The parent element
*/
VisibleInSelector(el, selector)
{
let top = el.offsetTop;
let height = el.offsetHeight;
let pagecontent = E(selector);
let pagetop = pagecontent.scrollTop;
let pagebottom = pagetop + pagecontent.clientHeight;
while (el.offsetParent)
{
el = el.offsetParent;
if (el.classList.contains(selector))
{
break;
}
top += el.offsetTop;
}
return (
(top < pagebottom)
&& ((top + height) > pagetop)
);
}
// ------------------------------------------------------------------
/** Adds a delayed event, which is an event that is called only afer
* a period where no other events have been triggered. This is handy
* for repeated actions such as the user resizing or scrolling the window.
*
* @param {string} eventname - The name of the event
* @param {event} e - The event to pass along
* @param {int} delay - The number of milliseconds to delay.
*/
AddDelayedEvent(eventname, e, delay = 250)
{
if (P.delayedevents[eventname])
{
clearTimeout(P.delayedevents[eventname]);
}
P.delayedevents[eventname] = setTimeout(
function()
{
P.RunHandlers(eventname, e);
P.delayedevents[eventname] = 0;
},
delay
);
}
// ------------------------------------------------------------------
/** Enables the system to process a final scroll event when the user
* stops scrolling.
*
* @param {event} e
*/
OnScroll(e)
{
P.AddDelayedEvent('scroll', e);
}
// ------------------------------------------------------------------
/** For large pages, lazy loading allows the page to appear faster by
* loading resources only as they come into view.
*
*/
async LazyLoad()
{
if (P.lazyloads)
{
for (let selector of P.lazyloads)
{
let elements = Q(selector);
for (let element of elements)
{
if (P.VisibleInSelector(element, '.PageBody') && !element.dataset.loaded)
{
element.dataset.loaded = 1;
element.classList.remove('LazyLoad');
let newsrc = element.dataset.src;
if (newsrc)
{
element.setAttribute('src', newsrc);
return;
}
newsrc = element.dataset.bgimage;
if (newsrc)
{
element.style.backgroundImage = "url('" + newsrc + "')";
return;
}
newsrc = element.dataset.contenturl;
if (newsrc)
{
let d = (await P.LoadingAPI(
element,
newsrc,
{
command: 'loadcontent',
id: element.dataset.contentid
}
)).json.data;
element.innerHTML = d;
}
}
}
}
}
}
// ------------------------------------------------------------------
/** Opens the user's default app to view the coordinates provided.
* Only works well on mobile devices with built-in map apps.
*
* @param {number} latitude
* @param {number} longitude
*/
ShowLocation(latitude, longitude)
{
let coordinates = latitude + ',' + longitude;
if (window.device && device.platform.toLowerCase() === "ios")
{
window.open("http://maps.apple.com/?sll=" + coordinates + "&z=100&t=k", "_system");
} else
{
window.open("geo:" + coordinates, "_system");
}
}
// ------------------------------------------------------------------
/** Converts a canvas into a drawing surface where the mouse or a
* finger can be used to make crude drawings. See /learn/classroom.js
* for an implementation of this with colors.
*
* Be aware the canvas coordinates must match its page client rect,
* so if you resize the canvas element, you must resize the canvas
* dimensions as well.
*
* @param {string} selector - CSS selector of the canvas
*/
MakeDrawableCanvas(selector)
{
let canvas = E(selector);
let ctx = canvas.getContext("2d");
P.On(
selector,
'mousedown touchstart mousemove touchmove mouseup touchend',
P.OnCanvasEvent
);
canvas.width = canvas.clientWidth;
canvas.height = canvas.clientHeight;
ctx.strokeStyle = "#000";
ctx.lineWidth = 6;
P.canvas = canvas;
P.ctx = ctx;
ctx.beginPath();
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, P.canvas.width, P.canvas.height);
ctx.closePath();
}
// ------------------------------------------------------------------
/** Helper function for P.MakeDrawableCanvas(), this converts mouse
* and touch events into drawing commands.
*
* @param {event} e
*/
OnCanvasEvent(e)
{
let scrolltop = E('.PageBodyGrid').scrollTop;
let istouch = e.type.indexOf('touch') > -1;
let x = istouch ? e.changedTouches[0].pageX : e.pageX;
let y = istouch ? e.changedTouches[0].pageY : e.pageY;
P.canvasx = x - P.canvas.offsetLeft;
P.canvasy = y - P.canvas.offsetTop + scrolltop;
switch (e.type)
{
case 'mousemove':
case 'touchmove':
if (P.canvasdown)
{
P.ctx.lineTo(P.canvasx, P.canvasy);
P.ctx.stroke();
}
break;
case 'mousedown':
case 'touchstart':
P.previouscanvases.push(P.canvas.toDataURL('image/jpeg', 0.7));
if (P.previouscanvases.length > P.numcanvasundos)
{
P.previouscanvases.shift();
}
P.canvasdown = 1;
P.ctx.beginPath()
P.ctx.moveTo(P.canvasx, P.canvasy);
break;
case 'mouseup':
case 'touchend':
P.ctx.closePath();
P.canvasdown = 0;
break;
default:
return;
}
P.CancelBubble(e);
}
// ------------------------------------------------------------------
/** Helper function for P.MakeDrawableCanvas(), this restores a
* previously saved canvas, allow undo and redo functionality.
*/
RestorePreviousCanvas()
{
if (P.previouscanvases.length)
{
P.nextcanvases.push(P.canvas.toDataURL('image/jpeg', 0.7));
if (P.nextcanvases.length > P.numcanvasundos)
{
P.nextcanvases.shift();
}
let img = new Image();
img.onload = function() {
P.ctx.beginPath();
P.ctx.drawImage(this, 0, 0);
P.ctx.closePath();
};
img.src = P.previouscanvases.pop();
}
}
// ------------------------------------------------------------------
/** Helper function for P.MakeDrawableCanvas(), this cancels out a
* previous undo command.
*/
RestoreNextCanvas()
{
if (P.nextcanvases.length)
{
P.previouscanvases.push(P.canvas.toDataURL('image/jpeg', 0.7));
if (P.previouscanvases.length > P.numcanvasundos)
{
P.previouscanvases.shift();
}
let img = new Image();
img.onload = function() {
P.ctx.beginPath();
P.ctx.drawImage(this, 0, 0);
P.ctx.closePath();
};
img.src = P.nextcanvases.pop();
}
}
// ------------------------------------------------------------------
/** Convenience function to make code more readable.
*
*/
OpenInNewTab(url)
{
window.open(url, '_blank');
}
// ------------------------------------------------------------------
/** Creates that ever popular website feature, the slideshow.
*
*
* @param {string} selector - CSS selector to put the slideshow in
* @param {object} options - An optional dict which may contain
*
* <dl>
* <dt>keycontrol</dt>
* <dd>Enable to permit the user to use the keyboard to switch
* between slides.</dd>
* </dl>
*/
MakeSlideShow(selector, options = {})
{
let el = E(selector);
let slides = el.querySelectorAll('.Slide');
el.classList.add('SlideShow');
el.style.overflow = 'auto';
el.style.position = 'relative';
P.Add(
el,
`<a class="Control LeftButton" data-action="Previous">
<
</a>
<a class="Control RightButton" data-action="Next">
>
</a>`
);
let ls = [
`<div class="SlideNavBar FlexCenter FlexWrap" style="z-index: 10000;">
<a class="SlideNavButton" data-action="First">⏮</a>
<a class="SlideNavButton" data-action="Previous">◀</a>
<a class="SlideNavButton" data-action="SlideSelect">📒</a>
<a class="SlideNavButton" data-action="Next">▶</a>
<a class="SlideNavButton" data-action="Last">⏭</a>
<a class="SlideNavButton" data-action="Random">🎲</a>
</div>`
];
P.Add(el, ls);
P.Hide(slides);
el.slidepos = -1;
P.ShowSlide(el, 0);
P.OnClick(
selector,
P.LBUTTON,
function(e)
{
let target = P.Target(e);
let action = target.dataset.action;
if (!action)
{
return;
}
P.CancelBubble();
switch (action)
{
case 'First':
P.ShowSlide(selector, 0);
break;
case 'Previous':
P.ShowSlide(selector, 0, -1);
break;
case 'Last':
P.ShowSlide(selector, 999999);
break;
case 'Next':
P.ShowSlide(selector, 0, 1);
break;
case 'Random':
P.ShowSlide(selector, RandInt(0, slides.length));
break;
case 'SlideSelect':
let pickoptions = [];
for (let slide of slides)
{
pickoptions.push([slide.querySelector('.SlideTitle').innerText, 'greenb']);
}
P.ShowFullScreenPicker(
'Which slide?',
pickoptions,
function(itemtext, resultsdiv)
{
for (let i = 0, l = slides.length; i < l; i++)
{
let slide = slides[i];
if (slide.querySelector('.SlideTitle').innerText == itemtext)
{
P.ShowSlide(selector, i);
P.RemoveFullScreen();
break;
}
}
}
);
break;
default:
let newpos = parseInt(action);
if (newpos > 0)
{
P.ShowSlide(selector, newpos - 1);
}
}
}
);
if (options.keycontrol)
{
P.On(
document,
'keydown',
function(e)
{
switch (e.keyCode)
{
case P.UP:
P.ShowSlide(selector, 0);
break;
case P.LEFT:
P.ShowSlide(selector, -1, -1);
break;
case P.DOWN:
P.ShowSlide(selector, 999999);
break;
case P.RIGHT:
P.ShowSlide(selector, -1, 1);
break;
};
}
);
}
}
// ------------------------------------------------------------------
/** Helper function for P.MakeSlideShow() to switch slides.
*
* @param {string} selector - The slideshow's CSS selector
* @param {int} pos - The position to show. Only used if diff is 0.
* @param {int} diff - The difference between the new position and
* the current position.
*
*/
ShowSlide(selector, pos, diff = 0)
{
let el = E(selector);
let slides = el.querySelectorAll('.Slide');
if (diff)
{
pos = el.slidepos + diff;
}
pos = Bound(pos, 0, slides.length - 1);
if (pos == el.slidepos)
{
return;
}
let currentslide = el.currentslide;
let nextslide = slides[pos];
let barheight = el.querySelector('.SlideNavBar').clientHeight;
let w = el.clientWidth;
let h = el.currentslide
? el.currentslide.clientHeight
: el.clientHeight - barheight;
CopyValues(
{
position: 'absolute',
left: (pos < el.slidepos)
? -w + 'px'
: w + 'px',
top: barheight + 'px',
width: w + 'px',
height: h + 'px'
},
nextslide.style
);
P.Show(nextslide);
P.Animate(
function(fraction)
{
if (pos < el.slidepos)
{
if (currentslide)
{
currentslide.style.left = (fraction * w) + 'px';
}
nextslide.style.left = -((1 - fraction) * w) + 'px';
} else
{
if (currentslide)
{
currentslide.style.left = -(fraction * w) + 'px';
}
nextslide.style.left = ((1 - fraction) * w) + 'px';
}
if (fraction == 1)
{
if (el.currentslide)
{
let slidetohide = el.currentslide;
setTimeout(
function()
{
P.Hide(slidetohide);
},
500
);
}
el.currentslide = nextslide;
el.slidepos = pos;
let leftbutton = el.querySelector('.LeftButton');
let rightbutton = el.querySelector('.RightButton');
P.Show(leftbutton)
P.Show(rightbutton)
if (pos == 0)
{
P.Hide(leftbutton);
}
if (pos == slides.length - 1)
{
P.Hide(rightbutton);
}
let navnums = el.querySelectorAll('.SlideNavButton');
for (let navnum of navnums)
{
navnum.style.backgroundColor = parseInt(navnum.innerText) == pos + 1
? 'yellow'
: 'transparent';
}
}
},
300
);
}
// ------------------------------------------------------------------
/** Resize an image into a data URL.
*
* @param {string} inputname - A selector for the file input that
* contains the image, or the file input itself.
* @param {int} largestdimension - The biggest width or height that
* the new image is allowed to have.
* @param {function} onsuccess - The function that receives the data
* URL with signature func(string)
*/
ResizeImage(inputname, largestdimension, onsuccess)
{
let f = E(inputname).files[0];
if (!(/image/i).test(f.type))
{
P.MessageBox(f.name + " is not an image!");
return 0;
}
// read the files
let fr = new FileReader();
fr.readAsArrayBuffer(f);
fr.onload = function (e)
{
let b = new Blob([e.target.result]); // create blob...
let img = new Image();
let url = window.URL || window.webkitURL;
img.src = window.URL.createObjectURL(b);
img.onload = function()
{
let canvas = document.createElement('canvas');
let w = img.width;
let h = img.height;
let neww = w;
let newh = h;
if (w > h)
{
if (w > largestdimension)
{
newh = Math.round(h *= largestdimension / w);
neww = largestdimension;
}
} else
{
if (h > largestdimension)
{
neww = Math.round(w *= largestdimension / h);
newh = largestdimension;
}
}
// resize the canvas and draw the image data into it
canvas.width = neww;
canvas.height = newh;
let ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, neww, newh);
onsuccess(canvas.toDataURL("image/jpeg", 0.7));
};
}
}
// ------------------------------------------------------------------
/** Creates an old Win95 style wizard for complicated tasks.
*
* @param {string} selector - CSS selector to put the wizard in
* @param {list} pages - A list of arrays in the format
* [id, headertext, content, onselectfunc]
*
* id is used for the pages' CSS selector, so only characters
* allowed in selectors should be used.
*
* onselectfunc is optional, but is called when the page appears
* and has signature func(selector, id).
*/
MakeWizard(selector, pages)
{
let el = E(selector);
if (!el)
{
P.ErrorPopupMsg('P.MakeWizard: No element was found for ' + selector);
return;
}
if (!pages)
{
P.ErrorPopupMsg('P.MakeWizard: No pages provided for ' + selector);
return;
}
let ls = ['<div class="Flex FlexWrap WizardNav"></div>'];
let radiobuttons = [];
ls.push('<div class="WizardPages">');
for (let page of pages)
{
ls.push(`<div class="Page Page${page[0]}">
<h4 class="Header">${page[1]}</h4>
<div class="Content">
${page[2]}
</div>
</div>`);
radiobuttons.push([page[1], page[0]]);
}
ls.push('</div>');
ls.push(`<table class="Width100 WizardButtons">
<tr>
<td class="Pad0 Left"><a class="PreviousButton Color1 Rounded Pad50"><< Previous</a></td>
<td></td>
<td class="Pad0 Right"><a class="NextButton Color3 Rounded Pad50">Next >></a></td>
</tr>
</table>`);
P.HTML(el, ls);
P.MakeRadioButtons(
selector + ' .WizardNav',
radiobuttons,
function(parent, value)
{
P.Hide(selector + ' .Page');
P.Show(selector + ' .Page' + value);
for (let page of pages)
{
if (value == page[0] && page[3])
{
page[3](el, value);
}
}
}
)
}
// ------------------------------------------------------------------
/** The normal template for Pafera consists of a center area called
* .PageBodyGrid with four drawers on every side called TopBarGrid,
* RightBarGrid, BottomBarGrid, and LeftBarGrid. This function lets
* you hide and show these drawers, where the main content area will
* automatically resize itself to fit the leftover area.
*
* @param {object} areas - An object containing optional top, right,
* bottom, and left properties, each containing the size of the
* drawer.
*/
SetLayout(areas)
{
areas = IsEmpty(areas) ? {} : areas;
P.savedlayout = areas;
let container = E('.ViewportGrid');
let topbar = E('.TopBarGrid');
let leftbar = E('.LeftBarGrid');
let rightbar = E('.RightBarGrid');
let bottombar = E('.BottomBarGrid');
let content = E('.PageBodyGrid');
let showtopbar = areas.top || 0;
let showleftbar = areas.left || 0;
let showrightbar = areas.right || 0;
let showbottombar = areas.bottom || 0;
if (showtopbar || showbottombar)
{
container.style.gridTemplateRows = showtopbar + ' 1fr ' + showbottombar;
}
if (showleftbar || showrightbar)
{
container.style.gridTemplateColumns = showleftbar + ' 1fr ' + showrightbar;
}
if (showtopbar && showbottombar)
{
content.style.gridRowStart = 2;
content.style.gridRowEnd = 3;
leftbar.style.gridRowStart = 2;
leftbar.style.gridRowEnd = 3;
rightbar.style.gridRowStart = 2;
rightbar.style.gridRowEnd = 3;
P.Show('.TopBarGrid');
P.Show('.BottomBarGrid');
if (showleftbar && showrightbar)
{
leftbar.style.gridColumnStart = 1;
leftbar.style.gridColumnEnd = 2;
rightbar.style.gridColumnStart = 3;
rightbar.style.gridColumnEnd = 4;
content.style.gridColumnStart = 2;
content.style.gridColumnEnd = 3;
P.Show('.LeftBarGrid');
P.Show('.RightBarGrid');
} else if (showleftbar)
{
leftbar.style.gridColumnStart = 1;
leftbar.style.gridColumnEnd = 2;
content.style.gridColumnStart = 2;
content.style.gridColumnEnd = 4;
P.Show('.LeftBarGrid');
P.Hide('.RightBarGrid');
} else if (showrightbar)
{
rightbar.style.gridColumnStart = 3;
rightbar.style.gridColumnEnd = 4;
content.style.gridColumnStart = 1;
content.style.gridColumnEnd = 3;
P.Hide('.LeftBarGrid');
P.Show('.RightBarGrid');
} else
{
content.style.gridColumnStart = 1;
content.style.gridColumnEnd = 4;
P.Hide('.LeftBarGrid');
P.Hide('.RightBarGrid');
}
} else if (showtopbar)
{
content.style.gridRowStart = 2;
content.style.gridRowEnd = 4;
leftbar.style.gridRowStart = 2;
leftbar.style.gridRowEnd = 4;
rightbar.style.gridRowStart = 2;
rightbar.style.gridRowEnd = 4;
P.Show('.TopBarGrid');
P.Hide('.BottomBarGrid');
if (showleftbar && showrightbar)
{
leftbar.style.gridColumnStart = 1;
leftbar.style.gridColumnEnd = 2;
rightbar.style.gridColumnStart = 3;
rightbar.style.gridColumnEnd = 4;
content.style.gridColumnStart = 2;
content.style.gridColumnEnd = 3;
P.Show('.LeftBarGrid');
P.Show('.RightBarGrid');
} else if (showleftbar)
{
leftbar.style.gridColumnStart = 1;
leftbar.style.gridColumnEnd = 2;
content.style.gridColumnStart = 2;
content.style.gridColumnEnd = 4;
P.Show('.LeftBarGrid');
P.Hide('.RightBarGrid');
} else if (showrightbar)
{
rightbar.style.gridColumnStart = 3;
rightbar.style.gridColumnEnd = 4;
content.style.gridColumnStart = 1;
content.style.gridColumnEnd = 3;
P.Hide('.LeftBarGrid');
P.Show('.RightBarGrid');
} else
{
content.style.gridColumnStart = 1;
content.style.gridColumnEnd = 4;
P.Hide('.LeftBarGrid');
P.Hide('.RightBarGrid');
}
} else if (showbottombar)
{
content.style.gridRowStart = 1;
content.style.gridRowEnd = 3;
leftbar.style.gridRowStart = 1;
leftbar.style.gridRowEnd = 3;
rightbar.style.gridRowStart = 1;
rightbar.style.gridRowEnd = 3;
P.Hide('.TopBarGrid');
P.Show('.BottomBarGrid');
if (showleftbar && showrightbar)
{
leftbar.style.gridColumnStart = 1;
leftbar.style.gridColumnEnd = 2;
rightbar.style.gridColumnStart = 3;
rightbar.style.gridColumnEnd = 4;
content.style.gridColumnStart = 2;
content.style.gridColumnEnd = 3;
P.Show('.LeftBarGrid');
P.Show('.RightBarGrid');
} else if (showleftbar)
{
leftbar.style.gridColumnStart = 1;
leftbar.style.gridColumnEnd = 2;
content.style.gridColumnStart = 2;
content.style.gridColumnEnd = 4;
P.Show('.LeftBarGrid');
P.Hide('.RightBarGrid');
} else if (showrightbar)
{
rightbar.style.gridColumnStart = 3;
rightbar.style.gridColumnEnd = 4;
content.style.gridColumnStart = 1;
content.style.gridColumnEnd = 3;
P.Hide('.LeftBarGrid');
P.Show('.RightBarGrid');
} else
{
content.style.gridColumnStart = 1;
content.style.gridColumnEnd = 4;
P.Hide('.LeftBarGrid');
P.Hide('.RightBarGrid');
}
} else
{
P.Hide('.LeftBarGrid');
P.Hide('.RightBarGrid');
P.Hide('.TopBarGrid');
P.Hide('.BottomBarGrid');
content.style.gridColumnStart = 1;
content.style.gridColumnEnd = 4;
content.style.gridRowStart = 1;
content.style.gridRowEnd = 4;
}
// Special handling for Firefox on Android, which does not properly
// position bottom bars
if (P.isandroid)
{
if (showbottombar)
{
let contentel = E('.PageBodyGrid');
let contentrect = contentel.getBoundingClientRect();
bottombar.style.position = 'absolute';
bottombar.style.left = contentrect.left + 'px';
bottombar.style.bottom = '-4px';
bottombar.style.width = contentrect.width + 'px';
bottombar.style.height = 'auto';
let bottombarrect = bottombar.getBoundingClientRect();
L(contentrect)
L(bottombarrect)
contentel.style.height = bottombarrect.top - contentrect.top;
L('Setting height to ', bottombarrect.top, contentrect.top, bottombarrect.top - contentrect.top)
} else
{
}
}
}
// ------------------------------------------------------------------
/** Convenience function to add or remove Disabled from a set of
* elements.
*
* @param {list} selectors - A set of strings containing CSS
* selectors to search for.
* @param {bool} enable - Set to true to remove the Disabled class,
* whereas false will add the Disabled class.
*/
Enable(selectors, enable)
{
if (!IsArray(selectors))
{
selectors = [selectors];
}
for (let selector of selectors)
{
let nodes = Q(selector);
for (let node of nodes)
{
if (enable)
{
node.classList.remove('Disabled');
} else
{
node.classList.add('Disabled');
}
}
}
}
// ------------------------------------------------------------------
/** Records sound using Chris Rudmin's opus-recorder library so that
* we don't have to send a huge WAV file to the server.
*
* Thanks to https://github.com/chris-rudmin/opus-recorder
*
* @param {string} displayel - CSS selector to place the recording
* status in
* @param {function} onfinished - A handler for the opus data with
* signature func(element, typedarray)
* @param {int} maxrecordtime - Although the user can stop the
* recording by click on the status display, this function will
* also automatically cease recording after this number of seconds.
*/
RecordOpus(displayel, onfinished, maxrecordtime)
{
maxrecordtime = maxrecordtime || 10;
P.CancelRecording();
try
{
P.opusrecorder = new Recorder({
encoderPath: '/libs/opusrecorder/encoderWorker.min.js'
});
P.opusrecorder.ondataavailable = function(typedArray)
{
let el = E(displayel);
if (!el)
{
P.opusrecorder.stop();
P.opusrecorder.close();
P.opusrecorder = 0;
return;
}
P.HTML(displayel, `<div class="yellowb Pad50">${T.processingdata}</div>`)
onfinished(displayel, typedArray);
};
P.HTML(displayel, `<div class="greenb Pad50">${T.speaknow}</div>`);
P.opusrecorder.start()
.then(
function()
{
P.opusrecordertimer = setTimeout(
function()
{
// Limit recordings to maxrecordtime
if (P.opusrecorder)
{
P.opusrecorder.stop();
P.opusrecorder.close();
P.opusrecorder = 0;
}
P.opusrecordertimer = 0;
},
maxrecordtime * 1000
);
}
).catch(
function(e)
{
P.ShowError(displayel, e);
}
);
} catch (e)
{
P.ShowError(displayel, e);
}
}
// ------------------------------------------------------------------
/** Cancels a previously started recording
*/
CancelRecording()
{
if (P.opusrecorder)
{
P.opusrecorder.close();
P.opusrecorder = 0;
if (P.opusrecordertimer)
{
clearTimeout(P.opusrecordertimer);
P.opusrecordertimer = 0;
}
}
}
// ------------------------------------------------------------------
/** A simple function to make a popup draggable. The selector must
* have position: absolute set.
*
* @param {string} selector - CSS selector of the popup
*
*/
MakeDraggable(selector)
{
let el = E(selector);
let titlebar = E(selector + ' .TitleBar');
let dragelement = el,
x = 0,
y = 0,
dx = 0,
dy = 0,
indrag = 0;
if (titlebar)
{
dragelement = titlebar;
} else
{
}
dragelement.onmousedown = function(e)
{
P.CancelBubble(e);
indrag = 1;
x = e.clientX;
y = e.clientY;
}
dragelement.onmousemove = function(e)
{
if (indrag)
{
P.CancelBubble(e);
dx = x - e.clientX;
dy = y - e.clientY;
x = e.clientX;
y = e.clientY;
el.style.left = (el.offsetLeft - dx) + "px";
el.style.top = (el.offsetTop - dy) + "px";
}
};
dragelement.onmouseup = function(e)
{
indrag = 0;
}
}
// ------------------------------------------------------------------
/** For some dynamically created elements, it seems that CSS rules
* aren't automatically applied as usual. Call this function to
* manually update the number of CSS grid columns inside this element.
*
* @param {string} selector - CSS selector of the grid
* @param {int} griditemwidth - How wide each item should be in ems
*/
AutoSetGridColumns(selector, griditemwidth)
{
let el = E(selector);
let parent = el.parentElement;
let emsize = P.EMSize(parent);
let parentwidth = parseInt(parent.style.width);
let columns = Math.floor(parentwidth / (emsize * griditemwidth));
el.style.gridTemplateColumns = `repeat(${columns}, 1fr)`;
}
// ------------------------------------------------------------------
/** Convenience function to return the base path for an already
* uploaded file given its short code. Note that you must add the
* extension to the returned path.
*
* @param {string} idcode - The short code of the file.
*/
FileURL(idcode)
{
return idcode[0] == '$'
? '/system/thumbnailer/' + P.CodeDir(idcode.substr(1))
: '/system/files/' + P.CodeDir(idcode);
}
// ------------------------------------------------------------------
/** Returns an img tag for the headshot of an user.
*
* @param {string} idcode - The user's short code
* @param {string} classes - extra classes for the image.
*/
HeadShotURL(idcode)
{
return `/system/headshots/${P.CodeDir(idcode)}.webp`;
}
// ------------------------------------------------------------------
/** Returns an img tag for the headshot of an user.
*
* @param {string} idcode - The user's short code
* @param {string} classes - extra classes for the image.
*/
HeadShotImg(idcode, classes)
{
classes = classes || '';
return `<img loading="lazy" alt="Headshot" class="${classes}" src="${
idcode
? this.HeadShotURL(idcode)
: ''
}">`;
}
// ------------------------------------------------------------------
/** Returns an img tag given the short code of a file.
*
* @param {string} idcode - The short code of the file.
* @param {string} classes - extra classes for the img tag.
* @param {string} attrs - extra attributes for the img tag.
*/
ImgFile(idcode, classes, attrs)
{
if (!idcode)
{
return '';
}
classes = classes || '';
attrs = attrs || '';
return `<img loading="lazy" alt="File icon" class="${classes}" ${attrs}
src="${P.FileURL(idcode) + '.webp'}">
`;
}
// ------------------------------------------------------------------
/** Returns an audio tag given the short code of a file.
*
* @param {string} idcode - The short code of the file.
* @param {string} classes - extra classes for the audio tag.
* @param {string} attrs - extra attributes for the audio tag.
*/
SoundFile(idcode, classes, attrs)
{
if (!idcode)
{
return '';
}
classes = classes || '';
attrs = attrs || '';
return `<audio class="${classes}" ${attrs}>
<source src="${P.FileURL(idcode) + '.mp3'}">
</audio>
`;
}
// ------------------------------------------------------------------
/** Returns a video tag given the short code of a file.
*
* @param {string} idcode - The short code of the file.
* @param {string} classes - extra classes for the video tag.
* @param {string} attrs - extra attributes for the video tag.
*/
VideoFile(idcode, classes, attrs)
{
if (!idcode)
{
return '';
}
classes = classes || '';
attrs = attrs || '';
return `<video class="${classes}" style="max-width: 100%;" ${attrs}>
<source src="${P.FileURL(idcode) + '.mp4'}">
</video>
`;
}
// ------------------------------------------------------------------
/** Shows the system standard user settings popup.
*
* @param {event} e - The event used to get the positioning of the
* popup in e.target
*/
async UserPopup(e)
{
let ls = [`
<div class="ContextMenu UserPopup">
<a href="/system/usersettings.html">
${T.settings}
</a>
<a href="/system/messages.html">
${T.messages}
</a>
`];
ls.push(`
<a href="/system/logout.html">
${T.logout}
</a>
</div>
`);
return P.Popup(
ls,
{
parent: e.target,
closeonmouseout: 1,
width: '10em'
}
);
}
// ------------------------------------------------------------------
/** Utility function to make a countdown display.
*
* @param {string} selector - The element to display the countdown
* time in. The remaining time in seconds should be placed in
* data-value attribute.
*/
MakeTimeTicker(selector)
{
selector = selector || '.TimeTicker';
if (P.timetickerid)
{
clearInterval(P.timetickerid);
P.timetickerid = 0;
}
P.timetickerid = setInterval(
function(e)
{
let elements = Q(selector);
if (!elements.length)
{
clearInterval(P.timetickerid);
P.timetickerid = 0;
return;
}
for (let element of elements)
{
let timeremaining = parseInt(element.dataset.value);
timeremaining--;
element.dataset.value = timeremaining;
element.innerText = '⏰ ' + SecondsToTime(timeremaining);
}
},
1000
);
}
// ------------------------------------------------------------------
/** A convenience function for touchscreens where you can use the
* whole screen to pick one value from a grid.
*
* @param {string} title - The title to show on the fullscreen layer
* @param {list} items - A list of arrays in the format
* [displaytext, cssclass]
* @param {function} onfinished - The handler when the user clicks
* on one of the items with signature func(itemtext, resultsdiv)
*/
ShowFullScreenPicker(title, items, onfinished)
{
let ls = [`
<h1>${title}</h1>
<div class="ButtonBar">
<input type="text" class=" Width600 FullScreenPickerText">
<a class="Color4 FullScreenPickerManualEntry">${T.finished}</a>
</div>
<div class="FlexGrid4 FullScreenPicker">
`];
let itemlength = 0;
let maxlength = 0;
for (let r of items)
{
ls.push(`
<div class="FullScreenPickerItem Pad50 HoverHighlight Center ${r[1]}">${r[0]}</div>
`);
itemlength = r[0].length;
if (itemlength > maxlength)
{
maxlength = itemlength;
}
};
ls.push(`
</div>
<div class="FullScreenPickerResults"></div>
<br>
<div class="ButtonBar">
<a class="Color1" onclick="P.RemoveFullScreen()">${T.back}</a>
</div>
`);
P.AddFullScreen(
'blueg',
'',
ls
);
P.AutoSetGridColumns('.FullScreenPicker', itemlength + 2);
P.OnClick(
'.FullScreenPicker',
P.LBUTTON,
function(e)
{
if (e.target.classList.contains('FullScreenPickerItem'))
{
onfinished(e.target.innerText, E('.FullScreenPickerResults'));
}
}
);
P.OnClick(
'.FullScreenPickerManualEntry',
P.LBUTTON,
function(e)
{
onfinished(E('.FullScreenPickerText').value, E('.FullScreenPickerResults'));
}
);
}
// ------------------------------------------------------------------
/** Remove all classes in classlist from the DOM elements in selector.
*
* @param {string} selector - The CSS selector of the DOM elements
* @param {variable} classlist - A string or list of the CSS classes to remove
*/
RemoveClasses(selector, classlist)
{
if (!IsArray(classlist))
{
classlist = [classlist];
}
for (let r of Q(selector))
{
for (let c of classlist)
{
r.classList.remove(c);
}
}
}
// ------------------------------------------------------------------
/** Converts a JavaScript to
*
* @param {string} selector - The CSS selector of the DOM elements
* @param {variable} classlist - A string or list of the CSS classes to remove
*/
ObjectToSelect(obj, selectname, selectclass, defaultvalue)
{
let ls = [`<select name="${selectname}" class=${selectclass}>`];
for (let k in obj)
{
ls.push(`
<option value="${k}" ${k == defaultvalue ? 'selected' : ''}>${obj[k]}</option>
`);
}
ls.push('</select>');
return ls.join('\n');
}
// ------------------------------------------------------------------
/** Returns a dict object of all of the query parameters for url,
* or the current page's URL if nothing is passed.
*
* @param {string} url - An URL to parse
*/
GetQueryStrings(url)
{
if (!url)
{
url = window.location;
}
url = new URL(url);
return url.searchParams;
}
// ------------------------------------------------------------------
/** Permits the current user to add other users to various groups or
* to send messages to other users.
*
* @param {string} userid - The ID code of the other user
*/
async UserActionsPopup(userid, parentel)
{
let ls = [`
<div class="ContextMenu UserActions">
<a onclick="P.AddUserToGroup('${userid}', 'acquaintances')">${T.add} ${T.acquaintances}</a>
<a onclick="P.AddUserToGroup('${userid}', 'friends')">${T.add} ${T.friends}</a>
<a onclick="P.AddUserToGroup('${userid}', 'favorites')">${T.add} ${T.favorites}</a>
<a onclick="P.AddUserToGroup('${userid}', 'blacklist')">${T.add} ${T.blacklist}</a>
<a onclick="P.SendMessage('${userid}')">${T.sendmessage}</a>
</div>
`];
return P.Popup(
ls,
{
parent: parentel,
closeonmouseout: 1,
width: '12em'
}
);
}
// ------------------------------------------------------------------
/** Adds the userid to the current user's groups for easy
* communication.
*
* @param {string} userid - The ID code of the user to add
* @param {string} group - The name of the group, which should be
* acquaintances, friends, favorites, or blacklist.
*/
async AddUserToGroup(userids, group)
{
if (!IsArray(userids))
{
userids = [userids];
}
let popupclass = 0;
let returned = await P.DialogAPI(
'/system/userapi',
{
command: 'changeusergroups',
action: 'add',
userids: userids,
group: group
}
);
}
// ------------------------------------------------------------------
/** Adds the userid to the current user's groups for easy
* communication.
*
* @param {string} userid - The ID code of the user to add
* @param {string} group - The name of the group, which should be
* acquaintances, friends, favorites, or blacklist.
*/
async SendMessage(userids, objecttype, objectid)
{
objecttype = objecttype || '';
objectid = objectid || '';
if (!IsArray(userids))
{
userids = [userids];
}
P.EditPopup(
[
['title', 'text', T.title, '', '', 'required'],
['content', 'multitext', T.message, '', '', 'required'],
['filelist', 'filelist', T.files, []]
],
async function(formelement, formdata, resultsdiv, event, saveandcontinue)
{
formdata.toids = userids;
formdata.objtype = objecttype;
formdata.objid = objectid;
let returned = await P.LoadingAPI(
resultsdiv,
'/system/messageapi',
{
command: 'save',
data: formdata
}
);
return !returned.json.error;
},
{
title: T.sendmessage
}
);
}
// ------------------------------------------------------------------
/** Uses the Intersection Observer API to handle lazy loading.
*
* Thanks to https://web.dev/lazy-loading-images/#:~:text=Chrome%20and%20Firefox%20both%20support%20lazy-loading%20with%20the,other%20images%20when%20the%20user%20scrolls%20near%20them.
*/
AddLazyLoad(selector, callback)
{
if ("IntersectionObserver" in window)
{
let elements = Q(selector);
if (!elements)
{
return;
}
let observer = new IntersectionObserver(
function(entries, observer)
{
entries.forEach(
function(entry)
{
if (entry.isIntersecting)
{
let target = entry.target;
callback(target);
observer.unobserve(target);
}
}
);
}
);
elements.forEach(
function(element)
{
observer.observe(element);
}
);
}
}
// ------------------------------------------------------------------
/** Returns true if the current user is an administrator.
*/
IsAdmin()
{
return P.userflags & 0x20;
}
// ------------------------------------------------------------------
/** Returns true if the current user is an administrator.
*/
RenderSessionVars()
{
let chooselanguageicon = E('.ChooseLanguageIcon');
if (chooselanguageicon)
{
chooselanguageicon.innerHTML = `<img alt="" loading="lazy" class="Square200" src="/flags/${P.lang}.webp">`
}
let systemusericon = E('.SystemUserIcon');
if (systemusericon && P.userid)
{
systemusericon.innerHTML = `<img alt="" loading="lazy" class="Square200" src="/system/headshots/${P.CodeDir(P.userid)}.webp">`
}
let messagecounticon = E('.MessageCountIcon');
if (messagecounticon && P.userid)
{
if (P.unreadmessagecount)
{
messagecounticon.innerHTML = `<div class="redb Rounded Pad50Horizontal">${P.unreadmessagecount}</div>`;
} else
{
messagecounticon.innerHTML = '';
}
}
}
}
// ********************************************************************
var P = new Pafera();
// Setup the pageload event
P.On(
document,
'readystatechange',
P.OnReadyStateChange
);
// Perform all setup functions
P.AddHandler(
'pageload',
function()
{
P.emsize = P.EMSize();
SetCookie('timeoffset', P.timeoffset);
//P.SetWallpaper(P.wallpaper + '-' + P.texttheme + '.jpg', 1);
for (let i = 1; i <= 6; i++)
{
let elements = Q('.Color' + i);
for (let i = 0, l = elements.length; i < l; i++)
{
elements[i].style.color = 'white !important';
}
}
P.On(
window,
'resize',
function(e)
{
P.OnResize(e)
}
);
P.On(
window,
'mousemove mouseenter touchmove',
P.CaptureMousePos
);
P.On(
window,
'orientationchange',
function(e)
{
switch (window.orientation)
{
case 90:
case -90:
P.screenorientation = 'landscape';
break;
default:
P.screenorientation = 'portrait';
};
P.OnResize(e);
}
);
P.On(
'.PageBodyGrid',
'scroll',
P.OnScroll
);
// Enables the escape key to exit dialogs and fullscreen layers
P.On(
document,
'keydown',
function(e)
{
if (e.keyCode == 27)
{
P.CloseTopLayer();
}
}
);
if (P.useadvanced)
{
if (P.isfirefox || P.ischrome || P.issafari)
{
P.On(
window,
'popstate',
P.OnPopState
);
}
P.InterceptHREF();
}
// Reset layout
P.SetLayout({});
// Give a little time for the page to load custom scripts
setTimeout(
function()
{
_loader.OnFinished(
function()
{
P.OnResize();
P.LazyLoad();
P.AddHandler(
'delayedresize',
function(e)
{
// Resize all fullscreen layers on resize
for (let layer of Q('.FullScreen'))
{
CopyValues(
P.FullScreenRect(),
layer.style
);
}
// Resize page grid elements
P.SetLayout(P.savedlayout);
}
);
}
);
},
250
);
}
);