From 79f4ecfbf1e2d76e731a7afc420b6538c965a5d7 Mon Sep 17 00:00:00 2001 From: Hakadao Date: Thu, 24 Mar 2022 22:09:54 +0800 Subject: [PATCH] first commit --- README.md | 133 + extension/assets/fonts/Bilibili_Icon.ttf | Bin 0 -> 1996 bytes extension/assets/icon-512.png | Bin 0 -> 2338 bytes extension/assets/icon.svg | 3 + package.json | 62 + pnpm-lock.yaml | 5861 +++++++++++++++++ scripts/manifest.ts | 10 + scripts/prepare.ts | 44 + scripts/utils.ts | 11 + shim.d.ts | 10 + src/background/contentScriptHMR.ts | 18 + src/background/index.html | 10 + src/background/main.ts | 172 + src/components/Logo.vue | 18 + src/components/README.md | 11 + src/components/SearchBar/index.vue | 229 + .../SearchBar/search-history-provider.ts | 48 + src/components/Settings.vue | 101 + src/components/Topbar/LogoMenuDropdown.vue | 89 + src/components/Topbar/NotiPopup.vue | 4 + src/components/Topbar/UserPanelDropdown.vue | 175 + src/components/Topbar/index.vue | 354 + src/composables/useStorageLocal.ts | 28 + src/contentScripts/index.ts | 50 + src/contentScripts/views/App.vue | 77 + .../views/Home/RecommendContent.vue | 265 + src/contentScripts/views/Home/index.vue | 18 + src/env.ts | 14 + src/global.d.ts | 6 + src/logic/index.ts | 1 + src/logic/storage.ts | 6 + src/manifest.ts | 56 + src/options/Options.vue | 23 + src/options/index.html | 12 + src/options/main.ts | 6 + src/popup/Popup.vue | 50 + src/popup/index.html | 12 + src/popup/main.ts | 6 + src/styles/index.ts | 3 + src/styles/main.scss | 95 + src/styles/reset.scss | 23 + src/utils/auth-provider.ts | 85 + src/utils/dataFormatter.ts | 52 + src/utils/index.ts | 43 + src/utils/svgIcons.ts | 1 + tsconfig.json | 24 + vite.config.content.ts | 43 + vite.config.ts | 108 + windi.config.ts | 11 + 49 files changed, 8481 insertions(+) create mode 100644 README.md create mode 100644 extension/assets/fonts/Bilibili_Icon.ttf create mode 100644 extension/assets/icon-512.png create mode 100644 extension/assets/icon.svg create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 scripts/manifest.ts create mode 100644 scripts/prepare.ts create mode 100644 scripts/utils.ts create mode 100644 shim.d.ts create mode 100644 src/background/contentScriptHMR.ts create mode 100644 src/background/index.html create mode 100644 src/background/main.ts create mode 100644 src/components/Logo.vue create mode 100644 src/components/README.md create mode 100644 src/components/SearchBar/index.vue create mode 100644 src/components/SearchBar/search-history-provider.ts create mode 100644 src/components/Settings.vue create mode 100644 src/components/Topbar/LogoMenuDropdown.vue create mode 100644 src/components/Topbar/NotiPopup.vue create mode 100644 src/components/Topbar/UserPanelDropdown.vue create mode 100644 src/components/Topbar/index.vue create mode 100644 src/composables/useStorageLocal.ts create mode 100644 src/contentScripts/index.ts create mode 100644 src/contentScripts/views/App.vue create mode 100644 src/contentScripts/views/Home/RecommendContent.vue create mode 100644 src/contentScripts/views/Home/index.vue create mode 100644 src/env.ts create mode 100644 src/global.d.ts create mode 100644 src/logic/index.ts create mode 100644 src/logic/storage.ts create mode 100644 src/manifest.ts create mode 100644 src/options/Options.vue create mode 100644 src/options/index.html create mode 100644 src/options/main.ts create mode 100644 src/popup/Popup.vue create mode 100644 src/popup/index.html create mode 100644 src/popup/main.ts create mode 100644 src/styles/index.ts create mode 100755 src/styles/main.scss create mode 100644 src/styles/reset.scss create mode 100644 src/utils/auth-provider.ts create mode 100644 src/utils/dataFormatter.ts create mode 100644 src/utils/index.ts create mode 100644 src/utils/svgIcons.ts create mode 100644 tsconfig.json create mode 100644 vite.config.content.ts create mode 100644 vite.config.ts create mode 100644 windi.config.ts diff --git a/README.md b/README.md new file mode 100644 index 00000000..dee6c1b3 --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# WebExtension Vite Starter + +A [Vite](https://vitejs.dev/) powered WebExtension ([Chrome](https://developer.chrome.com/docs/extensions/reference/), [FireFox](https://addons.mozilla.org/en-US/developers/), etc.) starter template. + +

+Popup
+
+Options Page
+
+Inject Vue App into the Content Script
+ +

+ +## Features + +- ⚑️ **Instant HMR** - use **Vite** on dev (no more refresh!) +- πŸ₯ Vue 3 - Composition API, [` + + diff --git a/src/background/main.ts b/src/background/main.ts new file mode 100644 index 00000000..894d73bb --- /dev/null +++ b/src/background/main.ts @@ -0,0 +1,172 @@ +import { createSharedComposable } from '@vueuse/core' +import { sendMessage, onMessage } from 'webext-bridge' +import { browserSettings, Tabs } from 'webextension-polyfill' + +// only on dev mode +if (import.meta.hot) { + // @ts-expect-error for background HMR + import('/@vite/client') + // load latest content script + import('./contentScriptHMR') +} + +let previousTabId = 0 + +// communication example: send previous tab title from background page +// see shim.d.ts for type declaration +browser.tabs.onActivated.addListener(async({ tabId }) => { + if (!previousTabId) { + previousTabId = tabId + return + } + + let tab: Tabs.Tab + + try { + tab = await browser.tabs.get(previousTabId) + previousTabId = tabId + } + catch { + return + } + + // eslint-disable-next-line no-console + console.log('previous tab', tab) + sendMessage('tab-prev', { title: tab.title }, { context: 'content-script', tabId }) +}) + +onMessage('get-current-tab', async() => { + try { + const tab = await browser.tabs.get(previousTabId) + return { + title: tab?.title, + } + } + catch { + return { + title: undefined, + } + } +}) + +// preinsert css +browser.tabs.onUpdated.addListener((tabId: number, changInfo: Tabs.OnUpdatedChangeInfoType, tab: Tabs.Tab) => { + if (/https?:\/\/bilibili.com\/?$/.test(`${tab.url}`) + || /https?:\/\/www.bilibili.com\/?$/.test(`${tab.url}`) + || /https?:\/\/bilibili.com\/\?spm_id_from=.*/.test(`${tab.url}`) + || /https?:\/\/www.bilibili.com\/\?spm_id_from=(.)*/.test(`${tab.url}`)) { + const isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches + if (changInfo.status === 'loading') { + const css = ` + body::after { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + content: ''; + background: ${isDark ? 'hsl(230 12% 6%)' : 'rgb(243 244 246)'}!important; + z-index: 99999; + } + ` + + browser.tabs.insertCSS(tabId, { + code: css, + runAt: 'document_start', + matchAboutBlank: true, + }) + } + + else if (changInfo.status === 'complete') { + const css = ` + body::after { + display: none; + } + ` + + browser.tabs.insertCSS(tabId, { + code: css, + runAt: 'document_start', + matchAboutBlank: true, + }) + } + } +}) + +chrome.runtime.onMessage.addListener((message: any, sender: chrome.runtime.MessageSender, sendResponse: any) => { + const APP_URL = 'https://app.bilibili.com' + const API_URL = 'https://api.bilibili.com' + + if (message.contentScriptQuery === 'getRecommendVideo') { + const url = `${APP_URL}/x/feed/index?build=1&idx=${message.idx}&appkey=27eb53fc9058f8c3&access_key=${message.accessKey}` + fetch(url) + .then(response => response.json()) + .then(data => sendResponse(data)) + .catch(error => console.error(error)) + return true + } + if (message.contentScriptQuery === 'getUserInfo') { + // https://api.bilibili.com/x/web-interface/nav + // const url = `${API_URL}/x/web-interface/card?mid=${message.mid}&photo=true` + const url = `${API_URL}/x/web-interface/nav` + fetch(url) + .then(response => response.json()) + .then(data => sendResponse(data)) + .catch(error => console.error(error)) + return true // Will respond asynchronously. + } + if (message.contentScriptQuery === 'getUserStat') { + const url = `${API_URL}/x/web-interface/nav/stat` + fetch(url) + .then(response => response.json()) + .then(data => sendResponse(data)) + .catch(error => console.error(error)) + return true + } + if (message.contentScriptQuery === 'getSearchSuggestion') { + const url = `https://s.search.bilibili.com/main/suggest?term=${message.term}` + fetch(url) + .then(response => response.json()) + .then(data => sendResponse(data)) + .catch(error => console.error(error)) + return true + } + if (message.contentScriptQuery === 'logout') { + const url = `https://passport.bilibili.com/login/exit/v2?biliCSRF=${message.biliCSRF}` + fetch(url, { + method: 'POST', + body: JSON.stringify({ + biliCSRF: message.biliJct, + }), + }) + .then(response => response.json()) + .then(data => sendResponse(data)) + .catch(error => console.error(error)) + return true + } + if (message.contentScriptQuery === 'getUnreadMsg') { + const url = `${API_URL}/x/msgfeed/unread?build=0&mobi_app=web` + fetch(url) + .then(response => response.json()) + .then(data => sendResponse(data)) + .catch(error => console.error(error)) + return true + } + if (message.contentScriptQuery === 'getUnreadDm') { + const url = 'https://api.vc.bilibili.com/session_svr/v1/session_svr/single_unread?build=0&mobi_app=web&unread_type=0' + fetch(url) + .then(response => response.json()) + .then(data => sendResponse(data)) + .catch(error => console.error(error)) + return true + } + if (message.contentScriptQuery === 'getNewMomentsCount') { + // https://api.bilibili.com/x/web-interface/dynamic/entrance + const url = `${API_URL}/x/web-interface/dynamic/entrance` + fetch(url) + .then(response => response.json()) + .then(data => sendResponse(data)) + .catch(error => console.error(error)) + return true + } +}) diff --git a/src/components/Logo.vue b/src/components/Logo.vue new file mode 100644 index 00000000..f0bdecd8 --- /dev/null +++ b/src/components/Logo.vue @@ -0,0 +1,18 @@ + + diff --git a/src/components/README.md b/src/components/README.md new file mode 100644 index 00000000..f9ed8ea1 --- /dev/null +++ b/src/components/README.md @@ -0,0 +1,11 @@ +## Components + +Components in this dir will be auto-registered and on-demand, powered by [`vite-plugin-components`](https://github.com/antfu/vite-plugin-components). + +Components can be shared in all views. + +### Icons + +You can use icons from almost any icon sets by the power of [Iconify](https://iconify.design/). + +It will only bundle the icons you use. Check out [vite-plugin-icons](https://github.com/antfu/vite-plugin-icons) for more details. diff --git a/src/components/SearchBar/index.vue b/src/components/SearchBar/index.vue new file mode 100644 index 00000000..3cf1a497 --- /dev/null +++ b/src/components/SearchBar/index.vue @@ -0,0 +1,229 @@ + + + + + diff --git a/src/components/SearchBar/search-history-provider.ts b/src/components/SearchBar/search-history-provider.ts new file mode 100644 index 00000000..c5386bdb --- /dev/null +++ b/src/components/SearchBar/search-history-provider.ts @@ -0,0 +1,48 @@ +const SEARCH_HISTORY_KEY = 'bew_search_history' +const SEARCH_HISTORY_LIMIT = 10 + +export interface HistoryItem { + value: string + timestamp: number +} + +const historySort = (historyItems: HistoryItem[]) => { + historyItems.sort((a, b) => b.timestamp - a.timestamp) + return historyItems +} + +export const getSearchHistory = (): HistoryItem[] => { + const history = localStorage.getItem(SEARCH_HISTORY_KEY) + if (!history) { + localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify([])) + return [] + } + return historySort(JSON.parse(history)) +} + +export const addSearchHistory = (historyItem: HistoryItem) => { + let history = getSearchHistory() + + let hasSameValue = false + history.forEach((item) => { + if (item.value === historyItem.value) { + item.timestamp = historyItem.timestamp + hasSameValue = true + } + }) + if (!hasSameValue) history.unshift(historyItem) + + // if out of limit, remove overflow items + history = history.filter((item, index) => { + if (index < SEARCH_HISTORY_LIMIT) return item + else return false + }) + + localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history)) +} + +export const removeSearchHistory = (value: string) => { + let history = getSearchHistory() + history = history.filter(item => item.value !== value) + localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history)) +} diff --git a/src/components/Settings.vue b/src/components/Settings.vue new file mode 100644 index 00000000..9c54e80d --- /dev/null +++ b/src/components/Settings.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/src/components/Topbar/LogoMenuDropdown.vue b/src/components/Topbar/LogoMenuDropdown.vue new file mode 100644 index 00000000..4807f976 --- /dev/null +++ b/src/components/Topbar/LogoMenuDropdown.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/src/components/Topbar/NotiPopup.vue b/src/components/Topbar/NotiPopup.vue new file mode 100644 index 00000000..f1db4079 --- /dev/null +++ b/src/components/Topbar/NotiPopup.vue @@ -0,0 +1,4 @@ + diff --git a/src/components/Topbar/UserPanelDropdown.vue b/src/components/Topbar/UserPanelDropdown.vue new file mode 100644 index 00000000..5299569e --- /dev/null +++ b/src/components/Topbar/UserPanelDropdown.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/src/components/Topbar/index.vue b/src/components/Topbar/index.vue new file mode 100644 index 00000000..349c0cac --- /dev/null +++ b/src/components/Topbar/index.vue @@ -0,0 +1,354 @@ + + + + + diff --git a/src/composables/useStorageLocal.ts b/src/composables/useStorageLocal.ts new file mode 100644 index 00000000..574a87a9 --- /dev/null +++ b/src/composables/useStorageLocal.ts @@ -0,0 +1,28 @@ +import { storage } from 'webextension-polyfill' +import { + useStorageAsync, + StorageLikeAsync, + MaybeRef, + StorageAsyncOptions, + RemovableRef, +} from '@vueuse/core' + +const storageLocal: StorageLikeAsync = { + removeItem(key: string) { + return storage.local.remove(key) + }, + + setItem(key: string, value: string) { + return storage.local.set({ [key]: value }) + }, + + async getItem(key: string) { + return (await storage.local.get(key))[key] + }, +} + +export const useStorageLocal = ( + key: string, + initialValue: MaybeRef, + options?: StorageAsyncOptions, +): RemovableRef => useStorageAsync(key, initialValue, storageLocal, options) diff --git a/src/contentScripts/index.ts b/src/contentScripts/index.ts new file mode 100644 index 00000000..bccdf9e4 --- /dev/null +++ b/src/contentScripts/index.ts @@ -0,0 +1,50 @@ +/* eslint-disable no-console */ +import { onMessage } from 'webext-bridge' +import { createApp } from 'vue' +import App from './views/App.vue' +import { getCookie, setCookie, SVG_ICONS } from '~/utils' + +// Firefox `browser.tabs.executeScript()` requires scripts return a primitive value +;(() => { + console.info('[vitesse-webext] Hello world from content script') + + // communication example: send previous tab title from background page + onMessage('tab-prev', ({ data }) => { + console.log(`[vitesse-webext] Navigate from page "${data.title}"`) + }) + + const currentUrl = document.URL + + if ( + /https?:\/\/bilibili.com\/?$/.test(currentUrl) + || /https?:\/\/www.bilibili.com\/?$/.test(currentUrl) + || /https?:\/\/bilibili.com\/\?spm_id_from=.*/.test(currentUrl) + || /https?:\/\/www.bilibili.com\/\?spm_id_from=(.)*/.test(currentUrl) + ) { + // if current homepage is old version, redirect to new version + if (`${getCookie('i-wanna-go-back')}` === '2') { + setCookie('i-wanna-go-back', '-1', 1) + location.reload() + } + else { + document.querySelectorAll('script').forEach(script => script.remove()) + document.body.innerHTML = '' + } + + const container = document.createElement('div') + const root = document.createElement('div') + const styleEl = document.createElement('link') + styleEl.setAttribute('rel', 'stylesheet') + styleEl.setAttribute('href', browser.runtime.getURL('dist/contentScripts/style.css')) + container.id = 'bewly' + container.appendChild(styleEl) + container.appendChild(root) + document.body.appendChild(container) + createApp(App).mount(root) + + // inject svg icons + const svgDiv = document.createElement('div') + svgDiv.innerHTML = SVG_ICONS + document.body.appendChild(svgDiv) + } +})() diff --git a/src/contentScripts/views/App.vue b/src/contentScripts/views/App.vue new file mode 100644 index 00000000..514284e5 --- /dev/null +++ b/src/contentScripts/views/App.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/src/contentScripts/views/Home/RecommendContent.vue b/src/contentScripts/views/Home/RecommendContent.vue new file mode 100644 index 00000000..3945c96d --- /dev/null +++ b/src/contentScripts/views/Home/RecommendContent.vue @@ -0,0 +1,265 @@ + + + + + diff --git a/src/contentScripts/views/Home/index.vue b/src/contentScripts/views/Home/index.vue new file mode 100644 index 00000000..dd5bec27 --- /dev/null +++ b/src/contentScripts/views/Home/index.vue @@ -0,0 +1,18 @@ + + + + + diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 00000000..34f43c46 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,14 @@ +const forbiddenProtocols = [ + 'chrome-extension://', + 'chrome-search://', + 'chrome://', + 'devtools://', + 'edge://', + 'https://chrome.google.com/webstore', +] + +export function isForbiddenUrl(url: string): boolean { + return forbiddenProtocols.some(protocol => url.startsWith(protocol)) +} + +export const isFirefox = navigator.userAgent.includes('Firefox') diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 00000000..fbf42dfb --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,6 @@ +declare const __DEV__: boolean + +declare module '*.vue' { + const component: any + export default component +} diff --git a/src/logic/index.ts b/src/logic/index.ts new file mode 100644 index 00000000..69b61ecc --- /dev/null +++ b/src/logic/index.ts @@ -0,0 +1 @@ +export * from './storage' diff --git a/src/logic/storage.ts b/src/logic/storage.ts new file mode 100644 index 00000000..42fb5cd5 --- /dev/null +++ b/src/logic/storage.ts @@ -0,0 +1,6 @@ +import { useStorageLocal } from '~/composables/useStorageLocal' + +export const storageDemo = useStorageLocal('webext-demo', 'Storage Demo', { listenToStorageChanges: true }) +export const isShowTopbar = useStorageLocal('isShowTopbar', true, { listenToStorageChanges: true }) +export const apperance = useStorageLocal('apperance', 'automatic', { listenToStorageChanges: true }) +export const accessKey = useStorageLocal('accessKey', null, { listenToStorageChanges: true }) diff --git a/src/manifest.ts b/src/manifest.ts new file mode 100644 index 00000000..1c5ec820 --- /dev/null +++ b/src/manifest.ts @@ -0,0 +1,56 @@ +import fs from 'fs-extra' +import type { Manifest } from 'webextension-polyfill' +import type PkgType from '../package.json' +import { isDev, port, r } from '../scripts/utils' + +export async function getManifest() { + const pkg = (await fs.readJSON(r('package.json'))) as typeof PkgType + + // update this file to update this manifest.json + // can also be conditional based on your need + const manifest: Manifest.WebExtensionManifest = { + manifest_version: 2, + name: pkg.displayName || pkg.name, + version: pkg.version, + description: pkg.description, + // browser_action: { + // default_icon: './assets/icon-512.png', + // default_popup: './dist/popup/index.html', + // }, + // options_ui: { + // page: './dist/options/index.html', + // open_in_tab: true, + // chrome_style: false, + // }, + background: { + page: './dist/background/index.html', + persistent: false, + }, + icons: { + 16: './assets/icon-512.png', + 48: './assets/icon-512.png', + 128: './assets/icon-512.png', + }, + permissions: ['tabs', 'storage', 'activeTab', 'https://app.bilibili.com/*', 'http://*/', 'https://*/'], + content_scripts: [ + { + matches: ['http://www.bilibili.com/*', 'https://www.bilibili.com/*'], + js: ['./dist/contentScripts/index.global.js'], + }, + ], + web_accessible_resources: ['dist/contentScripts/style.css'], + } + + if (isDev) { + // for content script, as browsers will cache them for each reload, + // we use a background script to always inject the latest version + // see src/background/contentScriptHMR.ts + delete manifest.content_scripts + manifest.permissions?.push('webNavigation') + + // this is required on dev for Vite script to load + manifest.content_security_policy = `script-src \'self\' http://localhost:${port}; object-src \'self\'` + } + + return manifest +} diff --git a/src/options/Options.vue b/src/options/Options.vue new file mode 100644 index 00000000..de40c821 --- /dev/null +++ b/src/options/Options.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/src/options/index.html b/src/options/index.html new file mode 100644 index 00000000..dbd67cb5 --- /dev/null +++ b/src/options/index.html @@ -0,0 +1,12 @@ + + + + + + Options + + +
+ + + diff --git a/src/options/main.ts b/src/options/main.ts new file mode 100644 index 00000000..1a911d5a --- /dev/null +++ b/src/options/main.ts @@ -0,0 +1,6 @@ +import { createApp } from 'vue' +import App from './Options.vue' +import '../styles' + +const app = createApp(App) +app.mount('#app') diff --git a/src/popup/Popup.vue b/src/popup/Popup.vue new file mode 100644 index 00000000..8ff02710 --- /dev/null +++ b/src/popup/Popup.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/src/popup/index.html b/src/popup/index.html new file mode 100644 index 00000000..032a15d4 --- /dev/null +++ b/src/popup/index.html @@ -0,0 +1,12 @@ + + + + + + Popup + + +
+ + + diff --git a/src/popup/main.ts b/src/popup/main.ts new file mode 100644 index 00000000..f7901f42 --- /dev/null +++ b/src/popup/main.ts @@ -0,0 +1,6 @@ +import { createApp } from 'vue' +import App from './Popup.vue' +import '../styles' + +const app = createApp(App) +app.mount('#app') diff --git a/src/styles/index.ts b/src/styles/index.ts new file mode 100644 index 00000000..cbc27b9f --- /dev/null +++ b/src/styles/index.ts @@ -0,0 +1,3 @@ +import './reset.scss' +import './main.scss' +import 'virtual:windi.css' diff --git a/src/styles/main.scss b/src/styles/main.scss new file mode 100755 index 00000000..0bdf8321 --- /dev/null +++ b/src/styles/main.scss @@ -0,0 +1,95 @@ +:root { + --bew-radius: 12px; + + --bew-filter-glass: blur(20px) saturate(180%); + + --bew-shadow-1: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --bew-shadow-2: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --bew-shadow-3: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); + --bew-shadow-4: 0 25px 50px -12px rgb(0 0 0 / 0.25); + + --bew-theme-color: rgb(0, 161, 214); + + --bew-logo-color: var(--bew-theme-color); + + --bew-text-1: hsl(217, 33%, 17%); + --bew-text-2: hsl(215, 19%, 35%); + --bew-text-3: hsl(215, 19%, 55%); + + --bew-bg: rgb(243 244 246); + --bew-content-1: hsl(0 0% 100% / 60%); + --bew-content-solid-1: hsl(0 0% 100%); + + --bew-fill-1: rgb(120 120 128 / 12%); + --bew-fill-2: rgb(120 120 128 / 22%); + --bew-fill-3: rgb(120 120 128 / 32%); + --bew-fill-4: rgb(120 120 128 / 42%); +} + +:root.dark { + // dark mode + --bew-logo-color: var(--bew-text-1); + + --bew-text-1: hsl(215, 19%, 98%); + --bew-text-2: hsl(215, 19%, 70%); + --bew-text-3: hsl(215, 19%, 50%); + + --bew-bg: hsl(230 12% 6%); + --bew-content-1: hsl(230 12% 13% / 60%); + --bew-content-solid-1: hsl(230 12% 13%); + + --bew-fill-1: rgb(120 120 128 / 16%); + --bew-fill-2: rgb(120 120 128 / 26%); + --bew-fill-3: rgb(120 120 128 / 36%); + --bew-fill-4: rgb(120 120 128 / 46%); +} + +html, +body, +#app { + margin: 0; + padding: 0; +} + +body { + background: var(--bew-bg); + color: var(--bew-text-1); +} + +.btn { + @apply px-4 py-2 rounded-$bew-radius inline-block + cursor-pointer transform duration-300 + filter outline-none + border-2 border-solid border-$bew-theme-color + bg-$bew-theme-color text-white + active:scale-95 active:bg-$bew-theme-color active:brightness-110 + focus:bg-$bew-theme-color + disabled:cursor-default disabled:bg-$bew-fill-4 disabled:opacity-50 + disabled:border-$bew-fill-4; +} + +.line-btn { + @apply px-4 py-2 rounded-$bew-radius inline-block + cursor-pointer transform duration-300 + filter outline-none + border-2 border-solid border-$bew-fill-4 + text-$bew-fill-4 + active:scale-95 active:bg-$bew-theme-color active:brightness-110 + focus:bg-$bew-theme-color + disabled:cursor-default disabled:bg-$bew-fill-4 disabled:opacity-50; +} + +.icon-btn { + @apply inline-block cursor-pointer select-none + opacity-75 transition duration-200 ease-in-out + hover:opacity-100 hover:text-$bew-theme-color; + font-size: 0.9em; +} + +.chk-btn { + @apply flex px-4 py-2 items-center select-none; + + input { + @apply ml-4; + } +} diff --git a/src/styles/reset.scss b/src/styles/reset.scss new file mode 100644 index 00000000..03a7b6aa --- /dev/null +++ b/src/styles/reset.scss @@ -0,0 +1,23 @@ +/** + * this stylesheet is used to reset the styles of the bilibili default style + */ + +html, body { + font-size: 14px; + min-width: unset; +} + +a, +a:hover { + color: unset; +} + +button { + border: unset; + background: unset; + + &:focus, + &:active { + background: currentColor + } +} diff --git a/src/utils/auth-provider.ts b/src/utils/auth-provider.ts new file mode 100644 index 00000000..83e17ab6 --- /dev/null +++ b/src/utils/auth-provider.ts @@ -0,0 +1,85 @@ +/* eslint-disable no-throw-literal */ +import { accessKey } from '~/logic/storage' + +/** + * ζ„Ÿθ¬ι€™δ»½ε°ˆζ‘ˆη΅¦ε‡Ίηš„η²ε–accessKeyηš„ζ–Ήζ³• + * https://github.com/indefined/UserScripts/blob/42e20281d2e4d7bce16b5c8033b67ccb6ad312e9/bilibiliHome/bilibiliHome.user.js#L1149 + */ + +export const revokeAccessKey = () => { + accessKey.value = null +} + +export const grantAccessKey = (element?: HTMLButtonElement): void => { + if (element) + setBtnState(element, 'loading') + + const tip = 'Failed to grant Access Key' + + fetch( + 'https://passport.bilibili.com/login/app/third?appkey=27eb53fc9058f8c3' + + '&api=https%3A%2F%2Fwww.mcbbs.net%2Ftemplate%2Fmcbbs%2Fimage%2Fspecial_photo_bg.png&sign=04224646d1fea004e79606d3b038c84a', + { + method: 'GET', + credentials: 'include', + }, + ) + .then(res => res.json()) + .then((data) => { + if (data.code || !data.data) throw { tip, msg: data.msg || data.message || data.code, data } + else if (!data.data.has_login) throw { tip, msg: 'Please login to bilibili first', data } + else if (!data.data.confirm_uri) throw { tip, msg: 'Unable to receive verified URL. Please go back and try againe.', data } + else return data.data.confirm_uri + }) + .then( + url => + new Promise((resolve, reject) => { + const iframe = document.createElement('iframe') + iframe.src = url + iframe.style.display = 'none' + document.body.appendChild(iframe) + + const timeout = setTimeout(() => { + document.body.contains(iframe) && document.body.removeChild(iframe) + reject(new Error(`${tip}: Request timeout`)) + }, 5000) + + window.addEventListener('message', (ev) => { + if (`${ev.origin}` !== 'https://www.mcbbs.net' || !ev.data) return + const key = ev.data.match(/access_key=([0-9a-z]{32})/) + if (key) { + accessKey.value = key[1] + clearTimeout(timeout) + document.body.contains(iframe) && document.body.removeChild(iframe) + resolve() + } + else { + // eslint-disable-next-line prefer-promise-reject-errors + reject({ tip, msg: 'Failed to get Access Key', data: ev }) + } + }) + }), + ) + .catch((error) => { + // eslint-disable-next-line no-alert + alert(`${error.tip}: ${error.msg}`) + console.error(`${error.msg}: `, error) + }).then(() => { + if (element) + setBtnState(element, 'default') + }) + + function setBtnState(element: HTMLButtonElement, state: string) { + const orginalInnerHTML = element.innerHTML + if (state === 'loading') { + element.innerHTML = ` + Loading... + ` + element.style.pointerEvents = 'none' + } + else if (state === 'default') { + element.innerHTML = orginalInnerHTML + element.style.pointerEvents = 'auto' + } + } +} diff --git a/src/utils/dataFormatter.ts b/src/utils/dataFormatter.ts new file mode 100644 index 00000000..a447318f --- /dev/null +++ b/src/utils/dataFormatter.ts @@ -0,0 +1,52 @@ +export const numFormatter = (num: number) => { + const digits = 1 // specify number of digits after decimal + const lookup = [ + { value: 1, symbol: '' }, + { value: 1e3, symbol: 'K' }, + { value: 1e6, symbol: 'M' }, + { value: 1e9, symbol: 'G' }, + { value: 1e12, symbol: 'T' }, + { value: 1e15, symbol: 'P' }, + { value: 1e18, symbol: 'E' }, + ] + const rx = /\.0+$|(\.[0-9]*[1-9])0+$/ + const item = lookup.slice().reverse().find((item) => { + return num >= item.value + }) + return item ? (num / item.value).toFixed(digits).replace(rx, '$1') + item.symbol : '0' +} + +export const calcTimeSince = (date: any) => { + const seconds = Math.floor(((new Date() as any) - date) / 1000) + let interval = seconds / 31536000 + if (interval > 1) + return `${Math.floor(interval) > 1 ? `${Math.floor(interval)} years` : `${Math.floor(interval)} year`}` + interval = seconds / 2592000 + if (interval > 1) + return `${Math.floor(interval) > 1 ? `${Math.floor(interval)} months` : `${Math.floor(interval)} month`}` + interval = seconds / 604800 + if (interval > 1) + return `${Math.floor(interval) > 1 ? `${Math.floor(interval)} weeks` : `${Math.floor(interval)} week`}` + interval = seconds / 86400 + if (interval > 1) + return `${Math.floor(interval) > 1 ? `${Math.floor(interval)} days` : `${Math.floor(interval)} day`}` + interval = seconds / 3600 + if (interval > 1) + return `${Math.floor(interval) > 1 ? `${Math.floor(interval)} hours` : `${Math.floor(interval)} hour`}` + interval = seconds / 60 + if (interval > 1) + return `${Math.floor(interval) > 1 ? `${Math.floor(interval)} minutes` : `${Math.floor(interval)} minute`}` + return `${Math.floor(seconds) > 1 ? `${Math.floor(seconds)} seconds` : `${Math.floor(seconds)}second`}` +} + +export const calcCurrentTime = (totalSeconds: number) => { + const hours = Math.floor(totalSeconds / 3600) + totalSeconds %= 3600 + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + + if (hours <= 0) + return `${minutes < 10 ? `0${minutes}` : minutes}:${seconds < 10 ? `0${seconds}` : seconds}` + + return `${hours < 10 ? `0${hours}` : hours}:${minutes < 10 ? `0${minutes}` : minutes}:${seconds < 10 ? `0${seconds}` : seconds}` +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 00000000..b5d8cae5 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,43 @@ +import { grantAccessKey, revokeAccessKey } from './auth-provider' +import { SVG_ICONS } from './svgIcons' + +export { + grantAccessKey, + revokeAccessKey, + SVG_ICONS, +} +export * from './dataFormatter' + +/** + * get cookie by name + * @param name cookie name + * @returns cookie value + */ +export const getCookie = (name: string) => { + const value = `; ${document.cookie}` + const parts: Array = value.split(`; ${name}=`) + if (parts.length === 2) return parts?.pop()?.split(';').shift() +} + +/** + * set cookie + * @param name cookie name + * @param value cookie value + */ +export const setCookie = (name: string, value: any, expDays: number) => { + const date = new Date() + date.setTime(date.getTime() + (expDays * 24 * 60 * 60 * 1000)) + const expires = `expires=${date.toUTCString()}` + document.cookie = `${name}=${value}; ${expires}; domain=.bilibili.com; path=/` +} + +/** + * get current login user id + * @returns userId + */ +export const getUserID = () => getCookie('DedeUserID') + +/** + * get csrf token + */ +export const getCSRF = () => getCookie('bili_jct') diff --git a/src/utils/svgIcons.ts b/src/utils/svgIcons.ts new file mode 100644 index 00000000..5b1fcff9 --- /dev/null +++ b/src/utils/svgIcons.ts @@ -0,0 +1 @@ +export const SVG_ICONS = '' diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..15403c73 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "module": "ESNext", + "target": "es2016", + "lib": ["DOM", "ESNext"], + "strict": true, + "esModuleInterop": true, + "incremental": false, + "skipLibCheck": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "noUnusedLocals": true, + "forceConsistentCasingInFileNames": true, + "types": [ + "vite/client", + "chrome", + ], + "paths": { + "~/*": ["src/*"], + } + }, + "exclude": ["dist", "node_modules"] +} diff --git a/vite.config.content.ts b/vite.config.content.ts new file mode 100644 index 00000000..354ab7b6 --- /dev/null +++ b/vite.config.content.ts @@ -0,0 +1,43 @@ +import { defineConfig } from 'vite' +import WindiCSS from 'vite-plugin-windicss' +import { sharedConfig } from './vite.config' +import { r, isDev } from './scripts/utils' +import windiConfig from './windi.config' +import packageJson from './package.json' + +// bundling the content script using Vite +export default defineConfig({ + ...sharedConfig, + build: { + watch: isDev + ? {} + : undefined, + outDir: r('extension/dist/contentScripts'), + cssCodeSplit: false, + emptyOutDir: false, + sourcemap: isDev ? 'inline' : false, + lib: { + entry: r('src/contentScripts/index.ts'), + name: packageJson.name, + formats: ['iife'], + }, + rollupOptions: { + output: { + entryFileNames: 'index.global.js', + extend: true, + }, + }, + }, + plugins: [ + ...sharedConfig.plugins!, + + // https://github.com/antfu/vite-plugin-windicss + WindiCSS({ + config: { + ...windiConfig, + // disable preflight to avoid css population + preflight: false, + }, + }), + ], +}) diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 00000000..de42c5cd --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,108 @@ +import { dirname, relative } from 'path' +import { defineConfig, UserConfig } from 'vite' +import Vue from '@vitejs/plugin-vue' +import Icons from 'unplugin-icons/vite' +import IconsResolver from 'unplugin-icons/resolver' +import Components from 'unplugin-vue-components/vite' +import AutoImport from 'unplugin-auto-import/vite' +import WindiCSS from 'vite-plugin-windicss' +import windiConfig from './windi.config' +import { r, port, isDev } from './scripts/utils' + +export const sharedConfig: UserConfig = { + root: r('src'), + resolve: { + alias: { + '~/': `${r('src')}/`, + }, + }, + define: { + __DEV__: isDev, + }, + plugins: [ + Vue(), + + AutoImport({ + imports: [ + 'vue', + { + 'webextension-polyfill': [ + ['*', 'browser'], + ], + }, + ], + dts: r('src/auto-imports.d.ts'), + }), + + // https://github.com/antfu/unplugin-vue-components + Components({ + dirs: [r('src/components')], + // generate `components.d.ts` for ts support with Volar + dts: true, + resolvers: [ + // auto import icons + IconsResolver({ + componentPrefix: '', + }), + ], + }), + + // https://github.com/antfu/unplugin-icons + Icons(), + + // rewrite assets to use relative path + { + name: 'assets-rewrite', + enforce: 'post', + apply: 'build', + transformIndexHtml(html, { path }) { + return html.replace(/"\/assets\//g, `"${relative(dirname(path), '/assets')}/`) + }, + }, + ], + optimizeDeps: { + include: [ + 'vue', + '@vueuse/core', + 'webextension-polyfill', + ], + exclude: [ + 'vue-demi', + ], + }, +} + +export default defineConfig(({ command }) => ({ + ...sharedConfig, + base: command === 'serve' ? `http://localhost:${port}/` : '/dist/', + server: { + port, + hmr: { + host: 'localhost', + }, + }, + build: { + outDir: r('extension/dist'), + emptyOutDir: false, + sourcemap: isDev ? 'inline' : false, + // https://developer.chrome.com/docs/webstore/program_policies/#:~:text=Code%20Readability%20Requirements + terserOptions: { + mangle: false, + }, + rollupOptions: { + input: { + background: r('src/background/index.html'), + options: r('src/options/index.html'), + popup: r('src/popup/index.html'), + }, + }, + }, + plugins: [ + ...sharedConfig.plugins!, + + // https://github.com/antfu/vite-plugin-windicss + WindiCSS({ + config: windiConfig, + }), + ], +})) diff --git a/windi.config.ts b/windi.config.ts new file mode 100644 index 00000000..279522cf --- /dev/null +++ b/windi.config.ts @@ -0,0 +1,11 @@ +import { resolve } from 'path' +import { defineConfig } from 'windicss/helpers' + +export default defineConfig({ + darkMode: 'class', + // https://windicss.org/posts/v30.html#attributify-mode + attributify: true, + extract: { + include: [resolve(__dirname, 'src/**/*.{vue,html}')], + }, +})