1
0
Fork 0
mirror of https://github.com/Eggbertx/gochan.git synced 2025-08-19 08:26:23 -07:00

Switch to TypeScript for frontend

This commit is contained in:
Eggbertx 2023-05-13 23:46:41 -07:00
parent 5e8804b9d2
commit 3d423e9623
45 changed files with 6395 additions and 8183 deletions

View file

@ -1,5 +1,10 @@
{
"presets": ["@babel/preset-env"],
"presets": [
[
"@babel/preset-env", {"targets": {"node": "current"}},
"@babel/preset-typescript"
]
],
"comments": false,
"sourceMaps": true
}

1
frontend/.eslintignore Normal file
View file

@ -0,0 +1 @@
/webpack.config.js

View file

@ -1,41 +1,53 @@
module.exports = {
"env": {
"browser": true,
"es2021": true
"parser": "@typescript-eslint/parser",
"env": {
"browser": true,
"jest": true,
"node": true,
"es6": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
],
"ignorePatterns": ["**/legacy/**"],
"parserOptions": {
"ecmaFeatures": {
"experimentalObjectRestSpread": true,
"jsx": true
},
"globals": {
"styles": "readonly",
"defaultStyle": "readonly",
"webroot": "readonly",
"serverTZ": "readonly"
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"rules": {
"no-unused-vars": ["warn", {
"argsIgnorePattern": "^_"
}],
"semi": "warn",
"no-constant-condition": "warn",
"no-whitespace-before-property": "warn",
"linebreak-style": ["error", "unix"],
"brace-style": ["error", "1tbs"],
"array-bracket-spacing": ["error", "never"],
"block-spacing": ["error", "always"],
"func-call-spacing": ["error", "never"],
"space-before-blocks": ["warn", "always"],
"no-undef": "error",
"keyword-spacing": ["warn", {
"overrides": {
"if": {"after": false},
"for": {"after": false},
"catch": {"after": false},
"switch": {"after": false},
"while": {"after": false}
}
}]
}
}
"sourceType": "module"
},
"plugins": [
"@typescript-eslint"
],
"rules": {
"indent": ["error", "tab"],
"linebreak-style": ["error", "unix"],
"quotes": ["error", "double", {
"allowTemplateLiterals": true
}],
"semi": ["error", "always"],
"no-var": ["error"],
"brace-style": ["error"],
"array-bracket-spacing": ["error", "never"],
"block-spacing": ["error", "always"],
"no-spaced-func": ["error"],
"no-whitespace-before-property": ["error"],
"space-before-blocks": ["error", "always"],
"keyword-spacing": ["error", {
"overrides": {
"if": {"after": false},
"for": {"after": false},
"catch": {"after": false},
"switch": {"after": false},
"while": {"after": false}
}
}],
"no-constant-condition": ["off"],
"@typescript-eslint/no-explicit-any": ["off"],
"@typescript-eslint/no-unused-vars": ["warn", {
"argsIgnorePattern": "^_"
}],
}
};

View file

@ -1,9 +0,0 @@
{
// this is here since parcel and babel don't seem to get along
"extends": "@parcel/config-default",
"transformers": {
"*.js": [
"@parcel/transformer-js"
]
}
}

View file

@ -1,14 +1,16 @@
import type {Config} from 'jest';
import { defaults } from "jest-config"
/*
* For a detailed explanation regarding each configuration property, visit:
* https://jestjs.io/docs/configuration
*/
export default {
const cfg: Config = {
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
bail: 1,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/tmp/jest_rs",
@ -70,16 +72,7 @@ export default {
// "node_modules"
// ],
// An array of file extensions your modules use
// moduleFileExtensions: [
// "js",
// "jsx",
// "ts",
// "tsx",
// "json",
// "node"
// ],
moduleFileExtensions: [...defaults.moduleFileExtensions, "mts"],
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
// moduleNameMapper: {},
@ -116,27 +109,6 @@ export default {
// The root directory that Jest should scan for tests and modules within
rootDir: "tests/",
// A list of paths to directories that Jest should use to search for files in
// roots: [
// "<rootDir>"
// ],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
// The number of seconds after which a test is considered as slow and reported as such in the results.
// slowTestThreshold: 5,
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// The test environment that will be used for testing
testEnvironment: "jsdom",
// Options that will be passed to the testEnvironment
@ -172,7 +144,9 @@ export default {
// timers: "real",
// A map from regular expressions to paths to transformers
// transform: undefined,
transform: {
"^.+\\.ts?$": "ts-jest"
},
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [
@ -184,7 +158,7 @@ export default {
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: undefined,
verbose: true,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
@ -192,3 +166,4 @@ export default {
// Whether to use watchman for file crawling
// watchman: true,
};
export default cfg;

View file

@ -1,23 +0,0 @@
let noop = ()=>{};
/**
* @param {JQuery<HTMLElement>} $elem
*/
export function updateUploadImage($elem, onLoad = noop) {
if($elem.length == 0) return;
$elem[0].onchange = function() {
let img = new Image();
img.src = URL.createObjectURL(this.files[0]);
img.onload = onLoad;
};
}
/**
* @returns {string}
*/
export function getUploadFilename() {
let elem = document.getElementById("imagefile");
if(elem === null) return "";
if(elem.files === undefined || elem.files.length < 1) return "";
return elem.files[0].name;
}

View file

@ -1,38 +0,0 @@
import { getCookie, setCookie } from "./cookies";
export function getStorageVal(key, defaultVal = "") {
if(localStorage == undefined)
return getCookie(key, defaultVal);
let val = localStorage.getItem(key);
if(val === null)
return defaultVal;
return val;
}
export function getBooleanStorageVal(key, defaultVal = false) {
let val = getStorageVal(key, defaultVal);
return val == true || val == "true";
}
export function getNumberStorageVal(key, defaultVal = 0) {
return Number.parseFloat(getStorageVal(key, defaultVal));
}
export function getJsonStorageVal(key, defaultVal) {
let val = defaultVal;
try {
val = JSON.parse(getStorageVal(key, defaultVal));
} catch(e) {
val = defaultVal;
}
return val;
}
export function setStorageVal(key, val, isJSON = false) {
let storeVal = isJSON?JSON.stringify(val):val;
if(localStorage == undefined)
setCookie(key, storeVal);
else
localStorage.setItem(key, storeVal);
}

View file

@ -1,157 +0,0 @@
import "jquery";
declare interface GochanStyle {
Name: string;
Filename: string;
}
// stored in /js/consts.json
declare var styles: GochanStyle[];
declare var defaultStyle: string;
declare var serverTZ: number;
interface Window {
styles: GochanStyle[];
defaultStyle: string;
webroot: string;
serverTZ: number;
openQR: () => void;
closeQR: () => void;
toTop: () => void;
toBottom: () => void;
}
// /boards.json
declare interface BoardsJSON {
boards: BoardJSON[];
}
declare interface BoardCooldowns {
threads: number;
replies: number
images: number;
}
declare interface BoardJSON {
pages: number;
board: string;
title: string;
meta_description: string;
max_filesize: number;
max_pages: number;
is_archived: boolean;
bump_limit: number;
image_limit: number;
max_comment_chars: number;
ws_board: boolean;
cooldowns: BoardCooldowns
per_page: number;
}
// an array of these are in /boarddir/catalog.json
declare interface CatalogBoard {
page: number;
threads: CatalogThread[];
}
declare interface CatalogThread {
replies: number;
images: number;
omitted_posts: number;
omitted_images: number;
sticky: number;
locked: number;
}
// /boarddir/res/#.json
declare interface BoardThread {
posts: ThreadPost[];
}
declare interface ThreadPost {
no: number;
resto: number;
name: string;
trip: string;
email: string;
sub: string;
com: string;
tim: string;
filename: string;
md5: string;
extension: string;
fsize: number;
w: number;
h: number;
tn_w: number;
tn_h: number;
capcode: string;
time: string;
last_modified: string;
}
/**
* An object representing a staff member retreived by requesting /manage/staffinfo
*/
interface StaffInfo {
/**
* The staff member's ID in the database
*/
ID: number;
/**
* The staff member's username
*/
Username: string;
/**
* The staff member's rank.
* 0 = not logged in.
* 1 = janitor.
* 2 = moderator.
* 3 = administrator.
*/
Rank: number;
}
/**
* An object representing a management action available to the current staff member
*/
interface StaffAction {
/**
* The GET key used when requesting /manage/<id>
*/
id?:string;
/**
* The title of the action, to be shown in the staff menu
*/
title: string;
/**
* The permission level required to access the action.
* 0 = accessible by anyone.
* 1 = user needs to be a janitor or higher.
* 2 = user needs to be a moderator or higher.
* 3 = user needs to be an administrator.
*/
perms: number;
/**
* The setting for how the request output is handled.
* 0 = never JSON.
* 1 = sometimes JSON depending on whether the `json` GET key is set to 1.
* 2 = always JSON.
*/
jsonOutput: number;
}
/**
* The result of requesting /manage/actions
*/
declare var staffActions: StaffAction[];
/**
* The menu shown when the Staff button on the top bar is clicked
*/
declare let $staffMenu: JQuery<HTMLElement>;
/**
* Defaults to "/"
*/
declare let webroot:string;

13310
frontend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,23 +2,15 @@
"name": "gochan.js",
"version": "3.6.0",
"description": "",
"type": "module",
"source": "js/gochan.js",
"main": "../html/js/gochan.js",
"targets": {
"frontend": {
"source": "js/gochan.js",
"distDir": "../html/js/"
}
},
"browserslist": "> 0.5%, last 2 versions, not dead",
"main": "./ts/main.ts",
"private": true,
"scripts": {
"build-js": "parcel build --no-cache",
"watch-js": "parcel watch --no-cache --no-hmr",
"eslint": "eslint ./js/",
"build-ts": "webpack --progress",
"watch-ts": "webpack --progress -w",
"eslint": "eslint ./ts/",
"eslint-tests": "eslint ./tests/ --env node",
"eslint-fix": "eslint --fix ./js/",
"eslint-fix": "eslint --fix ./ts/",
"tsc-check": "tsc --noEmit",
"test": "jest --verbose",
"build-sass": "sass --no-source-map sass:../html/css",
"minify-sass": "sass --no-source-map sass:../html/css",
@ -28,20 +20,27 @@
"license": "BSD-2-Clause",
"dependencies": {
"jquery": "^3.5.1",
"jquery-ui": "^1.13.2"
"jquery-ui": "^1.13.2",
"path-browserify": "^1.0.1",
"webstorage-polyfill": "^1.0.1"
},
"devDependencies": {
"@babel/preset-env": "^7.16.11",
"@jest/globals": "^27.4.6",
"@babel/preset-env": "^7.21.5",
"@babel/preset-typescript": "^7.21.5",
"@jest/globals": "^29.5.0",
"@types/jquery": "^3.3.38",
"@types/jqueryui": "^1.12.16",
"babel-jest": "^27.5.1",
"@typescript-eslint/eslint-plugin": "^5.59.5",
"eslint": "^8.18.0",
"jest": "^27.4.7",
"parcel": "^2.4.0",
"path-browserify": "^1.0.1",
"process": "^0.11.10",
"jest": "^29.5.0",
"jest-config": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
"sass": "^1.51.0",
"yargs-parser": ">=5.0.0-security.0"
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.2",
"ts-node": "^10.9.1",
"typescript": "^5.0.4",
"webpack": "^5.82.1",
"webpack-cli": "^5.1.1"
}
}

View file

@ -1,26 +1,25 @@
/* global simpleHTML */
'use strict';
import {test, expect} from "@jest/globals";
import $ from "jquery";
import "../js/vars";
import "../ts/vars";
import "./inittests";
import { applyBBCode, handleKeydown } from "../js/boardevents";
import { applyBBCode, handleKeydown } from "../ts/boardevents";
document.documentElement.innerHTML = simpleHTML;
function doBBCode(keycode, text, start, end) {
let $ta = $("<textarea/>");
function doBBCode(keycode: number, text: string, start: number, end: number) {
const $ta = $<HTMLTextAreaElement>("<textarea/>");
$ta.text(text);
let e = $.Event("keydown");
const e = $.Event("keydown");
e.ctrlKey = true;
$ta[0].selectionStart = start;
$ta[0].selectionEnd = end;
e.keyCode = keycode;
e.which = keycode;
e.target = $ta[0];
applyBBCode(e);
$ta.first().trigger(e);
applyBBCode(e as JQuery.KeyDownEvent);
return $ta.text();
}
@ -40,23 +39,23 @@ test("Tests BBCode events", () => {
text = doBBCode(85, text, text.length, text.length);
expect(text).toEqual("[?][s]strike[/s][/?][b]bold[/b][i]italics[/i][u][/u]");
let invalidKeyCode = doBBCode(0, text, 0, 1); // passes an invalid keycode to applyBBCode, no change
const invalidKeyCode = doBBCode(0, text, 0, 1); // passes an invalid keycode to applyBBCode, no change
expect(invalidKeyCode).toEqual(text);
});
test("Tests proper form submission via JS", () => {
let $form = $("form#postform");
let text = doBBCode(83, "text", 0, 4);
const $form = $("form#postform");
const text = doBBCode(83, "text", 0, 4);
$form.find("textarea#postmsg").text(text);
let submitted = false;
$form.on("submit", () => {
submitted = true;
return false;
});
let e = $.Event("keydown");
const e = $.Event("keydown");
e.ctrlKey = true;
e.keyCode = 10;
e.target = $form.find("textarea#postmsg")[0];
handleKeydown(e);
$form.find("textarea#postmsg").first().trigger(e);
handleKeydown(e as JQuery.KeyDownEvent);
expect(submitted).toBeTruthy();
});

View file

@ -1,37 +1,37 @@
import { expect, test } from "@jest/globals";
import { getBooleanCookie, getCookie, getNumberCookie, initCookies, setCookie } from "../js/cookies";
import { getBooleanStorageVal, getJsonStorageVal, getNumberStorageVal, getStorageVal, setStorageVal } from "../js/storage";
import { getBooleanCookie, getCookie, getNumberCookie, initCookies, setCookie } from "../ts/cookies";
import { getBooleanStorageVal, getJsonStorageVal, getNumberStorageVal, getStorageVal, setStorageVal } from "../ts/storage";
global.webroot = "/";
initCookies();
test("Test cookie types", () => {
setCookie("name", "value", 100, "/");
let value = getCookie("name");
setCookie("name", "value", "100", "/");
const value = getCookie("name");
expect(value).toStrictEqual("value");
// test number storage
setCookie("numberCookie", 32, 100, "/");
let numberCookie = getNumberCookie("numberCookie");
setCookie("numberCookie", "32", "100", "/");
const numberCookie = getNumberCookie("numberCookie");
expect(numberCookie).toStrictEqual(32);
setCookie("boolCookie", true, 100, "/");
let boolCookie = getBooleanCookie("boolCookie");
setCookie("boolCookie", "true", "100", "/");
const boolCookie = getBooleanCookie("boolCookie");
expect(boolCookie).toStrictEqual(true);
});
test("Test localStorage", () => {
setStorageVal("name", "value");
let value = getStorageVal("name");
const value = getStorageVal("name");
expect(value).toStrictEqual("value");
setStorageVal("numberVal", 33.2);
let numberVal = getNumberStorageVal("numberVal");
const numberVal = getNumberStorageVal("numberVal");
expect(numberVal).toStrictEqual(33.2);
setStorageVal("boolVal", true);
let boolVal = getBooleanStorageVal("boolVal");
const boolVal = getBooleanStorageVal("boolVal");
expect(boolVal).toStrictEqual(true);
setStorageVal("jsonVal", `{
@ -39,7 +39,7 @@ test("Test localStorage", () => {
"key2": 33,
"aaa": [1,2,3]
}`);
let jsonVal = getJsonStorageVal("jsonVal");
const jsonVal = getJsonStorageVal<{[k:string]:any}>("jsonVal", {});
expect(jsonVal).toStrictEqual({
"key1": "val1",
"key2": 33,

View file

@ -5,19 +5,19 @@ import $ from "jquery";
* @param {string} type
* @returns {number}
*/
function getCooldown(data, board, type) {
function getCooldown(data: BoardsJSON, board: string, type: string) {
for(const boardData of data.boards) {
if(boardData.board != board) continue;
return boardData.cooldowns[type];
return (boardData.cooldowns as any)[type];
}
}
export async function getThreadCooldown(board) {
export async function getThreadCooldown(board: string) {
const boards = await $.getJSON(`${webroot}boards.json`);
return getCooldown(boards, board, "threads");
}
export async function getReplyCooldown(board) {
export async function getReplyCooldown(board: string) {
const boards = await $.getJSON(`${webroot}boards.json`);
return getCooldown(boards, board, "replies");
}

View file

@ -2,20 +2,28 @@ import $ from "jquery";
/**
* Returns true if the post has a lock icon without making a GET request
* @param {JQuery<HTMLElement>} $elem the jQuery element of the post
* @param $elem the jQuery element of the post
*/
export function isThreadLocked($elem) {
export function isThreadLocked($elem: JQuery<HTMLElement>) {
return $elem.find("span.status-icons img.locked-icon").length == 1;
}
interface BoardLockJSON {
board: string;
thread: number;
json: number;
lock?: string;
unlock?: string;
}
/**
* Sends a POST request to the server to lock or unlock a thread
* @param {string} board The board dir of the thread to be (un)locked, e.g. "test2"
* @param {number} op The post number of the top post in the thread
* @param {boolean} lock If true, the thread will be locked, otherwise it will be unlocked
* @param board The board dir of the thread to be (un)locked, e.g. "test2"
* @param op The post number of the top post in the thread
* @param lock If true, the thread will be locked, otherwise it will be unlocked
*/
export async function updateThreadLock(board, op, lock) {
const data = {
export async function updateThreadLock(board: string, op: number, lock: boolean) {
const data: BoardLockJSON = {
board: board,
thread: op,
json: 1
@ -46,7 +54,7 @@ export async function updateThreadLock(board, op, lock) {
$(`div#op${op} img.locked-icon`).remove();
$lockOpt.text("Lock thread");
}
}).catch((data, status, xhr) => {
}).catch((data: any, _status: any, xhr: any) => {
if(data.responseJSON !== undefined && data.responseJSON.message !== undefined) {
alert(`Error updating thread /${board}/${op} lock status: ${data.responseJSON.message}`);
} else {

View file

@ -4,7 +4,12 @@ import $ from "jquery";
import { currentBoard, currentThread } from "../postinfo";
const nullBoardsList = {
interface BoardsList {
boards: any[];
currentBoard: string;
}
const nullBoardsList: BoardsList = {
boards: [],
currentBoard: ""
};
@ -16,7 +21,7 @@ export async function getBoardList() {
cache: false,
dataType: "json",
success: (d2 => {}),
error: function(err, status, statusText) {
error: function(_err, _status, statusText) {
console.error("Error getting board list: " + statusText);
return nullBoardsList;
},
@ -51,17 +56,17 @@ export async function getThread(board = "", thread = 0) {
if(board != "")
threadInfo.board = board;
if(thread > 0)
threadInfo.thread = thread;
threadInfo.id = thread;
if(threadInfo.board === "") {
return Promise.reject("not in a board");
}
if(threadInfo.thread < 1) {
if(threadInfo.id < 1) {
return Promise.reject("not in a thread");
}
const data = await $.ajax({
url: `${webroot}${threadInfo.board}/res/${threadInfo.thread}.json`,
url: `${webroot}${threadInfo.board}/res/${threadInfo.id}.json`,
cache: false,
dataType: "json",
error: function (err, status, statusText) {

View file

@ -2,7 +2,7 @@
import $ from "jquery";
export async function getThreadJSON(threadID, board) {
export async function getThreadJSON(threadID: number, board: string) {
return $.ajax({
url: `${webroot}${board}/res/${threadID}.json`,
cache: false,

View file

@ -2,7 +2,7 @@ import $ from "jquery";
import { openQR } from "./dom/qr";
export function handleKeydown(e) {
export function handleKeydown(e: JQuery.KeyDownEvent) {
let ta = e.target;
let isPostMsg = ta.nodeName == "TEXTAREA" && ta.name == "postmsg";
let inForm = ta.form != undefined;
@ -13,7 +13,7 @@ export function handleKeydown(e) {
}
}
export function applyBBCode(e) {
export function applyBBCode(e: JQuery.KeyDownEvent) {
let tag = "";
switch(e.keyCode) {
case 10: // Enter key

View file

@ -5,7 +5,7 @@ const YEAR_IN_MS = 365*24*60*60*1000;
/**
* @param {string} name
*/
export function getCookie(name, defaultVal = "") {
export function getCookie(name: string, defaultVal = "") {
let val = defaultVal;
let cookieArr = document.cookie.split("; ");
@ -22,11 +22,11 @@ export function getCookie(name, defaultVal = "") {
return val;
}
export function getNumberCookie(name, defaultVal = "0") {
export function getNumberCookie(name: string, defaultVal = "0") {
return parseFloat(getCookie(name, defaultVal));
}
export function getBooleanCookie(name, defaultVal = "true") {
export function getBooleanCookie(name: string, defaultVal = "true") {
return getCookie(name, defaultVal) == "true";
}
@ -42,15 +42,10 @@ function randomPassword(len = 8) {
/**
* Set a cookie
* @param {string} name
* @param {string} value
* @param {string} expires
*/
export function setCookie(name, value, expires, root) {
if(root === undefined || root === "")
root = webroot;
export function setCookie(name: string, value: string, expires = "", root = webroot) {
let expiresStr = "";
if(expires === undefined || expires == "") {
if(expires == "") {
expiresStr = ";expires=";
let d = new Date();
d.setTime(d.getTime() + YEAR_IN_MS);
@ -59,7 +54,7 @@ export function setCookie(name, value, expires, root) {
document.cookie = `${name}=${value}${expiresStr};path=${root};sameSite=strict`;
}
$(() => {
export function initCookies() {
let pwCookie = getCookie("password");
if(pwCookie == "") {
pwCookie = randomPassword();
@ -69,4 +64,6 @@ $(() => {
$("input[name=postemail]").val(getCookie("email"));
$("input[name=postpassword]").val(pwCookie);
$("input[name=delete-password]").val(pwCookie);
});
}
$(initCookies);

View file

@ -1,5 +1,12 @@
import $ from "jquery";
interface BannerProps {
src: string;
alt: string;
width?: number;
height?: number;
}
export function setPageBanner() {
const slashArr = location.pathname.split("/");
const board = (slashArr.length >= 2)?slashArr[1]:"";
@ -14,7 +21,7 @@ export function setPageBanner() {
if(!data || data.Filename == undefined || data.Filename == "") {
return; // no banners :(
}
const props = {
const props: BannerProps = {
src: `${webroot}static/banners/${data.Filename}`,
alt: "Page banner"
};

View file

@ -2,24 +2,25 @@ import $ from "jquery";
const emptyFunc = () => {};
export function removeLightbox(...customs) {
export function removeLightbox(...customs: any) {
$(".lightbox, .lightbox-bg").remove();
for(const custom of customs) {
$(custom).remove();
}
}
export function showLightBox(title, innerHTML) {
export function showLightBox(title: string, innerHTML: string) {
$(document.body).prepend(
`<div class="lightbox-bg"></div><div class="lightbox"><div class="lightbox-title">${title}<a href="javascript:;" class="lightbox-x">X</a><hr /></div>${innerHTML}</div>`
);
$("a.lightbox-x, .lightbox-bg").on("click", removeLightbox);
}
function simpleLightbox(properties = {}, customCSS = {}, $elements = []) {
function simpleLightbox(properties: any = {}, customCSS: any = {}, $elements: any[] = []) {
if(properties["class"] === undefined)
properties["class"] = "lightbox";
let defaultCSS = {
let defaultCSS: {[key: string]: string} = {
"display": "inline-block",
"top": "50%",
"left": "50%",
@ -44,7 +45,7 @@ function simpleLightbox(properties = {}, customCSS = {}, $elements = []) {
return $box;
}
export function promptLightbox(defVal = "", isMasked = false, onOk = emptyFunc, title = "") {
export function promptLightbox(defVal = "", isMasked = false, onOk: ($el:JQuery<HTMLElement>, val: any) => any = emptyFunc, title = "") {
let $ok = $("<button/>").prop({
"id": "okbutton"
}).text("OK");
@ -81,7 +82,7 @@ export function promptLightbox(defVal = "", isMasked = false, onOk = emptyFunc,
return $lb;
}
export function alertLightbox(msg = "", title = location.hostname, onOk = emptyFunc) {
export function alertLightbox(msg = "", title = location.hostname, onOk: ($el: JQuery<HTMLElement>) => any = emptyFunc) {
let $ok = $("<button/>").prop({
"id": "okbutton"
}).text("OK");

View file

@ -10,7 +10,7 @@ import { updateThreadLock } from "../api/management";
const idRe = /^((reply)|(op))(\d+)/;
function editPost(id, _board) {
function editPost(id: number, _board: string) {
let cookiePass = getCookie("password");
promptLightbox(cookiePass, true, (_jq, inputData) => {
$("input[type=checkbox]").prop("checked", false);
@ -20,7 +20,7 @@ function editPost(id, _board) {
}, "Edit post");
}
function moveThread(id, _board) {
function moveThread(id: number, _board: string) {
let cookiePass = getCookie("password");
promptLightbox(cookiePass, true, (_jq, inputData) => {
$("input[type=checkbox]").prop("checked", false);
@ -30,17 +30,17 @@ function moveThread(id, _board) {
}, "Move thread");
}
function reportPost(id, board) {
function reportPost(id: number, board: string) {
promptLightbox("", false, ($lb, reason) => {
if(reason == "" || reason === null) return;
let xhrFields = {
let xhrFields: {[k: string]: string} = {
board: board,
report_btn: "Report",
reason: reason,
json: "1"
};
xhrFields[`check${id}`] = "on";
$.post(webroot + "util", xhrFields).fail(data => {
$.post(webroot + "util", xhrFields).fail((data: any) => {
let errStr = data.error;
if(errStr === undefined)
errStr = data.statusText;
@ -51,11 +51,11 @@ function reportPost(id, board) {
} else {
alertLightbox("Report sent", "Success");
}
}, "json");
}, "json" as any);
}, "Report post");
}
function deletePostFile(id) {
function deletePostFile(id: number) {
let $elem = $(`div#op${id}.op-post, div#reply${id}.reply`);
if($elem.length === 0) return;
$elem.find(".file-info,.upload-container").remove();
@ -67,7 +67,7 @@ function deletePostFile(id) {
$(document).trigger("deletePostFile", id);
}
function deletePostElement(id) {
function deletePostElement(id: number) {
let $elem = $(`div#op${id}.op-post`);
if($elem.length > 0) {
$elem.parent().next().remove(); // also removes the <hr> element after
@ -78,10 +78,10 @@ function deletePostElement(id) {
}
}
function deletePost(id, board, fileOnly) {
function deletePost(id: number, board: string, fileOnly = false) {
let cookiePass = getCookie("password");
promptLightbox(cookiePass, true, ($lb, password) => {
let xhrFields = {
let xhrFields: {[k: string]: any} = {
board: board,
boardid: $("input[name=boardid]").val(),
delete_btn: "Delete",
@ -92,8 +92,8 @@ function deletePost(id, board, fileOnly) {
if(fileOnly) {
xhrFields.fileonly = "on";
}
$.post(webroot + "util", xhrFields).fail(data => {
if(data !== "");
$.post(webroot + "util", xhrFields).fail((data: any) => {
if(data !== "")
alertLightbox(`Delete failed: ${data.error}`, "Error");
}).done(data => {
if(data.error == undefined || data == "") {
@ -112,14 +112,14 @@ function deletePost(id, board, fileOnly) {
console.error(data);
}
}
}, "json");
}, "json" as any);
}, "Password");
}
function handleActions(action, postIDStr) {
function handleActions(action: string, postIDStr: string) {
let idArr = idRe.exec(postIDStr);
if(!idArr) return;
let postID = idArr[4];
let postID = Number.parseInt(idArr[4]);
let board = currentBoard();
switch(action) {
case "Watch thread":
@ -154,7 +154,7 @@ function handleActions(action, postIDStr) {
break;
case "Delete thread":
case "Delete post":
deletePost(postID, board);
deletePost(postID, board, false);
break;
// manage stuff
case "Lock thread":
@ -166,18 +166,18 @@ function handleActions(action, postIDStr) {
updateThreadLock(board, postID, false);
break;
case "Posts from this IP":
getPostInfo(postID).then(info => {
getPostInfo(postID).then((info: any) => {
window.open(`${webroot}manage/ipsearch?limit=100&ip=${info.ip}`);
}).catch(reason => {
}).catch((reason: JQuery.jqXHR) => {
alertLightbox(`Failed getting post IP: ${reason.statusText}`, "Error");
});
break;
case "Ban filename":
case "Ban file checksum": {
let banType = (action == "Ban filename")?"filename":"checksum";
getPostInfo(postID).then(info => {
getPostInfo(postID).then((info: any) => {
return banFile(banType, info.originalFilename, info.checksum, `Added from post dropdown for post /${board}/${postID}`);
}).then(result => {
}).then((result: any) => {
if(result.error !== undefined && result.error != "") {
if(result.message !== undefined)
alertLightbox(`Failed applying ${banType} ban: ${result.message}`, "Error");
@ -186,7 +186,7 @@ function handleActions(action, postIDStr) {
} else {
alertLightbox(`Successfully applied ${banType} ban`, "Success");
}
}).catch((reason) => {
}).catch((reason: any) => {
let messageDetail = "";
try {
const responseJSON = JSON.parse(reason.responseText);
@ -204,7 +204,7 @@ function handleActions(action, postIDStr) {
}
}
export function addPostDropdown($post) {
export function addPostDropdown($post: JQuery<HTMLElement>) {
if($post.find("select.post-actions").length > 0)
return $post;
let $postInfo = $post.find("label.post-info");
@ -216,7 +216,7 @@ export function addPostDropdown($post) {
class: "post-actions",
id: postID
}).append("<option disabled selected>Actions</option>");
let idNum = idRe.exec(postID)[4];
let idNum = Number.parseInt(idRe.exec(postID)[4]);
if(isOP) {
if(isThreadWatched(idNum, currentBoard())) {
$ddownMenu.append("<option>Unwatch thread</option>");
@ -233,7 +233,7 @@ export function addPostDropdown($post) {
`<option>Delete ${threadPost}</option>`,
).insertAfter($postInfo)
.on("change", _e => {
handleActions($ddownMenu.val(), postID);
handleActions($ddownMenu.val() as string, postID);
$ddownMenu.val("Actions");
});
if(hasUpload)
@ -247,12 +247,12 @@ export function addPostDropdown($post) {
$(() => {
$(document).on("watchThread", (_e, thread) => {
$(`div#op${thread.id} select.post-actions > option`).each((i, el) => {
$<HTMLOptionElement>(`div#op${thread.id} select.post-actions > option`).each((i, el) => {
if(el.text == "Watch thread")
el.text = "Unwatch thread";
});
}).on("unwatchThread", (_e, threadID) => {
$(`div#op${threadID} select.post-actions > option`).each((i, el) => {
$<HTMLOptionElement>(`div#op${threadID} select.post-actions > option`).each((i, el) => {
if(el.text == "Unwatch thread")
el.text = "Watch thread";
});

View file

@ -1,9 +1,3 @@
/**
* @typedef { import("../types/gochan").BoardThread } BoardThread
* @typedef { import("../types/gochan").ThreadPost } ThreadPost
*/
import $ from "jquery";
import { extname } from "path";
import { formatDateString, formatFileSize } from "../formatting";
@ -11,10 +5,8 @@ import { getThumbFilename } from "../postinfo";
/**
* creates an element from the given post data
* @param {ThreadPost} post
* @param {string} boardDir
*/
export function createPostElement(post, boardDir, elementClass = "inlinepostprev") {
export function createPostElement(post: ThreadPost, boardDir: string, elementClass = "inlinepostprev") {
let $post = $("<div/>")
.prop({
id: `reply${post.no}`,
@ -108,14 +100,8 @@ export function createPostElement(post, boardDir, elementClass = "inlinepostprev
return $post;
}
/**
* @param {JQuery<HTMLElement>} elem
*/
export function shrinkOriginalFilenames(elem) {
if(elem == undefined)
elem = $(document.body);
elem.find("a.file-orig").each((i, el) => {
export function shrinkOriginalFilenames(elem = $(document.body)) {
elem.find<HTMLAnchorElement>("a.file-orig").each((i, el) => {
let ext = extname(el.innerText);
let noExt = el.innerText.slice(0,el.innerText.lastIndexOf("."));
if(noExt.length > 16) {

View file

@ -6,9 +6,9 @@ const emptyFunc = () => {};
/**
* isPostVisible returns true if the post exists and is visible, otherwise false
* @param {number} id the id of the post
* @param id the id of the post
*/
export function isPostVisible(id) {
export function isPostVisible(id: number) {
let $post = $(`div#op${id}.op-post,div#reply${id}.reply`);
if($post.length === 0)
return false;
@ -18,11 +18,11 @@ export function isPostVisible(id) {
/**
* setPostVisibility sets the visibility of the post with the given ID. It returns true if it finds
* a post or thread with the given ID, otherwise false
* @param {number} id the id of the post to be toggled
* @param {boolean} visibility the visibility to be set
* @param id the id of the post to be toggled
* @param visibility the visibility to be set
* @param onComplete called after the visibility is set
*/
export function setPostVisibility(id, visibility, onComplete = emptyFunc) {
export function setPostVisibility(id: number|string, visibility: boolean, onComplete = emptyFunc) {
let $post = $(`div#op${id}.op-post, div#reply${id}.reply`);
if($post.length === 0)
@ -32,7 +32,7 @@ export function setPostVisibility(id, visibility, onComplete = emptyFunc) {
let hiddenStorage = getStorageVal("hiddenposts", "").split(",");
if(visibility) {
$toSet.show(0, onComplete);
$post.find("select.post-actions option").each((e, elem) => {
$post.find<HTMLOptionElement>("select.post-actions option").each((e, elem) => {
elem.text = elem.text.replace("Show", "Hide");
});
$backlink.text(id);
@ -43,11 +43,11 @@ export function setPostVisibility(id, visibility, onComplete = emptyFunc) {
setStorageVal("hiddenposts", newHidden.join(","));
} else {
$toSet.hide(0, onComplete);
$post.find("select.post-actions option").each((e, elem) => {
$post.find<HTMLOptionElement>("select.post-actions option").each((e, elem) => {
elem.text = elem.text.replace("Hide", "Show");
});
$backlink.text(`${id} (hidden)`);
if(hiddenStorage.indexOf(id) == -1) hiddenStorage.push(id);
if(hiddenStorage.indexOf(id as string) == -1) hiddenStorage.push(id as string);
setStorageVal("hiddenposts", hiddenStorage.join(","));
}
@ -57,10 +57,10 @@ export function setPostVisibility(id, visibility, onComplete = emptyFunc) {
/**
* setThreadVisibility sets the visibility of the thread with the given ID, as well as its replies.
* It returns true if it finds a thread with the given ID, otherwise false
* @param {number} id the id of the thread to be hidden
* @param {boolean} visibility the visibility to be set
* @param id the id of the thread to be hidden
* @param visibility the visibility to be set
*/
export function setThreadVisibility(opID, visibility) {
export function setThreadVisibility(opID: number|string, visibility: boolean) {
let $thread = $(`div#op${opID}.op-post`).parent(".thread");
if($thread.length === 0) return false;
return setPostVisibility(opID, visibility, () => {

View file

@ -18,10 +18,7 @@ import { getReplyCooldown, getThreadCooldown } from "../api/cooldowns";
import { getUploadFilename, updateUploadImage } from "./uploaddata";
import { alertLightbox } from "./lightbox";
/**
* @type {JQuery<HTMLElement>}
*/
export let $qr = null;
export let $qr: JQuery<HTMLElement> = null;
let threadCooldown = 0;
let replyCooldown = 0;
@ -37,14 +34,14 @@ const qrTitleBar =
function resetSubmitButtonText() {
if(currentThread().thread < 1) {
if(currentThread().id < 1) {
setSubmitButtonText("New Thread");
} else {
setSubmitButtonText("Reply");
}
}
function setSubmitButtonText(text) {
function setSubmitButtonText(text: string) {
$qr.find("input[type=submit]").attr("value", text);
}
@ -81,7 +78,7 @@ function qrUploadChange() {
function setButtonTimeout(prefix = "", cooldown = 5) {
let currentSeconds = cooldown;
let interval = 0;
let interval: NodeJS.Timer;
const timeoutCB = () => {
if(currentSeconds == 0) {
setSubmitButtonEnabled(true);
@ -206,17 +203,17 @@ export function initQR() {
openQR();
updateUploadImage($qrbuttons.find("input#imagefile"), qrUploadChange);
resetSubmitButtonText();
if(currentThread().thread < 1) {
if(currentThread().id < 1) {
$("form#qrpostform").on("submit", function(_e) {
copyCaptchaResponse($(this));
});
return;
}
$postform.on("submit", function(e) {
let $form = $(this);
let $form = $<HTMLFormElement>(this as HTMLFormElement);
e.preventDefault();
copyCaptchaResponse($form);
let data = new FormData(this);
let data = new FormData(this as HTMLFormElement);
$.ajax({
type: "POST",
@ -231,7 +228,7 @@ export function initQR() {
return;
}
clearQR();
let cooldown = (currentThread().thread > 0)?replyCooldown:threadCooldown;
let cooldown = (currentThread().id > 0)?replyCooldown:threadCooldown;
setButtonTimeout("", cooldown);
updateThread().then(clearQR).then(() => {
let persist = getBooleanStorageVal("persistentqr", false);
@ -247,7 +244,7 @@ export function initQR() {
});
}
function copyCaptchaResponse($copyToForm) {
function copyCaptchaResponse($copyToForm: JQuery<HTMLElement>) {
let $captchaResp = $("textarea[name=h-captcha-response]");
if($captchaResp.length > 0) {
$("<textarea/>").prop({

View file

@ -2,24 +2,28 @@ import $ from "jquery";
import { getBooleanStorageVal } from "../storage";
/**
* @type {JQuery<HTMLElement>}
*/
export let $topbar = null;
export let $topbar:JQuery<HTMLElement> = null;
export let topbarHeight = 32;
interface BeforeAfter {
before?: any;
after?: any;
}
/**
* TopBarButton A button to be added to the right side of the top bar
*/
export class TopBarButton {
title: string;
buttonAction: ()=>any;
button: JQuery<HTMLLinkElement>;
/**
* @param {string} title The text shown on the button
* @param {()=>any} action The function executed when the button is clicked
* @param title The text shown on the button
* @param action The function executed when the button is clicked
*/
constructor(title, action = () => {}, beforeAfter = {}) {
constructor(title: string, action: ()=>any = ()=>{}, beforeAfter:BeforeAfter = {}) {
this.title = title;
this.buttonAction = action;
this.button = $("<a/>").prop({
this.button = $<HTMLLinkElement>("<a/>").prop({
"href": "javascript:;",
"class": "dropdown-button",
"id": title.toLowerCase()

View file

@ -0,0 +1,17 @@
let noop = ()=>{};
export function updateUploadImage($elem: JQuery<HTMLElement>, onLoad = noop) {
if($elem.length == 0) return;
$elem[0].onchange = function() {
let img = new Image();
img.src = URL.createObjectURL((this as any).files[0]);
img.onload = onLoad;
};
}
export function getUploadFilename(): string {
let elem = document.getElementById("imagefile") as HTMLInputElement;
if(elem === null) return "";
if(elem.files === undefined || elem.files.length < 1) return "";
return elem.files[0].name;
}

View file

@ -1,17 +1,16 @@
/**
* Formats the timestamp strings from JSON into a more readable format
* @param {string} dateStr timestamp string, assumed to be in ISO Date-Time format
* @param dateStr timestamp string, assumed to be in ISO Date-Time format
*/
export function formatDateString(dateStr) {
export function formatDateString(dateStr: string) {
let date = new Date(dateStr);
return date.toDateString() + ", " + date.toLocaleTimeString();
}
/**
* Formats the given number of bytes into an easier to read filesize
* @param {number} size
*/
export function formatFileSize(size) {
export function formatFileSize(size: number) {
if(size < 1000) {
return `${size} B`;
} else if(size <= 100000) {

View file

@ -1,8 +1,3 @@
/**
* @typedef { import("../types/gochan").StaffAction } StaffAction
* @typedef { import("../types/gochan").StaffInfo } StaffInfo
*/
import $ from 'jquery';
import { alertLightbox } from "../dom/lightbox";
@ -11,10 +6,7 @@ import "./sections";
import "./filebans";
import { isThreadLocked } from '../api/management';
/**
* @type {StaffInfo}
*/
const notAStaff = {
const notAStaff: StaffInfo = {
ID: 0,
Username: "",
Rank: 0
@ -22,29 +14,22 @@ const notAStaff = {
const reportsTextRE = /^Reports( \(\d+\))?/;
/**
* @type StaffAction[]
*/
export let staffActions = [];
export let staffActions: StaffAction[] = [];
export let staffInfo = notAStaff;
let loginChecked = false;
/**
* @type {JQuery<HTMLElement>}
* The menu shown when the Staff button on the top bar is clicked
*/
let $staffMenu = null;
/**
* @type {TopBarButton}
* A button that opens $staffMenu
*/
let $staffBtn = null;
let $staffMenu: JQuery<HTMLElement> = null;
/**
* @param {HTMLElement} dropdown
* @param {string} item
* A button that opens $staffMenu
*/
function dropdownHasItem(dropdown, item) {
let $staffBtn: TopBarButton = null;
function dropdownHasItem(dropdown: any, item: string) {
return [...dropdown.children].filter(v => v.text === item).length > 0;
}
@ -80,8 +65,19 @@ function setupManagementEvents() {
});
}
export function banFile(banType, filename, checksum, staffNote = "") {
let xhrFields = {
interface BanFileJSON {
bantype: string;
board: string;
json: number;
staffnote: string;
filename?: string;
dofilenameban?: string;
checksum?: string;
dochecksumban?: string;
}
export function banFile(banType: string, filename: string, checksum: string, staffNote = "") {
const xhrFields: BanFileJSON = {
bantype: banType,
board: "",
staffnote: staffNote,
@ -150,7 +146,7 @@ export async function getStaffInfo() {
dataType: "json"
}).catch(() => {
return notAStaff;
}).then((info) => {
}).then((info: any) => {
if(info.error)
return notAStaff;
staffInfo = info;
@ -158,7 +154,7 @@ export async function getStaffInfo() {
});
}
export async function getPostInfo(id) {
export async function getPostInfo(id: number) {
return $.ajax({
method: "GET",
url: `${webroot}manage/postinfo`,
@ -189,26 +185,25 @@ export function banSelectedPost() {
let postID = 0;
for(let i = 0; i < checks.length; i++) {
if(checks[i].id.indexOf("check") === 0) {
postID = checks[i].id.replace("check", "");
postID = Number.parseInt(checks[i].id.replace("check", ""));
break;
}
}
window.location = `${webroot}manage/bans?dir=${boardDir}&postid=${postID}`;
window.location.pathname = `${webroot}manage/bans?dir=${boardDir}&postid=${postID}`;
}
/**
* A helper function for creating a menu item
* @param {StaffAction} action
*/
function menuItem(action, isCategory = false) {
return isCategory ? $("<div/>").append($("<b/>").text(action)) : $("<div/>").append(
function menuItem(action: StaffAction|string, isCategory = false) {
return isCategory ? $("<div/>").append($("<b/>").text(action as string)) : $("<div/>").append(
$("<a/>").prop({
href: `${webroot}manage/${action.id}`
}).text(action.title)
href: `${webroot}manage/${(action as StaffAction).id}`
}).text((action as StaffAction).title)
);
}
function getAction(id) {
function getAction(id: string) {
for(const action of staffActions) {
if(action.id == id) {
return action;
@ -216,11 +211,7 @@ function getAction(id) {
}
}
/**
* @param {StaffAction} action
* @param {number} perms
*/
function filterAction(action, perms) {
function filterAction(action: StaffAction, perms: number) {
return action.title != "Logout"
&& action.title != "Dashboard"
&& action.jsonOutput < 2
@ -230,7 +221,7 @@ function filterAction(action, perms) {
/**
* Creates a list of staff actions accessible to the user if they are logged in.
* It is shown when the user clicks the Staff button
* @param {StaffInfo} staff an object representing the staff's username and rank
* @param staff an object representing the staff's username and rank
*/
export function createStaffMenu(staff = staffInfo) {
let rank = staff.Rank;
@ -278,7 +269,7 @@ function createStaffButton() {
});
}
function updateReports(reports) {
function updateReports(reports: any[]) {
// append " (#)" to the Reports link, replacing # with the number of reports
$staffMenu.find("a").each((e, elem) => {
if(elem.text.search(reportsTextRE) != 0) return;

View file

@ -7,12 +7,9 @@ import "jquery-ui/ui/data";
import "jquery-ui/ui/widgets/sortable";
import { alertLightbox } from "../dom/lightbox";
/**
* @type {JQuery<HTMLTableElement>}
*/
let $sectionsTable = null;
let $sectionsTable: JQuery<HTMLTableElement> = null;
let changesButtonAdded = false;
let initialOrders = [];
let initialOrders: string[] = [];
function applyOrderChanges() {
let $sections = $sectionsTable.find("tr.sectionrow");

View file

@ -8,7 +8,7 @@ function canNotify() {
&& (typeof Notification !== "undefined");
}
export function notify(title, body, img = noteIcon) {
export function notify(title: string, body: string, img = noteIcon) {
let n = new Notification(title, {
body: body,
image: img,

View file

@ -1,4 +1,5 @@
import $ from "jquery";
import { WatchedThreadJSON } from "./watcher/watcher";
const opRE = /\/res\/(\d+)(p(\d)+)?.html$/;
const threadRE = /^\d+/;
@ -26,15 +27,16 @@ export function getPageThread() {
page: 0
};
if(arr === null) return info;
if(arr.length > 1) info.op = arr[1];
if(arr.length > 3) info.page = arr[3];
if(arr.board != "") info.boardID = $("form#postform input[name=boardid]").val() -1;
if(arr.length > 1) info.op = Number.parseInt(arr[1]);
if(arr.length > 3) info.page = Number.parseInt(arr[3]);
if(info.board != "") info.boardID = Number.parseInt($("form#postform input[name=boardid]").val() as string) -1;
return info;
}
export function currentThread() {
export function currentThread(): WatchedThreadJSON {
// returns the board and thread ID if we are viewing a thread
let thread = {board: currentBoard(), thread: 0};
let thread = {board: currentBoard(), id: 0};
let pathname = location.pathname;
if(typeof webroot == "string" && webroot != "/") {
pathname = pathname.slice(webroot.length);
@ -47,19 +49,18 @@ export function currentThread() {
return thread;
let reArr = threadRE.exec(splits[3]);
if(reArr.length > 0)
thread.thread = reArr[0];
thread.id = Number.parseInt(reArr[0]);
return thread;
}
export function insideOP(elem) {
export function insideOP(elem: any) {
return $(elem).parents("div.op-post").length > 0;
}
/**
* Return the appropriate thumbnail filename for the given upload filename (replacing gif/webm with jpg, etc)
* @param {string} filename
*/
export function getThumbFilename(filename) {
export function getThumbFilename(filename: string) {
let nameParts = /([^.]+)\.([^.]+)$/.exec(filename);
if(nameParts === null) return filename;
let name = nameParts[1] + "t";

View file

@ -1,9 +1,3 @@
/**
* @typedef { import("./types/gochan").BoardThread } BoardThread
* @typedef { import("./types/gochan").ThreadPost } ThreadPost
*/
import $ from "jquery";
import { alertLightbox } from "./dom/lightbox";
@ -16,38 +10,36 @@ import { openQR } from "./dom/qr";
let doClickPreview = false;
let doHoverPreview = false;
let $hoverPreview = null;
let $hoverPreview: JQuery<HTMLElement> = null;
const videoTestRE = /\.(mp4)|(webm)$/;
const imageTestRE = /\.(gif)|(jfif)|(jpe?g)|(png)|(webp)$/;
const postrefRE = /\/([^\s/]+)\/res\/(\d+)\.html(#(\d+))?/;
// data retrieved from /<board>/res/<thread>.json
/** @type {BoardThread} */
let currentThreadJSON = {
let currentThreadJSON: BoardThread = {
posts: []
};
export function getUploadPostID(upload, container) {
export function getUploadPostID(upload: any, container: any) {
// if container, upload is div.upload-container
// otherwise it's img or video
let jqu = container? $(upload) : $(upload).parent();
return insideOP(jqu) ? jqu.siblings().eq(4).text() : jqu.siblings().eq(3).text();
}
export function updateThreadJSON() {
export async function updateThreadJSON() {
let thread = currentThread();
if(thread.thread === 0) return; // not in a thread
return getThreadJSON(thread.thread, thread.board).then((json) => {
if(!(json.posts instanceof Array) || json.posts.length === 0)
return;
currentThreadJSON = json;
});
if(thread.id === 0) return; // not in a thread
const json = await getThreadJSON(thread.id, thread.board);
if (!(json.posts instanceof Array) || json.posts.length === 0)
return;
currentThreadJSON = json;
}
function updateThreadHTML() {
let thread = currentThread();
if(thread.thread === 0) return; // not in a thread
if(thread.id === 0) return; // not in a thread
let numAdded = 0;
for(const post of currentThreadJSON.posts) {
let selector = "";
@ -77,7 +69,7 @@ export function updateThread() {
return updateThreadJSON().then(updateThreadHTML);
}
function createPostPreview(e, $post, inline = true) {
function createPostPreview(e: JQuery.MouseEventBase, $post: JQuery<HTMLElement>, inline = true) {
let $preview = $post.clone();
if(inline) $preview = addPostDropdown($post.clone());
$preview
@ -91,7 +83,7 @@ function createPostPreview(e, $post, inline = true) {
return $preview;
}
function previewMoveHandler(e) {
function previewMoveHandler(e: JQuery.Event) {
if($hoverPreview === null) return;
$hoverPreview.css({position: "absolute"}).offset({
top: e.pageY + 8,
@ -99,7 +91,7 @@ function previewMoveHandler(e) {
});
}
function expandPost(e) {
function expandPost(e: JQuery.MouseEventBase) {
e.preventDefault();
if($hoverPreview !== null) $hoverPreview.remove();
let $next = $(e.target).next();
@ -134,7 +126,7 @@ function expandPost(e) {
}
}
export function initPostPreviews($post = null) {
export function initPostPreviews($post: JQuery<HTMLElement> = null) {
if(getPageThread().board == "" && $post === null) return;
doClickPreview = getBooleanStorageVal("enablepostclick", true);
doHoverPreview = getBooleanStorageVal("enableposthover", false);
@ -161,9 +153,9 @@ export function initPostPreviews($post = null) {
/**
* Sets thumbnails to expand when clicked. If a parent is provided, prepareThumbnails will only
* be applied to that parent
* @param {JQuery<HTMLElement>} $post the post (if set) to prepare the thumbnails for
* @param $post the post (if set) to prepare the thumbnails for
*/
export function prepareThumbnails($parent = null) {
export function prepareThumbnails($parent: JQuery<HTMLElement> = null) {
const $container = $parent === null ? $("a.upload-container") : $parent.find("a");
$container.on("click", function(e) {
const $a = $(this);
@ -221,15 +213,15 @@ function selectedText() {
return window.getSelection().toString();
}
export function quote(no) {
export function quote(no: number) {
if(getBooleanStorageVal("useqr", true)) {
openQR();
}
let msgboxID = "postmsg";
let msgbox = document.getElementById("qr" + msgboxID);
let msgbox = document.getElementById("qr" + msgboxID) as HTMLInputElement;
if(msgbox === null)
msgbox = document.getElementById(msgboxID);
msgbox = document.getElementById(msgboxID) as HTMLInputElement;
let selected = selectedText();
let lines = selected.split("\n");

View file

@ -7,54 +7,55 @@ import { initPostPreviews } from "./postutil";
import { closeQR, initQR } from "./dom/qr";
import { initWatcher } from "./watcher/watcher";
/**
* @type {TopBarButton}
*/
let $settingsButton = null;
/**
* @type {Map<string,Setting>}
*/
const settings = new Map();
let $settingsButton: TopBarButton = null;
const settings: Map<string, Setting<boolean|number|string,HTMLElement>> = new Map();
class Setting {
type ElementValue = string|number|string[];
class Setting<T = any, E extends HTMLElement = HTMLElement> {
key: string;
title: string;
defaultVal: T;
onSave: () => any;
element: JQuery<E>
/**
* @param {string} key The name of the setting
* @param {string} title text that gets shown in the Settings lightbox
* @param {string} defaultVal the setting's default value
* @param {string} onSave function that gets called when you save the settings
* @param key The name of the setting
* @param title text that gets shown in the Settings lightbox
* @param defaultVal the setting's default value
* @param onSave function that gets called when you save the settings
*/
constructor(key, title, defaultVal = "", onSave = () => {}) {
constructor(key: string, title: string, defaultVal:T, onSave = () => {}) {
this.key = key;
this.title = title;
this.defaultVal = defaultVal;
this.onSave = onSave;
this.element = null;
}
getElementValue() {
if(this.element === null) return "";
return this.element.val();
getElementValue(): T {
if(this.element === null) return this.defaultVal;
return this.element.val() as T;
}
setElementValue(newVal) {
setElementValue(newVal: T) {
if(this.element === null) return;
this.element.val(newVal);
this.element.val(newVal as ElementValue);
}
getStorageValue() {
return getStorageVal(this.key, this.defaultVal);
getStorageValue(): T {
return getStorageVal(this.key, this.defaultVal.toString()) as T;
}
setStorageValue(newVal) {
setStorageValue(newVal: T) {
setStorageVal(this.key, newVal);
}
createElement(selector = "<input/>", props = {}) {
return $(selector).prop(props).prop({
return $<E>(selector).prop(props).prop({
id: this.key,
name: this.key
});
}
}
class TextSetting extends Setting {
constructor(key, title, defaultVal = "", onSave = () => {}) {
class TextSetting extends Setting<string, HTMLTextAreaElement> {
constructor(key: string, title: string, defaultVal = "", onSave = () => {}) {
super(key, title, defaultVal, onSave);
this.element = this.createElement("<textarea/>");
this.element.text(defaultVal);
@ -68,19 +69,20 @@ class TextSetting extends Setting {
}
}
class DropdownSetting extends Setting {
constructor(key, title, options = [], defaultVal = "", onSave = () => {}) {
class DropdownSetting<T> extends Setting<ElementValue, HTMLSelectElement> {
constructor(key: string, title: string, options:any[] = [], defaultVal: ElementValue, onSave = () => {}) {
super(key, title, defaultVal, onSave);
this.element = this.createElement("<select/>");
for(const option of options) {
$("<option/>").val(option.val).text(option.text).appendTo(this.element);
let s: HTMLSelectElement
$<HTMLSelectElement>("<option/>").val(option.val).text(option.text).appendTo(this.element);
}
this.element.val(this.getStorageValue());
}
}
class BooleanSetting extends Setting {
constructor(key, title, defaultVal = false, onSave = () => {}) {
class BooleanSetting extends Setting<boolean, HTMLInputElement> {
constructor(key: string, title: string, defaultVal = false, onSave = () => {}) {
super(key, title, defaultVal, onSave);
this.element = this.createElement("<input/>", {
type: "checkbox",
@ -90,19 +92,24 @@ class BooleanSetting extends Setting {
getElementValue() {
return this.element.prop("checked");
}
setElementValue(newVal) {
this.element.prop({checked: newVal});
setElementValue(newVal: boolean) {
this.element.prop({checked: newVal?"on":"off"});
}
getStorageValue() {
let val = super.getStorageValue();
return val == true || val == "true";
return val == true;
}
}
class NumberSetting extends Setting {
constructor(key, title, defaultVal = 0, minMax = {min: null, max: null}, onSave = () => {}) {
interface MinMax {
type?: string;
min?: number;
max?: number;
}
class NumberSetting extends Setting<number, HTMLInputElement> {
constructor(key: string, title: string, defaultVal = 0, minMax: MinMax = {min: null, max: null}, onSave = () => {}) {
super(key, title, defaultVal, onSave);
let props = {
let props: MinMax = {
type: "number"
};
if(typeof minMax.min == "number" && !isNaN(minMax.min))
@ -112,7 +119,7 @@ class NumberSetting extends Setting {
this.element = this.createElement("<input />", props).val(this.getStorageValue());
}
getStorageValue() {
let val = Number.parseFloat(super.getStorageValue());
let val = Number.parseFloat(super.getStorageValue() as unknown as string);
if(isNaN(val))
val = this.defaultVal;
return val;
@ -166,10 +173,10 @@ $(() => {
for(const style of styles) {
styleOptions.push({text: style.Name, val: style.Filename});
}
settings.set("style", new DropdownSetting("style", "Style", styleOptions, defaultStyle, function() {
settings.set("style", new DropdownSetting<string>("style", "Style", styleOptions, defaultStyle, function() {
document.getElementById("theme").setAttribute("href",
`${webroot}css/${this.getElementValue()}`);
}));
}) as Setting);
settings.set("pintopbar", new BooleanSetting("pintopbar", "Pin top bar", true, initTopBar));
settings.set("enableposthover", new BooleanSetting("enableposthover", "Preview post on hover", false, initPostPreviews));
settings.set("enablepostclick", new BooleanSetting("enablepostclick", "Preview post on click", true, initPostPreviews));

38
frontend/ts/storage.ts Normal file
View file

@ -0,0 +1,38 @@
import { getCookie, setCookie } from "./cookies";
export function getStorageVal(key: string, defaultVal = "") {
if(localStorage == undefined)
return getCookie(key, defaultVal);
let val = localStorage.getItem(key);
if(val === null)
return defaultVal;
return val;
}
export function getBooleanStorageVal(key: string, defaultVal = false) {
let val = getStorageVal(key, defaultVal?"true":"false");
return val == "true";
}
export function getNumberStorageVal(key: string, defaultVal = 0) {
return Number.parseFloat(getStorageVal(key, defaultVal.toString()));
}
export function getJsonStorageVal<T>(key: string, defaultVal: T) {
let val = defaultVal;
try {
val = JSON.parse(getStorageVal(key, defaultVal as string));
} catch(e) {
val = defaultVal;
}
return val;
}
export function setStorageVal(key: string, val: any, isJSON = false) {
let storeVal = isJSON?JSON.stringify(val):val;
if(localStorage == undefined)
setCookie(key, storeVal);
else
localStorage.setItem(key, storeVal);
}

164
frontend/ts/types/index.d.ts vendored Normal file
View file

@ -0,0 +1,164 @@
import "jquery";
declare global {
interface GochanStyle {
Name: string;
Filename: string;
}
// stored in /js/consts.json
var styles: GochanStyle[];
var defaultStyle: string;
var serverTZ: number;
/**
* Defaults to "/"
*/
var webroot: string;
interface Window {
$: JQueryStatic;
jQuery: JQueryStatic;
styles: GochanStyle[];
defaultStyle: string;
webroot: string;
serverTZ: number;
openQR: () => void;
closeQR: () => void;
toTop: () => void;
toBottom: () => void;
quote: (no: number) => void;
}
// /boards.json
interface BoardsJSON {
boards: BoardJSON[];
}
interface BoardCooldowns {
threads: number;
replies: number
images: number;
}
interface BoardJSON {
pages: number;
board: string;
title: string;
meta_description: string;
max_filesize: number;
max_pages: number;
is_archived: boolean;
bump_limit: number;
image_limit: number;
max_comment_chars: number;
ws_board: boolean;
cooldowns: BoardCooldowns
per_page: number;
}
// an array of these are in /boarddir/catalog.json
interface CatalogBoard {
page: number;
threads: CatalogThread[];
}
interface CatalogThread {
replies: number;
images: number;
omitted_posts: number;
omitted_images: number;
sticky: number;
locked: number;
}
// /boarddir/res/#.json
interface BoardThread {
posts: ThreadPost[];
}
interface ThreadPost {
no: number;
resto: number;
name: string;
trip: string;
email: string;
sub: string;
com: string;
tim: string;
filename: string;
md5: string;
extension: string;
fsize: number;
w: number;
h: number;
tn_w: number;
tn_h: number;
capcode: string;
time: string;
last_modified: string;
}
/**
* An object representing a staff member retreived by requesting /manage/staffinfo
*/
interface StaffInfo {
/**
* The staff member's ID in the database
*/
ID: number;
/**
* The staff member's username
*/
Username: string;
/**
* The staff member's rank.
* 0 = not logged in.
* 1 = janitor.
* 2 = moderator.
* 3 = administrator.
*/
Rank: number;
}
/**
* An object representing a management action available to the current staff member
*/
interface StaffAction {
/**
* The GET key used when requesting /manage/<id>
*/
id?:string;
/**
* The title of the action, to be shown in the staff menu
*/
title: string;
/**
* The permission level required to access the action.
* 0 = accessible by anyone.
* 1 = user needs to be a janitor or higher.
* 2 = user needs to be a moderator or higher.
* 3 = user needs to be an administrator.
*/
perms: number;
/**
* The setting for how the request output is handled.
* 0 = never JSON.
* 1 = sometimes JSON depending on whether the `json` GET key is set to 1.
* 2 = always JSON.
*/
jsonOutput: number;
}
/**
* The result of requesting /manage/actions
*/
var staffActions: StaffAction[];
/**
* The menu shown when the Staff button on the top bar is clicked
*/
let $staffMenu: JQuery<HTMLElement>;
// used for testing
var simpleHTML: string;
}

View file

@ -1,9 +1,10 @@
import jquery from "jquery";
export default (window.$ = window.jQuery = jquery);
// overwrite jQuery's deferred exception hook, because otherwise the sourcemap
// is useless if AJAX is involved
jquery.Deferred.exceptionHook = function(err) {
jquery.Deferred.exceptionHook = function(err: any) {
// throw err;
return err;
};

View file

@ -3,15 +3,15 @@ import $ from "jquery";
import { $topbar, TopBarButton } from "../dom/topbar";
import { currentThread } from "../postinfo";
import { getJsonStorageVal } from "../storage";
import { unwatchThread } from "./watcher";
import { WatchedThreadJSON, WatchedThreadsListJSON, unwatchThread } from "./watcher";
import { downArrow } from "../vars";
let watcherBtn = null;
/** @type {JQuery<HTMLElement>} */
let $watcherMenu = null;
let watcherBtn:TopBarButton = null;
let $watcherMenu: JQuery<HTMLElement> = null;
let numUpdatedThreads = 0; // incremented for each watched thread with new posts, added to the watcher button
function addThreadToMenu(thread) {
function addThreadToMenu(thread: WatchedThreadJSON) {
if($watcherMenu.find(`div#thread${thread.id}.watcher-item`).length > 0) {
// thread is already in menu, check for updates to it
updateThreadInWatcherMenu(thread);
@ -56,13 +56,13 @@ function addThreadToMenu(thread) {
$watcherMenu.find("i#no-threads").remove();
}
function removeThreadFromMenu(threadID) {
function removeThreadFromMenu(threadID: number) {
$watcherMenu.find(`div#thread${threadID}`).remove();
if($watcherMenu.find("div.watcher-item").length == 0)
$watcherMenu.append(`<i id="no-threads">no watched threads</i>`);
}
function updateThreadInWatcherMenu(thread) {
function updateThreadInWatcherMenu(thread: WatchedThreadJSON) {
let currentPage = currentThread();
let $item = $watcherMenu.find(`div#thread${thread.op}`);
@ -72,13 +72,13 @@ function updateThreadInWatcherMenu(thread) {
id: "reply-counter"
}).insertBefore($item.find(`a#unwatch${thread.op}`));
if(currentPage.board == thread.board && currentPage.thread == thread.op) {
if(currentPage.board == thread.board && currentPage.id == thread.id) {
// we're currently in the thread
$replyCounter.text(` (Replies: ${thread.newNumPosts - 1}) `);
} else {
// we aren't currently in the thread, show a link to the first new thread
$replyCounter.append(
"(Replies: ", thread.newNumPosts - 1,", ",
"(Replies: ", (thread.newNumPosts - 1).toString(),", ",
$("<a/>").prop({
id: "newposts",
href: `${webroot}${thread.board}/res/${thread.op}.html#${thread.newPosts[0].no}`
@ -117,7 +117,7 @@ $(() => {
.on("beginNewPostsCheck", () => {
numUpdatedThreads = 0;
});
let watched = getJsonStorageVal("watched", {});
let watched = getJsonStorageVal<WatchedThreadsListJSON>("watched", {});
let boards = Object.keys(watched);
for(const board of boards) {
for(const thread of watched[board]) {

View file

@ -10,7 +10,7 @@ const subjectCuttoff = 24;
let watcherInterval = -1;
export function updateWatchedThreads() {
let watched = getJsonStorageVal("watched", {});
let watched = getJsonStorageVal<any>("watched", {});
let boards = Object.keys(watched);
let currentPage = currentThread();
for(const board of boards) {
@ -26,7 +26,7 @@ export function updateWatchedThreads() {
getThreadJSON(thread.id, board).then(data => {
if(data.posts.length > thread.posts) {
// watched thread has new posts, trigger a menu update
if(currentPage.board == board && currentPage.thread == thread.id) {
if(currentPage.board == board && currentPage.id == thread.id) {
// we're currently in the thread, update the cookie
watched[board][t].posts = data.posts.length;
watched[board][t].latest = data.posts[data.posts.length - 1].no;
@ -49,8 +49,25 @@ export function updateWatchedThreads() {
}
}
export function isThreadWatched(threadID, board) {
let watched = getJsonStorageVal("watched", {});
export interface WatchedThreadsListJSON {
[board: string]: WatchedThreadJSON[]
}
export interface WatchedThreadJSON {
id: number;
board?: string;
posts?: number;
op?: string;
latest?: string;
subject?: string;
newNumPosts?: number;
err?: string;
newPosts?: any[];
}
export function isThreadWatched(threadID: number, board: string) {
let watched = getJsonStorageVal<WatchedThreadsListJSON>("watched", {});
let threads = watched[board];
if(threads == undefined) return false;
for(const thread of threads) {
@ -59,13 +76,11 @@ export function isThreadWatched(threadID, board) {
return false;
}
/**
* @param {number|string} threadID
* @param {string} board
*/
export function watchThread(threadID, board) {
let watched = getJsonStorageVal("watched", {});
threadID = parseInt(threadID);
export function watchThread(threadID: string|number, board: string) {
let watched = getJsonStorageVal<WatchedThreadsListJSON>("watched", {});
if(typeof threadID == "string") {
threadID = parseInt(threadID);
}
if(!(watched[board] instanceof Array))
watched[board] = [];
@ -78,8 +93,8 @@ export function watchThread(threadID, board) {
}
getThreadJSON(threadID, board).then(data => {
const op = data.posts[0];
let threadObj = {
id: threadID,
let threadObj: WatchedThreadJSON = {
id: threadID as number,
board: board,
posts: data.posts.length,
op: op.name,
@ -98,14 +113,14 @@ export function watchThread(threadID, board) {
});
}
export function unwatchThread(threadID, board) {
let watched = getJsonStorageVal("watched", {});
export function unwatchThread(threadID: number, board: string) {
let watched = getJsonStorageVal<WatchedThreadsListJSON>("watched", {});
if(!(watched[board] instanceof Array))
return;
for(const i in watched[board]) {
if(watched[board][i].id == threadID) {
console.log(threadID);
watched[board].splice(i, 1);
watched[board].splice(i as any, 1);
setStorageVal("watched", watched, true);
$(document).trigger("unwatchThread", threadID);
return;
@ -119,7 +134,9 @@ export function stopThreadWatcher() {
export function resetThreadWatcherInterval() {
stopThreadWatcher();
watcherInterval = setInterval(updateWatchedThreads, getNumberStorageVal("watcherseconds", 10) * 1000);
(watcherInterval as unknown as NodeJS.Timer) = setInterval(
updateWatchedThreads,
getNumberStorageVal("watcherseconds", 10) * 1000);
}
export function initWatcher() {

13
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"outDir": "../js/",
"sourceMap": true,
"noImplicitAny": true,
"module": "es6",
"target": "es6",
"allowJs": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true
}
}

24
frontend/webpack.config.js Executable file
View file

@ -0,0 +1,24 @@
const path = require('path');
module.exports = {
entry: './ts/gochan.ts',
module: {
rules: [{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
}],
},
resolve: {
extensions: ['.ts', '.js'],
"fallback": {
"path": require.resolve("path-browserify")
}
},
output: {
filename: "gochan.js",
path: path.resolve(__dirname, '../html/js/'),
},
devtool: "source-map",
mode: "production"
};