diff --git a/html_generator.php b/html_generator.php index 2e492b7..a18a834 100644 --- a/html_generator.php +++ b/html_generator.php @@ -10,7 +10,7 @@ " " . PHP_EOL . " " . PHP_EOL . " " . PHP_EOL . - " " . PHP_EOL . + " " . PHP_EOL . " " . $title . "" . PHP_EOL . " " . PHP_EOL . " " . PHP_EOL; diff --git a/output/js/constants.js b/output/js/constants.js new file mode 100644 index 0000000..83ac3cb --- /dev/null +++ b/output/js/constants.js @@ -0,0 +1,58 @@ +// This file contains definitions which help to reduce the amount +// of redunant values in the main file, especially those that could +// change in the foreseeable future. + +export const dom = { + tbl_communities: () => document.getElementById("tbl_communities"), + td_last_checked: () => document.getElementById("td_last_checked"), + qr_modal: (communityID) => document.getElementById(`modal_${communityID}`), + join_urls: () => document.getElementsByClassName("td_join_url"), + td_summary: () => document.getElementById("td_summary"), + snackbar: () => document.getElementById("copy-snackbar") +} + +export const COLUMN = { + IDENTIFIER: 0, LANGUAGE: 1, NAME: 2, + DESCRIPTION: 3, USERS: 4, PREVIEW: 5, + QR_CODE: 6, JOIN_URL: 7 +}; + +// Reverse enum. +// Takes original key-value pairs, flips them, and casefolds the new values. +// Should correspond to #th_{} and .td_{} elements in communities table. +export const COLUMN_LITERAL = Object.fromEntries( + Object.entries(COLUMN).map(([name, id]) => [id, name.toLowerCase()]) +); + +export const COMPARISON = { + GREATER: 1, EQUAL: 0, SMALLER: -1 +}; + +export const ATTRIBUTES = { + SORTING: { + ACTIVE: 'data-sort', + ASCENDING: 'data-sort-asc', + COLUMN: 'data-sorted-by', + COLUMN_LITERAL: 'sorted-by' + } +}; + +export function columnAscendingByDefault(column) { + return column != COLUMN.USERS; +} + +export function columnIsSortable(column) { return column != COLUMN.QR_CODE; } + +export function columnNeedsCasefold(column) { + return [ + COLUMN.IDENTIFIER, + COLUMN.NAME, + COLUMN.DESCRIPTION + ].includes(column); +} + +export function columnIsNumeric(column) { + return [ + COLUMN.USERS + ].includes(column); +} diff --git a/output/main.js b/output/main.js new file mode 100644 index 0000000..c21a72d --- /dev/null +++ b/output/main.js @@ -0,0 +1,282 @@ +// Hello reader! +// This project can be found at: +// https://lokilocker.com/someguy/sessioncommunities.online + +/** + * This JavaScript file uses the JSDoc commenting style. + * Learn more: https://jsdoc.app/ + */ + + // Nudge TypeScript plugins to type-check using JSDoc comments. + // @ts-check + +// Early prevention for bugs introduced by lazy coding. +'use strict'; + +// Import magic numbers and data +import { + dom, COLUMN, COLUMN_LITERAL, COMPARISON, ATTRIBUTES, + columnAscendingByDefault, columnIsSortable, columnNeedsCasefold, + columnIsNumeric +} from './js/constants.js'; + +// Hidden communities for transparency. +const filteredCommunities = { + tests: [ + "2e9345+c7fb", // TestRoom + "762ba9+c7fb", // TesterRoom + "b4d829+c7fb", // Test + "e5853a+c7fb", // testtest + "fishing+8e2e", // Example group from PySOGS documentation + "test+118d", // Testing 1, 2, 3 + "test+13f6", // Testing room + "test+c01b", // Testing room + "test+fe93", // 测试(Test) + "xyz+efca", // XYZ Room + ], + + offensive: [ + "60fa60+c7fb", // "N-word" Community + "ab1a4d+c7fb", // zUnsensored Group (CSAM) + "gore+e5e0" // gore + ], + + // These communities should be checked regularly + // in case they update their PySOGS version + legacy: [ + "Ukraine+02bd" // https://reccacon.com/view/room/Ukraine + ] +}; + +// This can be achieved with `text-overflow: ellipsis` instead +// and generated entirely server-side. +const transformJoinURL = (join_link) => + `${join_link.substring(0, 31)}... + + `.trim(); + +function onLoad(timestamp) { + setLastChecked(timestamp); + hideBadCommunities(); + sortTable(COLUMN.NAME); + createJoinLinkButtons(); + markSortableColumns(); +} + +function displayQRModal(communityID) { + dom.qr_modal(communityID).style.display = "block"; +} + +function hideQRModal(communityID) { + dom.qr_modal(communityID).style.display = "none"; +} + +function createJoinLinkButtons() { + const join_URLs = dom.join_urls(); + Array.from(join_URLs).forEach((td_url) => { + const a_href = td_url.querySelector('a'); // get first (only) element + const join_link = a_href.getAttribute("href"); // get link + td_url.innerHTML = transformJoinURL(join_link); // add interactive content + }); +} + +function hideBadCommunities() { + let numberOfHiddenCommunities = 0; + + for (const category of ['tests', 'offensive', 'legacy']) { + filteredCommunities[category].forEach(hideElementByID); + numberOfHiddenCommunities += filteredCommunities[category].length; + } + + // Not ideal. Separate element should be allocated for content. + const summary = dom.td_summary(); + summary.innerText += ` (${numberOfHiddenCommunities} hidden)`; +} + +function hideElementByID(id) { + document.getElementById(id)?.remove(); +} + +/** + * Copies text to clipboard and shows an informative toast. + * @param {string} text - Text to copy to clipboard. + */ +function copyToClipboard(text) { + navigator.clipboard.writeText(text); + + // Find snackbar element + const snackbar = dom.snackbar(); + + snackbar.classList.add('show') + + // After 3 seconds, hide the snackbar. + setTimeout(() => snackbar.classList.remove('show'), 3000); +} + +/** + * Sets the "last checked indicator" based on a timestamp. + * @param {number} last_checked - Timestamp of last community list update. + */ +function setLastChecked(last_checked) { + const seconds_now = Math.floor(Date.now() / 1000); // timestamp in seconds + const time_passed_in_seconds = seconds_now - last_checked; + const time_passed_in_minutes = + Math.floor(time_passed_in_seconds / 60); // time in minutes, rounded down + const timestamp_element = dom.td_last_checked(); + timestamp_element.innerText = + `Last checked ${time_passed_in_minutes} minutes ago.`; +} + +/** + * Function comparing two elements. + * + * @callback comparer + * @param {*} fst - First value to compare. + * @param {*} snd - Second value to compare. + * @returns 1 if fst is to come first, -1 if snd is, 0 otherwise. + */ + +/** + * Performs a comparison on two arbitrary values. Treats "" as Infinity. + * @param {*} fst - First value to compare. + * @param {*} snd - Second value to compare. + * @returns 1 if fst > snd, -1 if fst < snd, 0 otherwise. + */ +function compareAscending(fst, snd) { + // Triple equals to avoid "" == 0. + if (fst === "") return COMPARISON.GREATER; + if (snd === "") return COMPARISON.SMALLER; + return (fst > snd) - (fst < snd); +} + +/** + * Performs a comparison on two arbitrary values. Treats "" as Infinity. + * @param {*} fst - First value to compare. + * @param {*} snd - Second value to compare. + * @returns -1 if fst > snd, 1 if fst < snd, 0 otherwise. + */ +function compareDescending(fst, snd) { + return -compareAscending(fst, snd); +} + +/** + * Produces a comparer dependent on a derived property of the compared elements. + * @param {comparer} comparer - Callback comparing derived properties. + * @param {Function} getProp - Callback to retrieve derived property. + * @returns {comparer} Function comparing elements based on derived property. + */ +function compareProp(comparer, getProp) { + return (fst, snd) => comparer(getProp(fst), getProp(snd)); +} + +/** + * Produces a comparer for table rows based on given sorting parameters. + * @param {number} column - Numeric ID of column to be sorted. + * @param {boolean} ascending - Sort ascending if true, descending otherwise. + * @returns {comparer} + */ +function makeRowComparer(column, ascending) { + if (!columnIsSortable(column)) { + throw new Error(`Column ${column} is not sortable`); + } + + // Callback to obtain sortable content from cell text. + let contentToSortable = (text) => text.trim(); + + if (columnNeedsCasefold(column)) { + // Make certain columns sort regardless of casing. + contentToSortable = (text) => text.toLowerCase().trim(); + } + else if (columnIsNumeric(column)) { + // Make certain columns sort on parsed numeric value instead of text. + contentToSortable = (text) => parseInt(text); + } + + // Construct comparer using derived property to determine sort order. + const rowComparer = compareProp( + ascending ? compareAscending : compareDescending, + row => contentToSortable(row.children[column].innerText) + ); + + return rowComparer; +} + +/** + * @typedef {Object} SortState + * @property {number} column - Column ID being sorted. + * @property {boolean} ascending - Whether the column is sorted ascending. + */ + +/** + * Retrieves a table's sort settings from the DOM. + * @param {HTMLElement} table - Table of communities being sorted. + * @returns {?SortState} + */ +function getSortState(table) { + if (!table.hasAttribute(ATTRIBUTES.SORTING.ACTIVE)) return null; + const directionState = table.getAttribute(ATTRIBUTES.SORTING.ASCENDING); + // This is not pretty, but the least annoying. + // Checking for classes would be more idiomatic. + const ascending = directionState.toString() === "true"; + const columnState = table.getAttribute(ATTRIBUTES.SORTING.COLUMN); + const column = parseInt(columnState); + if (!Number.isInteger(column)) { + throw new Error(`Invalid column number read from table: ${columnState}`) + } + return { ascending, column }; +} + +/** + * Sets a table's sort settings using the DOM. + * @param {HTMLElement} table - Table of communities being sorted. + * @param {SortState} sortState - Sorting settings being applied. + */ +function setSortState(table, { ascending, column }) { + if (!table.hasAttribute(ATTRIBUTES.SORTING.ACTIVE)) { + table.setAttribute(ATTRIBUTES.SORTING.ACTIVE, true); + } + table.setAttribute(ATTRIBUTES.SORTING.ASCENDING, ascending); + table.setAttribute(ATTRIBUTES.SORTING.COLUMN, column); + // This can be used to style column headers in a consistent way, i.e. + // #tbl_communities[data-sort-asc=true][sorted-by=name]::after #th_name, ... + table.setAttribute(ATTRIBUTES.SORTING.COLUMN_LITERAL, COLUMN_LITERAL[column]); +} + +// This is best done in JS, as it would require