Source: paferapage.js

"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
   * &lt;td data-action="up"&gt;Move Up&lt;/td&gt;
   * &lt;td data-action="down"&gt;Move Down&lt;/td&gt;
   * 
   * @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
   * &lt;input name="animals.felines.numcats" value="5"&gt; 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)">&lt;&lt;</a>`);

        if (start >= limit)
        {
          ls.push(`<a class="PreviousPage" 
              onclick="${listfunc}(${start - limit})">&lt;
            </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})">&gt;</a>`);
        }

        ls.push(`<a class="LastPage" onclick="${listfunc}(${Math.ceil((count - limit) / limit) * limit})">&gt;&gt;</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">&lt;&lt;</td>' 
            : '<th></th>'
          }
        <td colspan=5 class=ThisMonth>${currentyear}-${currentmonth < 9 ? '0' + (currentmonth + 1) : (currentmonth + 1)}</td>
        ${switchmonthfunc ? '<td class="Color2 NextMonthButton">&gt;&gt;</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">
        &lt;
      </a>
      <a class="Control RightButton" data-action="Next">
        &gt;
      </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">&lt;&lt; Previous</a></td>
        <td></td>
        <td class="Pad0 Right"><a class="NextButton Color3 Rounded Pad50">Next &gt;&gt;</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
    );
  }
);