new_posts_ajax_stuff #1

Merged
0 merged 27 commits from new_posts_ajax_stuff into deploy_spamnoticer 2024-02-26 20:13:30 +00:00
10 changed files with 584 additions and 61 deletions

View File

@ -384,6 +384,7 @@ class Post extends PostProps {
private $raw_body;
private $has_file;
private $tracked_cites;
private $filesize;
public function __construct($post, $root=null, $mod=false) {
global $config;
@ -394,8 +395,10 @@ class Post extends PostProps {
$this->$key = $value;
}
if (isset($this->files) && $this->files)
if (isset($this->files) && $this->files
&& is_string($this->files)) {
$this->files = @json_decode($this->files);
}
$this->subject = utf8tohtml($this->subject);
$this->name = utf8tohtml($this->name);

View File

@ -2303,6 +2303,8 @@ function buildThread($id, $return = false, $mod = false) {
$action = generation_strategy('sb_thread', array($board['uri'], $id));
$rendered_thread = null;
if ($action == 'rebuild' || $return || $mod) {
$query = prepare(sprintf("SELECT *,'%s' as board FROM ``posts_%s`` WHERE (`thread` IS NULL AND `id` = :id) OR `thread` = :id ORDER BY `thread`,`id`", $board['uri'],$board['uri']));
$query->bindValue(':id', $id, PDO::PARAM_INT);
@ -2323,10 +2325,12 @@ function buildThread($id, $return = false, $mod = false) {
$hasnoko50 = $thread->postCount() >= $config['noko50_min'];
$antibot = $mod || $return ? false : create_antibot($board['uri'], $id);
$rendered_thread = $thread->build();
$body = Element('thread.html', array(
'board' => $board,
'thread' => $thread,
'body' => $thread->build(),
'body' => $rendered_thread,
'config' => $config,
'id' => $id,
'mod' => $mod,
@ -2364,6 +2368,8 @@ function buildThread($id, $return = false, $mod = false) {
}
file_write($board['dir'] . $config['dir']['res'] . link_for($thread), $body);
return $rendered_thread;
}
}

View File

@ -403,8 +403,6 @@ $config['additional_javascript'][] = 'js/options/user-css.js';
$config['additional_javascript'][] = 'js/options/user-js.js';
$config['additional_javascript'][] = 'js/flag-preview.js';
$config['additional_javascript'][] = 'js/file-selector.js';
$config['additional_javascript_defer'][] = 'js/auto-reload.js';
$config['additional_javascript_defer'][] = 'js/thread-stats.js';
$config['additional_javascript_defer'][] = 'js/image-hover.js';
@ -419,6 +417,7 @@ $config['additional_javascript_defer'][] = 'js/expand-video.js';
// New LCN scripts
$config['additional_javascript'][] = 'js/lcn/utils.js';
$config['additional_javascript'][] = 'js/lcn/classes.js';
$config['additional_javascript'][] = 'js/lcn/thread_autoupdater.js';
$config['additional_javascript_compile'] = true;
$config['minify_js'] = true;

View File

@ -6,6 +6,34 @@
globalThis.LCNSite = class LCNSite {
static INSTANCE = null;
static "createAbortable" () {
const obj = { "abort": null, "controller": null, "signal": null }
const setupController = () => {
obj.controller = new AbortController()
obj.signal = obj.controller.signal
}
obj.abort = () => {
obj.controller.abort()
setupController()
}
setupController()
return obj
}
static "getThreadFromPages" (pages, thread_id) {
for (const page of pages) {
for (const thread of page.threads) {
if (thread_id == String(thread.no)) {
return { "page": page.page, ...thread }
}
}
}
return null
}
#isModerator = document.body.classList.contains("is-moderator");
#isThreadPage = document.body.classList.contains("active-thread");
#isBoardPage = document.body.classList.contains("active-board");
@ -53,10 +81,28 @@ globalThis.LCNSite = class LCNSite {
this.#favicon.href = `/favicon${type ? "-" + type : ""}.ico`
}
"getFloaterLContainer" () { return document.getElementById("bar-bottom-l"); }
"getFloaterRContainer" () { return document.getElementById("bar-bottom-r"); }
"getThreadStatsLContainer" () { return document.getElementById("lcn-threadstats-l"); }
"getThreadStatsRContainer" () { return document.getElementById("lcn-threadstats-r"); }
#generatedStyle = null;
"writeCSSStyle" (origin, stylesheet) {
if (this.#generatedStyle == null && (this.#generatedStyle = document.querySelector("head > style.generated-css")) == null) {
this.#generatedStyle = document.createElement("style")
this.#generatedStyle.classList.add("generated-css")
document.head.appendChild(this.#generatedStyle)
}
this.#generatedStyle.textContent += `${this.#generatedStyle.textContent.length ? "\n\n" : ""}/*** Generated by ${origin} ***/\n${stylesheet}`
}
}
globalThis.LCNPostInfo = class LCNPostInfo {
static nodeAttrib = "$LCNPostInfo";
static selector = ".post:not(.grid-li)";
#boardId = null;
#threadId = null;
#postId = null;
@ -74,7 +120,7 @@ globalThis.LCNPostInfo = class LCNPostInfo {
#isLocked = false;
#isSticky = false;
static "assign" (post) { return post.$LCNPostInfo ?? (post.$LCNPostInfo = this.from(post)); }
static "assign" (post) { return post[this.nodeAttrib] ?? (post[this.nodeAttrib] = this.from(post)); }
static "from" (post) {
assert.ok(post.classList.contains("post"), "Arty must be expected Element.")
const inst = new this()
@ -130,6 +176,8 @@ globalThis.LCNPostInfo = class LCNPostInfo {
globalThis.LCNPost = class LCNPost {
static nodeAttrib = "$LCNPost";
static selector = ".post:not(.grid-li)";
#parent = null;
#post = null;
#info = null;
@ -137,7 +185,7 @@ globalThis.LCNPost = class LCNPost {
#controls = null;
#customControlsSeperatorNode = null;
static "assign" (post) { return post.$LCNPost ?? (post.$LCNPost = this.from(post)); }
static "assign" (post) { return post[this.nodeAttrib] ?? (post[this.nodeAttrib] = this.from(post)); }
static "from" (post) { return new this(post); }
"constructor" (post) {
@ -146,7 +194,7 @@ globalThis.LCNPost = class LCNPost {
this.#post = post
this.#info = LCNPostInfo.assign(post)
this.#ipLink = intro.querySelector(".ip-link")
this.#controls = arrLast(post.querySelectorAll(".controls"))
this.#controls = Array.prototype.at.apply(post.querySelectorAll(".controls"), [ -1 ])
assert.equal(this.#info.getParent(), null, "Info should not have parent.")
this.#info.__setParent(this)
@ -193,26 +241,28 @@ globalThis.LCNPost = class LCNPost {
globalThis.LCNThread = class LCNThread {
static nodeAttrib = "$LCNThread";
static selector = ".thread:not(.grid-li)";
#element = null;
#parent = null;
#thread = null;
#op = null;
static "assign" (thread) { return thread.$LCNThread ?? (thread.$LCNThread = this.from(thread)); }
static "assign" (thread) { return thread[this.nodeAttrib] ?? (thread[this.nodeAttrib] = this.from(thread)); }
static "from" (thread) { return new this(thread); }
"constructor" (thread) {
assert.ok(thread.classList.contains("thread"), "Arty must be expected Element.")
this.#thread = thread
this.#op = LCNPost.assign(this.#thread.querySelector(".post.op"))
this.#element = thread
this.#op = LCNPost.assign(this.#element.querySelector(".post.op"))
assert.equal(this.#op.getParent(), null, "Op should not have parent.")
//assert.equal(this.#op.getParent(), null, "Op should not have parent.")
this.#op.__setParent(this)
}
"getElement" () { return this.#thread; }
"getElement" () { return this.#element; }
"getContent" () { return this.#op; }
"getPosts" () { return Array.prototype.map.apply(this.#thread.querySelectorAll(".post"), [ el => LCNPost.assign(el) ]); }
"getReplies" () { return Array.prototype.map.apply(this.#thread.querySelectorAll(".post:not(.op)"), [ el => LCNPost.assign(el) ]); }
"getPosts" () { return Array.prototype.map.apply(this.#element.querySelectorAll(".post"), [ el => LCNPost.assign(el) ]); }
"getReplies" () { return Array.prototype.map.apply(this.#element.querySelectorAll(".post:not(.op)"), [ el => LCNPost.assign(el) ]); }
"getParent" () { return this.#parent; }
"__setParent" (inst) { return this.#parent = inst; }
@ -221,19 +271,21 @@ globalThis.LCNThread = class LCNThread {
globalThis.LCNPostContainer = class LCNPostContainer {
static nodeAttrib = "$LCNPostContainer";
static selector = ".postcontainer";
#parent = null;
#container = null;
#element = null;
#content = null;
#postId = null;
#boardId = null;
static "assign" (container) { return container.$LCNPostContainer ?? (container.$LCNPostContainer = this.from(container)); }
static "assign" (container) { return container[this.nodeAttrib] ?? (container[this.nodeAttrib] = this.from(container)); }
static "from" (container) { return new this(container); }
"constructor" (container) {
assert.ok(container.classList.contains("postcontainer"), "Arty must be expected Element.")
const child = container.querySelector(".thread, .post")
this.#container = container
this.#element = container
this.#content = child.classList.contains("thread") ? LCNThread.assign(child) : LCNPost.assign(child)
this.#boardId = container.dataset.board
this.#postId = container.id.slice(2)
@ -242,7 +294,7 @@ globalThis.LCNPostContainer = class LCNPostContainer {
this.#content.__setParent(this)
}
"getContainer" () { return this.#container; }
"getElement" () { return this.#element; }
"getContent" () { return this.#content; }
"getBoardId" () { return this.#boardId; }
"getPostId" () { return this.#postId; }
@ -254,13 +306,15 @@ globalThis.LCNPostContainer = class LCNPostContainer {
globalThis.LCNPostWrapper = class LCNPostWrapper {
static nodeAttrib = "$LCNPostWrapper";
static selector = ".post-wrapper";
#wrapper = null;
#eitaLink = null;
#eitaId = null;
#eitaHref = null
#content = null;
static "assign" (wrapper) { return wrapper.$LCNPostWrapper ?? (wrapper.$LCNPostWrapper = this.from(wrapper)); }
static "assign" (wrapper) { return wrapper[this.nodeAttrib] ?? (wrapper[this.nodeAttrib] = this.from(wrapper)); }
static "from" (wrapper) { return new this(wrapper); }
"constructor" (wrapper) {
@ -298,23 +352,140 @@ globalThis.LCNPostWrapper = class LCNPostWrapper {
}
globalThis.LCNPost.all = () => Array.prototype.map.apply(document.querySelectorAll(".post:not(.grid-li)"), [ node => LCNPost.assign(node) ]);
globalThis.LCNThread.all = () => Array.prototype.map.apply(document.querySelectorAll(".thread:not(.grid-li)"), [ node => LCNThread.assign(node) ]);
globalThis.LCNPostContainer.all = () => Array.prototype.map.apply(document.querySelectorAll(".postcontainer"), [ node => LCNPostContainer.assign(node) ]);
globalThis.LCNPostWrapper.all = () => Array.prototype.map.apply(document.querySelectorAll(".post-wrapper"), [ node => LCNPostWrapper.assign(node) ]);
globalThis.LCNSetting = class LCNSetting {
#id = null;
#eventId = null;
#label = null;
#value = null;
#valueDefault = null;
static "build" (id) { return new this(id); }
"constructor" (id) {
this.#id = id;
this.#eventId = `lcnsetting::${this.#id}`
}
#getValue () {
const v = localStorage.getItem(this.#id)
if (v != null) {
return this.__builtinValueImporter(v)
} else {
return this.#valueDefault
}
}
"getValue" () { return this.#value ?? (this.#value = this.#getValue()); }
"setValue" (v) {
if (this.#value !== v) {
this.#value = v
localStorage.setItem(this.#id, this.__builtinValueExporter(this.#value))
setTimeout(() => $(document).trigger(`${this.#eventId}::change`, [ v, this ]), 1)
}
}
"getLabel" () { return this.#label; }
"setLabel" (label) { this.#label = label; return this; }
"getDefaultValue" () { return this.#valueDefault; }
"setDefaultValue" (vd) { this.#valueDefault = vd; return this; }
"onChange" (fn) { $(document).on(`${this.#eventId}::change`, (_,v,i) => fn(v, i)); }
__setIdPrefix (prefix) { this.#id = `${prefix}_${this.#id}`; }
}
globalThis.LCNToggleSetting = class LCNToggleSetting extends LCNSetting {
__builtinValueImporter (v) { return v == "1"; }
__builtinValueExporter (v) { return v ? "1" : ""; }
__builtinDOMConstructor () {
const div = document.createElement("div")
const chk = document.createElement("input")
const txt = document.createElement("label")
txt.innerText = this.getLabel()
chk.type = "checkbox"
chk.checked = this.getValue()
chk.addEventListener("click", e => {
e.preventDefault();
this.setValue(!this.getValue())
})
this.onChange(v => chk.checked = v)
div.appendChild(chk)
div.appendChild(txt)
return div
}
}
globalThis.LCNSettingsSubcategory = class LCNSettingsSubcategory {
#tab_id = null;
#id = null;
#fieldset = null;
#legend = null;
#label = null;
static "for" (tab_id, id) {
const domid = `lcnssc_${tab_id}_${id}`
const inst = document.getElementById(domid)?.$LCNSettingsSubcategory
if (inst == null) {
const fieldset = document.createElement("fieldset")
const legend = document.createElement("legend")
fieldset.id = domid
fieldset.appendChild(legend)
// XXX: extend_tab only takes a string so this hacky workaround is used to let us use the regular dom api
Options.extend_tab(tab_id, `<div id="__${domid}" hidden></div>`)
const div = document.getElementById(`__${domid}`)?.parentElement
assert.ok(div)
div.replaceChildren(fieldset)
return new this(tab_id, id, fieldset)
} else {
return inst
}
}
"constructor" (tab_id, id, fieldset) {
this.#tab_id = tab_id
this.#id = id
this.#fieldset = fieldset
this.#legend = this.#fieldset.querySelector("legend")
this.#fieldset.$LCNSettingsSubcategory = this
}
"getLabel" () { return this.#label; }
"setLabel" (label) { this.#legend.innerText = this.#label = label; return this; }
"addSetting" (setting) {
assert.ok(setting instanceof LCNSetting)
setting.__setIdPrefix(`lcnsetting_${this.#tab_id}_${this.#id}`)
if (setting.__builtinDOMConstructor != null) {
const div = setting.__builtinDOMConstructor()
div.classList.add("lcn-setting-entry")
this.#fieldset.appendChild(div)
}
return this
}
}
$().ready(() => {
LCNSite.INSTANCE = new LCNSite();
const clazzes = [ LCNPost, LCNThread, LCNPostContainer, LCNPostWrapper ]
for (const clazz of clazzes) {
clazz.forEach = fn => clazz.all().forEach(fn)
clazz.filter = fn => clazz.all().filter(fn)
for (const clazz of [ LCNPost, LCNPostInfo, LCNThread, LCNPostContainer, LCNPostWrapper ]) {
clazz.allNodes = (node=document) => node.querySelectorAll(clazz.selector)
clazz.all = (node=document) => Array.prototype.map.apply(clazz.allNodes(node), [ elem => clazz.assign(elem) ]);
clazz.clear = (node=document) => Array.prototype.forEach.apply(clazz.allNodes(node), [ elem => elem[clazz.nodeAttrib] = null ])
clazz.forEach = (fn, node=document) => clazz.allNodes(node).forEach(elem => fn(clazz.assign(elem)))
clazz.filter = (fn, node=document) => clazz.all(node).filter(fn)
clazz.find = fn => clazz.all().find(fn)
clazz.first = (node=document) => clazz.assign(node.querySelector(clazz.selector))
clazz.last = (node=document) => clazz.assign(Array.prototype.at.apply(clazz.allNodes(node), [ -1 ]))
}
// XXX: May be a cleaner way to do this but this should be fine for now.
for (const clazz of clazzes) { void clazz.all(); }
for (const clazz of [ LCNPostContainer, LCNPostWrapper, LCNThread, LCNPost ]) { void clazz.all(); }
$(document).on("new_post", (e, post) => {
if (LCNSite.INSTANCE.isModRecentsPage()) {
void LCNPostWrapper.all()
@ -322,4 +493,7 @@ $().ready(() => {
void LCNPostContainer.all()
}
})
$(window).on("focus", () => LCNSite.INSTANCE.clearUnseen())
$(document.body).on("mousemove", () => LCNSite.INSTANCE.clearUnseen())
})

View File

@ -0,0 +1,253 @@
/**
* @file Thread auto updater.
* @author jonsmy
*/
$().ready(() => {
const kIsEnabled = LCNToggleSetting.build("enabled")
//const kIsBellEnabled = LCNToggleSetting.build("bellEnabled")
void LCNSettingsSubcategory.for("general", "threadUpdater")
.setLabel("Thread Updater")
.addSetting(kIsEnabled
.setLabel(_("Fetch new replies in the background"))
.setDefaultValue(true))
/*.addSetting(kIsBellEnabled
.setLabel(_("Play an audible chime when new replies are found"))
.setDefaultValue(false))*/;
if (LCNSite.INSTANCE.isThreadPage()) {
let threadUpdateStatus = null
let secondsCounter = 0
let threadState = null
let threadStats = null
let statReplies = null
let statFiles = null
let statPage = null
let statUniqueIPs = null
const parser = new DOMParser()
const abortable = LCNSite.createAbortable()
const threadStatsItems = []
const updateDOMStatus = () => {
const text = threadState ?? (secondsCounter >= 0 ? `${secondsCounter}s` : "…")
threadUpdateStatus.innerText = text
}
const updateSecondsByTSLP = post_info => {
secondsCounter = Math.floor(((Date.now() - post_info.getCreatedAt().getTime()) / 30000))
secondsCounter = secondsCounter > 1000 ? 1000 : secondsCounter
secondsCounter = secondsCounter < 11 ? 11 : secondsCounter
}
const updateStatsFn = async thread => {
// XXX: Using /%b/%d.json would be better however the page number isn't provided.
const res = await fetch(`/${thread.getContent().getInfo().getBoardId()}/threads.json`, {
"signal": abortable.signal
})
if (res.ok) {
const stats = LCNSite.getThreadFromPages(await res.json(), thread.getContent().getInfo().getThreadId())
if (stats != null) {
threadStats = stats
} else {
threadState = String(res.status)
}
} else {
throw new Error(`Server responded with non-OK status '${res.status}'`)
}
}
const handleThreadUpdate = async (thread) => {
const threadPost = thread.getContent()
const res = await fetch(location.href, {
"signal": abortable.signal
})
if (res.ok) {
const dom = parser.parseFromString(await res.text(), "text/html")
const livePCList = Array.prototype.map.apply(dom.querySelectorAll(`#thread_${threadPost.getInfo().getThreadId()} > .postcontainer`), [ pc => LCNPostContainer.assign(pc) ])
updateThreadFn(thread, livePCList);
} else if (res.status == 404) {
threadState = String(res.status)
} else {
throw new Error(`Server responded with non-OK status '${res.status}'`)
}
}
function updateThreadFn(thread, lcn_pc_list) {
const threadPost = thread.getContent()
const threadReplies = thread.getReplies()
const lastPostC = threadReplies.at(-1).getParent()
const lastPostTs = lastPostC.getContent().getInfo().getCreatedAt().getTime()
const livePCList = lcn_pc_list;
const documentPCList = [ threadPost, ...threadReplies.map(p => p.getParent()) ]
const missingPCList = []
for (const pc of livePCList.reverse()) {
if (pc.getContent().getInfo().getCreatedAt().getTime() > lastPostTs) {
missingPCList.unshift(pc)
} else {
break
}
}
if (missingPCList.length) {
for (const pc of missingPCList) {
documentPCList.at(-1).getElement().after(pc.getElement())
documentPCList.push(pc)
}
for (const pc of missingPCList) {
$(document).trigger("new_post", [ pc.getContent().getElement() ])
}
LCNSite.INSTANCE.setUnseen(LCNSite.INSTANCE.getUnseen() + missingPCList.length)
}
}
const onTickClean = () => {
if (onTickId != null) {
clearTimeout(onTickId)
onTickId = null
}
abortable.abort()
}
let onTickId = null
const onTickFn = async () => {
void secondsCounter--;
onTickClean()
updateDOMStatus()
if (threadState == null) {
if (secondsCounter < 0) {
const thread = LCNThread.first()
try {
await updateStatsFn(thread)
if (threadState == null && threadStats.last_modified > (thread.getReplies().at(-1).getInfo().getCreatedAt().getTime() / 1000)) {
await handleThreadUpdate(thread)
}
const threadEl = thread.getElement()
statUniqueIPs.innerText = threadStats.unique_ips
statReplies.innerText = thread.getReplies().length
statFiles.innerText = threadEl.querySelectorAll(".files .file").length - threadEl.querySelectorAll(".files .file .post-image.deleted").length
statPage.innerText = threadStats.page + 1
updateSecondsByTSLP(thread.getReplies().at(-1).getInfo())
} catch (error) {
console.error("threadAutoUpdater: Failed while processing update. Probably a network error", error)
secondsCounter = 60
}
}
onTickId = setTimeout(onTickFn, 1000)
}
}
let floaterLinkBox = null
const onStateChangeFn = v => {
onTickClean()
if (v) {
_domsetup_btn: {
const container = LCNSite.INSTANCE.getFloaterLContainer()
floaterLinkBox = document.createElement("span")
const threadlink = document.createElement("span")
const threadUpdateLink = document.createElement("a")
threadUpdateStatus = document.createElement("span")
threadUpdateStatus.id = "thread-update-status"
threadUpdateStatus.innerText = "…"
threadUpdateLink.addEventListener("click", e => {
e.preventDefault()
if (secondsCounter >= 0) {
secondsCounter = 0
onTickFn()
}
})
threadUpdateLink.href = "#"
threadUpdateLink.appendChild(new Text("Refresh: "))
threadUpdateLink.appendChild(threadUpdateStatus)
threadlink.classList.add("threadlink")
threadlink.appendChild(threadUpdateLink)
floaterLinkBox.classList.add("threadlinks")
floaterLinkBox.appendChild(threadlink)
container.appendChild(floaterLinkBox)
}
_domsetup_stats: {
const container = LCNSite.INSTANCE.getThreadStatsRContainer()
const span1 = document.createElement("span")
const span2 = document.createElement("span")
const span3 = document.createElement("span")
statUniqueIPs = document.getElementById("lcn-uniqueips")
statReplies = document.createElement("span")
statFiles = document.createElement("span")
statPage = document.createElement("span")
statReplies.id = "lcn_replies_n"
statReplies.innerText = "…"
statFiles.id = "lcn_files_n"
statReplies.innerText = "…"
statPage.id = "lcn_page_n"
statPage.innerText = "…"
span1.appendChild(new Text("Replies: "))
span1.appendChild(statReplies)
span2.appendChild(new Text("Files: "))
span2.appendChild(statFiles)
span3.appendChild(new Text("Page: "))
span3.appendChild(statPage)
for (const span of [ span1, span2, span3 ]) {
threadStatsItems.push(span)
container.appendChild(span)
}
}
secondsCounter = 0
setTimeout(onTickFn, 1)
} else {
floaterLinkBox?.remove()
floaterLinkBox = null
statReplies = null
statFiles = null
statPage = null
while (threadStatsItems.length) {
threadStatsItems.shift().remove()
}
}
}
kIsEnabled.onChange(onStateChangeFn)
onStateChangeFn(kIsEnabled.getValue())
$(document).on("ajax_after_post", onNewPost);
function onNewPost(_, post_response) {
if (post_response == null) {
console.log("onNewPost data is null, can't do anything.");
return;
}
const thread_dom = parser.parseFromString(
post_response['thread'],
"text/html");
const thread_id_sel = "#thread_" + post_response['thread_id'];
const post_containers = [...thread_dom.querySelectorAll(`${thread_id_sel} > .postcontainer`)]
.map(elem => LCNPostContainer.assign(elem));
const thread_elem = document.querySelector(thread_id_sel);
const lcn_thread = new LCNThread(thread_elem);
updateThreadFn(lcn_thread, post_containers);
}
}
})

View File

@ -3,20 +3,13 @@
* @author jonsmy
*/
const arrLast = arr => arr[arr.length-1] ?? undefined;
const getConfigBool = (k,d) => { const v = localStorage.getItem(`jon-modjs::${k}`); return v ? v == "1" : d; }
const writeCSSStyle = textContent => {
const style = document.createElement("style")
style.textContent = textContent
document.head.appendChild(style)
}
const assert = {
"equal": (actual, expected, message="No message set") => {
if (actual !== expected) {
const err = new Error(`Assertion Failed. ${message}`)
err.data = { actual, expected}
Error.captureStackTrace(err, assert.equal)
// Seems like there's no such thing as captureStackTrace in firefox?
//Error.captureStackTrace(err, assert.equal)
debugger
throw err
}
@ -25,7 +18,7 @@ const assert = {
if (!actual) {
const err = new Error(`Assertion Failed. ${message}`)
err.data = { actual }
Error.captureStackTrace(err, assert.ok)
// Error.captureStackTrace(err, assert.ok)
debugger
throw err
}

View File

@ -1475,7 +1475,9 @@ function handle_post(){
}
buildThread($post['op'] ? $id : $post['thread']);
$thread_id = $post['op'] ? $id : $post['thread'];
$rendered_thread = buildThread($thread_id);
if ($config['syslog'])
_syslog(LOG_INFO, 'New post: /' . $board['dir'] . $config['dir']['res'] .
@ -1487,12 +1489,13 @@ function handle_post(){
header('Location: ' . $redirect, true, $config['redirect_http']);
} else {
header('Content-Type: text/json; charset=utf-8');
$api = new Api();
echo json_encode(array(
'redirect' => $redirect,
'noko' => $noko,
'id' => $id,
'post' => $api->translatePost(new Post($post))
'thread_id' => $thread_id,
'thread' => $rendered_thread
));
}

View File

@ -2015,3 +2015,71 @@ span.orangeQuote {
.options_general_tab--select_opt select {
float: none;
}
/* LCN */
.lcn-threadstats {
display: flex;
justify-content: space-between;
}
.lcn-threadstats-container {
display: flex;
gap: 8px;
}
.lcn-threadstats-container > :not(:first-child):before {
content: ' | ';
}
.lcn-setting-entry {
display: flex;
align-items: flex-end;
gap: 0.5em;
}
.lcn-bar {
display: flex!important;
justify-content: space-between;
gap: 6px;
}
#thread-update-status {
display: inline-block;
width: 2.5em;
text-align: right;
overflow-x: clip;
}
.bar-collection {
padding: 0px 3px;
}
.bar-collection .threadlinks:not(:first-child) {
margin-left:4px
}
.lcn-bar .threadlinks:before,
.lcn-bar .threadlinks:after,
.lcn-bar .threadlinks .threadlink:not(:first-child):before {
vertical-align: middle;
}
.threadlinks:before {
content: '[ ';
}
.threadlinks:after {
content: ' ]';
}
.threadlinks .threadlink:not(:first-child):before {
content: ' / ';
}
.indielinks-row .indielinks:not(:first-child):before {
content: ' | ';
}
.indielinks .indielink:not(:first-child) {
margin-left: 6px;
}

View File

@ -100,7 +100,12 @@
{% endfor %}
<br class="clear"/>
{% if not index %}
<div id="uniqueip"><span style="display: block; float: left;">Unique IPs: {{ iparray|length }} </span></div>
<div class="lcn-threadstats">
<div class="lcn-threadstats-container" id="lcn-threadstats-l">
<span>Unique IPs: <span id="lcn-uniqueips">{{ iparray|length }}</span></span>
</div>
<div class="lcn-threadstats-container" id="lcn-threadstats-r"></div>
</div>
{% endif %}
{% if hr %}<hr/>{% endif %}
</div>

View File

@ -56,9 +56,12 @@
{% if config.global_message %}<hr /><div class="blotter">{{ config.global_message }}</div>{% endif %}
<hr />
<div class="threadlinks-noup">
<span class="threadlink">[ <a href="{{ return }}">{% trans %}Return{% endtrans %}</a> /</span>
<span class="threadlink"><a href="#bottom" style="padding-left: 10px"> {% trans %}Go to bottom{% endtrans %}</a> ]</span>
<div class="threadlinks">
<span class="threadlink"><a href="{{ return }}">{% trans %}Return{% endtrans %}</a></span>
{% if config.catalog_link %}
<span class="threadlink"><a href="{{ config.root }}{{ board.dir }}{{ config.catalog_link }}">{% trans %}Catalog{% endtrans %}</a></span>
{% endif %}
<span class="threadlink"><a href="#bottom">{% trans %}Bottom{% endtrans %}</a></span>
</div>
<hr />
<form name="postcontrols" action="{{ config.post_url }}" method="post">
@ -68,15 +71,19 @@
{{ body }}
<div id="thread-interactions">
<span id="thread-links">
<a id="thread-return" href="{{ return }}">[{% trans %}Return{% endtrans %}]</a>
<a id="thread-top" href="#top">[{% trans %}Go to top{% endtrans %}]</a>
{% if config.catalog_link %}
<a id="thread-catalog" href="{{ config.root }}{{ board.dir }}{{ config.catalog_link }}">[{% trans %}Catalog{% endtrans %}]</a>
{% endif %}
{% if config.home_link %}
| <a id="thread-home" href="{{ config.root }}">[{% trans %}Home{% endtrans %}]</a>
{% endif %}
<span class="indielinks-row">
<span class="indielinks" id="thread-indielinks-l">
<span class="indielink"><a href="{{ return }}">[{% trans %}Return{% endtrans %}]</a></span>
{% if config.catalog_link %}
<span class="indielink"><a href="{{ config.root }}{{ board.dir }}{{ config.catalog_link }}">[{% trans %}Catalog{% endtrans %}]</a></span>
{% endif %}
<span class="indielink"><a href="#top">[{% trans %}Top{% endtrans %}]</a></span>
</span>
<span class="indielinks" id="thread-indielinks-r">
{% if config.home_link %}
<span class="indielink"><a href="{{ config.root }}">[{% trans %}Home{% endtrans %}]</a></span>
{% endif %}
</span>
</span>
<span id="thread-quick-reply">
@ -104,12 +111,24 @@
{% for footer in config.footer %}<p class="unimportant" style="text-align:center;">{{ footer }}</p>{% endfor %}
</footer>
</div>
<div class="bar bottom">
<div class="threadlinks">
<span class="threadlink">[ <a href="{{ return }}">{% trans %}Return{% endtrans %}</a> /</span>
<span class="threadlink"><a href="#" style="padding-left: 10px"> {% trans %}Go to top{% endtrans %}</a> /</span>
</div>
<div class=pages></div>
<div class="bar lcn-bar bottom">
<span class="bar-collection" id="bar-bottom-l">
<span class="threadlinks">
<span class="threadlink"><a href="{{ return }}">{% trans %}Return{% endtrans %}</a></span>
{% if config.catalog_link %}
<span class="threadlink"><a href="{{ config.root }}{{ board.dir }}{{ config.catalog_link }}">{% trans %}Catalog{% endtrans %}</a></span>
{% endif %}
<span class="threadlink"><a href="#top">{% trans %}Top{% endtrans %}</a></span>
<span class="threadlink"><a href="#bottom">{% trans %}Bottom{% endtrans %}</a></span>
</span>
</span>
<span class="bar-collection" id="bar-bottom-r">
<span class="threadlinks">
{% if config.home_link %}
<span class="threadlink"><a href="{{ config.root }}">{% trans %}Home{% endtrans %}</a></span>
{% endif %}
</span>
</span>
</div>
<script type="text/javascript">{% verbatim %}
ready();