browndust2.com news viewer (Vue 3 + Tailwind CSS)

Custom news viewer for browndust2.com using Vue 3 and Tailwind CSS

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         browndust2.com news viewer (Vue 3 + Tailwind CSS)
// @namespace    http://tampermonkey.net/
// @version      1.4.0
// @description  Custom news viewer for browndust2.com using Vue 3 and Tailwind CSS
// @author       SouSeiHaku
// @match        https://www.browndust2.com/robots.txt
// @grant        none
// @run-at       document-end
// @license      WTFPL
// ==/UserScript==

/*
 * This script is based on the original work by Rplus:
 * @name browndust2.com news viewer
 * @namespace Violentmonkey Scripts
 * @version 1.2.0
 * @author Rplus
 * @description custom news viewer for sucking browndust2.com
 * @license WTFPL
 *
 * Modified and extended by SouSeiHaku
 */

(function () {
    'use strict';

    function addScript(src) {
        return new Promise((resolve, reject) => {
            const script = document.createElement('script');
            script.src = src;
            script.onload = resolve;
            script.onerror = reject;
            document.head.appendChild(script);
        });
    }

    function addGlobalStyle() {
        const style = document.createElement('style');
        style.textContent = `
            body {
                color:#52525b;
            }

            .content-box * {
                font-size: 1rem !important;
            }

            .content-box [style*="font-size"] {
                font-size: 1rem !important;
            }

            .content-box span[style*="font-size"],
            .content-box p[style*="font-size"],
            .content-box div[style*="font-size"] {
                font-size: 1rem !important;
                color: #52525b;
            }

            .content-box strong {
                color: black;
                font-weight: bold;
                font-size: 20px !important;
            }

            .content-box p {
                margin-bottom:16px;
            }

            details[open] > summary {
                color: black;
            }

        `;
        document.head.appendChild(style);
    }

    Promise.all([
        addScript('https://unpkg.com/vue@3/dist/vue.global.js'),
        addScript('https://cdn.tailwindcss.com')
    ]).then(() => {
        addGlobalStyle();
        initializeApp();
    }).catch(error => {
        console.error('Error loading scripts:', error);
    });

    function initializeApp() {
        if (!window.Vue) return;

        const { createApp, ref, computed, onMounted } = Vue;

        const app = createApp({
            setup() {
                const data = ref([]);
                const newsMap = ref(new Map());
                const searchInput = ref('');
                const showAll = ref(false);
                const language = ref('tw')
                const readNews = ref(new Set());

                const updateReadNews = (id) => {
                    if (readNews.value.has(id)) return;
                    readNews.value.add(id);
                    localStorage.setItem('readNews', JSON.stringify(Array.from(readNews.value)));
                };

                const isNewsRead = computed(() => (id) => readNews.value.has(id));

                const filteredData = computed(() => {
                    const keyword = searchInput.value.trim().toLowerCase();
                    if (!keyword) return data.value;
                    return data.value.filter(item => {
                        const { lowercaseFields } = item;
                        return Object.values(lowercaseFields).some(field => field.includes(keyword));
                    });
                });

                const visibleData = computed(() => {
                    if (showAll.value) return filteredData.value;
                    return filteredData.value.slice(0, 20);
                });

                function formatTime(time) {
                    const _time = time ? new Date(time) : new Date();
                    const currentLang = languageOptions.find(option => option.value === language.value);
                    return _time.toLocaleString(currentLang.dateFormat.locale, currentLang.dateFormat.options);
                }

                function show(id) {
                    const info = newsMap.value.get(parseInt(id))?.attributes;
                    if (!info) return '';
                    const content = (info.content || info.NewContent).replace(/\<img\s/g, '<img loading="lazy" ');
                    const oriLink = `<a class="text-blue-400 underline mt-10" href="https://www.browndust2.com/zh-tw/news/view?id=${id}" target="_bd2news" title="official link">官方連結 ></a>`;
                    const closeButton = `<div class="flex justify-center pt-3"><button class="close-details mt-4 px-3 py-1 bg-slate-600 hover:bg-slate-500 text-white rounded-full text-xs" onclick="closeDetails(${id})">關閉</button></div>`;
                    return content + oriLink + closeButton;
                }

                const closeDetails = (id) => {
                    const detailsElement = document.querySelector(`details[data-detail-id="${id}"]`);
                    if (detailsElement) {
                        detailsElement.open = false;
                    }
                };

                const languageOptions = [
                    {
                        label: '繁體中文',
                        value: 'tw',
                        dateFormat: {
                            locale: 'zh-TW',
                            options: { weekday: 'narrow', year: 'numeric', month: '2-digit', day: '2-digit' }
                        }
                    },
                    {
                        label: '日本語',
                        value: 'jp',
                        dateFormat: {
                            locale: 'ja-JP',
                            options: { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }
                        }
                    },
                    {
                        label: 'English',
                        value: 'en',
                        dateFormat: {
                            locale: 'en-US',
                            options: { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }
                        }
                    },
                    {
                        label: '한국어',
                        value: 'kr',
                        dateFormat: {
                            locale: 'ko-KR',
                            options: { weekday: 'narrow', year: 'numeric', month: '2-digit', day: '2-digit' }
                        }
                    },
                    {
                        label: '简体中文',
                        value: 'cn',
                        dateFormat: {
                            locale: 'zh-CN',
                            options: { weekday: 'narrow', year: 'numeric', month: '2-digit', day: '2-digit' }
                        }
                    },
                ]

                const handleLangChange = () => {
                    searchInput.value = '';
                    load();
                }

                async function load() {
                    const dataUrl = `https://www.browndust2.com/api/newsData_${language.value}.json`;

                    try {
                        const response = await fetch(dataUrl);
                        const json = await response.json();
                        console.log('Data fetched successfully, item count:', json.data.length);

                        // 新增 lowercaseFields用於篩選功能
                        data.value = json.data.reverse().map(item => ({
                            ...item,
                            lowercaseFields: {
                                id: item.id.toString().toLowerCase(),
                                content: (item.attributes.content || '').toLowerCase(),
                                newContent: (item.attributes.NewContent || '').toLowerCase(),
                                tag: (item.attributes.tag || '').toLowerCase(),
                                subject: (item.attributes.subject || '').toLowerCase()
                            }
                        }));

                        // 更新 newsMap,但不需包含 lowercaseFields
                        newsMap.value.clear();
                        data.value.forEach(item => {
                            const { lowercaseFields, ...itemWithoutLowercaseFields } = item;
                            newsMap.value.set(item.id, itemWithoutLowercaseFields);
                        });
                    } catch (error) {
                        console.error('Error fetching or processing data:', error);
                    }
                }

                onMounted(() => {
                    load()
                    window.closeDetails = closeDetails;
                    const storedReadNews = JSON.parse(localStorage.getItem('readNews') || '[]');
                    readNews.value = new Set(storedReadNews);
                });

                return {
                    visibleData,
                    searchInput,
                    showAll,
                    language,
                    languageOptions,
                    formatTime,
                    show,
                    handleLangChange,
                    updateReadNews,
                    isNewsRead,
                };
            }
        });

        // Create a container for the Vue app
        const appContainer = document.createElement('div');
        appContainer.id = 'app';
        document.body.innerHTML = '';
        document.body.appendChild(appContainer);

        // Add the Vue template
        appContainer.innerHTML = `
        <div class=" w-full min-h-[100dvh] relative bg-white">
        <header class="sticky top-0 left-0 h-[60px] border-b border-slate-600 flex justify-between items-center bg-white z-10 px-3">
			<label class="flex gap-1 items-center">
				Filter
				<input v-model="searchInput" type="search" class="py-1 px-2 block w-full rounded text-sm disabled:pointer-events-none bg-white border border-neutral-300 text-black placeholder-neutral-500" tabindex="1">
			</label>
            <div class="flex gap-3 items-center">
                <label class="cursor-pointer flex gap-1 items-center">
                    <select v-model="language" @change="handleLangChange" class="py-1 px-2 block w-full rounded text-sm disabled:pointer-events-none bg-white border border-neutral-300 text-black placeholder-neutral-500" tabindex="1">
                        <option v-for="option in languageOptions" :key="option.value" :value="option.value">
                            {{ option.label }}
                        </option>
                    </select>
                </label>
                <label class="cursor-pointer flex gap-1 items-center">
                    <input v-model="showAll" type="checkbox" class="shrink-0 mt-0.5 bg-white border border-gray-200 rounded text-blue-600 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none">
                    Show all list
                </label>
            </div>
        </header>
            <div class="flex flex-col mx-auto w-full max-w-7xl py-8 px-3 space-y-4">
                <details v-for="item in visibleData" :key="item.id" :data-detail-id="item.id" class="rounded overflow-hidden shadow shadow-black/30">
                    <summary class="pl-4 pr-2 py-2 cursor-pointer transition duration-200"
                     @click="updateReadNews(item.id)"
                     :class="[isNewsRead(item.id) ? 'text-gray-500' : 'text-black','font-bold bg-slate-100 hover:bg-slate-200']">
                        <img :src="'https://www.browndust2.com/img/newsDetail/tag-' + item.attributes.tag + '.png'"
                            :alt="item.attributes.tag" :title="'#' + item.attributes.tag"
                            class="w-10 h-10 inline-block mr-2">
                        <time :datetime="item.attributes.publishedAt" :title="item.attributes.publishedAt">
                            {{ formatTime(item.attributes.publishedAt) }}
                        </time> -
                        {{ item.attributes.subject }}
                    </summary>
                    <div class="bg-white p-6 whitespace-pre-wrap content-box" v-html="show(item.id)"></div>
                </details>
            </div>
        </div>
        `;

        app.mount('#app');
    }
})();