first commit

This commit is contained in:
Hakadao
2022-03-24 22:09:54 +08:00
commit 79f4ecfbf1
49 changed files with 8481 additions and 0 deletions

133
README.md Normal file
View File

@@ -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.
<p align="center">
<sub>Popup</sub><br/>
<img width="655" src="https://user-images.githubusercontent.com/11247099/126741643-813b3773-17ff-4281-9737-f319e00feddc.png"><br/>
<sub>Options Page</sub><br/>
<img width="655" src="https://user-images.githubusercontent.com/11247099/126741653-43125b62-6578-4452-83a7-bee19be2eaa2.png"><br/>
<sub>Inject Vue App into the Content Script</sub><br/>
<img src="https://user-images.githubusercontent.com/11247099/130695439-52418cf0-e186-4085-8e19-23fe808a274e.png">
</p>
## Features
- ⚡️ **Instant HMR** - use **Vite** on dev (no more refresh!)
- 🥝 Vue 3 - Composition API, [`<script setup>` syntax](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0040-script-setup.md) and more!
- 💬 Effortless communications - powered by [`webext-bridge`](https://github.com/antfu/webext-bridge) and [VueUse](https://github.com/antfu/vueuse) storage
- 🍃 [Windi CSS](https://windicss.org/) - on-demand CSS utilities
- 🦾 [TypeScript](https://www.typescriptlang.org/) - type safe
- 📦 [Components auto importing](./src/components)
- 🌟 [Icons](./src/components) - Access to icons from any iconset directly
- 🖥 Content Script - Use Vue even in content script
- 🌍 WebExtension - isomorphic extension for Chrome, Firefox, and others
- 📃 Dynamic `manifest.json` with full type support
## Pre-packed
### WebExtension Libraries
- [`webextension-polyfill`](https://github.com/mozilla/webextension-polyfill) - WebExtension browser API Polyfill with types
- [`webext-bridge`](https://github.com/antfu/webext-bridge) - effortlessly communication between contexts
### Vite Plugins
- [`unplugin-auto-import`](https://github.com/antfu/unplugin-auto-import) - Directly use `browser` and Vue Composition API without importing
- [`unplugin-vue-components`](https://github.com/antfu/vite-plugin-components) - components auto import
- [`unplugin-icons`](https://github.com/antfu/unplugin-icons) - icons as components
- [Iconify](https://iconify.design) - use icons from any icon sets [🔍Icônes](https://icones.netlify.app/)
- [`vite-plugin-windicss`](https://github.com/antfu/vite-plugin-windicss) - WindiCSS support
### Vue Plugins
- [VueUse](https://github.com/antfu/vueuse) - collection of useful composition APIs
### UI Frameworks
- [Windi CSS](https://github.com/windicss/windicss) - next generation utility-first CSS framework
### Coding Style
- Use Composition API with [`<script setup>` SFC syntax](https://github.com/vuejs/rfcs/pull/227)
- [ESLint](https://eslint.org/) with [@antfu/eslint-config](https://github.com/antfu/eslint-config), single quotes, no semi
### Dev tools
- [TypeScript](https://www.typescriptlang.org/)
- [pnpm](https://pnpm.js.org/) - fast, disk space efficient package manager
- [esno](https://github.com/antfu/esno) - TypeScript / ESNext node runtime powered by esbuild
- [npm-run-all](https://github.com/mysticatea/npm-run-all) - Run multiple npm-scripts in parallel or sequential
- [web-ext](https://github.com/mozilla/web-ext) - Streamlined experience for developing web extensions
## Use the Template
### GitHub Template
[Create a repo from this template on GitHub](https://github.com/antfu/vitesse-webext/generate).
### Clone to local
If you prefer to do it manually with the cleaner git history
> If you don't have pnpm installed, run: npm install -g pnpm
```bash
npx degit antfu/vitesse-webext my-webext
cd my-webext
pnpm i
```
## Usage
### Folders
- `src` - main source.
- `contentScript` - scripts and components to be injected as `content_script`
- `background` - scripts for background.
- `components` - auto-imported Vue components that shared in popup and options page.
- `styles` - styles shared in popup and options page
- `manifest.ts` - manifest for the extension.
- `extension` - extension package root.
- `assets` - static assets.
- `dist` - built files, also serve stub entry for Vite on development.
- `scripts` - development and bundling helper scripts.
### Development
```bash
pnpm dev
```
Then **load extension in browser with the `extension/` folder**.
For Firefox developers, you can run the following command instead:
```bash
pnpm start:firefox
```
`web-ext` auto reload the extension when `extension/` files changed.
> While Vite handles HMR automatically in the most of the case, [Extensions Reloader](https://chrome.google.com/webstore/detail/fimgfedafeadlieiabdeeaodndnlbhid) is still recommanded for cleaner hard reloading.
### Build
To build the extension, run
```bash
pnpm build
```
And then pack files under `extension`, you can upload `extension.crx` or `extension.xpi` to appropriate extension store.
## Credits
![](https://user-images.githubusercontent.com/11247099/127029137-6b5ad5db-76c4-4061-86ff-489911a8adfb.png)
This template is originally made for the [volta.net](https://volta.net) browser extension.
## Variations
This is a variant of [Vitesse](https://github.com/antfu/vitesse), check out the [full variations list](https://github.com/antfu/vitesse#variations).
# BewlyBewly

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M26.6667 1.66667H24V7H8V9.66667H5.33333V20.3333H8V23H10.6667V28.3333H21.3333V25.6667H26.6667V23H21.3333V20.3333H26.6667V17.6667H21.3333V15H10.6667V20.3333H8V9.66667H24V7H26.6667V1.66667ZM18.6667 25.6667H13.3333V17.6667H18.6667V25.6667Z" fill="#888888"/>
</svg>

After

Width:  |  Height:  |  Size: 366 B

62
package.json Normal file
View File

@@ -0,0 +1,62 @@
{
"name": "bewly-bewly",
"displayName": "BewlyBewly",
"version": "0.0.1",
"description": "[description]",
"private": true,
"scripts": {
"dev": "npm run clear && cross-env NODE_ENV=development run-p dev:*",
"dev:prepare": "esno scripts/prepare.ts",
"dev:web": "vite",
"dev:js": "npm run build:js -- --mode development",
"build": "cross-env NODE_ENV=production run-s clear build:web build:prepare build:js",
"build:prepare": "esno scripts/prepare.ts",
"build:web": "vite build",
"build:js": "vite build --config vite.config.content.ts",
"pack": "cross-env NODE_ENV=production run-p pack:*",
"pack:zip": "rimraf extension.zip && jszip-cli add extension -o ./extension.zip",
"pack:crx": "crx pack extension -o ./extension.crx",
"pack:xpi": "cross-env WEB_EXT_ARTIFACTS_DIR=./ web-ext build --source-dir ./extension --filename extension.xpi --overwrite-dest",
"start:chromium": "web-ext run --source-dir ./extension --target=chromium",
"start:firefox": "web-ext run --source-dir ./extension --target=firefox-desktop",
"clear": "rimraf extension/dist extension/manifest.json extension.*",
"lint": "eslint 'src/**/*.{json,ts,js,vue}'"
},
"devDependencies": {
"@antfu/eslint-config": "^0.9.0",
"@ffflorian/jszip-cli": "^3.1.5",
"@iconify/json": "^1.1.408",
"@types/chrome": "^0.0.179",
"@types/fs-extra": "^9.0.13",
"@types/node": "^16.10.2",
"@types/webextension-polyfill": "^0.8.2",
"@typescript-eslint/eslint-plugin": "^4.32.0",
"@vitejs/plugin-vue": "^1.9.2",
"@vue/compiler-sfc": "^3.2.19",
"@vueuse/core": "^7.5.1",
"chokidar": "^3.5.2",
"cross-env": "^7.0.3",
"crx": "^5.0.1",
"eslint": "^7.32.0",
"esno": "^0.10.0",
"fs-extra": "^10.0.0",
"kolorist": "^1.5.0",
"npm-run-all": "^4.1.5",
"rimraf": "^3.0.2",
"typescript": "^4.4.3",
"unplugin-auto-import": "^0.5.11",
"unplugin-icons": "^0.11.4",
"unplugin-vue-components": "^0.15.4",
"vite": "^2.6.2",
"vite-plugin-windicss": "^1.4.8",
"vue": "^3.2.19",
"vue-demi": "^0.11.4",
"web-ext": "^6.4.0",
"webext-bridge": "^5.0.0",
"webextension-polyfill": "^0.8.0"
},
"dependencies": {
"esbuild-darwin-64": "0.13.3",
"vue-router": "4"
}
}

5861
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

10
scripts/manifest.ts Normal file
View File

@@ -0,0 +1,10 @@
import fs from 'fs-extra'
import { getManifest } from '../src/manifest'
import { r, log } from './utils'
export async function writeManifest() {
await fs.writeJSON(r('extension/manifest.json'), await getManifest(), { spaces: 2 })
log('PRE', 'write manifest.json')
}
writeManifest()

44
scripts/prepare.ts Normal file
View File

@@ -0,0 +1,44 @@
// generate stub index.html files for dev entry
import { execSync } from 'child_process'
import fs from 'fs-extra'
import chokidar from 'chokidar'
import { r, port, isDev, log } from './utils'
/**
* Stub index.html to use Vite in development
*/
async function stubIndexHtml() {
const views = [
'options',
'popup',
'background',
]
for (const view of views) {
await fs.ensureDir(r(`extension/dist/${view}`))
let data = await fs.readFile(r(`src/${view}/index.html`), 'utf-8')
data = data
.replace('"./main.ts"', `"http://localhost:${port}/${view}/main.ts"`)
.replace('<div id="app"></div>', '<div id="app">Vite server did not start</div>')
await fs.writeFile(r(`extension/dist/${view}/index.html`), data, 'utf-8')
log('PRE', `stub ${view}`)
}
}
function writeManifest() {
execSync('npx esno ./scripts/manifest.ts', { stdio: 'inherit' })
}
writeManifest()
if (isDev) {
stubIndexHtml()
chokidar.watch(r('src/**/*.html'))
.on('change', () => {
stubIndexHtml()
})
chokidar.watch([r('src/manifest.ts'), r('package.json')])
.on('change', () => {
writeManifest()
})
}

11
scripts/utils.ts Normal file
View File

@@ -0,0 +1,11 @@
import { resolve } from 'path'
import { bgCyan, black } from 'kolorist'
export const port = parseInt(process.env.PORT || '') || 3303
export const r = (...args: string[]) => resolve(__dirname, '..', ...args)
export const isDev = process.env.NODE_ENV !== 'production'
export function log(name: string, message: string) {
// eslint-disable-next-line no-console
console.log(black(bgCyan(` ${name} `)), message)
}

10
shim.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
import { ProtocolWithReturn } from 'webext-bridge'
declare module 'webext-bridge' {
export interface ProtocolMap {
// define message protocol types
// see https://github.com/antfu/webext-bridge#type-safe-protocols
'tab-prev': { title: string | undefined }
'get-current-tab': ProtocolWithReturn<{ tabId: number }, { title?: string }>
}
}

View File

@@ -0,0 +1,18 @@
import { isFirefox, isForbiddenUrl } from '~/env'
// Firefox fetch files from cache instead of reloading changes from disk,
// hmr will not work as Chromium based browser
browser.webNavigation.onCommitted.addListener(({ tabId, frameId, url }) => {
// Filter out non main window events.
if (frameId !== 0)
return
if (isForbiddenUrl(url))
return
// inject the latest scripts
browser.tabs.executeScript(tabId, {
file: `${isFirefox ? '' : '.'}/dist/contentScripts/index.global.js`,
runAt: 'document_end',
}).catch(error => console.error(error))
})

10
src/background/index.html Normal file
View File

@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Background</title>
</head>
<body>
<script type="module" src="./main.ts"></script>
</body>
</html>

172
src/background/main.ts Normal file
View File

@@ -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
}
})

18
src/components/Logo.vue Normal file
View File

@@ -0,0 +1,18 @@
<template>
<a
class="icon-btn mx-2 text-2xl"
rel="noreferrer"
href="https://github.com/antfu/vitesse-webext"
target="_blank"
title="GitHub"
>
<pixelarticons-power />
<user-line />
<IconAcceptDatabase />
</a>
</template>
<script setup>
import IconAccessibility from '~icons/carbon/accessibility'
import IconAccountBox from '~icons/mdi/account-box'
// import IconAcceptDatabase from '~icons/flat-color-icons/accept-database'
</script>

11
src/components/README.md Normal file
View File

@@ -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.

View File

@@ -0,0 +1,229 @@
<template>
<div
id="search-wrap"
w="full max-500px"
m="x-8"
pos="relative"
>
<div
v-if="isFocus"
class="fixed top-0 left-0"
w="full"
h="full"
content="~"
@click="isFocus = false"
></div>
<div class="search-bar">
<input
v-model.trim="keyword"
type="text"
@focus="isFocus = true"
@input="onSearchChange()"
@keyup.enter="goToSearchPage(keyword)"
@keyup.up="onUp"
@keyup.down="onDown"
/>
<button class="search-btn" @click="goToSearchPage(keyword)">
<tabler:search />
</button>
</div>
<transition>
<div v-if="isFocus && !keyword && searchHistory.length !== 0" id="search-history">
<div
v-for="(item) in searchHistory"
:key="item.value"
class="history-item"
@click="goToSearchPage(item.value)"
>
{{ item.value }}
<button class="delete" @click.stop="onDelHistoryItem(item.value)">
<ic-baseline-clear />
</button>
</div>
</div>
</transition>
<transition>
<div v-if="isFocus && Object.keys(suggestions).length !== 0" id="search-suggestion">
<div
v-for="(item, index) in suggestions"
:key="index"
class="suggestion-item"
@click="goToSearchPage(keyword)"
>
{{ item.name }}
</div>
</div>
</transition>
</div>
</template>
<script lang="ts">
import { addSearchHistory, getSearchHistory, HistoryItem, removeSearchHistory } from './search-history-provider'
export default defineComponent({
data() {
return {
isFocus: false,
keyword: '',
suggestions: {},
selectedIndex: -1,
searchHistory: [] as HistoryItem[],
}
},
mounted() {
this.searchHistory = getSearchHistory()
},
methods: {
onSearchChange() {
browser.runtime
.sendMessage({
contentScriptQuery: 'getSearchSuggestion',
term: this.keyword,
})
.then((res) => {
this.suggestions = res
})
},
goToSearchPage(keyword: string) {
if (keyword) {
window.open(`https://search.bilibili.com/all?keyword=${keyword}`, '_blank')
const searchItem = {
value: keyword,
timestamp: Number(new Date()),
}
addSearchHistory(searchItem)
this.searchHistory = getSearchHistory()
}
},
onDelHistoryItem(value: string) {
removeSearchHistory(value)
this.searchHistory = getSearchHistory()
},
onUp() {
if (this.selectedIndex <= 0) {
this.selectedIndex = 0
return
}
this.selectedIndex--
this.keyword = this.suggestions[this.selectedIndex].value
document.querySelectorAll('.suggestion-item').forEach((item, index) => {
if (index === this.selectedIndex)
item.classList.add('active')
else
item.classList.remove('active')
})
document.querySelectorAll('.history-item').forEach((item, index) => {
if (index === this.selectedIndex)
item.classList.add('active')
else
item.classList.remove('active')
})
},
onDown() {
if (this.selectedIndex >= Object.keys(this.suggestions).length - 1) {
this.selectedIndex = Object.keys(this.suggestions).length - 1
return
}
this.selectedIndex++
this.keyword = this.suggestions[this.selectedIndex].value
document.querySelectorAll('.suggestion-item').forEach((item, index) => {
if (index === this.selectedIndex)
item.classList.add('active')
else
item.classList.remove('active')
})
document.querySelectorAll('.history-item').forEach((item, index) => {
if (index === this.selectedIndex)
item.classList.add('active')
else
item.classList.remove('active')
})
},
},
})
</script>
<style lang="scss" scoped>
.v-enter-active,
.v-leave-active {
@apply transition-all duration-300
}
.v-enter-from,
.v-leave-to {
@apply transform translate-y-4 opacity-0 scale-95
}
#search-wrap {
@mixin card-content {
@apply text-base border-none outline-none w-full
bg-$bew-content-1 backdrop-$bew-filter-glass;
box-shadow: var(--bew-shadow-2);
backdrop-filter: var(--bew-filter-glass);
}
.search-bar {
@apply flex items-center relative;
input {
@include card-content;
@apply rounded-$bew-radius pl-6 pr-16 py-3 text-$bew-text-1;
}
.search-btn {
@apply p-2 rounded-full text-lg leading-0 duration-300
border-none outline-none absolute right-2
text-$bew-text-1
hover:bg-$bew-fill-2;
}
}
@mixin search-content {
@include card-content;
@apply mt-2 p-2 absolute rounded-$bew-radius
hover:block;
}
@mixin search-content-item {
@apply px-4 py-2 w-full rounded-$bew-radius duration-300 cursor-pointer
not-first:mt-1
hover:bg-$bew-fill-2;
&.active {
@apply bg-$bew-fill-2;
}
}
#search-history,
#search-suggestion {
@include search-content;
.history-item,
.suggestion-item {
@include search-content-item;
}
}
#search-history {
.history-item {
@apply flex justify-between items-center;
.delete {
@apply rounded-full duration-300 text-base
leading-0 pointer-events-auto cursor-pointer
text-$bew-text-2;
}
}
}
}
</style>

View File

@@ -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))
}

101
src/components/Settings.vue Normal file
View File

@@ -0,0 +1,101 @@
<template>
<div class="fixed w-full h-full top-0 left-0 bg-black bg-opacity-30" z="9998" @click="close"></div>
<div id="settings-window" z="9999">
<div
class="absolute right-0 top-0 transform translate-x-1/2 -translate-y-1/2"
text="2xl"
font="leading-0"
bg="$bew-content-solid-1"
w="32px"
h="32px"
p="1"
rounded="full"
shadow="md"
cursor="pointer"
@click="close"
>
<ic-baseline-clear />
</div>
<div text="3xl" m="b-4">
Settings
</div>
<div class="settings-item">
<div>
Authorize BewlyBewly to use Access Key
<br />
<span class="desc">this change will make you able to use recommend videos</span>
</div>
<button
v-if="accessKey + '' === 'undefined' || accessKey + '' === 'null'"
ref="authorizeBtn"
class="btn"
@click="onAuthorize"
>
Authorize
</button>
<button v-else class="line-btn" @click="onRevoke">
<span>Revoke</span>
</button>
</div>
<div class="settings-item">
<div>
Topbar visiable
<br />
<span class="desc">Compatible with bilibili evolved</span>
</div>
<div>
<label for="topbarVisiable" class="chk-btn" cursor="pointer" pointer="auto">
<template v-if="isShowTopbar">Show</template>
<template v-else>Hidden</template>
<input id="topbarVisiable" v-model="isShowTopbar" type="checkbox" />
</label>
</div>
</div>
</div>
</template>
<script lang="ts">
import { grantAccessKey, revokeAccessKey } from '~/utils/index'
import { isShowTopbar, apperance, accessKey } from '~/logic/storage'
export default defineComponent({
emits: ['close'],
data() {
return {
isShowTopbar,
apperance,
accessKey,
}
},
methods: {
close() {
this.$emit('close')
},
onAuthorize() {
const authorizeBtn = this.$refs.authorizeBtn as HTMLButtonElement
grantAccessKey(authorizeBtn)
},
onRevoke() {
revokeAccessKey()
},
},
})
</script>
<style lang="scss">
#settings-window {
@apply fixed top-1/5 left-1/2 w-700px h-1/2
transform -translate-x-1/2
rounded-$bew-radius p-8
bg-$bew-content-solid-1;
box-shadow: var(--bew-shadow-3);
.settings-item {
@apply flex justify-between items-center py-2;
.desc {
@apply text-xs text-$bew-text-3;
}
}
}
</style>

View File

@@ -0,0 +1,89 @@
<template>
<div
style="backdrop-filter: var(--bew-filter-glass); box-shadow: var(--bew-shadow-3);"
flex="~"
p="4"
bg="$bew-content-1"
rounded="$bew-radius"
>
<ul
v-for="(item, index) in [0, 10, 20, 30]"
:key="index"
m="r-1 last-of-type:r-0"
>
<li
v-for="genre in genres.slice(item, item + 10)"
:key="genre.name"
m="b-1 last-of-type:b-0"
text="sm"
>
<a
:href="genre.href"
target="_blank"
flex="~"
items="center"
w="170px"
p="x-2 y-2"
hover:bg="$bew-fill-2"
transition="duration-300"
border="rounded-$bew-radius"
>
<svg aria-hidden="true" class="svg-icon">
<use :xlink:href="genre.icon" />
</svg>
<span>{{ genre.name }}</span>
</a>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
const genres = [
{ name: 'Anime', icon: '#channel-anime', href: 'https://www.bilibili.com/anime' },
{ name: 'Movies', icon: '#channel-movie', href: 'https://www.bilibili.com/movie' },
{ name: 'Chinese anime', icon: '#channel-guochuang', href: 'https://www.bilibili.com/guochuang' },
{ name: 'TV shows', icon: '#channel-teleplay', href: 'https://www.bilibili.com/tv' },
{ name: 'Variety shows', icon: '#channel-zongyi', href: 'https://www.bilibili.com/variety' },
{ name: 'Documentary films', icon: '#channel-documentary', href: 'https://www.bilibili.com/documentary' },
{ name: 'Animations', icon: '#channel-douga', href: 'https://www.bilibili.com/v/douga' },
{ name: 'Gaming', icon: '#channel-game', href: 'https://www.bilibili.com/v/game' },
{ name: 'Kichiku', icon: '#channel-kichiku', href: 'https://www.bilibili.com/v/kichiku' },
{ name: 'Music', icon: '#channel-music', href: 'https://www.bilibili.com/v/music' },
{ name: 'Dance', icon: '#channel-dance', href: 'https://www.bilibili.com/v/dance' },
{ name: 'Cinephile', icon: '#channel-cinephile', href: 'https://www.bilibili.com/v/cinephile' },
{ name: 'Showbiz', icon: '#channel-ent', href: 'https://www.bilibili.com/v/ent' },
{ name: 'Knowledge', icon: '#channel-knowledge', href: 'https://www.bilibili.com/v/knowledge' },
{ name: 'Technology', icon: '#channel-tech', href: 'https://www.bilibili.com/v/tech' },
{ name: 'News', icon: '#channel-information', href: 'https://www.bilibili.com/v/information' },
{ name: 'Foods', icon: '#channel-food', href: 'https://www.bilibili.com/v/food' },
{ name: 'Life', icon: '#channel-life', href: 'https://www.bilibili.com/v/life' },
{ name: 'Cars', icon: '#channel-car', href: 'https://www.bilibili.com/v/car' },
{ name: 'Fashion', icon: '#channel-fashion', href: 'https://www.bilibili.com/v/fashion' },
{ name: 'Sports', icon: '#channel-sports', href: 'https://www.bilibili.com/v/sports' },
{ name: 'Animals', icon: '#channel-animal', href: 'https://www.bilibili.com/v/animal' },
{ name: 'VLOG', icon: '#channel-vlog', href: 'https://www.bilibili.com/v/life/daily/#/530003' },
{ name: 'Funny', icon: '#channel-gaoxiao', href: 'https://www.bilibili.com/v/life/funny' },
{ name: 'Standalone gaming', icon: '#channel-danjiyouxi', href: 'https://www.bilibili.com/v/game/stand_alone' },
{ name: 'Vtubers & Vups', icon: '#channel-vtuber', href: 'https://www.bilibili.com/v/virtual' },
{ name: 'Charitable events', icon: '#channel-love', href: 'https://love.bilibili.com' },
{ name: 'MOOCs', icon: '#channel-gongkaike', href: 'https://www.bilibili.com/mooc' },
{ name: 'Articles', icon: '#channel-read', href: 'https://www.bilibili.com/read/home' },
{ name: 'Live', icon: '#channel-live', href: 'https://live.bilibili.com' },
{ name: 'Activities', icon: '#channel-activity', href: 'https://www.bilibili.com/blackboard/activity-list.html' },
{ name: 'Paid courses', icon: '#channel-zhishi', href: 'https://www.bilibili.com/cheese' },
{ name: 'Community', icon: '#channel-blackroom', href: 'https://www.bilibili.com/blackboard/activity-5zJxM3spoS.html' },
{ name: 'Music plus', icon: '#channel-musicplus', href: 'https://www.bilibili.com/v/musicplus' },
]
</script>
<style lang="scss" scoped>
.svg-icon {
width: 2em;
height: 2em;
vertical-align: bottom;
fill: currentColor;
overflow: hidden;
margin-right: 1rem;
}
</style>

View File

@@ -0,0 +1,4 @@
<template>
<div>
</div>
</template>

View File

@@ -0,0 +1,175 @@
<template>
<div id="user-info-panel">
<div id="base-info">
{{ userInfo.uname ? userInfo.uname : '-' }}
<div
class="bg-$bew-theme-color px-3 py-1 ml-2 text-white rounded-$bew-radius text-base leading-none"
>
{{ userInfo.level_info?.current_level ? userInfo.level_info.current_level : '0' }}
</div>
</div>
<div class="text-sm text-$bew-text-2" flex="~" items="center" justify="center" m="t-1 b-3">
<a
class="mr-4"
href="https://account.bilibili.com/account/coin"
target="_blank"
>Money: {{ userInfo.money }}</a>
<a
href="https://pay.bilibili.com/pay-v2-web/bcoin_index"
target="_blank"
>B-coin: {{ userInfo.wallet?.bcoin_balance }}</a>
</div>
<div id="channel-info">
<a
:href="'https://space.bilibili.com/' + mid + '/fans/follow'"
target="_blank"
:title="userStat.following"
>
<div class="num">{{ userStat.following ? numFormatter(userStat.following) : '0' }}</div>
<div>following</div>
</a>
<a
:href="'https://space.bilibili.com/' + mid + '/fans/fans'"
target="_blank"
:title="userStat.follower"
>
<div class="num">{{ userStat.follower ? numFormatter(userStat.follower) : '0' }}</div>
<div>follower</div>
</a>
<a
href="https://t.bilibili.com/"
target="_blank"
:title="userStat.dynamic_count"
>
<div class="num">{{ userStat.dynamic_count ? numFormatter(userStat.dynamic_count) : '0' }}</div>
<div>posts</div>
</a>
</div>
<div id="other-link">
<a href="https://account.bilibili.com/account/home" target="_blank">
Account settings
<tabler:arrow-right />
</a>
<a href="https://member.bilibili.com/v2#/upload-manager/article" target="_blank">
Uploads manager
<tabler:arrow-right />
</a>
<a href="https://pay.bilibili.com/" target="_blank">
B-coin Wallet
<tabler:arrow-right />
</a>
<a href="https://show.bilibili.com/orderlist" target="_blank">
Orders
<tabler:arrow-right />
</a>
<a href="https://link.bilibili.com/p/center/index" target="_blank">
My stream info
<tabler:arrow-right />
</a>
<a href="https://www.bilibili.com/cheese/mine/list" target="_blank">
My course
<tabler:arrow-right />
</a>
<div id="logout" @click="logout()">
Log out
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { revokeAccessKey } from '../../utils/index'
import { getCSRF, getUserID, numFormatter } from '~/utils'
export default defineComponent({
props: {
userInfo: {
type: Object,
required: true,
},
},
data() {
return {
mid: getUserID(),
userStat: {} as any,
numFormatter,
}
},
mounted() {
browser.runtime
.sendMessage({
contentScriptQuery: 'getUserStat',
})
.then((res) => {
if (res.code === 0) this.userStat = res.data
})
},
methods: {
async logout() {
revokeAccessKey()
await browser.runtime
.sendMessage({
contentScriptQuery: 'logout',
biliCSRF: getCSRF(),
})
location.reload()
},
},
})
</script>
<style lang="scss" scoped>
#user-info-panel {
@apply p-4 rounded-$bew-radius w-300px -z-1
bg-$bew-content-solid-1;
box-shadow: var(--bew-shadow-3);
}
#base-info {
@apply mt-8 text-xl font-medium flex items-center justify-center;
}
#channel-info {
@apply grid grid-cols-3 gap-x-2 mb-2;
a {
@apply p-2 m-0 rounded-$bew-radius text-sm
flex flex-col items-center transition-all duration-300
bg-$bew-fill-1
hover:$bg-$bew-theme-color
hover:text-white '!hover:children:text-white';
> * {
@apply duration-300;
}
.num {
@apply font-bold text-xl;
+ div {
@apply text-$bew-text-2;
}
}
}
}
#other-link {
@apply flex justify-between flex-col mt-4;
a {
@apply px-4 py-2 mb-1 flex justify-between items-center
rounded-$bew-radius transition-all duration-300
hover:bg-$bew-fill-2;
span {
@apply text-$bew-text-2;
}
}
}
#logout {
@apply text-red-400 '!block' px-4 py-2 rounded-$bew-radius
duration-300 cursor-pointer
hover:bg-$bew-fill-2;
}
</style>

View File

@@ -0,0 +1,354 @@
<template>
<header>
<transition name="topbar">
<div
v-show="showTopbarMask"
class="fixed top-0 left-0"
w="full"
h="160px"
opacity="100"
pointer="none"
style="background: linear-gradient(var(--bew-bg), transparent)"
></div>
</transition>
<div
class="left-side"
@mouseenter.self="showLogoMenuDropdown()"
@mouseleave.self="closeLogoMenuDropdown()"
>
<div
ref="logo"
class="logo"
>
<svg
t="1645466458357"
class="icon"
viewBox="0 0 2299 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="2663"
width="60"
height="60"
>
<path
d="M1775.840814 322.588002c6.0164 1.002733 53.144869-9.525967 55.150336-6.016401 3.0082 4.5123 24.065601 155.92504 18.550567 156.927774s-44.621635 10.027334-44.621635 10.027334c-3.0082-20.556034-28.577901-147.903173-29.079268-160.938707m75.205003-14.539634l20.556034 162.944174c10.5287-0.501367 53.144869-3.509567 57.155803-4.010934-6.0164-61.668103-16.545101-158.933241-16.545101-158.93324-20.054668-4.010934-41.112069-4.010934-61.166736 0m-40.610702 226.116376s92.752838-23.564234 126.344406-12.0328c17.046467 61.668103 48.131202 407.611118 51.139402 421.649386-21.057401 2.506833-90.246004 8.523234-95.761037 10.027333-4.5123-26.071068-81.72277-403.098818-81.722771-419.643919m343.436183-207.565809c5.515034 1.5041 54.648969-5.013667 55.150335-1.5041 1.002733 12.032801 6.0164 157.42914 0.501367 157.930507s-44.621635 4.010934-44.621635 4.010934c-1.002733-20.054668-12.032801-146.90044-11.030067-160.437341m75.70637-4.010933l4.010933 160.938707c10.5287 0 52.643502 2.506833 57.155803 2.005467-1.002733-61.668103 0-158.933241 0-158.933241-20.054668-3.509567-40.610702-5.013667-61.166736-4.010933m-64.676303 216.089043s94.758304-12.534167 126.845772 2.506833c7.019134 72.196803 6.0164 408.613852 7.019134 422.652119-21.558768 0-90.246004 1.002733-95.761038 2.005467-1.002733-26.071068-39.607968-410.619319-38.103868-427.164419m-220.099977-413.627519c54.648969 278.759879 96.262404 755.058234 97.766504 785.641602 0 0 43.117535 1.002733 91.750105 4.010934C2105.740095 614.383415 2070.644427 134.575493 2071.145794 119.033126c-12.032801-13.536901-126.344406 6.0164-126.344406 6.0164m-120.328005 659.297196c-10.5287-78.213204-290.291313-166.955108-447.720454-138.377206 0 0-19.553301-172.470141-27.073801-339.425248-6.517767-143.390873-1.002733-282.770813 0.501366-305.833681-10.5287-7.5205-123.837572 46.627102-185.004308 69.188603 0 0 73.199537 309.844614 126.344406 952.59671 0 0 84.730971 9.0246 230.12731-19.051934s317.365114-115.815705 302.825481-219.097244m-341.932083 140.88404l-24.566967-176.982441c6.0164-3.0082 156.927774 53.144869 172.971507 63.172203-2.506833 11.030067-148.40454 113.810238-148.40454 113.810238M610.664628 322.588002c6.0164 1.002733 53.144869-9.525967 55.150335-6.016401 3.0082 4.5123 24.065601 155.92504 18.550568 156.927774s-44.621635 10.027334-44.621635 10.027334c-3.0082-20.556034-28.577901-147.903173-29.079268-160.938707m75.205003-14.539634l20.556034 162.944174c10.5287-0.501367 53.144869-3.509567 57.155803-4.010934-6.517767-61.668103-16.545101-158.933241-16.545101-158.93324-20.054668-4.010934-41.112069-4.010934-61.166736 0m-40.610702 226.116376s92.752838-23.564234 126.344406-12.0328c17.046467 61.668103 48.131202 407.611118 51.139402 421.649386-21.057401 2.506833-90.246004 8.523234-95.761037 10.027333-4.5123-26.071068-81.72277-403.098818-81.722771-419.643919m343.436182-207.565809c5.515034 1.5041 54.648969-5.013667 55.150336-1.5041 1.002733 12.032801 6.0164 157.42914 0.501367 157.930507s-44.621635 4.010934-44.621635 4.010934c-1.002733-20.054668-11.531434-146.90044-11.030068-160.437341m75.706371-4.010933l4.010933 160.938707c10.5287 0 52.643502 2.506833 57.155803 2.005467-1.002733-61.668103 0-158.933241 0-158.933241-20.054668-3.509567-40.610702-4.5123-61.166736-4.010933m-64.676303 216.089043s94.758304-12.534167 126.845772 2.506833c7.019134 72.196803 6.0164 408.613852 7.019134 422.652119-21.558768 0-90.246004 1.002733-95.761038 2.005467-0.501367-26.071068-39.607968-410.619319-38.103868-427.164419m-220.099977-413.627519c54.648969 278.759879 96.262404 755.058234 97.766504 785.641602 0 0 43.117535 1.002733 91.750105 4.010934-28.577901-300.318647-63.67357-780.126569-63.172203-796.170303-12.032801-13.035534-126.344406 6.517767-126.344406 6.517767m-120.328005 659.297196c-10.5287-78.213204-290.291313-166.955108-447.720454-138.377206 0 0-19.553301-172.470141-27.073801-339.425248-6.517767-143.390873-1.002733-282.770813 0.501366-305.833681C174.475608-6.308547 61.166736 47.337689 0 69.89919c0 0 73.199537 309.844614 126.344406 952.59671 0 0 84.730971 9.0246 230.12731-19.051934s317.365114-115.815705 302.825481-219.097244m-341.932083 140.88404l-24.566967-176.982441c6.0164-3.0082 156.927774 53.144869 172.971507 63.172203-2.506833 11.030067-148.40454 113.810238-148.40454 113.810238"
p-id="2664"
/>
</svg>
<tabler:chevron-down w="!4" h="!4" m="l-4" icon="stroke-4 fill-$bew-text-1" />
</div>
<transition name="slide">
<logo-menu-dropdown
v-if="showLogoDropDown"
pos="absolute"
m="t-2"
after:content="t"
after:select="none"
after:opacity="0"
after:w="full"
after:h="2"
after:pos="absolute -top-2 left-0"
after:z="-1"
></logo-menu-dropdown>
</transition>
</div>
<search-bar></search-bar>
<div class="right-side">
<div v-if="!isLogin" class="right-side-item">
<a href="https://passport.bilibili.com/login" class="login">
<ic-outline-account-circle class="text-base mr-2" />LOGIN IN
</a>
</div>
<template v-if="isLogin">
<div
id="avatar"
class="right-side-item"
@mouseenter="openUserPanel"
@mouseleave="closeUserPanel"
>
<div
id="avatar-img"
ref="avatarImg"
class="rounded-full z-1 w-40px h-40px bg-$bew-fill-3 bg-cover bg-center"
:style="{ backgroundImage: `url(${(userInfo.face + '').replace('http:', '')})` }"
></div>
<div
id="avatar-shadow"
ref="avatarShadow"
class="absolute top-0 rounded-full z-0 w-40px h-40px filter blur-sm bg-cover bg-center"
opacity="80"
:style="{ backgroundImage: `url(${(userInfo.face + '').replace('http:', '')})` }"
></div>
<transition name="slide">
<user-panel-dropdown
v-if="showUserPanel"
:user-info="userInfo"
pos="absolute top-60px left-1/2"
m="-l-150px"
></user-panel-dropdown>
</transition>
</div>
<div class="right-side-item">
<div
v-if="unReadmessageCount !== 0"
class="unread-message"
>
{{ unReadmessageCount > 999 ? '999+' : unReadmessageCount }}
</div>
<a href="https://message.bilibili.com" target="_blank" title="Notifications">
<tabler:bell />
</a>
</div>
<div class="right-side-item">
<div
v-if="newMomentsCount !== 0"
class="unread-message"
>
{{ newMomentsCount > 999 ? '999+' : newMomentsCount }}
</div>
<a href="https://t.bilibili.com" target="_blank" title="Moments">
<tabler:windmill />
</a>
</div>
<div class="right-side-item">
<a
:href="'https://space.bilibili.com/' + mid + '/favlist'"
target="_blank"
title="Faviours"
>
<tabler:star />
</a>
</div>
<div class="right-side-item">
<a href="https://www.bilibili.com/account/history" target="_blank" title="History">
<tabler:clock />
</a>
</div>
<div class="right-side-item">
<a
href="https://member.bilibili.com/platform/home"
target="_blank"
title="Creative Center"
>
<tabler:bulb />
</a>
</div>
<div class="right-side-item">
<a
href="https://member.bilibili.com/platform/upload/video/frame"
target="_blank"
class="bg-$bew-theme-color rounded-full !text-white !text-base !px-4 mx-1"
w="xl:auto <xl:42px"
h="xl:auto <xl:42px"
p="xl:auto <xl:unset"
>
<tabler:upload />
<span
m="l-2"
display="xl:block <xl:hidden"
>Upload</span>
</a>
</div>
</template>
</div>
</header>
</template>
<script lang="ts">
import { getUserID } from '~/utils'
export default defineComponent({
data() {
return {
mid: getUserID() || '',
userInfo: {} as any,
showLogoDropDown: false,
showUserPanel: false,
showTopbarMask: false,
isLogin: !!getUserID(),
unReadmessage: {},
unReadDm: {},
unReadmessageCount: 0,
newMomentsCount: 0,
}
},
mounted() {
this.initUserPanel()
document.addEventListener('scroll', () => {
if (window.scrollY > 0)
this.showTopbarMask = true
else
this.showTopbarMask = false
})
},
methods: {
initUserPanel() {
this.getUserInfo()
this.getUnreadMessageCount()
this.getNewMomentsCount()
},
showLogoMenuDropdown() {
const logo = this.$refs.logo as HTMLElement
logo.classList.add('hover')
this.showLogoDropDown = true
},
closeLogoMenuDropdown() {
const logo = this.$refs.logo as HTMLElement
logo.classList.remove('hover')
this.showLogoDropDown = false
},
openUserPanel() {
this.showUserPanel = true
const avatarImg = this.$refs.avatarImg as HTMLImageElement
const avatarShadow = this.$refs.avatarShadow as HTMLImageElement
avatarImg.classList.add('hover')
avatarShadow.classList.add('hover')
},
closeUserPanel() {
this.showUserPanel = false
const avatarImg = this.$refs.avatarImg as HTMLImageElement
const avatarShadow = this.$refs.avatarShadow as HTMLImageElement
avatarImg.classList.remove('hover')
avatarShadow.classList.remove('hover')
},
getUserInfo() {
browser.runtime
.sendMessage({
contentScriptQuery: 'getUserInfo',
})
.then((res) => {
if (res.code === 0)
this.userInfo = res.data
})
},
async getUnreadMessageCount() {
await browser.runtime
.sendMessage({
contentScriptQuery: 'getUnreadMsg',
}).then(res => this.unReadmessage = res.data)
await browser.runtime
.sendMessage({
contentScriptQuery: 'getUnreadDm',
}).then(res => this.unReadDm = res.data)
for (const [key, value] of Object.entries(this.unReadmessage))
if (key !== 'up') this.unReadmessageCount += parseInt(value)
for (const [key, value] of Object.entries(this.unReadDm))
this.unReadmessageCount += parseInt(value)
// this.unReadmessageCount = this.unReadmessage.at + this.unReadmessage.chat + this.unReadmessage.like
// + this.unReadmessage.reply + this.unReadmessage.sys_msg
},
getNewMomentsCount() {
browser.runtime
.sendMessage({
contentScriptQuery: 'getNewMomentsCount',
}).then((res) => {
this.newMomentsCount = res.data.update_info.item.count
})
},
},
})
</script>
<style lang="scss" scoped>
.slide-enter-active,
.slide-leave-active {
@apply transition-all duration-300;
}
.slide-leave-to,
.slide-enter-from {
@apply transform -translate-y-4 opacity-0;
}
.topbar-enter-active,
.topbar-leave-active {
@apply transition-all duration-600;
}
.topbar-leave-to,
.topbar-enter-from {
@apply opacity-0;
}
header {
@apply flex justify-between px-23 py-2 w-screen items-center;
}
.left-side {
@apply relative;
.logo {
@apply flex items-center;
&.hover {
svg:nth-child(2) {
@apply transform rotate-180;
}
}
svg {
@apply w-60px h-auto filter drop-shadow-lg
fill-$bew-logo-color;
&:nth-child(2) {
@apply duration-300;
}
}
}
}
.right-side {
@apply flex h-60px items-center rounded-full p-2
bg-$bew-content-1;
backdrop-filter: var(--bew-filter-glass);
box-shadow: var(--bew-shadow-2);
.unread-message {
@apply absolute -top-1 right-0 "!px-1" "!py-2" rounded-full
text-xs leading-0 z-1 min-w-16px h-16px
flex justify-center items-center
bg-$bew-theme-color text-white;
box-shadow: 0 2px 4px rgba(var(--tw-shadow-color), 0.4);
}
&-item {
@apply relative;
}
&-item:not(#avatar) {
@apply min-w-40px;
a {
@apply text-xl flex items-center py-2 px-4;
}
}
.login {
@apply rounded-full "!text-$bew-theme-color" "!px-4" mx-1 border-1
flex items-center "!text-base"
border-solid border-$bew-theme-color;
}
#avatar {
@apply flex items-center ml-1 pr-2 relative z-1;
#avatar-img,
#avatar-shadow {
@apply duration-300;
&.hover {
@apply transform scale-230 translate-y-4;
}
}
#avatar-shadow {
@apply pointer-events-none;
}
}
}
</style>

View File

@@ -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 = <T>(
key: string,
initialValue: MaybeRef<T>,
options?: StorageAsyncOptions<T>,
): RemovableRef<T> => useStorageAsync(key, initialValue, storageLocal, options)

View File

@@ -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)
}
})()

View File

@@ -0,0 +1,77 @@
<template>
<transition>
<topbar v-if="isShowTopbar" class="fixed z-50"></topbar>
</transition>
<!-- is home page -->
<home></home>
<!-- button -->
<div class="fixed bottom-5 right-5" flex="~ col">
<button
class="transform active:scale-90"
w="45px"
h="45px"
p="3"
m="b-3"
bg="$bew-content-1"
text="2xl $bew-text-1"
font="leading-0"
duration="300"
rounded="$bew-radius"
style="box-shadow: var(--bew-shadow-2); backdrop-filter: var(--bew-filter-glass);"
@click="toggleDark()"
>
<tabler:moon-stars v-if="isDark" />
<tabler:sun v-else />
</button>
<button
class="leading-none transform active:scale-90"
w="45px"
h="45px"
p="3"
bg="$bew-content-1"
text="2xl $bew-text-1"
font="leading-0"
duration="300"
rounded="$bew-radius"
style="box-shadow: var(--bew-shadow-2); backdrop-filter: var(--bew-filter-glass);"
@click="toggle()"
>
<tabler-settings />
</button>
</div>
<settings v-if="showSettings" @close="showSettings = false"></settings>
</template>
<script setup lang="ts">
import { accessKey, apperance, isShowTopbar } from '~/logic/storage'
import { useToggle, useDark } from '@vueuse/core'
import 'virtual:windi.css'
import '~/styles/index.ts'
import Home from './Home/index.vue'
import { getUserID, grantAccessKey } from '~/utils'
const [showSettings, toggle] = useToggle(false)
// auto dark mode
// const style = document.createElement('style')
const isDark = useDark()
const toggleDark = useToggle(isDark)
// if (getUserID())
// grantAccessKey()
</script>
<style lang="scss">
.v-enter-active,
.v-leave-active {
transition: all 0.5s ease;
}
.v-enter-from,
.v-leave-to {
@apply opacity-0 transform -translate-y-full;
}
</style>

View File

@@ -0,0 +1,265 @@
<template>
<div
m="x-22 b-0 t-0"
grid="~ xl:cols-4 lg:cols-3 md:cols-2 gap-4"
>
<transition-group
name="list"
@enter="onEnter"
>
<div
v-for="(video, index) in videoList"
:key="video.idx"
:data-index="index"
class="video-card"
>
<a :href="'/video/av' + video.uri.split('/')[3]" target="_blank">
<div class="thumbnail">
<div class="duration">{{ calcCurrentTime(video.duration) }}</div>
<div
class="overflow-hidden w-full relative rounded-$bew-radius z-1"
style="aspect-ratio: 16/9"
>
<img class="cover" :src="video.cover + '@672w_378h_1c'" loading="lazy" />
</div>
<img class="cover-shadow" :src="video.cover + '@672w_378h_1c'" loading="lazy" />
</div>
<div class="detail">
<div class="flex">
<a class="avatar" @click="gotoChannel(video.mid)">
<img
:src="(video.face + '').replace('http:', '') + '@60w_60h_1c'"
width="48"
height="48"
loading="lazy"
/>
</a>
</div>
<div class="meta">
<h3 class="video-title" :title="video.title">{{ video.title }}</h3>
<div class="channel-name" @click="gotoChannel(video.mid)">{{ video.name }}</div>
<div class="video-info">
{{ numFormatter(video.play) }} views
<span class="text-xs font-light"></span>
{{ calcTimeSince(new Date(video.ctime * 1000)) }} ago
</div>
</div>
</div>
</a>
</div>
</transition-group>
</div>
<div
v-if="isLoading"
w="full"
h="46px"
p="y-8"
flex="~"
justify="center"
items="center"
>
<img
src="https://s2.loli.net/2022/03/20/4YZhnF1cmya6tHO.gif"
alt="loading"
w="46px"
h="46px"
m="r-2"
/>
Loading more...
</div>
<div
v-if="!isLoading"
style="height: 100px"
w="full"
p="y-8"
flex="~"
items="center"
justify="center"
>
<button
style="box-shadow: var(--bew-shadow-2);"
bg="$bew-content-1"
p="x-8 y-4"
text="$bew-text-1"
rounded="$bew-radius"
flex="~"
items="center"
@click="onRefresh"
>
<tabler:refresh class="mr-2" />Click to refresh
</button>
</div>
</template>
<script lang="ts">
import { accessKey } from '~/logic/storage'
import { numFormatter, calcTimeSince, calcCurrentTime } from '~/utils'
export default defineComponent({
data() {
return {
videoList: [] as any[],
isLoading: true,
numFormatter,
calcTimeSince,
calcCurrentTime,
MAX_LIMIT: 150 as const,
}
},
mounted() {
this.getRecommendVideo()
this.getRecommendVideo()
window.onscroll = async() => {
if ((window.innerHeight + window.scrollY) >= document.body.scrollHeight) {
if (!this.isLoading)
return
if (this.videoList.length <= (this.MAX_LIMIT - 20)) {
await this.getRecommendVideo(this.videoList[this.videoList.length - 1]?.idx)
await this.getRecommendVideo(this.videoList[this.videoList.length - 1]?.idx)
}
else {
this.isLoading = false
}
}
}
},
methods: {
async getRecommendVideo(idx?: number) {
const response = await browser.runtime
.sendMessage({
contentScriptQuery: 'getRecommendVideo',
idx,
accessKey: accessKey.value,
})
if (response.code === 0) {
// when videoList has length property, it means it is the first time to load
if (!this.videoList.length) {
this.videoList = response.data
}
else {
// else we concat the new data to the old data
this.videoList = this.videoList.concat(response.data)
}
}
},
gotoChannel(mid: number) {
window.open(`//space.bilibili.com/${mid}`)
},
onRefresh() {
this.isLoading = true
this.videoList = []
},
onEnter(el, done) {
// el.dataset.index > 10
const delay = (el.dataset.index / 10).toFixed(1).split('.')[1]
el.style.transitionDelay = `${delay * 0.1}s`
done()
// gsap.to(el, {
// opacity: 1,
// height: '1.6em',
// delay: el.dataset.index * 0.15,
// onComplete: done,
// })
},
},
})
</script>
<style lang="scss" scoped>
.list-move,
.list-enter-active,
.list-leave-active {
transition: opacity 0.5s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
}
.list-leave-active {
display: none;
}
.video-card {
@apply p-1 rounded-$bew-radius duration-300
z-0
active:bg-$bew-fill-2;
&:hover {
@apply z-10;
}
.cover-shadow {
@apply absolute top-0 left-0 w-full h-full filter -z-1
pointer-events-none duration-600 rounded-$bew-radius
opacity-80;
aspect-ratio: 16/9;
}
&:hover .cover-shadow {
@apply blur-lg transform;
}
.thumbnail {
@apply w-full rounded-$bew-radius relative duration-300;
aspect-ratio: 16/9;
.duration {
@apply absolute z-2 bottom-0 right-0 px-2 py-1
m-1 rounded-$bew-radius text-xs
text-white
bg-black bg-opacity-60;
}
.cover {
@apply w-full h-full bg-cover bg-center transform scale-110 duration-300
absolute bg-$bew-fill-3;
aspect-ratio: 16/9;
}
}
&:hover .thumbnail {
@apply transform scale-105;
}
&:hover .cover {
@apply transform scale-100;
}
.detail {
@apply flex mt-4;
.avatar {
@apply mr-4 h-48px rounded-$bew-radius overflow-hidden
object-center object-cover
bg-$bew-fill-3;
}
.meta {
@apply flex flex-col items-start;
.video-title {
@apply text-lg max-h-13 overflow-hidden overflow-ellipsis whitespace-normal
text-$bew-text-1;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.channel-name,
.video-info {
@apply text-base text-$bew-text-2;
}
.channel-name {
@apply mt-2;
}
}
}
}
</style>

View File

@@ -0,0 +1,18 @@
<template>
<div bg="$bew-bg">
<div class="banner flex justify-center items-center flex-col"></div>
<recommend-content></recommend-content>
</div>
</template>
<script setup lang="ts">
import RecommendContent from './RecommendContent.vue'
</script>
<style lang="scss">
.banner {
height: 83px;
max-height: unset;
transition: 0.3s;
}
</style>

14
src/env.ts Normal file
View File

@@ -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')

6
src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
declare const __DEV__: boolean
declare module '*.vue' {
const component: any
export default component
}

1
src/logic/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './storage'

6
src/logic/storage.ts Normal file
View File

@@ -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 })

56
src/manifest.ts Normal file
View File

@@ -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
}

23
src/options/Options.vue Normal file
View File

@@ -0,0 +1,23 @@
<template>
<main class="px-4 py-10 text-center text-gray-700 dark:text-gray-200">
<pixelarticons-sliders class="icon-btn mx-2 text-2xl" />
<div>Options</div>
<p class="mt-2 opacity-50">
This is the options page
</p>
<input v-model="storageDemo" class="border border-gray-400 rounded px-2 py-1 mt-2" />
<div class="mt-4">
Powered by Vite <pixelarticons-zap class="align-middle" />
</div>
</main>
</template>
<script setup lang="ts">
import { storageDemo } from '~/logic/storage'
</script>
<style lang="scss" scoped>
</style>

12
src/options/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<base target="_blank">
<title>Options</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>

6
src/options/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import App from './Options.vue'
import '../styles'
const app = createApp(App)
app.mount('#app')

50
src/popup/Popup.vue Normal file
View File

@@ -0,0 +1,50 @@
<template>
<main class="w-[300px] px-4 py-5 text-center text-gray-700">
<Logo />
<div>BewlyBewly</div>
<p class="mt-2 opacity-50">
This is the popup page
</p>
<div>
Disable topbar (Compatible with bilibili evolved)
<input v-model="isShowTopbar" type="checkbox" />
{{ isShowTopbar }}
</div>
<button class="btn mt-2" @click="openOptionsPage">
Open Options
</button>
<div class="mt-2">
<span class="opacity-50">Storage:</span>
{{ storageDemo }}
</div>
</main>
</template>
<script lang="ts">
import { storageDemo, isShowTopbar } from '~/logic/storage'
export default defineComponent({
data() {
return {
isShowTopbar,
storageDemo,
}
},
methods: {
openOptionsPage() {
browser.runtime.openOptionsPage()
},
},
})
// const isShowTopbar = false
// console.log(isShowTopbar)
// function openOptionsPage() {
// browser.runtime.openOptionsPage()
// }
</script>
<style lang="scss" scoped>
main {
font-size: 12px!important;
}
</style>

12
src/popup/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<base target="_blank">
<title>Popup</title>
</head>
<body style="min-width: 100px">
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>

6
src/popup/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import App from './Popup.vue'
import '../styles'
const app = createApp(App)
app.mount('#app')

3
src/styles/index.ts Normal file
View File

@@ -0,0 +1,3 @@
import './reset.scss'
import './main.scss'
import 'virtual:windi.css'

95
src/styles/main.scss Executable file
View File

@@ -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;
}
}

23
src/styles/reset.scss Normal file
View File

@@ -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
}
}

View File

@@ -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<void>((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 = `
<span class="animate-pulse">Loading...</span>
`
element.style.pointerEvents = 'none'
}
else if (state === 'default') {
element.innerHTML = orginalInnerHTML
element.style.pointerEvents = 'auto'
}
}
}

View File

@@ -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}`
}

43
src/utils/index.ts Normal file
View File

@@ -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<string> = 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')

1
src/utils/svgIcons.ts Normal file

File diff suppressed because one or more lines are too long

24
tsconfig.json Normal file
View File

@@ -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"]
}

43
vite.config.content.ts Normal file
View File

@@ -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,
},
}),
],
})

108
vite.config.ts Normal file
View File

@@ -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,
}),
],
}))

11
windi.config.ts Normal file
View File

@@ -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}')],
},
})