arca.live enhancements

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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();