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

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

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

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