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

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

目前为 2024-10-18 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name browndust2.com news viewer (Vue 3 + Tailwind CSS)
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.3.0
  5. // @description Custom news viewer for browndust2.com using Vue 3 and Tailwind CSS
  6. // @author SouSeiHaku
  7. // @match https://www.browndust2.com/robots.txt
  8. // @grant none
  9. // @run-at document-end
  10. // @license WTFPL
  11. // ==/UserScript==
  12.  
  13. /*
  14. * This script is based on the original work by Rplus:
  15. * @name browndust2.com news viewer
  16. * @namespace Violentmonkey Scripts
  17. * @version 1.2.0
  18. * @author Rplus
  19. * @description custom news viewer for sucking browndust2.com
  20. * @license WTFPL
  21. *
  22. * Modified and extended by SouSeiHaku
  23. */
  24.  
  25. (function () {
  26. 'use strict';
  27.  
  28. function addScript(src) {
  29. return new Promise((resolve, reject) => {
  30. const script = document.createElement('script');
  31. script.src = src;
  32. script.onload = resolve;
  33. script.onerror = reject;
  34. document.head.appendChild(script);
  35. });
  36. }
  37.  
  38. function addGlobalStyle() {
  39. const style = document.createElement('style');
  40. style.textContent = `
  41. body {
  42. color: white;
  43. }
  44.  
  45. .content-box * {
  46. font-size: 1rem !important;
  47. }
  48.  
  49. .content-box [style*="font-size"] {
  50. font-size: 1rem !important;
  51. }
  52.  
  53. .content-box span[style*="font-size"],
  54. .content-box p[style*="font-size"],
  55. .content-box div[style*="font-size"] {
  56. font-size: 1rem !important;
  57. color: #d1d5db;
  58. }
  59.  
  60. .content-box strong {
  61. color: white;
  62. }
  63.  
  64. .content-box p {
  65. margin-bottom:16px;
  66. }
  67.  
  68. details[open] > summary {
  69. background-color: #334155;
  70. color: white;
  71. }
  72.  
  73. `;
  74. document.head.appendChild(style);
  75. }
  76.  
  77. Promise.all([
  78. addScript('https://unpkg.com/vue@3/dist/vue.global.js'),
  79. addScript('https://cdn.tailwindcss.com')
  80. ]).then(() => {
  81. addGlobalStyle();
  82. initializeApp();
  83. }).catch(error => {
  84. console.error('Error loading scripts:', error);
  85. });
  86.  
  87. function initializeApp() {
  88. if (!window.Vue) return;
  89.  
  90. const { createApp, ref, computed, onMounted } = Vue;
  91.  
  92. const app = createApp({
  93. setup() {
  94. const data = ref([]);
  95. const newsMap = ref(new Map());
  96. const searchInput = ref('');
  97. const showAll = ref(false);
  98. const language = ref('tw')
  99. const readNews = ref(new Set());
  100.  
  101. const updateReadNews = (id) => {
  102. if (readNews.value.has(id)) return;
  103. readNews.value.add(id);
  104. localStorage.setItem('readNews', JSON.stringify(Array.from(readNews.value)));
  105. };
  106.  
  107. const isNewsRead = computed(() => (id) => readNews.value.has(id));
  108.  
  109. const filteredData = computed(() => {
  110. const keyword = searchInput.value.trim().toLowerCase();
  111. if (!keyword) return data.value;
  112. return data.value.filter(item => {
  113. const { lowercaseFields } = item;
  114. return Object.values(lowercaseFields).some(field => field.includes(keyword));
  115. });
  116. });
  117.  
  118. const visibleData = computed(() => {
  119. if (showAll.value) return filteredData.value;
  120. return filteredData.value.slice(0, 20);
  121. });
  122.  
  123. function formatTime(time) {
  124. const _time = time ? new Date(time) : new Date();
  125. const currentLang = languageOptions.find(option => option.value === language.value);
  126. return _time.toLocaleString(currentLang.dateFormat.locale, currentLang.dateFormat.options);
  127. }
  128.  
  129. function show(id) {
  130. const info = newsMap.value.get(parseInt(id))?.attributes;
  131. if (!info) return '';
  132. const content = (info.content || info.NewContent).replace(/\<img\s/g, '<img loading="lazy" ');
  133. 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>`;
  134. 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>`;
  135. return content + oriLink + closeButton;
  136. }
  137.  
  138. const closeDetails = (id) => {
  139. const detailsElement = document.querySelector(`details[data-detail-id="${id}"]`);
  140. if (detailsElement) {
  141. detailsElement.open = false;
  142. }
  143. };
  144.  
  145. const languageOptions = [
  146. {
  147. label: '繁體中文',
  148. value: 'tw',
  149. dateFormat: {
  150. locale: 'zh-TW',
  151. options: { weekday: 'narrow', year: 'numeric', month: '2-digit', day: '2-digit' }
  152. }
  153. },
  154. {
  155. label: '日本語',
  156. value: 'jp',
  157. dateFormat: {
  158. locale: 'ja-JP',
  159. options: { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }
  160. }
  161. },
  162. {
  163. label: 'English',
  164. value: 'en',
  165. dateFormat: {
  166. locale: 'en-US',
  167. options: { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }
  168. }
  169. },
  170. {
  171. label: '한국어',
  172. value: 'kr',
  173. dateFormat: {
  174. locale: 'ko-KR',
  175. options: { weekday: 'narrow', year: 'numeric', month: '2-digit', day: '2-digit' }
  176. }
  177. },
  178. {
  179. label: '简体中文',
  180. value: 'cn',
  181. dateFormat: {
  182. locale: 'zh-CN',
  183. options: { weekday: 'narrow', year: 'numeric', month: '2-digit', day: '2-digit' }
  184. }
  185. },
  186. ]
  187.  
  188. const handleLangChange = () => {
  189. searchInput.value = '';
  190. load();
  191. }
  192.  
  193. async function load() {
  194. const dataUrl = `https://www.browndust2.com/api/newsData_${language.value}.json`;
  195.  
  196. try {
  197. const response = await fetch(dataUrl);
  198. const json = await response.json();
  199. console.log('Data fetched successfully, item count:', json.data.length);
  200.  
  201. // 新增 lowercaseFields用於篩選功能
  202. data.value = json.data.reverse().map(item => ({
  203. ...item,
  204. lowercaseFields: {
  205. id: item.id.toString().toLowerCase(),
  206. content: (item.attributes.content || '').toLowerCase(),
  207. newContent: (item.attributes.NewContent || '').toLowerCase(),
  208. tag: (item.attributes.tag || '').toLowerCase(),
  209. subject: (item.attributes.subject || '').toLowerCase()
  210. }
  211. }));
  212.  
  213. // 更新 newsMap,但不需包含 lowercaseFields
  214. newsMap.value.clear();
  215. data.value.forEach(item => {
  216. const { lowercaseFields, ...itemWithoutLowercaseFields } = item;
  217. newsMap.value.set(item.id, itemWithoutLowercaseFields);
  218. });
  219. } catch (error) {
  220. console.error('Error fetching or processing data:', error);
  221. }
  222. }
  223.  
  224. onMounted(() => {
  225. load()
  226. window.closeDetails = closeDetails;
  227. const storedReadNews = JSON.parse(localStorage.getItem('readNews') || '[]');
  228. readNews.value = new Set(storedReadNews);
  229. });
  230.  
  231. return {
  232. visibleData,
  233. searchInput,
  234. showAll,
  235. language,
  236. languageOptions,
  237. formatTime,
  238. show,
  239. handleLangChange,
  240. updateReadNews,
  241. isNewsRead,
  242. };
  243. }
  244. });
  245.  
  246. // Create a container for the Vue app
  247. const appContainer = document.createElement('div');
  248. appContainer.id = 'app';
  249. document.body.innerHTML = '';
  250. document.body.appendChild(appContainer);
  251.  
  252. // Add the Vue template
  253. appContainer.innerHTML = `
  254. <div class=" w-full min-h-[100dvh] relative bg-slate-900">
  255. <header class="sticky top-0 left-0 h-[60px] border-b border-slate-600 flex justify-between items-center bg-slate-900 z-10 px-3">
  256. <label class="flex gap-1 items-center">
  257. Filter
  258. <input v-model="searchInput" type="search" class="py-1 px-2 block w-full rounded text-sm disabled:pointer-events-none bg-neutral-700 border-neutral-700 text-neutral-100 placeholder-neutral-500 focus:ring-neutral-600" tabindex="1">
  259. </label>
  260. <div class="flex gap-3 items-center">
  261. <label class="cursor-pointer flex gap-1 items-center">
  262. <select v-model="language" @change="handleLangChange" class="py-1 px-2 block w-full rounded text-sm disabled:pointer-events-none bg-neutral-700 border-neutral-700 text-neutral-100 placeholder-neutral-500 focus:ring-neutral-600" tabindex="1">
  263. <option v-for="option in languageOptions" :key="option.value" :value="option.value">
  264. {{ option.label }}
  265. </option>
  266. </select>
  267. </label>
  268. <label class="cursor-pointer flex gap-1 items-center">
  269. <input v-model="showAll" type="checkbox" class="shrink-0 mt-0.5 border-gray-200 rounded text-blue-600 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-800 dark:border-neutral-700 dark:checked:bg-blue-500 dark:checked:border-blue-500 dark:focus:ring-offset-gray-800">
  270. Show all list
  271. </label>
  272. </div>
  273. </header>
  274. <div class="flex flex-col mx-auto w-full max-w-7xl py-8 px-3 space-y-4">
  275. <details v-for="item in visibleData" :key="item.id" :data-detail-id="item.id" class="rounded overflow-hidden">
  276. <summary class="pl-4 pr-2 py-2 cursor-pointer transition duration-200"
  277. @click="updateReadNews(item.id)"
  278. :class="isNewsRead(item.id) ? 'text-gray-500 bg-slate-800 hover:bg-slate-700' : 'text-white bg-slate-700 hover:bg-slate-600'">
  279. <img :src="'https://www.browndust2.com/img/newsDetail/tag-' + item.attributes.tag + '.png'"
  280. :alt="item.attributes.tag" :title="'#' + item.attributes.tag"
  281. class="w-10 h-10 inline-block mr-2">
  282. <time :datetime="item.attributes.publishedAt" :title="item.attributes.publishedAt">
  283. {{ formatTime(item.attributes.publishedAt) }}
  284. </time> -
  285. {{ item.attributes.subject }}
  286. </summary>
  287. <div class="bg-gray-700/50 p-4 whitespace-pre-wrap content-box" v-html="show(item.id)"></div>
  288. </details>
  289. </div>
  290. </div>
  291. `;
  292.  
  293. app.mount('#app');
  294. }
  295. })();