arca.live enhancements

Adds quality of life improvements for browinsg and downloading Genshin Impact mods from arca.live

// ==UserScript==
// @author      jvlflame
// @name        arca.live enhancements
// @version     0.0.8
// @license     MIT
// @include     https://arca.live/*
// @include     https://kioskloud.io/e/*
// @include     https://kiosk.ac/c/*
// @include     https://nahida.live/mods/*

// @description Adds quality of life improvements for browinsg and downloading Genshin Impact mods from arca.live

// @namespace https://greasyfork.org/users/63118
// ==/UserScript==


let GLOBAL_STYLES = `
    .vrow.column.head {
        height: initial !important;
    }

    .vrow.column {
        height: 135px !important;
    }

    .vrow.column.visited .vrow-inner {
        opacity: 0.4;
    }

    .vrow.column.visited .vrow-preview {
        opacity: 0.4;
    }


    .vrow-inner {
        padding-left: 115px;
    }

    .vrow-btn-group {
         position: absolute;
         bottom: 1rem;
         right: 1rem;
         display: flex;
         gap: 0.5rem;
         opacity: 0.7;
     }

     .vrow-info {
         position: absolute;
         top: 1rem;
         right: 1rem;
         display: flex;
         opacity: 0.7;
     }

    .vrow-preview {
        display: block !important;
        top: 10px !important;
    }

    .notice.column {
        height: 2.4rem !important;
    }

    .body .board-article .article-list .list-table a.vrow:visited {
        color: inherit !important;
        background-color: inherit !important;
    }

    .enh-action-bar {
        display: flex;
        padding: 0.5rem 0;
        gap: 0.5rem;
    }
`;

const CURRENT_URL = window.location.href;
const CURRENT_PAGE = window.location.pathname;
const IS_KIOSKLOUDIO = CURRENT_URL.includes('kioskloud.io');
const IS_KIOSKAC = CURRENT_URL.includes('kiosk.ac');
const IS_ARCALIVE = CURRENT_URL.includes('arca.live');
const IS_NAHIDALIVE = CURRENT_URL.includes('nahida.live');
const IS_POST_PAGE = CURRENT_PAGE.match(/\/b\/.*\/\d+/g);
const IS_LIST_PAGE = !IS_POST_PAGE;
const CATEGORY = CURRENT_PAGE.match(/\/b\/(\w+)/g);
const BASE64_REGEX = /^[-A-Za-z0-9+\/]*={0,3}$/g;
const VISITED_POSTS = getPostsFromLocalStorage(CATEGORY);

const styleSheet = document.createElement("style");
styleSheet.type = "text/css";
styleSheet.innerText = GLOBAL_STYLES;
document.head.appendChild(styleSheet);

function formatISODate(isoDateString) {
    const date = new Date(isoDateString);

    // Options for date formatting
    const options = {
        year: 'numeric', // Full year
        month: 'short', // Short month name (e.g., 'Aug')
        day: 'numeric', // Day of the month
    };

    // Format the date part
    const datePart = date.toLocaleString('en-US', options);

    // Format the time part (24-hour format)
    const timeOptions = {
        hour: '2-digit',  // Hour with leading zero
        minute: '2-digit', // Minute with leading zero
        hour12: false      // Use 24-hour time format
    };
    const timePart = date.toLocaleString('en-US', timeOptions);

    // Combine the date and time parts
    return `${datePart} ${timePart}`;
}


function getPostRows() {
    const table = document.getElementsByClassName("list-table table");

    // Get the list table row elements
    const rows = table[0].querySelectorAll("a.vrow.column");

    const postRows = [];

    for (const row of rows) {
           if (row.classList.contains("notice")) {
               continue;
           };

        postRows.push(row);
    }

    return postRows;
}

function getPost(id, title, unixTimestamp) {
    const dateTimeString = unixTimestamp ? new Date(unixTimestamp * 1000).toISOString() : new Date().toISOString();

    const post = {
        t: title,
        v: dateTimeString
    }

    return {
        id: id,
        post: post
    };
}

function getPostsFromLocalStorage(category) {
    const posts = JSON.parse(localStorage.getItem(`visited-posts-${category}`));
    return posts ? posts : {};
}

function isPostVisited(visitedPosts, id) {
    return visitedPosts[id];
}

function appendPostToLocalStorage(post, category) {
    const existingPosts = getPostsFromLocalStorage(category);
    existingPosts[post.id] = post.post;
    localStorage.setItem(`visited-posts-${category}`, JSON.stringify(existingPosts));
}

function appendPostsToLocalStorage(posts, category) {
    const existingPosts = getPostsFromLocalStorage(category);

    for (const post of posts) {
        existingPosts[post.id] = post.post;
    }

    localStorage.setItem(`visited-posts-${category}`, JSON.stringify(existingPosts));
}

function removePostFromLocalStorage(post, category) {
    const existingPosts = getPostsFromLocalStorage(category);
    delete existingPosts[post.id]
    localStorage.setItem(`visited-posts-${category}`, JSON.stringify(existingPosts));
}

function removePostsFromLocalStorage(posts, category) {
    const existingPosts = getPostsFromLocalStorage(category);

    for (const post of posts) {
        delete existingPosts[post.id]
    }

    localStorage.setItem(`visited-posts-${category}`, JSON.stringify(existingPosts));
}

function migrateRecentArticles() {
    if (!IS_ARCALIVE) {
        return;
    }

    /* recent_articles
        {
           boardName: string;
           slug: string;
           articleId: number;
           title: string;
           regdateAt: number
        }[]
    */

    const isMigrated = localStorage.getItem('migrated-timestamp');

    if (isMigrated) {
        return;
    }

    const nativeVisitedPosts = localStorage.getItem('recent_articles');

    if (!nativeVisitedPosts) {
        return;
    }

    for (const visitedPost of JSON.parse(nativeVisitedPosts)) {
        const post = getPost(visitedPost.articleId, visitedPost.title, visitedPost.regdateAt);
        const category = `/b/${visitedPost.slug}`;
        appendPostToLocalStorage(post, category);
    }

    localStorage.setItem('migrated-timestamp', new Date().toISOString());
}

function getAllPostsOnPage() {
    const rows = getPostRows();
    const posts = [];

    for (const row of rows) {
        const href = row.href;
        const id = href.split(/\/b\/\w+\//)[1].split(/\?/)[0];
        const titleElement = row.querySelectorAll(".title")[0];
        const title = titleElement ? titleElement.outerText : '';

        posts.push(getPost(id, title));
    }

    return posts;
}

function getPostOnPage(id) {
    const rows = getPostRows();
    const posts = [];

    for (const row of rows) {
        const href = row.href;
        const postId = href.split(/\/b\/\w+\//)[1].split(/\?/)[0];
        const titleElement = row.querySelectorAll(".title")[0];
        const title = titleElement ? titleElement.outerText : '';

        if (id === postId) {
            posts.push(getPost(postId, title));
        }
    }

    return posts;
}

function handleMarkPageAsRead() {
    const posts = getAllPostsOnPage();
    appendPostsToLocalStorage(posts, CATEGORY);
}

function handleMarkPageAsUnread() {
    const posts = getAllPostsOnPage();
    removePostsFromLocalStorage(posts, CATEGORY);
}

function handleMarkAsRead(id) {
    const posts = getPostOnPage(id);
    appendPostsToLocalStorage(posts, CATEGORY);
}

function handleMarkAsUnread(id) {
    const posts = getPostOnPage(id);
    removePostsFromLocalStorage(posts, CATEGORY);
}

function createBtnElement(text, handler) {
    const btnElement = document.createElement('button');
    btnElement.classList.add("btn", "btn-sm", "btn-arca");
    btnElement.textContent = text;
    btnElement.onclick = handler;
    return btnElement;
}

function createActionBarElement() {
    const actionBarElement = document.createElement('div');
    actionBarElement.classList.add('enh-action-bar');
    return actionBarElement;
}

function createMarkPageAsReadBtn(parentElement) {
    const btnElement = createBtnElement('Mark page as read', handleMarkPageAsRead);
    parentElement.prepend(btnElement);
}

function createRowInfo(parentElement, posts, rowId) {
    const infoElement = document.createElement('div');
    infoElement.classList.add('vrow-info');
    const postData = posts[rowId];

    if (postData) {
        infoElement.innerHTML += formatISODate(postData.v);
    }
    parentElement.append(infoElement);
    return infoElement;
}

function createRowButtonGroup(parentElement) {
    const buttonGroupElement = document.createElement('div');
    buttonGroupElement.classList.add('vrow-btn-group');
    parentElement.append(buttonGroupElement);
    return buttonGroupElement;
}

function createMarkPageAsUnreadBtn(parentElement) {
    const btnElement = createBtnElement('Mark page as unread', handleMarkPageAsUnread);
    parentElement.prepend(btnElement);
}

function createMarkRowAsReadBtn(parentElement, rowId, rowElement) {
    const btnElement = createBtnElement('Mark as read', (e) => {
        e.preventDefault();
        e.stopPropagation();
        handleMarkAsRead(rowId);
        rowElement.classList.add('visited');

        const infoElement = rowElement.querySelector('.vrow-info');
        if (infoElement) {
            infoElement.innerHTML = formatISODate(new Date().toISOString());
        }
    });
    parentElement.append(btnElement);
    return btnElement;
}


function createMarkRowAsUnreadBtn(parentElement, rowId, rowElement) {
    const btnElement = createBtnElement('Mark as unread', (e) => {
        e.preventDefault();
        e.stopPropagation();
        handleMarkAsUnread(rowId);
        rowElement.classList.remove('visited');

        const infoElement = rowElement.querySelector('.vrow-info');
        if (infoElement) {
            infoElement.innerHTML = ''
        }
    });
    parentElement.append(btnElement);
    return btnElement;
}

function createActionBar() {
    const topActionBarElement = createActionBarElement();
    const bottomActionBarElement = createActionBarElement();
    const listElement = document.querySelector('.article-list');

    listElement.prepend(topActionBarElement);
    listElement.appendChild(bottomActionBarElement);

    for (const parentElement of [topActionBarElement, bottomActionBarElement]) {
        createMarkPageAsUnreadBtn(parentElement);
        createMarkPageAsReadBtn(parentElement);
    }
}

function addArcaRowEnhancements() {
    const rows = getPostRows();
    const posts = getPostsFromLocalStorage(CATEGORY);

    for (const row of rows) {
        const previewElement = row.querySelectorAll('.vrow-preview');
        const hasPreview = Boolean(previewElement[0])

        if (!hasPreview) {
            const dummyPreviewElement = document.createElement('div');
            dummyPreviewElement.classList.add('vrow-preview');
            row.appendChild(dummyPreviewElement);
        }

        const href = row.href;

        // Remove the query string so it's easier to copy article id when archiving
        const hrefWithoutQuery = href.split('?')[0];
        row.setAttribute("href", hrefWithoutQuery);
        row.style.position = "relative";

        const id = href.split(/\/b\/\w+\//)[1].split(/\?/)[0];
        const titleElement = row.querySelectorAll(".title")[0];
        const title = titleElement ? titleElement.outerText : '';

        const isVisited = isPostVisited(VISITED_POSTS, id);

        const rowInfoElement = createRowInfo(row, posts, id);
        const rowButtonGroup = createRowButtonGroup(row);
        const markRowAsReadBtn = createMarkRowAsReadBtn(rowButtonGroup, id, row);
        const markRowAsUnreadBtn = createMarkRowAsUnreadBtn(rowButtonGroup, id, row);


        if (isVisited) {
            row.classList.add("visited");
        } else {
            row.addEventListener("click", (e) => {
                if (e.button === 2) return;
                const post = getPost(id, title);
                appendPostToLocalStorage(post, CATEGORY);
                row.classList.add('visited');
                rowInfoElement.innerHTML = formatISODate(new Date().toISOString());
            });

            row.addEventListener("auxclick", () => {
                const post = getPost(id, title);
                appendPostToLocalStorage(post, CATEGORY);
                row.classList.add('visited');
                rowInfoElement.innerHTML = formatISODate(new Date().toISOString());
            });
        }
    }
}


if (IS_ARCALIVE && IS_LIST_PAGE) {
    createActionBar();
    addArcaRowEnhancements();
}

if (IS_ARCALIVE && IS_POST_PAGE) {
    addArcaRowEnhancements();
    const title = document.querySelectorAll(".title-row .title")[0].outerText;
    const id = CURRENT_PAGE.split(/\/b\/\w+\//)[1].split(/\?/)[0];
    const post = getPost(id, title);

    // Attempt to automatically decode base64 links inside the article content
    const articleContentElement = document.querySelector(".fr-view.article-content");
    const textBlocks = articleContentElement.querySelectorAll("p");

    for (const text of textBlocks) {
        const innerText = text.innerText;
        const isBase64 = innerText.match(BASE64_REGEX);

        if (isBase64) {
            const decoded = atob(innerText);
            const linkElement = document.createElement('a');
            const brElement = document.createElement('br');
            linkElement.setAttribute('href', decoded);
            linkElement.textContent += decoded;
            text.appendChild(brElement);
            text.appendChild(linkElement);
        }
    }


    if (isPostVisited(VISITED_POSTS, id)) {
        return;
    };

    appendPostToLocalStorage(post, CATEGORY);
}

/* nahida.live doesn't use a form for submit, but rather uses a value prop to read the password input. Any changes to the input value does not reflect on the value which prevents us from setting the value programatically
if (IS_NAHIDALIVE) {
    const DEFAULT_PASSWORD = localStorage.getItem('default-password');
    const passwordInputElement = document.querySelector('input[placeholder="Password"]');

    const defaultPasswordInputElement = document.createElement('input');
    defaultPasswordInputElement.type = 'text';
    defaultPasswordInputElement.placeholder = 'Enter default password';
    defaultPasswordInputElement.classList.add('flex', 'h-10', 'w-full', 'rounded-md', 'border', 'border-input', 'bg-background', 'ring-offset-background', 'file:border-0', 'file:bg-transparent', 'file:text-sm', 'file:font-medium', 'placeholder:text-muted-foreground', 'focus-visible:outline-none', 'focus-visible:ring-2', 'focus-visible:ring-ring', 'focus-visible:ring-offset-2', 'disabled:cursor-not-allowed', 'disabled:opacity-50', 'px-2', 'py-1', 'mb-2', 'max-w-[210px]', 'text-base');
    defaultPasswordInputElement.style.position = 'absolute';
    defaultPasswordInputElement.style.height = '50px';
    defaultPasswordInputElement.style.top = '0.375rem';
    defaultPasswordInputElement.style.left = '6.5rem';
    defaultPasswordInputElement.style.zIndex = '500';
    defaultPasswordInputElement.value = DEFAULT_PASSWORD;

    const bodyElement = document.querySelector('body');
    bodyElement.appendChild(defaultPasswordInputElement);

    defaultPasswordInputElement.addEventListener("input", (e) => {
        localStorage.setItem('default-password', e.currentTarget.value || '');
    })

    if (DEFAULT_PASSWORD) {
        passwordInputElement.value = DEFAULT_PASSWORD;
        const submitBtnElement = document.querySelector('section').querySelector('button')
        submitBtnElement.click();
    }
}
*/

if (IS_KIOSKLOUDIO) {
    const DEFAULT_PASSWORD = localStorage.getItem('default-password');
    const passwordInputElement = document.querySelector('.swal2-input');
    const autoSubmitToggleElement = document.createElement('button');

    const defaultPasswordInputElement = document.createElement('input');
    defaultPasswordInputElement.type = 'text';
    defaultPasswordInputElement.placeholder = 'Enter default password';
    defaultPasswordInputElement.classList.add('swal2-input');
    defaultPasswordInputElement.value = DEFAULT_PASSWORD;
    passwordInputElement.after(defaultPasswordInputElement);

    defaultPasswordInputElement.addEventListener("input", (e) => {
        localStorage.setItem('default-password', e.currentTarget.value || '');
    })

    if (DEFAULT_PASSWORD) {
        passwordInputElement.value = DEFAULT_PASSWORD;
        const submitBtnElement = document.querySelector('.swal2-actions').querySelector('.swal2-confirm');
        submitBtnElement.click();
    }
}


if (IS_KIOSKAC) {
    const DEFAULT_PASSWORD = localStorage.getItem('default-password');
    const passwordInputElement = document.querySelector('input[placeholder="Password"]');

    const defaultPasswordInputElement = document.createElement('input');
    defaultPasswordInputElement.type = 'text';
    defaultPasswordInputElement.placeholder = 'Enter default password';
    defaultPasswordInputElement.classList.add('input', 'shadow-xl', 'flex-grow');
    defaultPasswordInputElement.value = DEFAULT_PASSWORD;
    passwordInputElement.after(defaultPasswordInputElement);

    defaultPasswordInputElement.addEventListener("input", (e) => {
        localStorage.setItem('default-password', e.currentTarget.value || '');
    })

    if (DEFAULT_PASSWORD) {
        passwordInputElement.value = DEFAULT_PASSWORD;
        const submitBtnElement = document.querySelector('.btn.btn-ghost.w-full.mt-2.rounded-md');
        submitBtnElement.click();

        setTimeout(() => {
            const dropdownElement = document.querySelector('.dropdown-menu');
            const defaultDownloadBtn = dropdownElement.querySelector('button');
            defaultDownloadBtn.click();
        }, 1000);
    }
}

migrateRecentArticles();