github.com/Azareal/Gosora@v0.0.0-20210729070923-553e66b59003/public/global.js (about)

     1  'use strict';
     2  var formVars={};
     3  var alertMapping={};
     4  var alertList=[];
     5  var alertCount=0;
     6  var moreTopicCount=0;
     7  var conn=false;
     8  var selectedTopics=[];
     9  var attachItemCallback=()=>{}
    10  var quoteItemCallback=()=>{}
    11  var baseTitle=document.title;
    12  var wsBackoff=0;
    13  var noAlerts=false;
    14  
    15  // Topic move
    16  var forumToMoveTo=0;
    17  
    18  function pushNotice(msg) {
    19  	let aBox = document.getElementsByClassName("alertbox")[0];
    20  	let n = document.createElement('div');
    21  	n.innerHTML = Tmpl_notice(msg).trim();
    22  	aBox.appendChild(n);
    23  	runInitHook("after_notice");
    24  }
    25  
    26  // TODO: Write a friendlier error handler which uses a .notice or something, we could have a specialised one for alerts
    27  function ajaxError(xhr,status,e) {
    28  	log("The AJAX request failed");
    29  	log("xhr",xhr);
    30  	log("status",status);
    31  	log("e",e);
    32  	if(status=="parsererror") log("The server didn't respond with a valid JSON response");
    33  	console.trace();
    34  }
    35  
    36  function postLink(ev) {
    37  	ev.preventDefault();
    38  	let formAction = $(ev.target).closest('a').attr("href");
    39  	$.ajax({ url:formAction, type:"POST", dataType:"json", error: ajaxError, data: {js: 1} });
    40  }
    41  
    42  function bindToAlerts() {
    43  	log("bindToAlerts");
    44  	$(".alertItem.withAvatar a").unbind("click");
    45  	$(".alertItem.withAvatar a").click(function(ev) {
    46  		ev.stopPropagation();
    47  		ev.preventDefault();
    48  		$.ajax({
    49  			url: "/api/?a=set&m=dismiss-alert",
    50  			type: "POST",
    51  			dataType: "json",
    52  			data: { id: $(this).attr("data-asid") },
    53  			//error: ajaxError,
    54  			success: () => {
    55  				window.location.href = this.getAttribute("href");
    56  			}
    57  		});
    58  	});
    59  }
    60  
    61  function addAlert(msg,notice=false) {
    62  	var mmsg = msg.msg;
    63  	if(mmsg[0]==".") mmsg = phraseBox["alerts"]["alerts"+mmsg];
    64  	if("sub" in msg) {
    65  		for(var i=0; i<msg.sub.length; i++) mmsg = mmsg.replace("\{"+i+"\}",msg.sub[i]);
    66  	}
    67  
    68  	let aItem = Tmpl_alert({
    69  		ASID: msg.id,
    70  		Path: msg.path,
    71  		Avatar: msg.img || "",
    72  		Message: mmsg
    73  	})
    74  	//alertMapping[msg.id] = aItem;
    75  	let div = document.createElement('div');
    76  	div.innerHTML = aItem.trim();
    77  	alertMapping[msg.id] = div.firstChild;
    78  	alertList.push(msg.id);
    79  
    80  	if(notice) {
    81  		// TODO: Add some sort of notification queue to avoid flooding the end-user with notices?
    82  		// TODO: Use the site name instead of "Something Happened"
    83  		if(Notification.permission==="granted") {
    84  			var n = new Notification("Something Happened",{
    85  				body: mmsg,
    86  				icon: msg.img,
    87  			});
    88  			setTimeout(n.close.bind(n),8000);
    89  		}
    90  	}
    91  
    92  	runInitHook("after_add_alert");
    93  }
    94  
    95  function updateAlertList(menuAlerts) {
    96  	log("enter updateAlertList");
    97  	log("alertList:",alertList);
    98  	log("alertMapping:",alertMapping);
    99  	log("alertCount:",alertCount);
   100  	let alertListNode = menuAlerts.getElementsByClassName("alertList")[0];
   101  	let alertCounterNode = menuAlerts.getElementsByClassName("alert_counter")[0];
   102  	alertCounterNode.textContent = "0";
   103  	
   104  	alertListNode.innerHTML = "";
   105  	let any = false;
   106  	let j = 0;
   107  	for(var i=0; i<alertList.length && j<8; i++) {
   108  		any = true;
   109  		alertListNode.appendChild(alertMapping[alertList[i]]);
   110  		//outList += alertMapping[alertList[i]];
   111  		j++;
   112  	}
   113  	if(!any) alertListNode.innerHTML = "<div class='alertItem'>"+phraseBox["alerts"]["alerts.no_alerts"]+"</div>";
   114  
   115  	if(alertCount!=0) {
   116  		alertCounterNode.textContent = alertCount;
   117  		menuAlerts.classList.add("has_alerts");
   118  		let nTitle = "("+alertCount+") "+baseTitle;
   119  		if(document.title!=nTitle) document.title = nTitle;
   120  	} else {
   121  		menuAlerts.classList.remove("has_alerts");
   122  		if(document.title!=baseTitle) document.title = baseTitle;
   123  	}
   124  
   125  	bindToAlerts();
   126  	log("alertCount",alertCount)
   127  	runInitHook("after_update_alert_list",alertCount);
   128  }
   129  
   130  function setAlertError(menuAlerts,msg) {
   131  	let n = menuAlerts.getElementsByClassName("alertList")[0];
   132  	n.innerHTML = "<div class='alertItem'>"+msg+"</div>";
   133  }
   134  
   135  var alertsInitted = false;
   136  var lastTc = 0;
   137  function loadAlerts(menuAlerts,eTc=false) {
   138  	if(!alertsInitted) return;
   139  	let tc = "";
   140  	if(eTc && lastTc!=0) tc = "&t="+lastTc+"&c="+alertCount;
   141  	$.ajax({
   142  		type:'get',
   143  		dataType:'json',
   144  		url:'/api/?m=alerts'+tc,
   145  		success: data => {
   146  			if("errmsg" in data) {
   147  				setAlertError(menuAlerts,data.errmsg)
   148  				return;
   149  			}
   150  			if(!eTc) {
   151  				alertList=[];
   152  				alertMapping={};
   153  			}
   154  			if(!data.hasOwnProperty("msgs")) data = {"msgs":[],"count":alertCount,"tc":lastTc};
   155  			/*else if(data.count != (alertCount + data.msgs.length)) tc = false;
   156  			if(eTc && lastTc!=0) {
   157  				for(var i in data.msgs) wsAlertEvent(data.msgs[i]);
   158  			} else {*/
   159  			log("data",data);
   160  			for(var i in data.msgs) addAlert(data.msgs[i]);
   161  			alertCount = data.count;
   162  			updateAlertList(menuAlerts);
   163  			try {
   164  				localStorage.setItem("alertList",JSON.stringify(alertList));
   165  				localStorage.setItem("alertMapping",JSON.stringify(alertMapping));
   166  				localStorage.setItem("alertCount",alertCount);
   167  			} catch(e) {
   168  				localStorage.clear();
   169  			}
   170  			//}
   171  			lastTc = data.tc;
   172  		},
   173  		error: (magic,status,er) => {
   174  			let errtxt = "Unable to get the alerts";
   175  			try {
   176  				let dat = JSON.parse(magic.responseText);
   177  				if("errmsg" in dat) errtxt = dat.errmsg;
   178  			} catch(e) {
   179  				log(magic.responseText);
   180  				log(e);
   181  			}
   182  			log("er",er);
   183  			setAlertError(menuAlerts,errtxt);
   184  		}
   185  	});
   186  }
   187  
   188  function SplitN(data,ch,n) {
   189  	var o = [];
   190  	if(data.length===0) return o;
   191  
   192  	var lastI = 0;
   193  	var j = 0;
   194  	var lastN = 1;
   195  	for(let i=0; i<data.length; i++) {
   196  		if(data[i]===ch) {
   197  			o[j++] = data.substring(lastI,i);
   198  			lastI = i;
   199  			if(lastN===n) break;
   200  			lastN++;
   201  		}
   202  	}
   203  	if(data.length > lastI) o[o.length-1] += data.substring(lastI);
   204  	return o;
   205  }
   206  
   207  function wsAlertEvent(dat) {
   208  	log("wsAlertEvent",dat)
   209  	addAlert(dat,true);
   210  	alertCount++;
   211  
   212  	let aTmp = alertList;
   213  	alertList = [alertList[alertList.length-1]];
   214  	aTmp = aTmp.slice(0,-1);
   215  	for(let i=0; i<aTmp.length; i++) alertList.push(aTmp[i]);
   216  	// TODO: Add support for other alert feeds like PM Alerts
   217  	let n = document.getElementById("general_alerts");
   218  	// TODO: Make sure we update alertCount here
   219  	lastTc = 0;
   220  	updateAlertList(n/*,alist*/);
   221  }
   222  
   223  function runWebSockets(resume=false) {
   224  	let s = "";
   225  	if(window.location.protocol == "https:") s = "s";
   226  	conn = new WebSocket("ws"+s+"://" + document.location.host + "/ws/");
   227  
   228  	conn.onerror = e => {
   229  		log(e);
   230  	}
   231  
   232  	// TODO: Sync alerts, topic list, etc.
   233  	conn.onopen = () => {
   234  		log("The WebSockets connection was opened");
   235  		if(resume) conn.send("resume " + document.location.pathname + " " + Math.round((new Date()).getTime() / 1000) + '\r');
   236  		else conn.send("page " + document.location.pathname + '\r');
   237  		// TODO: Don't ask again, if it's denied. We could have a setting in the UCP which automatically requests this when someone flips desktop notifications on
   238  		if(me.User.ID > 0) Notification.requestPermission();
   239  	}
   240  
   241  	conn.onclose = () => {
   242  		conn = false;
   243  		log("The WebSockets connection was closed");
   244  		let backoff = 0.8;
   245  		if(wsBackoff < 0) wsBackoff = 0;
   246  		else if(wsBackoff > 12) backoff = 11;
   247  		else if(wsBackoff > 5) backoff = 5;
   248  		wsBackoff++;
   249  
   250  		setTimeout(() => {
   251  			if(!noAlerts) {
   252  				let nl = document.getElementsByClassName("menu_alerts");
   253  				for(var i=0; i < nl.length; i++) loadAlerts(nl[i],true);
   254  			}
   255  			runWebSockets(true);
   256  		}, backoff * 60 * 1000);
   257  
   258  		if(wsBackoff > 0) {
   259  			if(wsBackoff <= 5) setTimeout(() => wsBackoff--, 5.5 * 60 * 1000);
   260  			else if(wsBackoff <= 12) setTimeout(() => wsBackoff--, 11.5 * 60 * 1000);
   261  			else setTimeout(() => wsBackoff--, 20 * 60 * 1000);
   262  		}
   263  	}
   264  
   265  	conn.onmessage = (event) => {
   266  		if(!noAlerts && event.data[0] == "{") {
   267  			log("json message");
   268  			let data = "";
   269  			try {
   270  				data = JSON.parse(event.data);
   271  			} catch(e) {
   272  				log(e);
   273  				return;
   274  			}
   275  
   276  			if("msg" in data) wsAlertEvent(data);
   277  			else if("event" in data) {
   278  				if(data.event=="dismiss-alert"){
   279  					Object.keys(alertMapping).forEach((key) => {
   280  						if(key!=data.id) return;
   281  						alertCount--;
   282  						let index = -1;
   283  						for(var i=0; i < alertList.length; i++) {
   284  							if(alertList[i]==key) {
   285  								alertList[i] = 0;
   286  								index = i;
   287  							}
   288  						}
   289  						if(index==-1) return;
   290  
   291  						for(var i = index; (i+1) < alertList.length; i++) alertList[i] = alertList[i+1];
   292  						alertList.splice(alertList.length-1,1);
   293  						delete alertMapping[key];
   294  
   295  						// TODO: Add support for other alert feeds like PM Alerts
   296  						let generalAlerts = document.getElementById("general_alerts");
   297  						if(alertList.length < 8) loadAlerts(generalAlerts,true);
   298  						else updateAlertList(generalAlerts);
   299  					});
   300  				}
   301  			} else if("Topics" in data) {
   302  				log("topic in data");
   303  				log("data",data);
   304  				// TODO: Handle desyncs more gracefully?
   305  				// TODO: Send less unneccessary data?
   306  				let topic = data.Topics[0];
   307  				if(topic===undefined){
   308  					log("empty topic list");
   309  					return;
   310  				}
   311  				if("mod" in data) {
   312  					topic.CanMod = data.mod==1 || data.mod[0]==1;
   313  					if(data.lock==1) {
   314  						$(".val_lock").each(function(){
   315  							this.classList.remove("auto_hide");
   316  						});
   317  					}
   318  					if(data.move==1) {
   319  						$(".val_move").each(function(){
   320  							this.classList.remove("auto_hide");
   321  						});
   322  					}
   323  				}
   324  				// TODO: Fix the data race where the function hasn't been loaded yet
   325  				let renTopic = Tmpl_topics_topic(topic);
   326  				$(".topic_row[data-tid='"+topic.ID+"']").addClass("ajax_topic_dupe");
   327  
   328  				let node = $(renTopic);
   329  				node.addClass("new_item hide_ajax_topic");
   330  				log("Prepending to topic list");
   331  				$(".topic_list").prepend(node);
   332  				moreTopicCount++;
   333  
   334  				let blocks = document.getElementsByClassName("more_topic_block_initial");
   335  				for(let i=0; i<blocks.length; i++) {
   336  					let block = blocks[i];
   337  					block.classList.remove("more_topic_block_initial");
   338  					block.classList.add("more_topic_block_active");
   339  
   340  					log("phraseBox",phraseBox);
   341  					let msgBox = block.getElementsByClassName("more_topics")[0];
   342  					msgBox.innerText = phraseBox["topic_list"]["topic_list.changed_topics"].replace("%d",moreTopicCount);
   343  				}
   344  			} else log("unknown message",data);
   345  		}
   346  
   347  		let messages = event.data.split('\r');
   348  		for(var i=0; i<messages.length; i++) {
   349  			let message = messages[i];
   350  			//log("message",message);
   351  			let msgblocks = SplitN(message," ",3);
   352  			if(msgblocks.length < 3) continue;
   353  			if(message.startsWith("set ")) {
   354  				let oldInnerHTML = document.querySelector(msgblocks[1]).innerHTML;
   355  				if(msgblocks[2]==oldInnerHTML) continue;
   356  				document.querySelector(msgblocks[1]).innerHTML = msgblocks[2];
   357  			} else if(message.startsWith("set-class ")) {
   358  				// Fix to stop the inspector from getting all jittery
   359  				let oldClassName = document.querySelector(msgblocks[1]).className;
   360  				if(msgblocks[2]==oldClassName) continue;
   361  				document.querySelector(msgblocks[1]).className = msgblocks[2];
   362  			}
   363  		}
   364  	}
   365  }
   366  
   367  // TODO: Surely, there's a prettier and more elegant way of doing this?
   368  function getExt(name) {
   369  	if(!(name.indexOf('.') > -1)) throw("This file doesn't have an extension");
   370  	return name.split('.').pop();
   371  }
   372  
   373  (() => {
   374  	addInitHook("pre_init", () => {
   375  		runInitHook("pre_global");
   376  		log("before notify on alert")
   377  		// We can only get away with this because template_alert has no phrases, otherwise it too would have to be part of the "dance", I miss Go concurrency :(
   378  		log("noAlerts:",noAlerts);
   379  		if(!noAlerts) {
   380  		notifyOnScriptW("tmpl_alert", e => {
   381  			if(e!=undefined) log("failed alert? why?",e)
   382  		}, () => {
   383  			if(!Tmpl_alert) throw("tmpl func not found");
   384  			addInitHook("after_phrases", () => {
   385  				// TODO: The load part of loadAlerts could be done asynchronously while the update of the DOM could be deferred
   386  				$(document).ready(() => {
   387  					log("checking local storage cache");
   388  					alertsInitted = true;
   389  					let al = document.getElementsByClassName("menu_alerts");
   390  					let sAlertList = localStorage.getItem("alertList");
   391  					let sAlertMapping = localStorage.getItem("alertMapping");
   392  					let sAlertCount = localStorage.getItem("alertCount");
   393  					if(sAlertList!=null && sAlertList!="" &&
   394  						sAlertMapping!=null && sAlertMapping!="" &&sAlertCount!=null && sAlertCount!="" && sAlertCount!="0"
   395  					) {
   396  						log("sAlertList",sAlertList)
   397  						log("sAlertMapping",sAlertMapping)
   398  						log("sAlertCount",sAlertCount)
   399  						alertList = JSON.parse(sAlertList)
   400  						alertMapping = JSON.parse(sAlertMapping)
   401  						alertCount =  parseInt(sAlertCount)
   402  						log("alertList",alertList)
   403  						log("alertMapping",alertMapping)
   404  						log("alertCount",alertCount)
   405  						for(var i=0; i<al.length; i++) loadAlerts(al[i],true);
   406  					} else for(var i=0; i<al.length; i++) loadAlerts(al[i]);
   407  					if(window["WebSocket"]) runWebSockets();
   408  				});
   409  			});
   410  		});
   411  		} else {
   412  			addInitHook("after_phrases", () => {
   413  				$(document).ready(() => {
   414  					if(window["WebSocket"]) runWebSockets();
   415  				});
   416  			});
   417  		}
   418  
   419  		$(document).ready(mainInit);
   420  	});
   421  })();
   422  
   423  // TODO: Use these in .filter_item and pass back an item count from the backend to work with here
   424  // Ported from common/parser.go
   425  function PageOffset(count,page,perPage) {
   426  	let offset = 0;
   427  	let lastPage = LastPage(count, perPage)
   428  	if(page > 1) offset = (perPage * page) - perPage;
   429  	else if (page == -1) {
   430  		page = lastPage;
   431  		offset = (perPage * page) - perPage;
   432  	} else page = 1;
   433  
   434  	// We don't want the offset to overflow the slices, if everything's in memory
   435  	//if(offset >= (count - 1)) offset = 0;
   436  	return {Offset:offset,Page:page,LastPage:lastPage};
   437  }
   438  function LastPage(count,perPage) {
   439  	return (count / perPage) + 1
   440  }
   441  function Paginate(currentPage,lastPage,maxPages) {
   442  	let diff = lastPage - currentPage;
   443  	let pre = 3;
   444  	if(diff < 3) pre = maxPages - diff;
   445  	
   446  	let page = currentPage - pre;
   447  	if(page < 0) page = 0;
   448  	let o = [];
   449  	while(o.length < maxPages && page < lastPage){
   450  		page++;
   451  		o.push(page);
   452  	}
   453  	return o;
   454  }
   455  
   456  function mainInit(){
   457  	log("enter mainInit");
   458  	runInitHook("start_init");
   459  
   460  	$(".more_topics").click(ev => {
   461  		ev.preventDefault();
   462  		let blocks = document.getElementsByClassName("more_topic_block_active");
   463  		for(let i=0; i<blocks.length; i++) {
   464  			let bl = blocks[i];
   465  			bl.classList.remove("more_topic_block_active");
   466  			bl.classList.add("more_topic_block_initial");
   467  		}
   468  		$(".ajax_topic_dupe").fadeOut("slow", function(){
   469  			$(this).remove();
   470  		});
   471  		$(".hide_ajax_topic").removeClass("hide_ajax_topic"); // TODO: Do Fade
   472  		moreTopicCount = 0;
   473  	})
   474  
   475  	$(".add_like,.remove_like").click(function(ev) {
   476  		ev.preventDefault();
   477  		//$(this).unbind("click");
   478  		let target = this.closest("a").getAttribute("href");
   479  		log("target",target);
   480  
   481  		let controls = this.closest(".controls");
   482  		let hadLikes = controls.classList.contains("has_likes");
   483  		let likeCountNode = controls.getElementsByClassName("like_count")[0];
   484  		log("likeCountNode",likeCountNode);
   485  		if(this.classList.contains("add_like")) {
   486  			this.classList.remove("add_like");
   487  			this.classList.add("remove_like");
   488  			if(!hadLikes) controls.classList.add("has_likes");
   489  			this.closest("a").setAttribute("href",target.replace("like","unlike"));
   490  			likeCountNode.innerHTML = parseInt(likeCountNode.innerHTML) + 1;
   491  		} else {
   492  			this.classList.remove("remove_like");
   493  			this.classList.add("add_like");
   494  			let likeCount = parseInt(likeCountNode.innerHTML);
   495  			if(likeCount==1) controls.classList.remove("has_likes");
   496  			this.closest("a").setAttribute("href",target.replace("unlike","like"));
   497  			likeCountNode.innerHTML = parseInt(likeCountNode.innerHTML) - 1;
   498  		}
   499  
   500  		//let likeButton = this;
   501  		$.ajax({
   502  			url:target,
   503  			type:"POST",
   504  			dataType:"json",
   505  			data: { js: 1 },
   506  			error: ajaxError,
   507  			success: function (dat,status,xhr) {
   508  				if("success" in dat && dat["success"] == "1") return;
   509  				// addNotice("Failed to add a like: {err}")
   510  				//likeCountNode.innerHTML = parseInt(likeCountNode.innerHTML)-1;
   511  				log("data",dat);
   512  				log("status",status);
   513  				log("xhr",xhr);
   514  			}
   515  		});
   516  	});
   517  
   518  	$(".link_label").click(function(ev) {
   519  		ev.preventDefault();
   520  		let linkSel = $('#'+$(this).attr("data-for"));
   521  		if(!linkSel.hasClass("link_opened")) {
   522  			ev.stopPropagation();
   523  			linkSel.addClass("link_opened");
   524  		}
   525  	});
   526  
   527  	function rebuildPaginator(lastPage) {
   528  		let urlParams = new URLSearchParams(window.location.search);
   529  		let page = urlParams.get('page');
   530  		if(page=="") page = 1;
   531  
   532  		let pageList = Paginate(page,lastPage,5)
   533  		//$(".pageset").html(Tmpl_paginator({PageList:pageList,Page:page,LastPage:lastPage}));
   534  		let ok = false;
   535  		$(".pageset").each(function(){
   536  			this.outerHTML = Tmpl_paginator({PageList:pageList,Page:page,LastPage:lastPage});
   537  			ok = true;
   538  		});
   539  		if(!ok) $(Tmpl_paginator({PageList:pageList,Page:page,LastPage:lastPage})).insertAfter("#topic_list");
   540  	}
   541  
   542  	function rebindPaginator() {
   543  		// TODO: Take mostviewed into account
   544  		// TODO: Get this to work with topics too
   545  		$(".pageitem a").unbind("click");
   546  		$(".pageitem a").click(function(ev) {
   547  			ev.preventDefault();
   548  			let url = "//"+window.location.host+window.location.pathname;
   549  			let urlParams = new URLSearchParams(window.location.search);
   550  			urlParams.set("page",new URLSearchParams(this.getAttribute("href")).get("page"));
   551  			let q = "?";
   552  			for(let item of urlParams.entries()) q += item[0]+"="+item[1]+"&";
   553  			if(q.length>1) q = q.slice(0,-1);
   554  
   555  			// TODO: Try to de-duplicate some of these fetch calls
   556  			fetch(url+q+"&js=1",{credentials:"same-origin"})
   557  				.then(r => {
   558  					if(!r.ok) throw(url+q+"&js=1 failed to load");
   559  					return r.json();
   560  				}).then(d => {
   561  					if(!"Topics" in d) throw("no Topics in data");
   562  					let topics = d["Topics"];
   563  					log("ajax navigated to different page");
   564  
   565  					// TODO: Fix the data race where the function hasn't been loaded yet
   566  					let out = "";
   567  					for(let i=0;i<topics.length;i++) out += Tmpl_topics_topic(topics[i]);
   568  					$(".topic_list").html(out);
   569  
   570  					let obj = {Title:document.title,Url:url+q};
   571  					history.pushState(obj,obj.Title,obj.Url);
   572  					rebuildPaginator(d.LastPage);
   573  					rebindPaginator();
   574  				}).catch(e => {
   575  					log("Unable to get script "+url+q+"&js=1",e);
   576  					console.trace();
   577  				});
   578  		});
   579  	}
   580  
   581  	// TODO: Render a headless topics.html instead of the individual topic rows and a bit of JS glue
   582  	$(".filter_item").click(function(ev) {
   583  		if(!window.location.pathname.startsWith("/topics/")) return
   584  		ev.preventDefault();
   585  		let that = this;
   586  		let fid = this.getAttribute("data-fid");
   587  		// TODO: Take mostviewed into account
   588  		let url = "//"+window.location.host+"/topics/?fids="+fid;
   589  
   590  		fetch(url+"&js=1",{credentials:"same-origin"})
   591  		.then(r => {
   592  			if(!r.ok) throw(url+"&js=1 failed to load");
   593  			return r.json();
   594  		}).then(d => {
   595  			log("data",d);
   596  			if(!"Topics" in d) throw("no Topics in data");
   597  			let topics = d["Topics"];
   598  			log("ajax navigated to "+that.innerText);
   599  			
   600  			// TODO: Fix the data race where the function hasn't been loaded yet
   601  			let out = "";
   602  			for(let i=0;i<topics.length;i++) out += Tmpl_topics_topic(topics[i]);
   603  			$(".topic_list").html(out);
   604  			//$(".topic_list").addClass("single_forum");
   605  
   606  			baseTitle = that.innerText;
   607  			if(alertCount > 0) document.title = "("+alertCount+") "+baseTitle;
   608  			else document.title = baseTitle;
   609  			let obj = {Title:document.title,Url:url};
   610  			history.pushState(obj,obj.Title,obj.Url);
   611  			rebuildPaginator(d.LastPage)
   612  			rebindPaginator();
   613  
   614  			$(".filter_item").each(function(){
   615  				this.classList.remove("filter_selected");
   616  			});
   617  			that.classList.add("filter_selected");
   618  			$(".topic_list_title h1").text(that.innerText);
   619  			$(".link_select .link_option .link_recent").attr("href","//"+window.location.host+"/topics/?fids="+fid);
   620  			$(".link_select .link_option .link_most_viewed").attr("href","//"+window.location.host+"/topics/most-viewed/?fids="+fid);
   621  			unbindPage();
   622  			bindPage();
   623  		}).catch(e => {
   624  			log("Unable to get script "+url+"&js=1",e);
   625  			console.trace();
   626  		});
   627  	});
   628  
   629  	if(document.getElementById("topicsItemList")!==null) rebindPaginator();
   630  	if(document.getElementById("forumItemList")!==null) rebindPaginator();
   631  
   632  	// TODO: Show a search button when JS is disabled?
   633  	$(".widget_search_input").keypress(function(e) {
   634  		if(e.keyCode!='13') return;
   635  		// TODO: Only fire on /topics/
   636  		event.preventDefault();
   637  		// TODO: Take mostviewed into account
   638  		let url = "//"+window.location.host+window.location.pathname;
   639  		let urlParams = new URLSearchParams(window.location.search);
   640  		urlParams.set("q",this.value);
   641  		let q = "?";
   642  		for(let item of urlParams.entries()) q += item[0]+"="+item[1]+"&";
   643  		if(q.length>1) q = q.slice(0,-1);
   644  
   645  		// TODO: Try to de-duplicate some of these fetch calls
   646  		fetch(url+q+"&js=1",{credentials:"same-origin"})
   647  			.then(r => {
   648  				if(!r.ok) throw(url+q+"&js=1 failed to load");
   649  				return r.json();
   650  			}).then(d => {
   651  				if(!"Topics" in d) throw("no Topics in data");
   652  				let topics = d["Topics"];
   653  				log("ajax navigated to search page");
   654  
   655  				// TODO: Fix the data race where the function hasn't been loaded yet
   656  				let out = "";
   657  				for(let i=0;i<topics.length;i++) out += Tmpl_topics_topic(topics[i]);
   658  				$(".topic_list").html(out);
   659  
   660  				baseTitle = phraseBox["topic_list"]["topic_list.search_head"];
   661  				$(".topic_list_title h1").text(phraseBox["topic_list"]["topic_list.search_head"]);
   662  				if(alertCount > 0) document.title = "("+alertCount+") "+baseTitle;
   663  				else document.title = baseTitle;
   664  				let obj = {Title: document.title, Url: url+q};
   665  				history.pushState(obj,obj.Title,obj.Url);
   666  				rebuildPaginator(d.LastPage);
   667  				rebindPaginator();
   668  				$(".link_select .link_option .link_recent").attr("href",url+q);
   669  				$(".link_select .link_option .link_most_viewed").attr("href",url+q);
   670  		}).catch(e => {
   671  			log("Unable to get script "+url+q+"&js=1",e);
   672  			console.trace();
   673  		});
   674  	});
   675  
   676  	runInitHook("before_init_bind_page");
   677  	bindPage();
   678  	runInitHook("after_init_bind_page");
   679  
   680  	$(".edit_field").click(function(ev) {
   681  		ev.preventDefault();
   682  		let bp = $(this).closest('.editable_parent');
   683  		let block = bp.find('.editable_block').eq(0);
   684  		block.html("<input name='edit_field'value='"+block.text()+"'type='text'><a href='"+$(this).closest('a').attr("href")+"'><button class='submit_edit'type='submit'>Update</button></a>");
   685  
   686  		$(".submit_edit").click(function(ev) {
   687  			ev.preventDefault();
   688  			let bp = $(this).closest('.editable_parent');
   689  			let bl = bp.find('.editable_block').eq(0);
   690  			let content = bl.find('input').eq(0).val();
   691  			bl.html(content);
   692  
   693  			let formAction = $(this).closest('a').attr("href");
   694  			$.ajax({
   695  				url: formAction+"?s="+me.User.S,
   696  				type:"POST",
   697  				dataType:"json",
   698  				error: ajaxError,
   699  				data: { js: 1, edit_item: content }
   700  			});
   701  		});
   702  	});
   703  
   704  	$(".edit_fields").click(function(ev) {
   705  		ev.preventDefault();
   706  		if($(this).find("input").length!==0) return;
   707  		//log("clicked .edit_fields");
   708  		var bp = $(this).closest('.editable_parent');
   709  		bp.find('.hide_on_edit').addClass("edit_opened");
   710  		bp.find('.show_on_edit').addClass("edit_opened");
   711  		bp.find('.editable_block').show();
   712  		bp.find('.editable_block').each(function(){
   713  			var fieldName = this.getAttribute("data-field");
   714  			var fieldType = this.getAttribute("data-type");
   715  			if(fieldType=="list") {
   716  				var fieldValue = this.getAttribute("data-value");
   717  				if(fieldName in formVars) var it = formVars[fieldName];
   718  				else var it = ['No','Yes'];
   719  				var itLen = it.length;
   720  				var out = "";
   721  				for (var i=0; i<itLen; i++) {
   722  					var sel = "";
   723  					if(fieldValue == i || fieldValue == it[i]) {
   724  						sel = "selected ";
   725  						this.classList.remove(fieldName+'_'+it[i]);
   726  						this.innerHTML = "";
   727  					}
   728  					out += "<option "+sel+"value='"+i+"'>"+it[i]+"</option>";
   729  				}
   730  				this.innerHTML = "<select data-field='"+fieldName+"'name='"+fieldName+"'>"+out+"</select>";
   731  			}
   732  			else if(fieldType=="hidden") {}
   733  			else this.innerHTML = "<input name='"+fieldName+"'value='"+this.textContent+"'type='text'>";
   734  		});
   735  
   736  		// Remove any handlers already attached to the submitter
   737  		$(".submit_edit").unbind("click");
   738  
   739  		$(".submit_edit").click(function(ev) {
   740  			ev.preventDefault();
   741  			var outData = {js: 1}
   742  			var bp = $(this).closest('.editable_parent');
   743  			bp.find('.editable_block').each(function() {
   744  				var fieldName = this.getAttribute("data-field");
   745  				var fieldType = this.getAttribute("data-type");
   746  				if(fieldType=="list") {
   747  					var newContent = $(this).find('select :selected').text();
   748  					this.classList.add(fieldName+'_'+newContent);
   749  					this.innerHTML = "";
   750  				} else if(fieldType=="hidden") {
   751  					var newContent = $(this).val();
   752  				} else {
   753  					var newContent = $(this).find('input').eq(0).val();
   754  					this.innerHTML = newContent;
   755  				}
   756  				this.setAttribute("data-value",newContent);
   757  				outData[fieldName] = newContent;
   758  			});
   759  
   760  			let href = $(this).closest('a').attr("href");
   761  			//log("href",href);
   762  			//log(outData);
   763  			$.ajax({ url: href+"?s="+me.User.S, type:"POST", dataType:"json", data: outData, error: ajaxError });
   764  			bp.find('.hide_on_edit').removeClass("edit_opened");
   765  			bp.find('.show_on_edit').removeClass("edit_opened");
   766  		});
   767  	});
   768  
   769  	$(this).click(() => {
   770  		$(".selectedAlert").removeClass("selectedAlert");
   771  		$("#back").removeClass("alertActive");
   772  		$(".link_select").removeClass("link_opened");
   773  	});
   774  
   775  	$(".alert_bell").click(function(){
   776  		let menuAlerts = $(this).parent();
   777  		if(menuAlerts.hasClass("selectedAlert")) {
   778  			event.stopPropagation();
   779  			menuAlerts.removeClass("selectedAlert");
   780  			$("#back").removeClass("alertActive");
   781  		}
   782  	});
   783  	$(".menu_alerts").click(function(ev) {
   784  		ev.stopPropagation();
   785  		if($(this).hasClass("selectedAlert")) return;
   786  		if(!conn) loadAlerts(this,true);
   787  		this.className += " selectedAlert";
   788  		document.getElementById("back").className += " alertActive"
   789  	});
   790  	$(".link_select").click(ev => ev.stopPropagation());
   791  
   792  	$("input,textarea,select,option").keyup(ev => ev.stopPropagation())
   793  
   794  	$("#themeSelectorSelect").change(function(){
   795  		log("Changing the theme to "+this.options[this.selectedIndex].getAttribute("value"));
   796  		$.ajax({
   797  			url: this.form.getAttribute("action")+"?s="+me.User.S,
   798  			type:"POST",
   799  			dataType:"json",
   800  			data: { "theme": this.options[this.selectedIndex].getAttribute("value"), js: 1 },
   801  			error: ajaxError,
   802  			success: function (dat,status,xhr) {
   803  				log("Theme successfully switched");
   804  				log("dat",dat);
   805  				log("status",status);
   806  				log("xhr",xhr);
   807  				window.location.reload();
   808  			}
   809  		});
   810  	});
   811  
   812  	// The time range selector for the time graphs in the Control Panel
   813  	$(".autoSubmitRedirect").change(function(){
   814  		let els = this.form.elements;
   815  		let s = "";
   816  		for(let i=0; i<els.length; i++) {
   817  			let el = els[i];
   818  			if(el.nodeName=="SELECT") {
   819  				s += el.name+"="+el.options[el.selectedIndex].getAttribute("value")+"&";
   820  			}
   821  			// TODO: Implement other element types...
   822  		}
   823  		if(s.length > 0) s = "?"+s.substr(0, s.length-1);
   824  
   825  		window.location = this.form.getAttribute("action")+s; // Do a redirect as a form submission refuses to work properly
   826  	});
   827  
   828  	$(".unix_to_24_hour_time").each(function(){
   829  		let unixTime = this.innerText;
   830  		let date = new Date(unixTime*1000);
   831  		log("date",date);
   832  		let mins = "0"+date.getMinutes();
   833  		let formattedTime = date.getHours()+":"+mins.substr(-2);
   834  		log("formattedTime",formattedTime);
   835  		this.innerText = formattedTime;
   836  	});
   837  
   838  	$(".unix_to_date").each(function(){
   839  		// TODO: Localise this
   840  		let monthList = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
   841  		let date = new Date(this.innerText * 1000);
   842  		log("date",date);
   843  		let day = "0"+date.getDate();
   844  		let formattedTime = monthList[date.getMonth()]+" "+day.substr(-2)+" "+date.getFullYear();
   845  		log("formattedTime",formattedTime);
   846  		this.innerText = formattedTime;
   847  	});
   848  
   849  	$("spoiler").addClass("hide_spoil");
   850  	$(".hide_spoil").click(function(ev) {
   851  		ev.stopPropagation();
   852  		ev.preventDefault();
   853  		$(this).removeClass("hide_spoil");
   854  		$(this).unbind("click");
   855  	});
   856  
   857  	this.onkeyup = function(ev) {
   858  		if(ev.which==37) this.querySelectorAll("#prevFloat a")[0].click();
   859  		if(ev.which==39) this.querySelectorAll("#nextFloat a")[0].click();
   860  	};
   861  
   862  	function asyncGetSheet(src) {
   863  		return new Promise((resolve,reject) => {
   864  			let res = document.createElement('link');
   865  			res.async = true;
   866  	
   867  			const onloadHandler = (e,isAbort) => {
   868  				if (isAbort || !res.readyState || /loaded|complete/.test(res.readyState)) {
   869  					res.onload = null;
   870  					res.onreadystatechange = null;
   871  					res = undefined;
   872  	
   873  					isAbort ? reject(e) : resolve();
   874  				}
   875  			}
   876  	
   877  			res.onerror = (e) => {
   878  				reject(e);
   879  			};
   880  			res.onload = onloadHandler;
   881  			res.onreadystatechange = onloadHandler;
   882  			res.href = src;
   883  			res.rel = "stylesheet";
   884  			res.type = "text/css";
   885  	
   886  			const prior = document.getElementsByTagName('link')[0];
   887  			prior.parentNode.insertBefore(res,prior);
   888  		});
   889  	}
   890  
   891  	function stripQ(name) {
   892  		return name.split('?')[0];
   893  	}
   894  
   895  	function loadArb(base,href,h=null) {
   896  		fetch(href,{credentials:"same-origin"})
   897  			.then(resp => {
   898  				if(!resp.ok) throw(href+" failed to load");
   899  				let xr = resp.headers.get("x-res")
   900  				if(xr!=null) {
   901  					for(let res of xr.split(",")) {
   902  						let pro;
   903  						if(stripQ(getExt(res))=="css") pro = asyncGetSheet(pre+res)
   904  						else pro = asyncGetScript(pre+res)
   905  							pro.then(() => log("Loaded "+res))
   906  							.catch(e => {
   907  								log("Unable to get "+res,e);
   908  								console.trace();
   909  							});
   910  					}
   911  				}
   912  				return resp.text();
   913  			}).then(d => {
   914  				document.querySelector("#back").outerHTML = d;
   915  				if(h!==null) h(d);
   916  				$(".elapsed").remove();
   917  				let obj = {Title:document.title,Url:base};
   918  				history.pushState(obj,obj.Title,obj.Url);
   919  			}).catch(e => {
   920  				log("Unable to get script "+href,e);
   921  				console.trace();
   922  			});
   923  	}
   924  
   925  	/*$(".rowtopic a,a.rowtopic,a.forum_poster").click(function(ev) {
   926  		let base = this.getAttribute("href");
   927  		loadArb(base,base+"?i=1", () => {
   928  			unbindTopic();
   929  			bindTopic();
   930  		});
   931  		ev.stopPropagation();
   932  		ev.preventDefault();
   933  	})*/
   934  	$("a").click(function(ev) {
   935  		let base = this.getAttribute("href");
   936  		if(base!="/topics/") return;
   937  		loadArb(base,base+"?i=1", () => {
   938  			unbindPage();
   939  			bindPage();
   940  		});
   941  		ev.stopPropagation();
   942  		ev.preventDefault();
   943  	})
   944  
   945  	runInitHook("almost_end_init");
   946  	runInitHook("end_init");
   947  }
   948  
   949  function bindPage() {
   950  	log("enter bindPage");
   951  	$(".create_topic_link").click(ev => {
   952  		ev.preventDefault();
   953  		$(".topic_create_form").removeClass("auto_hide");
   954  	});
   955  	$(".topic_create_form .close_form").click(ev => {
   956  		ev.preventDefault();
   957  		$(".topic_create_form").addClass("auto_hide");
   958  	});
   959  	
   960  	bindTopic();
   961  	runInitHook("end_bind_page")
   962  }
   963  
   964  function unbindPage() {
   965  	log("enter unbindPage");
   966  	$(".create_topic_link").unbind("click");
   967  	$(".topic_create_form .close_form").unbind("click");
   968  	unbindTopic();
   969  	runHook("end_unbind_page")
   970  }
   971  
   972  function bindTopic() {
   973  	log("enter bindTopic");
   974  	$(".open_edit").click(ev => {
   975  		ev.preventDefault();
   976  		$('.hide_on_edit').addClass("edit_opened");
   977  		$('.show_on_edit').addClass("edit_opened");
   978  		runHook("open_edit");
   979  	});
   980  	
   981  	$(".topic_item .submit_edit").click(function(ev){
   982  		ev.preventDefault();
   983  		let nameInput = $(".topic_name_input").val();
   984  		$(".topic_name").html(nameInput);
   985  		$(".topic_name").attr(nameInput);
   986  		let contentInput = $('.topic_content_input').val();
   987  		$(".topic_content").html(quickParse(contentInput));
   988  		let statusInput = $('.topic_status_input').val();
   989  		$(".topic_status_e:not(.open_edit)").html(statusInput);
   990  
   991  		$('.hide_on_edit').removeClass("edit_opened");
   992  		$('.show_on_edit').removeClass("edit_opened");
   993  		runHook("close_edit");
   994  
   995  		$.ajax({
   996  			url: this.form.getAttribute("action"),
   997  			type:"POST",
   998  			dataType:"json",
   999  			data: {
  1000  				name: nameInput,
  1001  				status: statusInput,
  1002  				content: contentInput,
  1003  				js: 1
  1004  			},
  1005  			error: ajaxError,
  1006  			success: (dat,status,xhr) => {
  1007  				if("Content" in dat) $(".topic_content").html(dat["Content"]);
  1008  			}
  1009  		});
  1010  	});
  1011  	
  1012  	$(".delete_item").click(function(ev) {
  1013  		postLink(ev);
  1014  		$(this).closest('.deletable_block').remove();
  1015  	});
  1016  
  1017  	// Miniature implementation of the parser to avoid sending as much data back and forth
  1018  	function quickParse(m) {
  1019  		const r = (o,n) => {
  1020  			m = m.replace(o,n)
  1021  		}
  1022  		r(":)", "😀")
  1023  		r(":(", "😞")
  1024  		r(":D", "😃")
  1025  		r(":P", "😛")
  1026  		r(":O", "😲")
  1027  		r(":p", "😛")
  1028  		r(":o", "😲")
  1029  		r(";)", "😉")
  1030  		r("\n","<br>")
  1031  		return m
  1032  	}
  1033  
  1034  	$(".edit_item").click(function(ev){
  1035  		ev.preventDefault();
  1036  
  1037  		let bp = this.closest('.editable_parent');
  1038  		$(bp).find('.hide_on_edit').addClass("edit_opened");
  1039  		$(bp).find('.show_on_edit').addClass("edit_opened");
  1040  		$(bp).find('.hide_on_block_edit').addClass("edit_opened");
  1041  		$(bp).find('.show_on_block_edit').addClass("edit_opened");
  1042  		let srcNode = bp.querySelector(".edit_source");
  1043  		let block = bp.querySelector('.editable_block');
  1044  		block.classList.add("in_edit");
  1045  
  1046  		let src = "";
  1047  		if(srcNode!=null) src = srcNode.innerText;
  1048  		else src = block.innerHTML;
  1049  		block.innerHTML = Tmpl_topic_c_edit_post({
  1050  			ID: bp.getAttribute("id").slice("post-".length),
  1051  			Source: src,
  1052  			Ref: this.closest('a').getAttribute("href")
  1053  		})
  1054  		runHook("edit_item_pre_bind");
  1055  
  1056  		$(".submit_edit").click(function(ev){
  1057  			ev.preventDefault();
  1058  			$(bp).find('.hide_on_edit').removeClass("edit_opened");
  1059  			$(bp).find('.show_on_edit').removeClass("edit_opened");
  1060  			$(bp).find('.hide_on_block_edit').removeClass("edit_opened");
  1061  			$(bp).find('.show_on_block_edit').removeClass("edit_opened");
  1062  			block.classList.remove("in_edit");
  1063  			let con = block.querySelector('textarea').value;
  1064  			block.innerHTML = quickParse(con);
  1065  			if(srcNode!=null) srcNode.innerText = con;
  1066  
  1067  			let formAction = this.closest('a').getAttribute("href");
  1068  			// TODO: Bounce the parsed post back and set innerHTML to it?
  1069  			$.ajax({
  1070  				url: formAction,
  1071  				type:"POST",
  1072  				dataType:"json",
  1073  				data: { js: 1, edit_item: con },
  1074  				error: ajaxError,
  1075  				success: (dat,status,xhr) => {
  1076  					if("Content" in dat) block.innerHTML = dat["Content"];
  1077  				}
  1078  			});
  1079  		});
  1080  	});
  1081  
  1082  	$(".quote_item").click(function(ev){
  1083  		ev.preventDefault();
  1084  		ev.stopPropagation();
  1085  		let src = this.closest(".post_item").getElementsByClassName("edit_source")[0];
  1086  		let con = document.getElementById("input_content")
  1087  		log("con.value",con.value);
  1088  
  1089  		let item;
  1090  		if(con.value=="") item = "<blockquote>"+src.innerHTML+"</blockquote>"
  1091  		else item = "\r\n<blockquote>"+src.innerHTML+"</blockquote>";
  1092  		con.value = con.value+item;
  1093  		log("con.value",con.value);
  1094  
  1095  		// For custom / third party text editors
  1096  		quoteItemCallback(src.innerHTML,item);
  1097  	});
  1098  
  1099  	//id="poll_results_{pollid}" class="poll_results auto_hide"
  1100  	$(".poll_results_button").click(function(){
  1101  		let pollID = $(this).attr("data-poll-id");
  1102  		$("#poll_results_"+pollID).removeClass("auto_hide");
  1103  		fetch("/poll/results/"+pollID, {
  1104  			credentials: 'same-origin'
  1105  		}).then(resp => resp.text()).catch(e => console.error("e",e)).then(rawData => {
  1106  			// TODO: Make sure the received data is actually a list of integers
  1107  			let data = JSON.parse(rawData);
  1108  			let allZero = true;
  1109  			for(let i=0; i<data.length; i++) {
  1110  				if(data[i]!="0") allZero = false;
  1111  			}
  1112  			if(allZero) {
  1113  				$("#poll_results_"+pollID+" .poll_no_results").removeClass("auto_hide");
  1114  				log("all zero")
  1115  				return;
  1116  			}
  1117  
  1118  			$("#poll_results_"+pollID+" .user_content").html("<div id='poll_results_chart_"+pollID+"'></div>");
  1119  			log("rawData",rawData);
  1120  			log("series",data);
  1121  			Chartist.Pie('#poll_results_chart_'+pollID, {
  1122   				series: data,
  1123  			}, {
  1124  				height: '120px',
  1125  			});
  1126  		})
  1127  	});
  1128  
  1129  	runInitHook("end_bind_topic");
  1130  }
  1131  
  1132  function unbindTopic() {
  1133  	log("enter unbindTopic");
  1134  	$(".open_edit").unbind("click");
  1135  	$(".topic_item .submit_edit").unbind("click");
  1136  	$(".delete_item").unbind("click");
  1137  	$(".edit_item").unbind("click");
  1138  	$(".submit_edit").unbind("click");
  1139  	$(".quote_item").unbind("click");
  1140  	$(".poll_results_button").unbind("click");
  1141  	runHook("end_unbind_topic");
  1142  }