├── .prettierignore ├── website ├── .prettierrc.cjs ├── public │ ├── LeoKu.jpg │ ├── pixel-mona-heart.gif │ ├── manifest.webmanifest │ ├── favicon.svg │ └── favicon-dark.svg ├── .vscode │ └── settings.json ├── postcss.config.cjs ├── src │ ├── content │ │ ├── support.mdx │ │ ├── blog │ │ │ ├── whats-news-popup.md │ │ │ ├── whats-news-firefox-reading-preload.md │ │ │ ├── whats-news-preview-reply-share-image-and-more.md │ │ │ ├── first-release.md │ │ │ ├── major-feature-updates-of-note.md │ │ │ └── whats-news-2024-first-update.md │ │ ├── docs │ │ │ └── use-in-safari.md │ │ └── changelog.md │ ├── app │ │ ├── share │ │ │ ├── [topicId] │ │ │ │ └── page.tsx │ │ │ ├── components │ │ │ │ ├── ShareLoading.tsx │ │ │ │ ├── TopicLinkInput.tsx │ │ │ │ └── ShareCardThemeBasic.tsx │ │ │ └── page.tsx │ │ ├── api │ │ │ ├── img-to-base64 │ │ │ │ └── route.ts │ │ │ ├── share │ │ │ │ └── route.ts │ │ │ └── og │ │ │ │ └── route.tsx │ │ ├── changelog │ │ │ └── page.tsx │ │ ├── support │ │ │ └── page.tsx │ │ ├── blog │ │ │ ├── page.tsx │ │ │ └── [slug] │ │ │ │ └── page.tsx │ │ └── layout.tsx │ ├── components │ │ ├── Initial.tsx │ │ ├── PageHeaderTitle.tsx │ │ ├── QA.tsx │ │ ├── PageContainer.tsx │ │ ├── Introduction.tsx │ │ ├── HoverButton.tsx │ │ ├── icons │ │ │ ├── ChromeIcon.tsx │ │ │ ├── TampermonkeyIcon.tsx │ │ │ └── EdgeIcon.tsx │ │ ├── FeatureBg.tsx │ │ ├── Article.tsx │ │ ├── Feature.tsx │ │ ├── support │ │ │ ├── SupportText.tsx │ │ │ ├── SupportOptions.tsx │ │ │ └── SupportTable.tsx │ │ ├── NavLink.tsx │ │ ├── Nav.tsx │ │ ├── InstallButton.tsx │ │ ├── screens │ │ │ ├── ScreenHome.tsx │ │ │ ├── ScreenTopicWrite.tsx │ │ │ └── ScreenTopic.tsx │ │ ├── Screenshot.tsx │ │ └── Logo.tsx │ ├── utils.ts │ └── styles │ │ └── globals.css ├── .stylelintrc.cjs ├── tsconfig.json ├── .gitignore ├── .eslintrc.cjs ├── next.config.mjs ├── tailwind.config.ts ├── package.json └── contentlayer.config.ts ├── .prettierrc.cjs ├── assets └── appreciation-code.png ├── extension ├── images │ ├── icon-16.png │ ├── icon-32.png │ ├── icon-48.png │ ├── icon-128.png │ ├── icon-16-dark.png │ ├── icon-32-dark.png │ ├── icon-48-dark.png │ └── icon-128-dark.png └── scripts │ └── polyfill.js ├── src ├── contents │ ├── decode-base64.ts │ ├── write │ │ ├── index.ts │ │ └── write.ts │ ├── reading-list.ts │ ├── topic │ │ ├── paging.ts │ │ ├── index.ts │ │ ├── layout.ts │ │ ├── avatar.ts │ │ └── tool.ts │ ├── polyfill.ts │ ├── home │ │ ├── index.ts │ │ └── hot-topics.ts │ ├── globals.ts │ ├── member │ │ └── index.ts │ └── dom.ts ├── styles │ ├── v2ex-theme-mobile.scss │ ├── v2ex-theme-compact.scss │ ├── reset.css │ ├── v2ex-theme-dark.scss │ ├── v2ex-theme-dawn.scss │ ├── share.scss │ └── v2ex-theme-var.scss ├── pages │ ├── popup.var.ts │ ├── popup.type.ts │ └── popup.helper.ts ├── background │ ├── toggle-icon.ts │ ├── daily-check-in.ts │ └── main.ts ├── components │ ├── button.ts │ ├── toast.ts │ ├── image-upload.ts │ ├── modal.ts │ └── popup.ts ├── web_accessible_resources.ts └── user-scripts │ ├── write-style.mjs │ └── index.ts ├── tsconfig.json ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ └── custom.md └── CONTRIBUTING.md ├── .stylelintrc.cjs ├── .vscode └── settings.json ├── tsup.config.ts ├── .eslintrc.cjs ├── scripts ├── build.ts └── build-manifest.ts ├── package.json └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | asset 3 | website 4 | **/*.min.js 5 | -------------------------------------------------------------------------------- /website/.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require('prefer-code-style/prettier') 2 | -------------------------------------------------------------------------------- /website/public/LeoKu.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coolpace/V2EX_Polish/HEAD/website/public/LeoKu.jpg -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | const prettier = require('prefer-code-style/prettier') 2 | 3 | module.exports = prettier 4 | -------------------------------------------------------------------------------- /assets/appreciation-code.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coolpace/V2EX_Polish/HEAD/assets/appreciation-code.png -------------------------------------------------------------------------------- /extension/images/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coolpace/V2EX_Polish/HEAD/extension/images/icon-16.png -------------------------------------------------------------------------------- /extension/images/icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coolpace/V2EX_Polish/HEAD/extension/images/icon-32.png -------------------------------------------------------------------------------- /extension/images/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coolpace/V2EX_Polish/HEAD/extension/images/icon-48.png -------------------------------------------------------------------------------- /extension/images/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coolpace/V2EX_Polish/HEAD/extension/images/icon-128.png -------------------------------------------------------------------------------- /src/contents/decode-base64.ts: -------------------------------------------------------------------------------- 1 | import { decodeBase64TopicPage } from './helpers' 2 | 3 | decodeBase64TopicPage() 4 | -------------------------------------------------------------------------------- /extension/images/icon-16-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coolpace/V2EX_Polish/HEAD/extension/images/icon-16-dark.png -------------------------------------------------------------------------------- /extension/images/icon-32-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coolpace/V2EX_Polish/HEAD/extension/images/icon-32-dark.png -------------------------------------------------------------------------------- /extension/images/icon-48-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coolpace/V2EX_Polish/HEAD/extension/images/icon-48-dark.png -------------------------------------------------------------------------------- /extension/images/icon-128-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coolpace/V2EX_Polish/HEAD/extension/images/icon-128-dark.png -------------------------------------------------------------------------------- /website/public/pixel-mona-heart.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coolpace/V2EX_Polish/HEAD/website/public/pixel-mona-heart.gif -------------------------------------------------------------------------------- /src/contents/write/index.ts: -------------------------------------------------------------------------------- 1 | import { loadIcons } from '../helpers' 2 | import { handleWrite } from './write' 3 | 4 | handleWrite() 5 | 6 | loadIcons() 7 | -------------------------------------------------------------------------------- /website/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.preferences.importModuleSpecifier": "non-relative", 3 | "tailwindCSS.classAttributes": ["className", "tw"] 4 | } 5 | -------------------------------------------------------------------------------- /website/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'postcss-import': {}, 4 | 'tailwindcss/nesting': {}, 5 | tailwindcss: {}, 6 | autoprefixer: {}, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /src/styles/v2ex-theme-mobile.scss: -------------------------------------------------------------------------------- 1 | body.v2p-mobile { 2 | #Wrapper { 3 | .content { 4 | display: block; 5 | padding: 0; 6 | } 7 | } 8 | 9 | .box { 10 | border-radius: 0; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /website/src/content/support.mdx: -------------------------------------------------------------------------------- 1 | 作为开发者,创造对他人有用的东西始终是我们的热情所在,这个项目也不例外。我们投入了大量的时间和精力,致力于为 V2EX 用户带来更好的体验。 2 | 3 | 如果 V2EX Polish 帮助你节省了时间,让你的生活更加愉快,可以给开发者一点小小的赞赏,这会帮助插件更持续地发展! 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /website/src/app/share/[topicId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { ShareInfo } from '../components/ShareInfo' 2 | 3 | export default function TopicSharePage() { 4 | return ( 5 |
6 | 7 |
8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /website/.stylelintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [require.resolve('prefer-code-style/stylelint')], 3 | 4 | rules: { 5 | 'color-function-notation': 'modern', 6 | 'selector-id-pattern': null, 7 | }, 8 | 9 | ignoreFiles: ['public'], 10 | } 11 | -------------------------------------------------------------------------------- /extension/scripts/polyfill.js: -------------------------------------------------------------------------------- 1 | if (!CSS.supports('selector(:has(*))')) { 2 | // 检测到如果浏览器不支持 `:has()` 选择器,则使用后备方案能切换至深色模式。 3 | if (document.querySelector('#Wrapper')?.classList.contains('Night')) { 4 | document.body.classList.add('v2p-theme-dark-default') 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /website/src/components/Initial.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useEffect } from 'react' 4 | 5 | import splitbee from '@splitbee/web' 6 | 7 | export function Initial() { 8 | useEffect(() => { 9 | splitbee.init() 10 | }, []) 11 | 12 | return null 13 | } 14 | -------------------------------------------------------------------------------- /src/pages/popup.var.ts: -------------------------------------------------------------------------------- 1 | export const enum TabId { 2 | Reading = 'tab-reading', 3 | Hot = 'tab-hot', 4 | Latest = 'tab-latest', 5 | Message = 'tab-message', 6 | Feature = 'tab-feature', 7 | Setting = 'tab-setting', 8 | } 9 | 10 | export const defaultValue = '-' 11 | -------------------------------------------------------------------------------- /website/src/components/PageHeaderTitle.tsx: -------------------------------------------------------------------------------- 1 | export function PageHeaderTitle(props: React.PropsWithChildren) { 2 | return ( 3 |

4 | {props.children} 5 |

6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@codennnn/tsconfig/esm.json", 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "module": "ESNext", 6 | "moduleResolution": "Bundler", 7 | "verbatimModuleSyntax": false 8 | }, 9 | "include": ["./tsup.config.ts", "src/**/*", "scripts/**/*.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/contents/reading-list.ts: -------------------------------------------------------------------------------- 1 | import { addToReadingList } from './helpers' 2 | 3 | const url = $('head meta[property="og:url"]').prop('content') 4 | const title = $('head meta[property="og:title"]').prop('content') 5 | const content = $('head meta[property="og:description"]').prop('content') 6 | 7 | void addToReadingList({ url, title, content }) 8 | -------------------------------------------------------------------------------- /website/src/components/QA.tsx: -------------------------------------------------------------------------------- 1 | interface OAProps { 2 | q: string 3 | a: string 4 | } 5 | 6 | export function QA(props: OAProps) { 7 | return ( 8 |
9 |
{props.q}
10 |
{props.a}
11 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /website/src/components/PageContainer.tsx: -------------------------------------------------------------------------------- 1 | export function PageContainer(props: React.PropsWithChildren<{ className?: string }>) { 2 | return ( 3 |
7 | {props.children} 8 |
9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /website/public/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "V2EX Polish 浏览器插件官网", 3 | "name": "V2EX Polish 浏览器插件官网", 4 | "description": "专为 V2EX 用户设计的浏览器插件,提供了丰富的扩展功能为你带来出色的体验。", 5 | "theme_color": "#1e293b", 6 | "icons": [ 7 | { 8 | "src": "/favicon.svg", 9 | "type": "image/svg+xml", 10 | "sizes": "256x256" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /website/src/components/Introduction.tsx: -------------------------------------------------------------------------------- 1 | interface IntroductionProps { 2 | title: string 3 | content: string 4 | } 5 | 6 | export function Introduction(props: IntroductionProps) { 7 | return ( 8 |
9 | {props.title} 10 | {props.content} 11 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /dist 3 | .DS_Store 4 | .pnpm-debug.log* 5 | *.tsbuildinfo 6 | *.zip 7 | 8 | /src/user-scripts/style.ts 9 | 10 | # 由工具编译的产物 11 | /build* 12 | /extension/css/* 13 | /extension/scripts/* 14 | /extension/manifest.json 15 | /extension/manifest-firefox.json 16 | !/extension/scripts/jquery.min.js 17 | !/extension/scripts/polyfill.js 18 | 19 | # web-ext 产生的浏览器配置 20 | /*-profile 21 | /.idea 22 | -------------------------------------------------------------------------------- /src/background/toggle-icon.ts: -------------------------------------------------------------------------------- 1 | import { MessageKey } from '../constants' 2 | 3 | const setColorScheme = (perfersDark: boolean) => { 4 | void chrome.runtime.sendMessage({ [MessageKey.colorScheme]: perfersDark ? 'dark' : 'light' }) 5 | } 6 | 7 | const perfersDark = window.matchMedia('(prefers-color-scheme: dark)') 8 | 9 | setColorScheme(perfersDark.matches) 10 | 11 | perfersDark.addEventListener('change', ({ matches }) => { 12 | setColorScheme(matches) 13 | }) 14 | -------------------------------------------------------------------------------- /website/src/components/HoverButton.tsx: -------------------------------------------------------------------------------- 1 | export function HoverButton(props: React.PropsWithChildren) { 2 | return ( 3 | 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /src/components/button.ts: -------------------------------------------------------------------------------- 1 | export function createButton(props: { 2 | children: string 3 | className?: string 4 | type?: 'button' | 'submit' 5 | tag?: 'button' | 'a' 6 | }): JQuery { 7 | const { children, className = '', type = 'button', tag = 'button' } = props 8 | 9 | const $button = $(`<${tag} class="normal button ${className}">${children}`) 10 | 11 | if (tag === 'button') { 12 | $button.prop('type', type) 13 | } 14 | 15 | return $button 16 | } 17 | -------------------------------------------------------------------------------- /src/pages/popup.type.ts: -------------------------------------------------------------------------------- 1 | import type { Topic } from '../types' 2 | import type { TabId } from './popup.var' 3 | 4 | interface CommonTabStore { 5 | lastScrollTop?: number 6 | } 7 | 8 | export interface RemoteDataStore extends CommonTabStore { 9 | data?: Topic[] 10 | lastFetchTime?: number 11 | } 12 | 13 | export interface PopupStorageData { 14 | lastActiveTab: TabId 15 | [TabId.Hot]: RemoteDataStore 16 | [TabId.Latest]: RemoteDataStore 17 | [TabId.Setting]: CommonTabStore 18 | } 19 | -------------------------------------------------------------------------------- /website/src/components/icons/ChromeIcon.tsx: -------------------------------------------------------------------------------- 1 | export function ChromeIcon() { 2 | return ( 3 | 4 | 5 | 6 | 7 | 8 | 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /website/src/components/FeatureBg.tsx: -------------------------------------------------------------------------------- 1 | export function FeaturesBg(props: React.PropsWithChildren<{ className?: string }>) { 2 | const { children, className } = props 3 | 4 | return ( 5 |
12 | {children} 13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 提出问题或改善建议 3 | about: 发现问题?有改进的建议?向我们报告吧~ 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | 13 | ### 📝 问题描述 14 | 15 | 简要描述问题或需求,并说明对项目的影响。 16 | 17 | 例如: 18 | 19 | - 遇到的具体问题是什么? 20 | - 期望的结果或改进是什么? 21 | 22 | ### 📷 截图或示例 23 | 24 | 提供相关的截图或代码片段,帮助他人更好地理解问题或需求。 25 | 26 | ### 🧩 重现步骤 27 | 28 | 1. 详细列出重现问题的步骤。 29 | 2. 步骤 2 - 描述。 30 | 3. 步骤 3 - 描述。 31 | 32 | ### 🚀 其他信息 33 | 34 | 任何其他有助于问题解决的补充信息。 35 | -------------------------------------------------------------------------------- /.stylelintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | customSyntax: 'postcss-scss', 3 | 4 | extends: [require.resolve('prefer-code-style/stylelint')], 5 | 6 | rules: { 7 | 'selector-id-pattern': null, 8 | 'selector-class-pattern': null, 9 | 'no-descending-specificity': null, 10 | 'at-rule-no-unknown': null, 11 | 'value-keyword-case': null, 12 | }, 13 | 14 | ignoreFiles: [ 15 | 'node_modules/**', 16 | 'dist/**', 17 | 'asset/**', 18 | 'extension/css/**', 19 | '**/*.min.css', 20 | 'build*/**', 21 | ], 22 | } 23 | -------------------------------------------------------------------------------- /website/src/components/Article.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * 文章内容主体。 3 | */ 4 | export function Article(props: React.PropsWithChildren<{ header?: React.ReactNode }>) { 5 | return ( 6 |
7 | {props.header ?
{props.header}
: null} 8 | 9 |
10 | {props.children} 11 |
12 |
13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /website/src/components/icons/TampermonkeyIcon.tsx: -------------------------------------------------------------------------------- 1 | export function TampermonkeyIcon() { 2 | return ( 3 | 4 | 8 | 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /src/contents/topic/paging.ts: -------------------------------------------------------------------------------- 1 | import { $commentBox } from '../globals' 2 | 3 | /** 4 | * 处理主题分页。 5 | */ 6 | export function handlePaging() { 7 | const $notCommentCells = $commentBox.find('> .cell:not([id^="r_"])') 8 | 9 | if ($notCommentCells.length <= 1) { 10 | return 11 | } 12 | 13 | // $notCommentCells 的第一个为 “xxx 条回复” 14 | const pagingCells = $notCommentCells.slice(1).addClass('v2p-paging') 15 | 16 | const pageBtns = pagingCells.find('.super.button') 17 | pageBtns.eq(0).addClass('v2p-prev-btn') 18 | pageBtns.eq(1).addClass('v2p-next-btn') 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "search.exclude": { 3 | "asset": true, 4 | "extension/**/{*.min.js}": true, 5 | "extension/**/{*.min.css}": true, 6 | "extension/css/**/*.css": true, 7 | "/website": true 8 | }, 9 | "files.readonlyInclude": { 10 | "dist/**/*": true, 11 | "extension/css/*": true, 12 | "extension/scripts/*": true, 13 | "extension/manifest*.json": true 14 | }, 15 | "files.readonlyExclude": { 16 | "extension/scripts/polyfill.js": true 17 | }, 18 | "workbench.editor.customLabels.patterns": { 19 | "**/src/**/{index}.ts": "${dirname} - ${filename}" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /website/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@codennnn/tsconfig/next.json", 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "paths": { 6 | "~/*": ["./src/*"], 7 | "contentlayer/generated": ["./.contentlayer/generated"] 8 | }, 9 | "plugins": [ 10 | { 11 | "name": "next" 12 | } 13 | ], 14 | "strictNullChecks": true 15 | }, 16 | "include": [ 17 | "next-env.d.ts", 18 | "src/**/*", 19 | ".next/types/**/*.ts", 20 | "./tailwind.config.ts", 21 | "./contentlayer.config.ts", 22 | ".contentlayer/generated" 23 | ], 24 | "exclude": ["node_modules", "**/.*/", ".git"] 25 | } 26 | -------------------------------------------------------------------------------- /website/src/components/Feature.tsx: -------------------------------------------------------------------------------- 1 | interface FeatureProps { 2 | icon: React.ReactNode 3 | title: string 4 | description: string 5 | } 6 | 7 | export function Feature(props: FeatureProps) { 8 | return ( 9 |
10 |
11 | 12 | {props.icon} 13 | 14 |
15 | 16 |

{props.title}

17 | 18 |

{props.description}

19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /website/src/content/blog/whats-news-popup.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 迭代更新:Popup 支持深色模式、主题预览增强 3 | date: 2023-09-05 4 | author: 5 | name: LeoKu 6 | avatar: https://avatars.githubusercontent.com/u/47730755?v=4 7 | link: https://github.com/Codennnn 8 | --- 9 | 10 | ## 更新概览 11 | 12 | ### 新功能 13 | 14 | - Popup 支持深色模式的颜色主题。 15 | - 添加了对 `cn.v2ex.com` 域名的识别。 16 | - 支持设置「用户标签」的展示形式。 17 | - 添加关闭「楼中楼」功能的选项。 18 | - 支持预览热议主题。 19 | 20 | ### 优化改进 21 | 22 | - 使用 [lucide icon](https://lucide.dev/icons/) 作为新的展示图标。 23 | - 支持在预览内容中展示附言。 24 | - 用户标签改进:支持单独给题主(OP)设置标签。 25 | 26 | ### 优化改进 27 | 28 | - 使用 [lucide icon](https://lucide.dev/icons/) 作为新的展示图标。 29 | - 支持在预览内容中展示附言。 30 | -------------------------------------------------------------------------------- /website/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | next-env.d.ts 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | .idea 22 | *.pem 23 | 24 | # debug 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | .pnpm-debug.log* 29 | 30 | # local env files 31 | .env.local 32 | .env.development.local 33 | .env.test.local 34 | .env.production.local 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | 42 | .contentlayer -------------------------------------------------------------------------------- /website/src/components/support/SupportText.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | 3 | import { donationList } from '~/components/support/SupportTable' 4 | 5 | export function SupportText() { 6 | const total = donationList.reduce((total, donation) => total + Number(donation.money), 0) 7 | 8 | return ( 9 |
10 |

11 | 迄今为止,我们已收到 {donationList.length} 笔赞赏,共计{' '} 12 | {total} 元。对于你们的大方支持,我们感慨万分! 13 |

14 | 15 | 支付宝支付码 16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /website/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | const { TYPESCRIPT_FILES } = require('prefer-code-style/constants') 2 | 3 | module.exports = { 4 | root: true, 5 | 6 | extends: [require.resolve('prefer-code-style/eslint/preset/next'), 'plugin:jsx-a11y/recommended'], 7 | 8 | rules: { 9 | 'import/no-unresolved': [ 10 | 2, 11 | { 12 | ignore: ['^\\~/', '^(contentlayer|next-contentlayer)'], 13 | }, 14 | ], 15 | }, 16 | 17 | overrides: [ 18 | { 19 | files: TYPESCRIPT_FILES, 20 | rules: { 21 | '@typescript-eslint/no-unsafe-enum-comparison': 0, 22 | }, 23 | parserOptions: { 24 | project: true, 25 | tsconfigRootDir: __dirname, 26 | }, 27 | }, 28 | ], 29 | } 30 | -------------------------------------------------------------------------------- /website/next.config.mjs: -------------------------------------------------------------------------------- 1 | import { withContentlayer } from 'next-contentlayer' 2 | 3 | /** @type {import('next').NextConfig} */ 4 | 5 | export default withContentlayer({ 6 | reactStrictMode: true, 7 | 8 | async redirects() { 9 | return [ 10 | { 11 | source: '/github', 12 | destination: 'https://github.com/coolpace/V2EX_Polish', 13 | permanent: true, 14 | }, 15 | ] 16 | }, 17 | 18 | images: { 19 | remotePatterns: [ 20 | { 21 | protocol: 'https', 22 | hostname: 'avatars.githubusercontent.com', 23 | pathname: '/**', 24 | }, 25 | ], 26 | }, 27 | 28 | experimental: { 29 | optimizePackageImports: ['lucide-react'], 30 | }, 31 | }) 32 | -------------------------------------------------------------------------------- /website/src/utils.ts: -------------------------------------------------------------------------------- 1 | import splitbee from '@splitbee/web' 2 | 3 | const isDev: boolean = process.env.NODE_ENV === 'development' 4 | 5 | export const HOST = 'https://www.v2p.app' 6 | 7 | export const OG_WIDTH = 1200 8 | export const OG_HEIGHT = 630 9 | 10 | export function getPageTitle(title?: string): string { 11 | const mainTitle = 'V2EX Polish 浏览器插件' 12 | 13 | return title ? `${title} - ${mainTitle}` : mainTitle 14 | } 15 | 16 | export function isNumeric(str: string) { 17 | return /^\d+$/.test(str) 18 | } 19 | 20 | export function trackEvent( 21 | event: string, 22 | data?: Record 23 | ): void { 24 | if (isDev) { 25 | return 26 | } 27 | 28 | void splitbee.track(event, data) 29 | } 30 | -------------------------------------------------------------------------------- /website/src/app/api/img-to-base64/route.ts: -------------------------------------------------------------------------------- 1 | import { type NextRequest, NextResponse } from 'next/server' 2 | import { type Input, object, parse, string } from 'valibot' 3 | 4 | const RequestDataSchema = object({ 5 | imgUrl: string(), 6 | }) 7 | 8 | export type RequestData = Input 9 | 10 | export async function POST(request: NextRequest) { 11 | const body = parse(RequestDataSchema, await request.json()) 12 | 13 | const res = await fetch(body.imgUrl) 14 | const buffer = await res.arrayBuffer() 15 | const stringifiedBuffer = Buffer.from(buffer).toString('base64') 16 | const contentType = res.headers.get('content-type') 17 | const base64 = `data:${contentType};base64,${stringifiedBuffer}` 18 | 19 | return NextResponse.json({ data: base64 }, { status: 200 }) 20 | } 21 | -------------------------------------------------------------------------------- /website/src/components/NavLink.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Link from 'next/link' 4 | import { usePathname } from 'next/navigation' 5 | 6 | export function NavLink(props: React.PropsWithChildren>) { 7 | const { ...restLinkProps } = props 8 | 9 | const pathname = usePathname() 10 | 11 | const isActive = pathname === restLinkProps.href 12 | 13 | return ( 14 | 18 | {props.children} 19 | 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /website/src/app/share/components/ShareLoading.tsx: -------------------------------------------------------------------------------- 1 | export function ShareLoading() { 2 | return ( 3 |
4 |
5 |
6 |
7 |

8 |

9 |

10 |

11 |

12 |

13 |

14 |

15 |

16 |

17 |

18 |

19 |

20 |
21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/contents/polyfill.ts: -------------------------------------------------------------------------------- 1 | { 2 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 3 | if (!window.requestIdleCallback) { 4 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 5 | // @ts-expect-error 6 | window.requestIdleCallback = function (callback) { 7 | const start = Date.now() 8 | 9 | return setTimeout(function () { 10 | callback({ 11 | didTimeout: false, 12 | timeRemaining: function () { 13 | return Math.max(0, 50 - (Date.now() - start)) 14 | }, 15 | }) 16 | }, 1) 17 | } 18 | } 19 | 20 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 21 | if (!window.cancelIdleCallback) { 22 | window.cancelIdleCallback = function (id) { 23 | clearTimeout(id) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/components/toast.ts: -------------------------------------------------------------------------------- 1 | import { $body } from '../contents/globals' 2 | 3 | interface CreateToastProps { 4 | message: string 5 | duration?: number 6 | } 7 | 8 | interface ToastControl { 9 | clear: () => void 10 | } 11 | 12 | export function createToast(props: CreateToastProps): ToastControl { 13 | const { message, duration = 3000 } = props 14 | 15 | const $existTosat = $('.v2p-toast') 16 | 17 | if ($existTosat.length > 0) { 18 | $existTosat.remove() 19 | } 20 | 21 | const $toast = $(`
${message}
`).hide() 22 | 23 | $body.append($toast) 24 | 25 | $toast.fadeIn('fast') 26 | 27 | if (duration !== 0) { 28 | setTimeout(() => { 29 | $toast.fadeOut('fast', () => { 30 | $toast.remove() 31 | }) 32 | }, duration) 33 | } 34 | 35 | return { 36 | clear() { 37 | $toast.remove() 38 | }, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/web_accessible_resources.ts: -------------------------------------------------------------------------------- 1 | import { MessageFrom } from './constants' 2 | import type { MessageData, Once } from './types' 3 | 4 | declare global { 5 | interface Window { 6 | once: Once 7 | } 8 | } 9 | 10 | window.addEventListener('message', (ev: MessageEvent) => { 11 | if (ev.data.from === MessageFrom.Content) { 12 | const task = ev.data.payload?.task 13 | 14 | if (task) { 15 | // eslint-disable-next-line @typescript-eslint/no-implied-eval 16 | const result = Function(`"use strict"; ${task.expression}`)() 17 | const messageData: MessageData = { 18 | from: MessageFrom.Web, 19 | payload: { task: { id: task.id, result } }, 20 | } 21 | window.postMessage(messageData) 22 | } 23 | } 24 | }) 25 | 26 | const messageData: MessageData = { 27 | from: MessageFrom.Web, 28 | payload: { status: 'ready' }, 29 | } 30 | 31 | window.postMessage(messageData) 32 | -------------------------------------------------------------------------------- /src/contents/write/write.ts: -------------------------------------------------------------------------------- 1 | import { bindImageUpload } from '../../components/image-upload' 2 | import { postTask } from '../helpers' 3 | 4 | export function handleWrite() { 5 | bindImageUpload({ 6 | $wrapper: $('#workspace'), 7 | insertText: (text: string) => { 8 | postTask(`editor.getDoc().replaceRange("${text}", editor.getCursor())`) 9 | }, 10 | replaceText: (find: string, replace: string) => { 11 | if (replace) { 12 | const mode = $('input[name=syntax]:checked').val() 13 | 14 | // 特殊处理markdown模式下的图片插入格式。 15 | if (mode === 'markdown') { 16 | replace = `![](${replace})` 17 | } 18 | } 19 | 20 | postTask(` 21 | editor.setValue(editor.getValue().replace("${find}", "${replace}")); 22 | const doc = editor.getDoc(); 23 | const lastLine = doc.lastLine(); 24 | const lastChar = doc.getLine(lastLine).length; 25 | doc.setCursor({ line: doc.lastLine(), ch: lastChar }); 26 | `) 27 | }, 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: { 5 | 'common.min': 'src/contents/common.ts', 6 | 'v2ex-home.min': 'src/contents/home/index.ts', 7 | 'v2ex-topic.min': 'src/contents/topic/index.ts', 8 | 'v2ex-write.min': 'src/contents/write/index.ts', 9 | 'v2ex-member.min': 'src/contents/member/index.ts', 10 | 'decode-base64.min': 'src/contents/decode-base64.ts', 11 | 'reading-list.min': 'src/contents/reading-list.ts', 12 | 13 | 'popup.min': 'src/pages/popup.ts', 14 | 'options.min': 'src/pages/options.ts', 15 | 16 | 'toggle-icon.min': 'src/background/toggle-icon.ts', 17 | 'background.min': 'src/background/main.ts', 18 | 'web_accessible_resources.min': 'src/web_accessible_resources.ts', 19 | }, 20 | 21 | outDir: './extension/scripts', 22 | 23 | minify: false, 24 | 25 | noExternal: ['@floating-ui/dom', 'webext-patterns', 'lucide'], 26 | 27 | esbuildOptions(options) { 28 | options.write = false 29 | }, 30 | }) 31 | -------------------------------------------------------------------------------- /website/src/app/changelog/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import { allChangelogs } from 'contentlayer/generated' 3 | 4 | import { Article } from '~/components/Article' 5 | import { PageContainer } from '~/components/PageContainer' 6 | import { PageHeaderTitle } from '~/components/PageHeaderTitle' 7 | import { getPageTitle } from '~/utils' 8 | 9 | export const metadata: Metadata = { 10 | title: getPageTitle('Changelog'), 11 | } 12 | 13 | export default function ChangelogPage() { 14 | const changelog = allChangelogs.at(0) 15 | 16 | if (!changelog) { 17 | return 18 | } 19 | 20 | return ( 21 | 22 |
23 | Changelog 24 | 25 |
29 |
30 |
31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /website/src/components/support/SupportOptions.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | 3 | export function SupportOptions() { 4 | return ( 5 |
6 |
7 | 微信赞赏码 8 |
9 | 10 |
11 |
12 | 微信支付码 13 |
14 | 15 |
16 | 支付宝支付码 17 |
18 |
19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/styles/v2ex-theme-compact.scss: -------------------------------------------------------------------------------- 1 | body.v2p-mode-compact { 2 | --v2p-nav-height: 45px; 3 | --v2p-layout-column-gap: 16px; 4 | --v2p-tp-item-x: 12px; 5 | --v2p-tp-item-y: 0px; 6 | --v2p-tp-tabs-pd: 6px; 7 | --v2p-tp-nested-pd: 10px; 8 | 9 | visibility: visible; 10 | 11 | #Wrapper { 12 | --component-margin: var(--v2p-layout-column-gap); 13 | 14 | .sep20 { 15 | height: var(--component-margin); 16 | } 17 | } 18 | 19 | #Main { 20 | .cell { 21 | .item_title { 22 | .topic-link { 23 | font-size: 14px; 24 | font-weight: 500; 25 | } 26 | } 27 | } 28 | 29 | .topic_content { 30 | font-size: 14.5px; 31 | } 32 | 33 | .reply_content { 34 | font-size: 14px; 35 | } 36 | 37 | .topic_buttons { 38 | padding: 4px 0; 39 | } 40 | } 41 | 42 | #Rightbar { 43 | .v2p-tool-box { 44 | .v2p-tools { 45 | gap: 5px 10px; 46 | } 47 | } 48 | } 49 | 50 | .v2p-topic-preview { 51 | padding: 20px; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | const { TYPESCRIPT_FILES } = require('prefer-code-style/constants') 2 | 3 | module.exports = { 4 | root: true, 5 | 6 | env: { 7 | browser: true, 8 | webextensions: true, 9 | jquery: true, 10 | }, 11 | 12 | extends: [ 13 | require.resolve('prefer-code-style/eslint/browser'), 14 | require.resolve('prefer-code-style/eslint/node'), 15 | require.resolve('prefer-code-style/eslint/typescript'), 16 | ], 17 | 18 | ignorePatterns: [ 19 | 'dist/**/*', 20 | 'build*/**/*', 21 | 'extension/scripts/**/*.min.js', 22 | 'src/user-scripts/style.ts', 23 | 'website/**/*', 24 | 'chrome-profile/**/*', 25 | ], 26 | 27 | overrides: [ 28 | { 29 | files: TYPESCRIPT_FILES, 30 | parserOptions: { 31 | project: true, 32 | tsconfigRootDir: __dirname, 33 | }, 34 | extends: [require.resolve('prefer-code-style/eslint/rules/typescript-prefer-strict')], 35 | rules: { 36 | '@typescript-eslint/prefer-readonly-parameter-types': 0, 37 | }, 38 | }, 39 | ], 40 | } 41 | -------------------------------------------------------------------------------- /website/src/app/share/components/TopicLinkInput.tsx: -------------------------------------------------------------------------------- 1 | import { TextField } from '@radix-ui/themes' 2 | import { SearchIcon } from 'lucide-react' 3 | 4 | export interface TopicLinkInputProps { 5 | value?: string 6 | onChange?: (value: string) => void 7 | disabled?: boolean 8 | onSearh?: (value: string) => void 9 | } 10 | 11 | export function TopicLinkInput(props: TopicLinkInputProps) { 12 | const { value = '', onChange, disabled, onSearh } = props 13 | 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | { 26 | onChange?.(ev.target.value) 27 | }} 28 | onKeyUp={(ev) => { 29 | if (ev.key === 'Enter') { 30 | onSearh?.(ev.currentTarget.value) 31 | } 32 | }} 33 | /> 34 | 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /website/src/components/Nav.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | 3 | import { Logo } from './Logo' 4 | import { NavLink } from './NavLink' 5 | 6 | export function Nav() { 7 | return ( 8 | 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /website/src/content/docs/use-in-safari.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 在 Safari 中使用 V2EX Polish 3 | date: 2024-01-12 4 | author: 5 | name: LeoKu 6 | avatar: https://avatars.githubusercontent.com/u/47730755?v=4 7 | link: https://github.com/Codennnn 8 | --- 9 | 10 | # 在 Safari 中使用 V2EX Polish 11 | 12 | 如果你想在 Safari 中使用 V2EX Polish,虽然目前不支持 Safari 扩展,但你仍然可以通过安装脚本的方式来实现。 13 | 14 | ## 安装 UserScripts 15 | 16 | 首先,你需要在 App Store 中搜索并下载 [UserScripts](https://apps.apple.com/us/app/userscripts/id1463298887)。这是一个免费的 Safari 扩展,它允许你在 Safari 中运行用户脚本,功能上类似于 Tampermonkey。 17 | 18 | ![UserScripts in App Store](https://i.imgur.com/AeN8BnK.png) 19 | 20 | ## 添加脚本 21 | 22 | 安装完 UserScripts 后,你可以从 Greasy Fork 获取 [V2EX Polish 的脚本代码](https://greasyfork.org/zh-CN/scripts/459848-v2ex-polish-%E4%BD%93%E9%AA%8C%E6%9B%B4%E7%8E%B0%E4%BB%A3%E5%8C%96%E7%9A%84-v2ex/code)。 23 | 24 | 打开 UserScripts,将脚本代码粘贴进去。确保代码正确无误后,保存脚本。 25 | 26 | ![V2EX Polish 的脚本代码](https://i.imgur.com/NITKAa7.png) 27 | 28 | ## 启用脚本 29 | 30 | 在 UserScripts 的脚本列表中,找到你刚刚添加的 V2EX Polish 脚本,并确保它已被启用。启用后,你可以访问 V2EX 看到脚本带来的效果。 31 | 32 | ![V2EX Polish 的效果](https://i.imgur.com/fAv0xsH.png) 33 | -------------------------------------------------------------------------------- /website/src/content/blog/whats-news-firefox-reading-preload.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 迭代更新:支持 Firefox、稍后阅读、预加载多页回复 3 | date: 2023-05-25 4 | author: 5 | name: LeoKu 6 | avatar: https://avatars.githubusercontent.com/u/47730755?v=4 7 | link: https://github.com/Codennnn 8 | --- 9 | 10 | ## 更新概览 11 | 12 | ### 新功能 13 | 14 | - 支持 Firefox。 15 | - 增加 SOV2EX 作为搜索入口。 16 | - 支持主题颜色自动跟随系统选择。 17 | - 新增“稍后阅读”功能,让主题“飞一会”。 18 | - 支持预加载多页回复,让嵌套回复更完美。 19 | 20 | ### 优化改进 21 | 22 | - 优化用户信息卡片:鼠标悬浮头像弹出. 23 | - 回复时自动添加楼层号. 24 | - 完善用户配置的备份与同步. 25 | 26 | ## 新功能亮点介绍 27 | 28 | **新增“稍后阅读”功能,让主题“飞一会”** 29 | 30 | 当你遇到感兴趣的主题时,你可能想把它囤起来等有空再看,或想让评论区发酵一会。 31 | 以前你可能会“收藏主题”,现在,你有了更方便的选择:稍后阅读。在主题页的空白处右键菜单,选择「添加进稍后阅读」,然后点击 V2EX Polish 的扩展图标,查看已添加的主题。 32 | 33 | **支持预加载多页回复,让嵌套回复更完美** 34 | 35 | 以前,V2EX Polish 无法处理超过一页的回复的嵌套关系,但是现在,在开启「预加载多页回复」后,可以完整地查看多页回复组成的“楼中楼”。 36 | 37 | **完善用户配置的备份与同步** 38 | 39 | 你可以将 V2EX Polish 配置都保存进 [V2EX 记事本](https://www.v2ex.com/notes) 中,这些配置包括你设置的用户标签、稍后阅读列表、个性化选项,在跨浏览器、跨设备使用 V2EX Polish 时,都能使用同一份配置。 40 | 41 | **回复时自动添加楼层号** 42 | 43 | 当你回复的人在页面内的回复超过一条时,会自动添加楼层号,这会增加楼中楼识别的准确率,让楼层嵌套更合理。 44 | 45 | **支持主题颜色自动跟随系统选择** 46 | 47 | 在开启“自动跟随系统切换浅色/深色模式”后,你可以根据当前系统的选择自动切换 V2EX 的浅色和深色模式。 48 | -------------------------------------------------------------------------------- /src/contents/home/index.ts: -------------------------------------------------------------------------------- 1 | import { StorageKey } from '../../constants' 2 | import { getStorage, isSameDay } from '../../utils' 3 | import { $infoCard } from '../globals' 4 | import { loadIcons } from '../helpers' 5 | import { handleHotTopics } from './hot-topics' 6 | import { handleTopicList } from './topic-list' 7 | 8 | void (async () => { 9 | const storage = await getStorage() 10 | const options = storage[StorageKey.Options] 11 | 12 | { 13 | $('#Main .tab').addClass('v2p-hover-btn') 14 | 15 | if (options.openInNewTab) { 16 | $('#Main .topic-link, .item_hot_topic_title > a, .item_node, a[href="/write"]').prop( 17 | 'target', 18 | '_blank' 19 | ) 20 | } 21 | } 22 | 23 | handleTopicList() 24 | 25 | { 26 | const dailyInfo = storage[StorageKey.Daily] 27 | 28 | if (dailyInfo?.lastCheckInTime) { 29 | if (isSameDay(dailyInfo.lastCheckInTime, Date.now())) { 30 | const $info = $(` 31 | 32 | 今日已自动签到 33 | 34 | `) 35 | 36 | $infoCard.append($info) 37 | } 38 | } 39 | } 40 | 41 | handleHotTopics() 42 | 43 | loadIcons() 44 | })() 45 | -------------------------------------------------------------------------------- /website/src/components/InstallButton.tsx: -------------------------------------------------------------------------------- 1 | import { ChromeIcon } from '~/components/icons/ChromeIcon' 2 | 3 | export function InstallButton() { 4 | return ( 5 | 11 | 12 | 13 | 14 | 安装至 Chrome 15 | 19 | 23 | 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /website/src/content/blog/whats-news-preview-reply-share-image-and-more.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 迭代更新:预览回复、生成主题分享图片、隐藏回复用户名称、更多好玩的回复表情! 3 | date: 2023-09-27 4 | author: 5 | name: LeoKu 6 | avatar: https://avatars.githubusercontent.com/u/47730755?v=4 7 | link: https://github.com/Codennnn 8 | --- 9 | 10 | ## 功能更新简要 11 | 12 | - 主题的回复可以预览了。 13 | - 一键生成主题分享图片。 14 | - 支持隐藏回复中用户的名称。 15 | - 支持在回复中插入更多热门流行表情。 16 | 17 | ## 详细介绍 18 | 19 | - 主题回复内容预览: 20 | 21 | 如何在回复中正确地插入图片、主题链接?相信不少 V 友都有这个疑问。有了编辑内容的预览后,你能够直观地看到回复发送后的效果,所见即所得! 22 | 23 | ![主题回复内容预览](https://i.imgur.com/FYSZ0n5.gif) 24 | 25 | - 一键生成主题分享图片: 26 | 27 | 当你想要分享主题到别的地方时,以往你可能会使用滚动截屏工具。现在,在 V2EX Polish 的支持下,能够轻松地一键生成分享图片,对移动端中查看分享图片更友好。 28 | 29 | ![一键生成主题分享图片](https://i.imgur.com/ysfCBav.gif) 30 | 31 | - 隐藏回复用户名称: 32 | 33 | 在嵌套回复中展示 @ 用户名显得有些冗余,为了更简洁的浏览效果,你可以隐藏用户名称啦,这个功能需要在选项设置中手动开启。 34 | 35 | ![隐藏回复用户名称](https://i.imgur.com/jB8s6kd.gif) 36 | 37 | - 在回复中插入更多热门流行表情: 38 | 39 | 为了增加回复内容的趣味,V2EX Polish 新增了一些热门流行的回复表情供你选择,快去玩玩吧~ 40 | 41 | ![在回复中插入更多热门流行表情](https://i.imgur.com/5Xkwk4L.gif) 42 | 43 | ## 感谢与帮助 44 | 45 | 本周,V2EX Polish 的用户数量终于突破了 10,000 ,感谢大家的青睐。 46 | 47 | ![用户数量突破了 10,000](https://i.imgur.com/6xJNotW.png) 48 | 49 | 如果这个插件帮助你节省了时间,让你的生活更加愉快,可以给开发者一点小小的赞赏,这会帮助插件更持续地发展。 50 | 51 | (截至目前共收到 11 笔赞赏,共 127 元,真诚地感谢你们的慷慨) 52 | 53 | ![微信赞赏码](https://i.imgur.com/AHmfQyK.jpg) 54 | -------------------------------------------------------------------------------- /src/user-scripts/write-style.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from 'node:fs' 2 | import { dirname, resolve } from 'node:path' 3 | import { fileURLToPath } from 'node:url' 4 | 5 | const __filename = fileURLToPath(import.meta.url) 6 | const __dirname = dirname(__filename) 7 | 8 | // 输出文件路径:要将样式文件内容写入到此文件。 9 | const outputFile = resolve(__dirname, './style.ts') 10 | 11 | // 初始化输出文件:输出文件内容为「✨」。 12 | writeFileSync(outputFile, 'export const style = `✨`') 13 | 14 | const cssVar = readFileSync(resolve(__dirname, '../../extension/css/v2ex-theme-var.css'), 'utf8') 15 | const cssThemeLightDefault = readFileSync( 16 | resolve(__dirname, '../../extension/css/v2ex-theme-default.css'), 17 | 'utf8' 18 | ) 19 | const cssThemeDarkDefault = readFileSync( 20 | resolve(__dirname, '../../extension/css/v2ex-theme-dark.css'), 21 | 'utf8' 22 | ) 23 | const cssThemeRosePineDawn = readFileSync( 24 | resolve(__dirname, '../../extension/css/v2ex-theme-dawn.css'), 25 | 'utf8' 26 | ) 27 | const cssEffect = readFileSync(resolve(__dirname, '../../extension/css/v2ex-effect.css'), 'utf8') 28 | 29 | const contentToReplace = readFileSync(outputFile, 'utf8') 30 | 31 | // 将样式文件内容写入输出文件:将「✨」替换为样式文件内容。 32 | const newContent = contentToReplace.replace( 33 | '✨', 34 | cssVar + cssThemeLightDefault + cssThemeDarkDefault + cssThemeRosePineDawn + cssEffect 35 | ) 36 | 37 | writeFileSync(outputFile, newContent) 38 | -------------------------------------------------------------------------------- /website/src/app/support/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import { notFound } from 'next/navigation' 3 | import { useMDXComponent } from 'next-contentlayer/hooks' 4 | import { allSupports } from 'contentlayer/generated' 5 | import type { MDXComponents } from 'mdx/types' 6 | 7 | import { Article } from '~/components/Article' 8 | import { PageContainer } from '~/components/PageContainer' 9 | import { PageHeaderTitle } from '~/components/PageHeaderTitle' 10 | import { SupportOptions } from '~/components/support/SupportOptions' 11 | import { SupportTable } from '~/components/support/SupportTable' 12 | import { SupportText } from '~/components/support/SupportText' 13 | import { getPageTitle } from '~/utils' 14 | 15 | export const metadata: Metadata = { 16 | title: getPageTitle('赞赏支持'), 17 | } 18 | 19 | const mdxComponents: MDXComponents = { 20 | SupportOptions: () => , 21 | SupportText: () => , 22 | SupportTable: () => , 23 | } 24 | 25 | export default function DonationPage() { 26 | const support = allSupports.at(0) 27 | 28 | if (!support) { 29 | notFound() 30 | } 31 | 32 | const MDXContent = useMDXComponent(support.body.code) 33 | 34 | return ( 35 | 36 |
37 | 赞赏支持 38 | 39 | 40 |
41 |
42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /website/src/app/share/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState } from 'react' 4 | 5 | import { useRouter } from 'next/navigation' 6 | import { Button, Flex } from '@radix-ui/themes' 7 | 8 | import { isNumeric } from '~/utils' 9 | 10 | import { TopicLinkInput } from './components/TopicLinkInput' 11 | 12 | export default function SharePage() { 13 | const router = useRouter() 14 | 15 | const [searchValue, setSearchValue] = useState() 16 | 17 | const handleSearchTopic = () => { 18 | let topicId: string | undefined 19 | 20 | if (searchValue) { 21 | if (searchValue.startsWith('http')) { 22 | topicId = searchValue.split('/').pop() 23 | } else if (isNumeric(searchValue)) { 24 | topicId = searchValue 25 | } 26 | 27 | if (topicId && isNumeric(topicId)) { 28 | router.push(`/share/${topicId}`) 29 | } 30 | } 31 | } 32 | 33 | return ( 34 |
35 | 36 |
37 | 42 |
43 | 44 | 53 |
54 |
55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /website/src/content/blog/first-release.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: V2EX 超强浏览器插件:体验更先进的 V2EX! 3 | date: 2023-04-06 4 | author: 5 | name: LeoKu 6 | avatar: https://avatars.githubusercontent.com/u/47730755?v=4 7 | link: https://github.com/Codennnn 8 | --- 9 | 10 | ![V2EX Polish 的宣传图片](https://i.imgur.com/vdYuBpY.jpg) 11 | 12 | **V2EX Polish 是一款专为 V2EX 用户设计的浏览器插件,提供了丰富的扩展功能,让你的 V2EX 页面焕然一新 !** 13 | 14 | ## 安装使用 15 | 16 | [👉 在 Chrome 商店中获取](https://chromewebstore.google.com/detail/v2ex-polish/onnepejgdiojhiflfoemillegpgpabdm) 17 | 18 | 目前仅在 Chrome 和 Edge 中可用,后续会同步支持 Firefox。 19 | 20 | ## 特色功能 21 | 22 | 🪄 界面美化:UI 设计更现代化,为你带来愉悦的视觉体验。 23 | 24 | 📥 评论回复嵌套层级:主题下的评论回复支持层级展示,可以更轻松地跟踪和回复其他用户的评论。 25 | 26 | 🔥 热门回复展示:自动筛选出最受欢迎的评论,更容易找到热门回复。 27 | 28 | 😀 表情回复支持:评论输入框可以选择表情,让回复更加生动和有趣。 29 | 30 | 📃 长回复优化:智能折叠长篇回复,一键展开查看完整内容。 31 | 32 | 📰 内置主题列表:无需打开网页,插件内即可快速获取最热、最新的主题列表和消息通知。 33 | 34 | ### 更多实用功能: 35 | 36 | ⊙ 点击用户头像,查看用户信息。 37 | 38 | ⊙ 右键菜单扩展:支持解析页面中 Base64 编码内容。 39 | 40 | ⊙ 在主题列表中即可预览内容,无需再进入主题页面。 41 | 42 | ⊙ 翻页后自动跳转到回复区。 43 | 44 | ## 更多信息 45 | 46 | 关于 V2EX Polish 的更多细节,请关注我们的: 47 | 48 | - [官方主页 - www.v2p.app](https://www.v2p.app) 49 | - [GitHub 源码仓库](https://github.com/coolpace/V2EX_Polish) 50 | 51 | 我们会持续发布关于此插件有价值的信息。 52 | 53 | ## 问题反馈 54 | 55 | 如果你在使用过程中遇到任何问题,或者有任何想法,请在[这里](https://github.com/coolpace/V2EX_Polish/discussions/1)与我们讨论,也可以加入我们的[Telegram 群组](https://t.me/+zH9GxA2DYLtjYjhl)进行快速交流。 56 | 57 | ## 注意事项 58 | 59 | 同时运行其他类似的脚本或插件可能会导致冲突,如果在使用后发现网页内容有误,建议关闭其他插件以排查问题。 60 | 61 | ## 最后 62 | 63 | 感谢你尝试 V2EX Polish,我们希望打造一个超高质量的 V2EX 扩展,提供令人愉悦的刷帖体验。 64 | 65 | 如果你愿意,请为我们的项目点个 Star ⭐️ 或分享给他人,让更多的人知道我们的存在。 66 | -------------------------------------------------------------------------------- /website/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | 3 | export default { 4 | content: ['./src/{app,components}/**/*.{js,jsx,ts,tsx}'], 5 | 6 | theme: { 7 | extend: { 8 | maxWidth: { 9 | container: '1280px', 10 | }, 11 | 12 | colors: { 13 | main: { 14 | 50: 'var(--v2p-color-main-50)', 15 | 100: 'var(--v2p-color-main-100)', 16 | 200: 'var(--v2p-color-main-200)', 17 | 300: 'var(--v2p-color-main-300)', 18 | 350: 'var(--v2p-color-main-350)', 19 | 400: 'var(--v2p-color-main-400)', 20 | 500: 'var(--v2p-color-main-500)', 21 | 600: 'var(--v2p-color-main-600)', 22 | 700: 'var(--v2p-color-main-700)', 23 | 800: 'var(--v2p-color-main-800)', 24 | }, 25 | accent: { 26 | 50: 'var(--v2p-color-accent-50)', 27 | 100: 'var(--v2p-color-accent-100)', 28 | 200: 'var(--v2p-color-accent-200)', 29 | 300: 'var(--v2p-color-accent-300)', 30 | 400: 'var(--v2p-color-accent-400)', 31 | 500: 'var(--v2p-color-accent-500)', 32 | 600: 'var(--v2p-color-accent-600)', 33 | }, 34 | foreground: 'var(--v2p-color-foreground)', 35 | background: 'var(--v2p-color-background)', 36 | content: 'var(--v2p-color-content)', 37 | subtle: 'var(--v2p-color-bg-subtle)', 38 | }, 39 | 40 | boxShadow: { 41 | box: 'var(--v2p-box-shadow)', 42 | }, 43 | }, 44 | }, 45 | 46 | plugins: [require('@tailwindcss/typography'), require('@tailwindcss/container-queries')], 47 | } satisfies Config 48 | -------------------------------------------------------------------------------- /website/src/content/blog/major-feature-updates-of-note.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 大量功能更新:更多、更强、更易用,即刻安装体验! 3 | date: 2023-04-25 4 | author: 5 | name: LeoKu 6 | avatar: https://avatars.githubusercontent.com/u/47730755?v=4 7 | link: https://github.com/Codennnn 8 | --- 9 | 10 | 四月初,我们在 V 站上首次[介绍了 V2EX Polish](https://www.v2ex.com/t/930155?p=4#reply376),目前超过 5000+ 小伙伴加入了体验之旅,十分感谢大家的支持! 11 | 12 | 自发布以来,我们收到了大家热烈的[建议和反馈](https://github.com/coolpace/V2EX_Polish/discussions/1),这极大地帮助了我们持续不断地完善功能。 13 | 14 | 如今,四月已近尾声,我们非常高兴再次向大家介绍这个月的努力成果: 15 | 16 | - 主题样式优化,细节更丰富。 17 | - 支持[油猴脚本](https://greasyfork.org/zh-CN/scripts/459848-v2ex-polish-%E4%BD%93%E9%AA%8C%E6%9B%B4%E7%8E%B0%E4%BB%A3%E5%8C%96%E7%9A%84-v2ex),方便大家轻松体验。 18 | - 支持自动领取每日登录奖励。 19 | - 新增更多个性化配置,包括楼中楼显示效果、备份配置等。 20 | - 新增便捷回复操作:文字转 Base64、上传图片。 21 | - 支持设置用户标签。 22 | 23 | ## 更多功能演示 24 | 25 | - 更友好的界面设计 26 | ![更友好的界面设计](https://i.imgur.com/yaBXwFw.png) 27 | - 更丰富个性化设置 28 | ![更友好的界面设计](https://i.imgur.com/guRBbVB.png) 29 | - 便捷上传回复图片 30 | ![更友好的界面设计](https://i.imgur.com/1vfybCs.gif) 31 | - 设置用户的标签,标记用户 32 | ![更友好的界面设计](https://i.imgur.com/YNFJeFv.gif) 33 | - 快速解密页面中的 Base64 34 | ![更友好的界面设计](https://i.imgur.com/6v7HGCc.gif) 35 | - 快速浏览主题列表 36 | ![更友好的界面设计](https://i.imgur.com/Jcb2w1X.gif) 37 | - 楼中楼嵌套回复 38 | ![更友好的界面设计](https://i.imgur.com/13EBDrV.png) 39 | - 热门回复展示 40 | ![更友好的界面设计](https://i.imgur.com/IyffX1w.png) 41 | 42 | --- 43 | 44 | 关于 V2EX Polish 的更多信息,请查看我们的 [GitHub 主页](https://github.com/coolpace/V2EX_Polish)。 45 | 46 | 在繁忙的工作之余,我们仍然投入了不少精力开发这款扩展。如果你觉得不错,请为这个项目点个 Star ⭐,并在[应用商店](https://chrome.google.com/webstore/detail/v2ex-polish/onnepejgdiojhiflfoemillegpgpabdm)中给予我们五星好评,这将鼓励我们持续用爱发电~ 47 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "v2ex-polish-website", 3 | "private": true, 4 | "description": "Official homepage of the V2EX Polish extension.", 5 | "license": "UNLECENSED", 6 | "scripts": { 7 | "build": "next build", 8 | "deps": "pnpm up --interactive --latest", 9 | "dev": "next dev", 10 | "lint": "run-p lint:ts lint:es lint:css", 11 | "lint:css": "stylelint **/*.css", 12 | "lint:es": "eslint **/*.{ts,tsx}", 13 | "lint:ts": "tsc --noEmit --skipLibCheck", 14 | "start": "next start" 15 | }, 16 | "dependencies": { 17 | "@radix-ui/themes": "^2.0.3", 18 | "@splitbee/web": "^0.3.0", 19 | "cheerio": "1.0.0-rc.12", 20 | "contentlayer": "^0.3.4", 21 | "date-fns": "^3.6.0", 22 | "eslint-plugin-jsx-a11y": "^6.8.0", 23 | "html-to-image": "^1.11.11", 24 | "lucide-react": "^0.378.0", 25 | "next": "^14.2.3", 26 | "next-contentlayer": "^0.3.4", 27 | "react": "18.3.1", 28 | "react-dom": "18.3.1", 29 | "react-use-event-hook": "^0.9.6", 30 | "valibot": "^0.30.0" 31 | }, 32 | "devDependencies": { 33 | "@codennnn/tsconfig": "^1.2.1", 34 | "@tailwindcss/container-queries": "^0.1.1", 35 | "@tailwindcss/typography": "^0.5.13", 36 | "@types/mdx": "^2.0.13", 37 | "@types/react": "18.3.2", 38 | "@types/react-dom": "^18.3.0", 39 | "autoprefixer": "^10.4.19", 40 | "npm-run-all": "^4.1.5", 41 | "postcss": "^8.4.38", 42 | "postcss-import": "^16.1.0", 43 | "prefer-code-style": "2.1.1", 44 | "stylelint": "^16.5.0", 45 | "tailwindcss": "^3.4.3", 46 | "typescript": "5.4.5" 47 | }, 48 | "engines": { 49 | "node": ">=18", 50 | "pnpm": "^8 || ^9" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/contents/globals.ts: -------------------------------------------------------------------------------- 1 | export const $body = $(document.body) 2 | export const $wrapper = $('#Wrapper') 3 | export const $wrapperContent = $wrapper.find('> .content') 4 | 5 | export const $main = $('#Main') 6 | 7 | export const $topicList = $( 8 | '#Main #Tabs ~ .cell.item, #Main #TopicsNode > .cell, #Main .cell.item:has(.item_title > .topic-link)' 9 | ) 10 | 11 | /** 个人信息卡片。 */ 12 | export const $infoCard = $('#Rightbar > .box:has("#member-activity")') 13 | 14 | /** 主题内容区。 */ 15 | export const $topicContentBox = $('#Main .box:has(.topic_buttons)') 16 | 17 | /** 主题内容区的头部 */ 18 | export const $topicHeader = $topicContentBox.find('.header') 19 | 20 | /** 主题下的评论区。 */ 21 | export const $commentBox = $('#Main .box:has(.cell[id^="r_"])') 22 | 23 | /** 评论区的回复。 */ 24 | export let $commentCells = $commentBox.find('.cell[id^="r_"]') 25 | 26 | export let $commentTableRows = $commentCells.find('> table > tbody > tr') 27 | 28 | /** 当文档结构发生改变时,执行此方法以重新选择到最新的元素。 */ 29 | export function updateCommentCells() { 30 | $commentCells = $commentBox.find('.cell[id^="r_"]') 31 | $commentTableRows = $commentCells.find('> table > tbody > tr') 32 | } 33 | 34 | /** 回复输入控件。 */ 35 | export const $replyBox = $('#reply-box') 36 | export const $replyForm = $replyBox.find('form[action^="/t"]') 37 | 38 | /** 主题回复输入框。 */ 39 | export const $replyTextArea = $('#reply_content') 40 | export const replyTextArea = document.querySelector('#reply_content') 41 | 42 | /** 登录人的昵称 */ 43 | export const loginName = $('#Top .tools > a[href^="/member"]').text() 44 | 45 | /** 题主的昵称。 */ 46 | export const topicOwnerName = $topicHeader.find('> small > a[href^="/member"]').text() 47 | 48 | export const topicId = window.location.pathname.match(/\/t\/(\d+)/)?.at(1) 49 | -------------------------------------------------------------------------------- /website/contentlayer.config.ts: -------------------------------------------------------------------------------- 1 | import { defineDocumentType, defineNestedType, makeSource } from 'contentlayer/source-files' 2 | 3 | const Author = defineNestedType(() => ({ 4 | name: 'Author', 5 | fields: { 6 | name: { type: 'string', required: true }, 7 | avatar: { type: 'string', required: true }, 8 | link: { type: 'string', required: true }, 9 | }, 10 | })) 11 | 12 | export const Blog = defineDocumentType(() => ({ 13 | name: 'Blog', 14 | filePathPattern: 'blog/**/*.md', 15 | fields: { 16 | title: { type: 'string', required: true }, 17 | date: { type: 'date', required: true }, 18 | author: { type: 'nested', of: Author, required: true }, 19 | }, 20 | computedFields: { 21 | slug: { 22 | type: 'string', 23 | resolve: (post) => post._raw.sourceFileName.replace(/\.md$/, ''), 24 | }, 25 | }, 26 | })) 27 | 28 | export const Docs = defineDocumentType(() => ({ 29 | name: 'Docs', 30 | filePathPattern: 'docs/**/*.md', 31 | fields: { 32 | title: { type: 'string', required: true }, 33 | date: { type: 'date', required: true }, 34 | author: { type: 'nested', of: Author, required: true }, 35 | }, 36 | computedFields: { 37 | slug: { 38 | type: 'string', 39 | resolve: (post) => post._raw.sourceFileName.replace(/\.md$/, ''), 40 | }, 41 | }, 42 | })) 43 | 44 | export const Changelog = defineDocumentType(() => ({ 45 | name: 'Changelog', 46 | filePathPattern: 'changelog.md', 47 | })) 48 | 49 | export const Support = defineDocumentType(() => ({ 50 | name: 'Support', 51 | filePathPattern: 'support.mdx', 52 | contentType: 'mdx', 53 | })) 54 | 55 | export default makeSource({ 56 | contentDirPath: 'src/content', 57 | documentTypes: [Blog, Changelog, Support], 58 | }) 59 | -------------------------------------------------------------------------------- /src/user-scripts/index.ts: -------------------------------------------------------------------------------- 1 | // 如果要在 GreaseMonkey 中运行本地文件,需设置「@require file:///[file path]」。 2 | 3 | import { patternToRegex } from 'webext-patterns' 4 | 5 | import { style } from './style' 6 | 7 | function runAfterLoaded(fn: () => void): void { 8 | if (document.readyState !== 'loading') { 9 | fn() 10 | } else { 11 | document.addEventListener('DOMContentLoaded', () => { 12 | fn() 13 | }) 14 | } 15 | } 16 | 17 | if (typeof window.GM_addStyle !== 'undefined') { 18 | // 使用「GM_addStyle」配合「@run-at document-start」,可以解决样式切换导致的页面闪烁问题。 19 | window.GM_addStyle(style) 20 | } else { 21 | runAfterLoaded(() => { 22 | $(``).appendTo('head') 23 | }) 24 | } 25 | 26 | const allowedHosts = [ 27 | 'https://v2ex.com', 28 | 'https://www.v2ex.com', 29 | 'https://cn.v2ex.com', 30 | 'https://jp.v2ex.com', 31 | 'https://de.v2ex.com', 32 | 'https://us.v2ex.com', 33 | 'https://hk.v2ex.com', 34 | 'https://global.v2ex.com', 35 | 'https://fast.v2ex.com', 36 | 'https://s.v2ex.com', 37 | 'https://origin.v2ex.com', 38 | 'https://staging.v2ex.com', 39 | ] 40 | 41 | const commonRegex = patternToRegex(...allowedHosts.map((host) => `${host}/*`)) 42 | const topicRegex = patternToRegex(...allowedHosts.map((host) => `${host}/t/*`)) 43 | const writeRegex = patternToRegex(...allowedHosts.map((host) => `${host}/write/*`)) 44 | 45 | runAfterLoaded(() => { 46 | const url = window.location.href 47 | 48 | void (async () => { 49 | if (commonRegex.test(url)) { 50 | await import('../contents/common') 51 | await import('../contents/home/index') 52 | } 53 | 54 | if (topicRegex.test(url)) { 55 | await import('../contents/topic/index') 56 | } 57 | 58 | if (writeRegex.test(url)) { 59 | await import('../contents/write/index') 60 | } 61 | })() 62 | }) 63 | -------------------------------------------------------------------------------- /src/contents/topic/index.ts: -------------------------------------------------------------------------------- 1 | import { StorageKey } from '../../constants' 2 | import { getStorage } from '../../utils' 3 | import { $commentTableRows, $replyBox, $topicHeader } from '../globals' 4 | import { loadIcons } from '../helpers' 5 | import { handleComments } from './comment' 6 | import { handleContent } from './content' 7 | import { handleLayout } from './layout' 8 | import { handlePaging } from './paging' 9 | import { handleReply } from './reply' 10 | import { handleTools } from './tool' 11 | 12 | void (async () => { 13 | const storage = await getStorage() 14 | const options = storage[StorageKey.Options] 15 | 16 | handleLayout() 17 | 18 | if (options.openInNewTab) { 19 | $topicHeader.find('a[href^="/member/"]').prop('target', '_blank') 20 | 21 | // 支持新页签打开用户主页链接。 22 | $commentTableRows.find('> td:nth-child(3) > strong > a').prop('target', '_blank') 23 | } 24 | 25 | handleTools() 26 | 27 | // 按 Esc 隐藏回复框。 28 | { 29 | $(document).on('keydown', (ev) => { 30 | if (!ev.isDefaultPrevented()) { 31 | if (ev.key === 'Escape') { 32 | const $replyContent = $('#reply_content') 33 | 34 | if ($replyBox.hasClass('reply-box-sticky')) { 35 | $replyBox.removeClass('reply-box-sticky') 36 | $('#undock-button').css('display', 'none') 37 | } 38 | 39 | $replyContent.trigger('blur') 40 | } 41 | } 42 | }) 43 | } 44 | 45 | handleContent() 46 | 47 | // 如果是从相同的主题跳转过来的,且含有分页参数,则被认为是执行翻页操作,跳过正文内容直接滚动到评论区。 48 | if (document.referrer !== '') { 49 | if (document.referrer.includes(document.location.pathname)) { 50 | const url = new URL(document.location.href) 51 | const page = url.searchParams.get('p') 52 | if (page && page !== '1') { 53 | document.querySelector('.topic_buttons')?.scrollIntoView({ behavior: 'smooth' }) 54 | } 55 | } 56 | } 57 | 58 | handlePaging() 59 | await handleComments() 60 | handleReply() 61 | 62 | loadIcons() 63 | })() 64 | -------------------------------------------------------------------------------- /website/src/app/blog/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import Image from 'next/image' 3 | import Link from 'next/link' 4 | import { allBlogs } from 'contentlayer/generated' 5 | import { compareDesc, format, parseISO } from 'date-fns' 6 | 7 | import { PageContainer } from '~/components/PageContainer' 8 | import { PageHeaderTitle } from '~/components/PageHeaderTitle' 9 | import { getPageTitle } from '~/utils' 10 | 11 | export const metadata: Metadata = { 12 | title: getPageTitle('Blog'), 13 | description: 'V2P 博客文章,在这里发现更多 V2P 的相关信息。', 14 | } 15 | 16 | export default function BlogIndexPage() { 17 | const blogs = allBlogs.sort((a, b) => compareDesc(new Date(a.date), new Date(b.date))) 18 | 19 | return ( 20 | 21 |
22 | Blog 23 | 24 |
25 | {blogs.map((blog) => { 26 | return ( 27 | 32 |

{blog.title}

33 | 34 | 35 | 作者头像 42 | {blog.author.name} 43 | 46 | 47 | 48 | ) 49 | })} 50 |
51 |
52 |
53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /website/src/content/blog/whats-news-2024-first-update.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 迭代更新:主题水平布局、选项页美化、更多功能... 3 | date: 2024-01-05 4 | author: 5 | name: LeoKu 6 | avatar: https://avatars.githubusercontent.com/u/47730755?v=4 7 | link: https://github.com/Codennnn 8 | --- 9 | 10 | 好久不见,V2EX 的朋友们! 11 | 12 | 自上次发布了令人兴奋的[功能更新](https://www.v2ex.com/t/977271)以来,时间已过去了三个月。2024 年伊始,V2EX Polish 踏着新春的脚步,带着新功能又和大家见面啦! 13 | 14 | ## 更新亮点 15 | 16 | - 主题页支持水平方向划分内容区和回复区。 17 | - 插件选项页界面美化。 18 | - 选项页中新增用户标签管理。 19 | - Popup 中支持查看未读消息的数量。 20 | - 回复内容中默认不显示 @ 提及的用户名。 21 | - 默认不显示回复时间。 22 | 23 | ## 详细介绍 24 | 25 | ### ⊙ 主题页支持水平方向划分内容区和回复区。 26 | 27 | 这绝对是一个实用的功能。它有效地利用了页面的水平空间,让主题内容与回复同屏展示,告别了来回滚动查阅内容和回复的恼人体验。 28 | 29 | ![主题页水平布局](https://i.imgur.com/2blSYZt.png) 30 | 31 | ### ⊙ 插件选项页界面美化 32 | 33 | 为了适应 V2EX Polish 日益复杂的选项配置,我们重新设计了选项页,现在它看上去更美观了。 34 | 35 | ![选项页界面美化](https://i.imgur.com/EhpTZ4v.png) 36 | 37 | ### ⊙ 选项页中新增用户标签管理。 38 | 39 | 在去年四月份的[更新](https://www.v2ex.com/t/935916)中,我们就已经支持设置用户标签,但一直缺少管理标签的界面。现在,它终于来了,你可以在选项页中方便地管理你所设置的标签。 40 | 41 | ![管理用户标签](https://i.imgur.com/GpNy24a.png) 42 | 43 | ### ⊙ Popup 中支持查看未读消息的数量。 44 | 45 | 你可以在不访问网页的情况下,快速查看是否有未读消息。 46 | 47 | ![展示未读消息数量](https://i.imgur.com/fisZVqI.png) 48 | 49 | ### ⊙ 回复内容中默认不显示 @ 提及的用户名。 50 | 51 | 鉴于有了“楼中楼”功能,回复内容中提及用户的用户名显得没有必要。为了让界面显得简洁,我们默认隐藏了用户名,不过你可以在选项页中关闭这个功能。 52 | 53 | ### ⊙ 默认不显示回复时间。 54 | 55 | 同样的,为了回复区的简洁利落,我们在回复区域隐藏了回复时间。只有当鼠标浮动到某个回复上时,才会显示该回复的时间。 56 | 57 | ## 如果你第一次听说 V2EX Polish 58 | 59 | V2EX Polish 是一款开源的浏览器插件,为改善 V2EX 落后的浏览体验而诞生。这款轻量级但功能丰富的插件提供了丰富的功能扩展,让你在浏览 V2EX 时更加得心应手。 60 | 61 | - 查看官网了解更多: [https://www.v2p.app](https://www.v2p.app) 62 | - 开源地址: [https://github.com/coolpace/V2EX_Polish](https://github.com/coolpace/V2EX_Polish) 63 | - 安装地址: [https://chromewebstore.google.com/detail/v2ex-polish/onnepejgdiojhiflfoemillegpgpabdm](https://chromewebstore.google.com/detail/v2ex-polish/onnepejgdiojhiflfoemillegpgpabdm) 64 | 65 | ## 赞赏支持 66 | 67 | 如果这个插件改善了你浏览 V2EX 的体验,让你的生活更加愉快,可以给开发者一点小小的赞赏,这会帮助插件更持续地发展。对于你们的大方支持,我们感慨万分! 68 | 69 | ![微信赞赏码](https://i.imgur.com/AHmfQyK.jpg) 70 | -------------------------------------------------------------------------------- /scripts/build.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import path from 'node:path' 3 | 4 | function deleteFolderRecursive(folderPath: string) { 5 | if (fs.existsSync(folderPath)) { 6 | fs.readdirSync(folderPath).forEach((file) => { 7 | const curPath = path.join(folderPath, file) 8 | 9 | if (fs.lstatSync(curPath).isDirectory()) { 10 | deleteFolderRecursive(curPath) 11 | } else { 12 | fs.unlinkSync(curPath) 13 | } 14 | }) 15 | 16 | fs.rmdirSync(folderPath) 17 | } 18 | } 19 | 20 | interface BuildParams { 21 | /** 存放需要构建的源码的文件夹名称。 */ 22 | source: string 23 | /** 构建输出的文件夹名称。 */ 24 | target: string 25 | /** 不需要包含的文件。 */ 26 | excludeFile?: string 27 | } 28 | 29 | function build({ source, target, excludeFile: exclude }: BuildParams) { 30 | if (!fs.existsSync(source)) { 31 | throw new Error('源文件夹不存在。') 32 | } 33 | 34 | if (fs.existsSync(target)) { 35 | deleteFolderRecursive(target) 36 | } 37 | 38 | fs.mkdirSync(target) 39 | 40 | const files = fs.readdirSync(source) 41 | 42 | files.forEach((file) => { 43 | const sourcePath = path.join(source, file) 44 | const targetPath = path.join(target, file) 45 | 46 | if (file !== exclude) { 47 | if (fs.statSync(sourcePath).isDirectory()) { 48 | build({ source: sourcePath, target: targetPath }) 49 | } else { 50 | fs.copyFileSync(sourcePath, targetPath) 51 | } 52 | } 53 | }) 54 | 55 | // 删除空的文件夹。 56 | if (fs.readdirSync(target).length === 0) { 57 | fs.rmdirSync(target) 58 | } 59 | 60 | if (target === 'build-firefox') { 61 | const oldFileName = path.join(target, 'manifest-firefox.json') 62 | const newFileName = path.join(target, 'manifest.json') 63 | 64 | if (fs.existsSync(oldFileName)) { 65 | fs.renameSync(oldFileName, newFileName) 66 | } 67 | } 68 | } 69 | 70 | build({ 71 | source: 'extension', 72 | target: 'build-chrome', 73 | excludeFile: 'manifest-firefox.json', 74 | }) 75 | 76 | build({ 77 | source: 'extension', 78 | target: 'build-firefox', 79 | excludeFile: 'manifest.json', 80 | }) 81 | -------------------------------------------------------------------------------- /src/contents/member/index.ts: -------------------------------------------------------------------------------- 1 | import { createButton } from '../../components/button' 2 | import { StorageKey } from '../../constants' 3 | import { getStorage } from '../../utils' 4 | import { loginName } from '../globals' 5 | import { getTagsText } from '../helpers' 6 | import { type CallbackFunctions, openTagsSetter } from '../topic/content' 7 | 8 | void (async () => { 9 | const storage = await getStorage() 10 | 11 | const $memberName = $('h1') 12 | const memberName = $memberName.text() 13 | 14 | if (memberName !== loginName) { 15 | const memberAvatar = $('.avatar').prop('src') 16 | 17 | const tagData = storage[StorageKey.MemberTag] 18 | 19 | const $tagBlock = $('
') 20 | 21 | let $addTagBtn: JQuery | undefined 22 | 23 | const callbacks: CallbackFunctions = { 24 | onRemoveExistingTagBlock: () => { 25 | // eslint-disable-next-line @typescript-eslint/no-use-before-define 26 | insertAddBtn() 27 | }, 28 | 29 | onInsertNewTagBlock: ({ $tags }) => { 30 | $tagBlock.append($tags) 31 | $addTagBtn?.remove() 32 | }, 33 | } 34 | 35 | const insertAddBtn = () => { 36 | $addTagBtn = createButton({ children: '添加用户标签' }) 37 | .on('click', () => { 38 | openTagsSetter({ memberName, memberAvatar, ...callbacks }) 39 | }) 40 | .appendTo($tagBlock) 41 | } 42 | 43 | if (tagData && Reflect.has(tagData, memberName)) { 44 | const tags = tagData[memberName].tags 45 | const tagsText = tags ? getTagsText(tags) : undefined 46 | 47 | if (tagsText) { 48 | const $tags = $( 49 | `
# ${tagsText}
` 50 | ) 51 | $tags.on('click', () => { 52 | openTagsSetter({ memberName, memberAvatar, ...callbacks }) 53 | }) 54 | $tagBlock.append($tags) 55 | } else { 56 | insertAddBtn() 57 | } 58 | } else { 59 | insertAddBtn() 60 | } 61 | 62 | $tagBlock.insertAfter($memberName) 63 | } 64 | })() 65 | -------------------------------------------------------------------------------- /website/src/content/changelog.md: -------------------------------------------------------------------------------- 1 | ## 1.10.x 2 | 3 | ### 优化改进 4 | 5 | - 优化「主题预览」功能。 6 | - 优化 modal 点击区域意外关闭的问题。 7 | 8 | --- 9 | 10 | ## 1.9.x 11 | 12 | ### 新功能 13 | 14 | - 支持在主题列表中直接屏蔽主题。 15 | - 增加图片分享操作按钮。 16 | - 增加「近期热议主题」板块。 17 | 18 | ### 优化改进 19 | 20 | - 新增 `s.v2ex.com` 作为匹配子域名。 21 | - 优化渲染用户标签的逻辑。 22 | - 修复无法自动隐藏回复时间的问题。 23 | - 修复无法正确打开 Chrome 侧边栏的问题。 24 | - 修复 Popup 主题列表内容转义的问题。 25 | - 修复导致登录页无法正常跳转的问题。 26 | 27 | --- 28 | 29 | ## 1.8.x 30 | 31 | ### 新功能 32 | 33 | - 主题页支持水平方向划分内容区和回复区。 34 | - 选项页中新增用户标签管理。 35 | - Popup 中支持查看未读消息的数量。 36 | - 支持自动备份和同步配置数据。 37 | - 自动标注 30 天内注册的用户。 38 | 39 | ### 优化改进 40 | 41 | - 回复内容中默认不显示 @ 提及的用户名。 42 | - 默认不显示回复时间。 43 | - 新增更多对 v2ex.com 子域名的匹配。 44 | - 选项页支持自动跟随系统切换主题颜色。 45 | - 优化了选项页的响应式设计。 46 | - 整合了⌈热门回复⌋和⌈最近回复⌋的展示,新增⌈题主回复⌋。 47 | 48 | --- 49 | 50 | ## 1.7.x 51 | 52 | ### 新功能 53 | 54 | - 添加主题回复内容预览。 55 | - 新增生成分享图片功能。 56 | - 支持隐藏回复中用户名称。 57 | - 回复表情添加「bilibili / 小红书」流行表情。 58 | 59 | ### 优化改进 60 | 61 | - 美化主题编辑页。 62 | 63 | --- 64 | 65 | ## 1.5.x - 1.6.x 66 | 67 | ### 新功能 68 | 69 | - Popup 支持深色模式的颜色主题。 70 | - 添加了对 `cn.v2ex.com` 域名的识别。 71 | - 支持设置「用户标签」的展示形式。 72 | - 添加关闭「楼中楼」功能的选项。 73 | - 支持预览热议主题。 74 | 75 | ### 优化改进 76 | 77 | - 使用 [lucide icon](https://lucide.dev/icons/) 作为新的展示图标。 78 | - 支持在预览内容中展示附言。 79 | - 用户标签改进:支持单独给题主(OP)设置标签。 80 | 81 | ### 优化改进 82 | 83 | - 使用 [lucide icon](https://lucide.dev/icons/) 作为新的展示图标。 84 | - 支持在预览内容中展示附言。 85 | 86 | --- 87 | 88 | ## 1.4.x 89 | 90 | ### 新功能 91 | 92 | - 新增“稍后阅读”功能。 93 | - 支持预加载多页回复,让嵌套回复更完美。 94 | - 创建主题时也支持上传图片。 95 | 96 | ### 优化改进 97 | 98 | - 修复感谢功能状态与提示不同步的问题。 99 | - 修复 LOL 节点下文本颜色不兼容的问题。 100 | - 完善用户配置的备份与同步。 101 | - 回复时自动添加楼层号。 102 | - 收藏主题时无须刷新页面。 103 | 104 | --- 105 | 106 | ## 1.3.x 107 | 108 | ### 新功能 109 | 110 | - 新增同步和备份个人配置功能。 111 | - 新增设置用户标签功能。 112 | - 新增 SOV2EX 作为搜索选项。 113 | 114 | ### 优化改进 115 | 116 | - 优化用户信息卡片:鼠标悬浮头像弹出。 117 | - 浅色和深色模式现在可以自动跟随系统切换。 118 | - 支持设置是否自动折叠长回复。 119 | - 调整部分样式细节。 120 | 121 | --- 122 | 123 | ## 1.2.x 124 | 125 | ### 新功能和改进 126 | 127 | - 主题样式优化。 128 | - 便捷回复工具箱:文字转 Base64、上传图片。 129 | - 支持更多个性化配置。 130 | - 支持油猴脚本。 131 | -------------------------------------------------------------------------------- /src/background/daily-check-in.ts: -------------------------------------------------------------------------------- 1 | import { StorageKey, V2EX } from '../constants' 2 | import type { DailyInfo } from '../types' 3 | import { getStorage, isSameDay, setStorage } from '../utils' 4 | 5 | const successText = '每日登录奖励已领取' 6 | 7 | const handleCheckedIn = async (htmlText: string) => { 8 | const matchedArr = htmlText.match(/已连续登录 (\d+) 天/) 9 | 10 | let checkInDays: number | undefined 11 | 12 | if (matchedArr) { 13 | const days = Number([...matchedArr].at(1)) 14 | if (!Number.isNaN(days)) { 15 | checkInDays = days 16 | } 17 | } 18 | 19 | const dailyInfo: DailyInfo = { lastCheckInTime: Date.now(), checkInDays } 20 | 21 | await setStorage(StorageKey.Daily, dailyInfo) 22 | } 23 | 24 | export async function checkIn() { 25 | // 「自动签到」在每天早上 8 点后才生效。 26 | if (new Date().getHours() < 8) { 27 | return 28 | } 29 | 30 | const storage = await getStorage(false) 31 | const dailyInfo = storage[StorageKey.Daily] 32 | const lastCheckInTime = dailyInfo?.lastCheckInTime 33 | 34 | if (lastCheckInTime) { 35 | if (isSameDay(lastCheckInTime, Date.now())) { 36 | return 37 | } 38 | } 39 | 40 | const targetTextFragment = '/mission/daily/redeem' 41 | const targetUrl = `${V2EX.Origin}${targetTextFragment}` 42 | 43 | const res = await fetch(targetUrl, { headers: { Referer: V2EX.Origin } }) 44 | const htmlPlainText = await res.text() 45 | 46 | const startIndex = htmlPlainText.indexOf(targetTextFragment) 47 | 48 | if (startIndex !== -1) { 49 | const endIndex = htmlPlainText.indexOf("'", startIndex + targetTextFragment.length) 50 | 51 | if (endIndex !== -1) { 52 | const matchedString = htmlPlainText.slice(startIndex, endIndex) // 拿到 /mission/daily/redeem?once=xxxxx 53 | const checkInUrl = `${V2EX.Origin}${matchedString}` 54 | const checkInResult = await fetch(checkInUrl, { headers: { Referer: `${V2EX.Origin}/mission/daily` } }) 55 | const text = await checkInResult.text() 56 | 57 | if (text.includes(successText)) { 58 | await handleCheckedIn(text) 59 | } 60 | } 61 | } else { 62 | if (htmlPlainText.includes(successText)) { 63 | await handleCheckedIn(htmlPlainText) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "v2ex_polish", 3 | "version": "0.1.0", 4 | "private": "true", 5 | "license": "UNLICENSED", 6 | "author": "LeoKu (https://github.com/Codennnn)", 7 | "scripts": { 8 | "dev": "run-p build:manifest watch run:chrome", 9 | "dev:firefox": "run-p watch run:firefox", 10 | "run:chrome": "web-ext run -t chromium --source-dir ./extension --chromium-profile ./chrome-profile --profile-create-if-missing --keep-profile-changes --start-url https://www.v2ex.com/", 11 | "run:firefox": "web-ext run --source-dir ./extension --start-url https://www.v2ex.com/", 12 | "build": "bun build:all && bun scripts/build.ts && bun pack:chrome && bun pack:firefox", 13 | "build:all": "run-p build:manifest build:style build:ext build:userscript", 14 | "build:userscript": "run-s output:css output:userscript", 15 | "build:ext": "tsup", 16 | "build:manifest": "bun scripts/build-manifest.ts", 17 | "build:style": "sass src/styles:extension/css --no-source-map --style=compressed", 18 | "pack:chrome": "web-ext build -s build-chrome -a build-chrome -o", 19 | "pack:firefox": "web-ext build -s build-firefox -a build-firefox -o", 20 | "output:userscript": "tsup src/user-scripts/index.ts --no-minify --outDir dist", 21 | "output:css": "bun src/user-scripts/write-style.mjs", 22 | "watch": "run-p watch:style watch:ext", 23 | "watch:ext": "bun build:ext --watch", 24 | "watch:style": "bun build:style --watch", 25 | "lint": "run-p lint:ts lint:es lint:style lint:prettier", 26 | "lint:ts": "tsc --noEmit --skipLibCheck", 27 | "lint:es": "eslint --config ./.eslintrc.cjs **/*.{js,ts}", 28 | "lint:prettier": "prettier --write **/*.{md,json}", 29 | "lint:style": "stylelint **/*.{css,scss}", 30 | "deps": "pnpm up --interactive --latest" 31 | }, 32 | "dependencies": { 33 | "@floating-ui/dom": "1.4.5", 34 | "lucide": "0.445.0", 35 | "webext-patterns": "1.5.0" 36 | }, 37 | "devDependencies": { 38 | "@codennnn/tsconfig": "^1.2.1", 39 | "@types/chrome": "^0.0.271", 40 | "@types/firefox-webext-browser": "^120.0.4", 41 | "@types/jquery": "^3.5.30", 42 | "@types/node": "^20.14.12", 43 | "bun": "^1.1.29", 44 | "npm-run-all": "^4.1.5", 45 | "postcss-scss": "^4.0.9", 46 | "prefer-code-style": "2.1.7", 47 | "sass": "^1.79.3", 48 | "stylelint": "^16.9.0", 49 | "tsup": "^8.3.0", 50 | "typescript": "^5.5.4", 51 | "web-ext": "^8.3.0" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # 贡献指南 2 | 3 | 感谢您考虑为这个项目做出贡献!请先阅读以下内容,以确保贡献过程顺利。 4 | 5 | ## 项目结构 6 | 7 | ```bash 8 | ├── extension # 运行时的代码 9 | ├── src # 开发时的代码 10 | │ ├── background # 插件后台脚本 11 | │ ├── contents # 网页内容脚本 12 | │ ├── pages # 浏览器扩展页面相关的页面文件 13 | │ ├── styles # 浏览器扩展相关的样式文件 14 | │ ├── user-scripts # 油猴脚本相关 15 | │ ├── constants.ts # 常量 16 | │ ├── icons.ts # SVG 图标 17 | │ ├── services # API 服务 18 | │ ├── types.ts # TS 类型定义 19 | │ └── utils.ts # 工具函数 20 | ├── website # 产品官网(基于 Next.js) 21 | ├── scripts # 与项目构建相关的脚本 22 | └── tsup.config.ts # tsup 配置 23 | ``` 24 | 25 | ## 开发运行 26 | 27 | ### 本地开发 28 | 29 | 本项目使用 [web-ext](https://github.com/mozilla/web-ext) 帮助开发,会在代码改动后自动重新加载扩展,所以不需要每次都在扩展程序页面中手动刷新扩展。 30 | 31 | #### 如果使用 web-ext,在运行 `pnpm dev` 后,会自动打开 Chrome 浏览器,并自动加载扩展。 32 | 33 | #### 如果不使用 web-ext,则可以遵循以下的开发流程: 34 | 35 | 1. `pnpm install` 安装依赖。 36 | 1. `pnpm dev` 启动本地开发服务器。 37 | 1. 打开 Chrome 浏览器,输入 `chrome://extensions/` 进入扩展程序页面。 38 | 1. 点击右上角的开发者模式,然后点击 `加载已解压的扩展程序`,选择 `extension` 文件夹。 39 | 1. 编辑 `src` 目录中的代码,保存文件后会自动编译。 40 | 1. 在扩展程序页面中,点击刷新按钮,接着再刷新目标页面查看效果。 41 | 42 | ### 生产构建 43 | 44 | 在发布前,进入 `/scripts/build-manifest.ts`,修改其中的 `version`,然后执行 `pnpm build`,这可以分别构建出浏览器(Chrome/Firefox)扩展和油猴脚本。 45 | 46 | `pnpm build` 其实包含了多条命令的执行,包括编译输出 JS 脚本、样式、打包产物。执行完这条命令之后,会在根目录下生成 `build-chrome` 和 `build-firefox` 目录,这两个目录下的 `v2ex_polish-[版本号].zip` 就是可以上传到扩展平台的最终产物。 47 | 48 | 此外,还会生成 `dist` 目录,其中包含了油猴脚本的 JS 脚本,可以直接粘贴到油猴脚本编辑器中,然后发布。 49 | 50 |
51 | 项目脚本解释: 52 | 53 | | 脚本名称 | 描述 | 54 | | ------------------- | ------------------------ | 55 | | `build:manifest` | 构建 manifest.json | 56 | | `build:style` | 构建浏览器扩展用到的样式 | 57 | | `build:ext` | 构建浏览器扩展 | 58 | | `build:userscript` | 构建油猴脚本 | 59 | | `pack:chrome` | 打包最终产物 | 60 | | `output:userscript` | 生成油猴脚本 | 61 | | `output:css` | 生成油猴脚本样式 | 62 | 63 |
64 | 65 | ### 运行官网 66 | 67 | ```bash 68 | cd website/ # 进入官网项目目录 69 | pnpm install && pnpm dev # 安装依赖、启动本地开发服务器 70 | ``` 71 | 72 | ## 提交问题和请求功能 73 | 74 | 如果您在使用该项目时遇到了问题或需要某个功能,请先查看项目是否已有相应的记录。如果没有记录,请提交新的 [issue](https://github.com/coolpace/V2EX_Polish/issues)。 75 | 76 | ## 贡献代码 77 | 78 | 直接向本仓库提交 **pull request** 即可。本项目的维护者将根据 CONTRIBUTING.md 文件和提交的代码进行评估和审查。如果您的代码被接受,您将成为该项目的贡献者之一。如果您的代码被拒绝,我们将向您提供反馈意见。 79 | 80 | 以下是一些有助于您的代码被接受的建议: 81 | 82 | 1. 请确保您的代码符合项目的风格和规范。 83 | 2. 请确保您的代码已经经过充分的测试,并且可以成功编译。 84 | 3. 请将您的代码提交到新的分支上,并且使用清晰的 commit 信息。 85 | -------------------------------------------------------------------------------- /src/styles/reset.css: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | box-sizing: border-box; 5 | margin: 0; 6 | padding: 0; 7 | border-color: currentColor; 8 | border-style: solid; 9 | border-width: 0; 10 | } 11 | 12 | :where([hidden]:not([hidden='until-found'])) { 13 | display: none !important; 14 | } 15 | 16 | @supports not (min-block-size: 100dvb) { 17 | :where(html) { 18 | block-size: 100%; 19 | } 20 | } 21 | 22 | @media (prefers-reduced-motion: no-preference) { 23 | :where(html:focus-within) { 24 | scroll-behavior: smooth; 25 | } 26 | } 27 | 28 | :where(body) { 29 | font-family: system-ui, sans-serif; 30 | -webkit-font-smoothing: antialiased; 31 | line-height: 1.5; 32 | } 33 | 34 | :where(input, button, textarea, select, textarea) { 35 | margin: 0; 36 | padding: 0; 37 | font-family: inherit; 38 | font-size: 100%; 39 | font-weight: inherit; 40 | line-height: inherit; 41 | color: inherit; 42 | } 43 | 44 | :where(textarea) { 45 | resize: vertical; 46 | } 47 | 48 | :where(button, label, select, summary, [role='button'], [role='option']) { 49 | cursor: pointer; 50 | } 51 | 52 | :where(button, [type='button'], [type='reset'], [type='submit']) { 53 | appearance: button; 54 | background-color: transparent; 55 | background-image: none; 56 | } 57 | 58 | :where(:disabled) { 59 | cursor: not-allowed; 60 | } 61 | 62 | :where(label:has(> input:disabled), label:has(+ input:disabled)) { 63 | cursor: not-allowed; 64 | } 65 | 66 | :where(h1, h2, h3, h4, h5, h6) { 67 | font-size: inherit; 68 | font-weight: inherit; 69 | } 70 | 71 | :where(a) { 72 | color: inherit; 73 | text-decoration: none; 74 | } 75 | 76 | :where(ul, ol) { 77 | margin: 0; 78 | padding: 0; 79 | list-style: none; 80 | } 81 | 82 | :where(fieldset) { 83 | margin: 0; 84 | padding: 0; 85 | } 86 | 87 | :where(legend) { 88 | padding: 0; 89 | } 90 | 91 | :where(img, svg, video, canvas, audio, iframe, embed, object) { 92 | display: block; 93 | } 94 | 95 | :where(img, picture, svg) { 96 | max-inline-size: 100%; 97 | block-size: auto; 98 | } 99 | 100 | :where(p, h1, h2, h3, h4, h5, h6) { 101 | overflow-wrap: break-word; 102 | } 103 | 104 | :where(h1, h2, h3) { 105 | line-height: calc(1em + 0.5rem); 106 | } 107 | 108 | :where(hr) { 109 | overflow: visible; 110 | height: 0; 111 | block-size: 0; 112 | color: inherit; 113 | border: none; 114 | border-block-start: 1px solid; 115 | } 116 | 117 | :where(:focus-visible) { 118 | outline: 2px solid var(--focus-color, Highlight); 119 | outline-offset: 2px; 120 | } 121 | -------------------------------------------------------------------------------- /website/src/app/api/share/route.ts: -------------------------------------------------------------------------------- 1 | import { type NextRequest, NextResponse } from 'next/server' 2 | import { load } from 'cheerio' 3 | import { array, type Input, nullish, number, object, parse, string } from 'valibot' 4 | 5 | const RequestDataSchema = object({ 6 | topicId: string(), 7 | }) 8 | 9 | export type RequestData = Input 10 | 11 | const TopicInfoSchema = object({ 12 | title: string(), 13 | content: nullish(string()), 14 | supplements: nullish(array(object({ content: string() }))), 15 | member: object({ 16 | username: string(), 17 | avatar: string(), 18 | }), 19 | time: object({ 20 | year: number(), 21 | month: number(), 22 | day: number(), 23 | }), 24 | url: string(), 25 | }) 26 | 27 | export type TopicInfo = Input 28 | 29 | export const enum ResponseCode { 30 | Success, 31 | /** 由于不存在主题或需要授权访问,无法爬取到主题内容。 */ 32 | NotFound, 33 | } 34 | 35 | export type ResponseJson = 36 | | { 37 | code: ResponseCode.Success 38 | data: TopicInfo 39 | } 40 | | { 41 | code: ResponseCode.NotFound 42 | data: null 43 | } 44 | 45 | export async function POST(request: NextRequest) { 46 | const body = parse(RequestDataSchema, await request.json()) 47 | 48 | const url = `https://www.v2ex.com/t/${body.topicId}` 49 | const res = await fetch(url, { method: 'GET' }) 50 | const htmlText = await res.text() 51 | 52 | const $ = load(htmlText) 53 | const title = $('.header h1').text() 54 | const content = $('.topic_content').html() 55 | 56 | if (!title && !content) { 57 | return NextResponse.json( 58 | { code: ResponseCode.NotFound, data: null }, 59 | { status: 200 } 60 | ) 61 | } 62 | 63 | let supplements: TopicInfo['supplements'] = null 64 | const $subtles = $('.subtle .topic_content') 65 | if ($subtles.length > 1) { 66 | supplements = [] 67 | $subtles.each((_: number, ele: any) => { 68 | supplements!.push({ content: $(ele).html()! }) 69 | }) 70 | } 71 | 72 | const timeString = $('.header .gray span[title^="20"]').prop('title') 73 | const date = new Date(timeString) 74 | const time = { 75 | year: date.getFullYear(), 76 | month: date.getMonth() + 1, 77 | day: date.getDate(), 78 | } 79 | const member = { 80 | username: $('.header .gray a[href^="/member"]').text(), 81 | avatar: $('.header .avatar').prop('src'), 82 | } 83 | 84 | const data = parse(TopicInfoSchema, { 85 | title, 86 | content, 87 | supplements, 88 | member, 89 | time, 90 | url, 91 | }) 92 | 93 | return NextResponse.json({ code: ResponseCode.Success, data }, { status: 200 }) 94 | } 95 | -------------------------------------------------------------------------------- /src/pages/popup.helper.ts: -------------------------------------------------------------------------------- 1 | import type { ReadingItem, Topic } from '../types' 2 | import { escapeHTML } from '../utils' 3 | import { TabId } from './popup.var' 4 | 5 | export function isTabId(tabId: any): tabId is TabId { 6 | if (typeof tabId === 'string') { 7 | if ( 8 | /* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */ 9 | tabId === TabId.Reading || 10 | tabId === TabId.Hot || 11 | tabId === TabId.Latest || 12 | tabId === TabId.Message || 13 | tabId === TabId.Feature || 14 | tabId === TabId.Setting 15 | /* eslint-enable @typescript-eslint/no-unsafe-enum-comparison */ 16 | ) { 17 | return true 18 | } 19 | } 20 | return false 21 | } 22 | 23 | export const generateReadingItmes = (items: ReadingItem[]) => { 24 | return items 25 | .map((topic) => { 26 | const escapedText = $('
').text(topic.content).html() 27 | 28 | return ` 29 | 39 | ` 40 | }) 41 | .join('') 42 | } 43 | 44 | export const generateTopicItmes = (topics: Topic[]) => { 45 | return topics 46 | .map((topic) => { 47 | const escapedText = $('
').text(topic.content).html() 48 | 49 | return ` 50 |
  • 51 | 52 | ${escapeHTML(topic.title)} 53 | ${escapedText} 54 | 55 |
  • 56 | ` 57 | }) 58 | .join('') 59 | } 60 | 61 | /** 62 | * 计算 local storage 的数据大小。 63 | */ 64 | export function calculateLocalStorageSize(): number { 65 | let total = 0 66 | 67 | for (let i = 0; i < window.localStorage.length; i++) { 68 | const key = window.localStorage.key(i) 69 | if (key) { 70 | const value = window.localStorage.getItem(key) 71 | if (value) { 72 | total += key.length + value.length 73 | } 74 | } 75 | } 76 | 77 | return total 78 | } 79 | 80 | /** 81 | * 将字节数格式化易读的单位。 82 | */ 83 | export function formatSizeUnits(bytes: number): string { 84 | const units = ['bytes', 'KB', 'MB', 'GB', 'TB'] 85 | let i = 0 86 | 87 | while (bytes >= 1024 && i < 4) { 88 | bytes /= 1024 89 | i++ 90 | } 91 | 92 | return bytes.toFixed(2) + ' ' + units[i] 93 | } 94 | -------------------------------------------------------------------------------- /website/src/app/blog/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import Image from 'next/image' 3 | import Link from 'next/link' 4 | import { allBlogs } from 'contentlayer/generated' 5 | import { format, parseISO } from 'date-fns' 6 | 7 | import { Article } from '~/components/Article' 8 | import { PageContainer } from '~/components/PageContainer' 9 | import { HOST } from '~/utils' 10 | 11 | export const generateStaticParams = () => { 12 | return allBlogs.map((blog) => ({ 13 | slug: blog.slug, 14 | })) 15 | } 16 | 17 | export const generateMetadata = ({ params }: { params: { slug: string } }): Metadata => { 18 | const blog = allBlogs.find((blog) => blog.slug === params.slug) 19 | 20 | if (blog) { 21 | return { 22 | title: blog.title, 23 | openGraph: { 24 | type: 'article', 25 | title: blog.title, 26 | description: '专为 V2EX 用户设计的浏览器插件,提供了丰富的扩展功能为你带来出色的体验。', 27 | url: `${HOST}/blog/${blog.slug}`, 28 | images: `${HOST}/api/og/blog?title=${blog.title}`, 29 | }, 30 | } 31 | } 32 | 33 | return {} 34 | } 35 | 36 | export default function BlogPage({ params }: { params: { slug: string } }) { 37 | const blog = allBlogs.find((blog) => blog.slug === params.slug) 38 | 39 | if (!blog) { 40 | return 41 | } 42 | 43 | return ( 44 | 45 |
    51 | 52 |
    55 |

    {blog.title}

    56 | 57 |
    58 | 62 | 作者头像 69 | {blog.author.name} 70 | 71 | 74 |
    75 | 76 | } 77 | > 78 |
    79 |
    80 | 81 | ) 82 | } 83 | -------------------------------------------------------------------------------- /website/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | /* 解决 tailwindCSS 和 radixCSS 顺序冲突的问题: https://github.com/radix-ui/themes/issues/109 */ 2 | @import url('tailwindcss/base'); 3 | @import url('@radix-ui/themes/styles.css'); 4 | 5 | @tailwind components; 6 | @tailwind utilities; 7 | 8 | :root { 9 | --v2p-color-main-50: #f7f9fb; 10 | --v2p-color-main-100: #f1f5f9; 11 | --v2p-color-main-200: #e2e8f0; 12 | --v2p-color-main-300: #cbd5e1; 13 | --v2p-color-main-350: #94a3b8cc; 14 | --v2p-color-main-400: #94a3b8; 15 | --v2p-color-main-500: #64748b; 16 | --v2p-color-main-600: #475569; 17 | --v2p-color-main-700: #334155; 18 | --v2p-color-main-800: #1e293b; 19 | --v2p-color-accent-50: #ecfdf5; 20 | --v2p-color-accent-100: #d1fae5; 21 | --v2p-color-accent-200: #a7f3d0; 22 | --v2p-color-accent-300: #6ee7b7; 23 | --v2p-color-accent-400: #34d399; 24 | --v2p-color-accent-500: #10b981; 25 | --v2p-color-accent-600: #059669; 26 | --v2p-color-content: #fff; 27 | --v2p-box-shadow: 0 3px 5px 0 rgb(0 0 0 / 4%); 28 | --v2p-color-border: var(--v2p-color-main-200); 29 | --v2p-color-foreground: var(--v2p-color-main-800); 30 | --v2p-color-background: #f2f3f5; 31 | --v2p-color-bg-input: var(--v2p-color-main-50); 32 | --v2p-color-bg-subtle: rgb(236 253 245 / 90%); 33 | 34 | .radix-themes { 35 | --cursor-button: pointer; 36 | } 37 | } 38 | 39 | .theme-dark { 40 | --v2p-color-main-50: #1c2127; 41 | --v2p-color-main-100: #2d333b; 42 | --v2p-color-main-200: #374151; 43 | --v2p-color-main-300: #374151; 44 | --v2p-color-main-350: #6b7280cc; 45 | --v2p-color-main-400: #6b7280; 46 | --v2p-color-main-500: #9ca3af; 47 | --v2p-color-main-600: #9ca3af; 48 | --v2p-color-main-700: #d1d5db; 49 | --v2p-color-main-800: #e5e7eb; 50 | --v2p-color-accent-50: #064e3b; 51 | --v2p-color-accent-100: #065f46; 52 | --v2p-color-accent-200: #047857; 53 | --v2p-color-accent-300: #059669; 54 | --v2p-color-accent-400: #10b981; 55 | --v2p-color-accent-500: #34d399; 56 | --v2p-color-accent-600: #6ee7b7; 57 | --v2p-color-content: #22272e; 58 | --v2p-color-border: #444c56; 59 | --v2p-color-foreground: #adbac7; 60 | --v2p-color-background: #1c2128; 61 | --v2p-color-bg-input: var(--v2p-color-background); 62 | --v2p-color-bg-subtle: rgb(6 78 59 / 30%); 63 | } 64 | 65 | ::selection { 66 | color: currentcolor; 67 | background-color: rgb(30 41 59 / 10%); 68 | } 69 | 70 | .text-with-shadow { 71 | text-shadow: 2px 2px 0 var(--v2p-color-main-300); 72 | } 73 | 74 | @media (width >= 768px) { 75 | .text-with-shadow { 76 | text-shadow: 2px 4px 0 var(--v2p-color-main-300); 77 | } 78 | } 79 | 80 | .text-polish { 81 | text-shadow: 2px 4px 0 var(--v2p-color-main-600); 82 | -webkit-text-fill-color: #fff; 83 | -webkit-text-stroke-color: var(--v2p-color-main-800); 84 | -webkit-text-stroke-width: 2px; 85 | } 86 | -------------------------------------------------------------------------------- /src/contents/topic/layout.ts: -------------------------------------------------------------------------------- 1 | import { createElement, PanelRight, PanelTop } from 'lucide' 2 | 3 | import { StorageKey } from '../../constants' 4 | import { getStorageSync } from '../../utils' 5 | import { $main, $topicContentBox, $wrapperContent } from '../globals' 6 | 7 | const $layoutToggle = $('') 8 | 9 | const iconLayoutV = createElement(PanelTop) 10 | iconLayoutV.setAttribute('width', '100%') 11 | iconLayoutV.setAttribute('height', '100%') 12 | 13 | const iconLayoutH = createElement(PanelRight) 14 | iconLayoutH.setAttribute('width', '100%') 15 | iconLayoutH.setAttribute('height', '100%') 16 | 17 | /** 将主题页切换为水平布局。 */ 18 | const switchToHorizontalLayout = () => { 19 | if (!$wrapperContent.hasClass('v2p-content-layout')) { 20 | const $divider1 = $main.find('> .sep20:first-of-type') 21 | const $leftGroup = $divider1.add($divider1.next('.box')) 22 | const $leftSide = $('
    ') 23 | $leftGroup.wrapAll($leftSide) 24 | const $content = $leftGroup.find('> .cell') 25 | $content.add($content.nextAll('.subtle')).wrapAll('
    ') 26 | 27 | const $divider2 = $main.find('.sep20:nth-of-type(2)') 28 | const $rightGroup = $divider2.add($divider2.nextAll()) 29 | $rightGroup.wrapAll('
    ') 30 | 31 | $wrapperContent.addClass('v2p-content-layout') 32 | $main.addClass('v2p-horizontal-layout') 33 | } 34 | 35 | $layoutToggle.html(iconLayoutV) 36 | $layoutToggle.attr('title', '切换为垂直布局') 37 | $('.v2p-reply-tool-layout').text('切换为垂直布局') 38 | } 39 | 40 | /** 将主题页切换为垂直布局。 */ 41 | const switchToVerticalLayout = () => { 42 | if ($wrapperContent.hasClass('v2p-content-layout')) { 43 | $wrapperContent.removeClass('v2p-content-layout') 44 | $main.removeClass('v2p-horizontal-layout') 45 | 46 | $('.v2p-left-side-content').children().unwrap() 47 | 48 | $('.v2p-left-side').children().unwrap() 49 | $('.v2p-right-side').children().unwrap() 50 | } 51 | 52 | $layoutToggle.html(iconLayoutH) 53 | $layoutToggle.attr('title', '切换为水平布局') 54 | $('.v2p-reply-tool-layout').text('切换为水平布局') 55 | } 56 | 57 | export function toggleTopicLayout() { 58 | if ($wrapperContent.hasClass('v2p-content-layout')) { 59 | switchToVerticalLayout() 60 | } else { 61 | switchToHorizontalLayout() 62 | } 63 | } 64 | 65 | /** 66 | * 控制主题布局水平分屏显示。 67 | */ 68 | export function handleLayout() { 69 | const storage = getStorageSync() 70 | const options = storage[StorageKey.Options] 71 | 72 | if (options.reply.layout === 'auto') { 73 | const contentHeight = $topicContentBox.height() 74 | 75 | if (typeof contentHeight === 'number' && contentHeight >= 600) { 76 | switchToHorizontalLayout() 77 | } else { 78 | switchToVerticalLayout() 79 | } 80 | } else { 81 | if (options.reply.layout === 'horizontal') { 82 | switchToHorizontalLayout() 83 | } else { 84 | switchToVerticalLayout() 85 | } 86 | } 87 | 88 | $layoutToggle.on('click', () => { 89 | toggleTopicLayout() 90 | }) 91 | 92 | $('.tools').prepend($layoutToggle) 93 | } 94 | -------------------------------------------------------------------------------- /website/src/components/screens/ScreenHome.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Content, 4 | Header, 5 | RightSide, 6 | TopicItem, 7 | TopicItemRight, 8 | UserPanel, 9 | Wrapper, 10 | } from '~/components/ui' 11 | 12 | export function ScreenHome() { 13 | return ( 14 | 15 |
    16 | 17 | 18 | 19 |
    20 | 技术 21 | 创意 22 | 好玩 23 | Apple 24 | 酷工作 25 | 交易 26 | 27 | 城市 28 | 29 | 问与答 30 | 最热 31 | 全部 32 | R2 33 | 节点 34 | 关注 35 |
    36 | 37 |
    38 | 北京 39 | 上海 40 | 深圳 41 | 广州 42 | 杭州 43 | 成都 44 | 新加波 45 | 纽约 46 | 洛杉矶 47 |
    48 | 49 |
    50 | 58 | 66 | 74 | 82 |
    83 |
    84 | 85 | 86 | 87 | 88 | 89 |
    今日热议主题
    90 | 91 | 92 | 93 |
    94 |
    95 |
    96 | 97 | ) 98 | } 99 | -------------------------------------------------------------------------------- /src/components/image-upload.ts: -------------------------------------------------------------------------------- 1 | import { uploadImage } from '../services' 2 | 3 | interface ImageUploadProps { 4 | $wrapper: JQuery 5 | $input?: JQuery 6 | /** 向文本输入框中插入占位文本。 */ 7 | insertText: (text: string) => void 8 | /** 替换占位文本,当 replace 为非空字符串时,表示图片上传成功,参数值为图片链接。 */ 9 | replaceText: (find: string, replace: string) => void 10 | } 11 | 12 | const uploadTip = '选择、粘贴、拖放上传图片。' 13 | 14 | interface ImageUploadControl { 15 | uploadBar: JQuery 16 | } 17 | 18 | export function bindImageUpload(props: ImageUploadProps): ImageUploadControl { 19 | const { $wrapper, $input, insertText, replaceText } = props 20 | 21 | const $uploadBar = $(`
    ${uploadTip}
    `) 22 | 23 | const handleUploadImage = (file: File) => { 24 | const placeholder = '[上传图片中...]' 25 | insertText(` ${placeholder} `) 26 | $uploadBar.addClass('v2p-reply-upload-bar-disabled').text('正在上传图片...') 27 | 28 | uploadImage(file) 29 | .then((imgLink) => { 30 | replaceText(placeholder, imgLink) 31 | }) 32 | .catch(() => { 33 | replaceText(placeholder, '') 34 | 35 | window.alert('❌ 上传图片失败,请打开控制台查看原因') 36 | }) 37 | .finally(() => { 38 | $uploadBar.removeClass('v2p-reply-upload-bar-disabled').text(uploadTip) 39 | }) 40 | } 41 | 42 | const handleClickUploadImage = () => { 43 | const imgInput = document.createElement('input') 44 | 45 | imgInput.style.display = 'none' 46 | imgInput.type = 'file' 47 | imgInput.accept = 'image/*' 48 | 49 | imgInput.addEventListener('change', () => { 50 | const selectedFile = imgInput.files?.[0] 51 | 52 | if (selectedFile) { 53 | handleUploadImage(selectedFile) 54 | } 55 | }) 56 | 57 | imgInput.click() 58 | } 59 | 60 | // 粘贴图片并上传的功能。 61 | document.addEventListener('paste', (ev) => { 62 | if (!(ev instanceof ClipboardEvent)) { 63 | return 64 | } 65 | 66 | if ($input && !$input.get(0)?.matches(':focus')) { 67 | return 68 | } 69 | 70 | const items = ev.clipboardData?.items 71 | 72 | if (!items) { 73 | return 74 | } 75 | 76 | // 查找属于图像类型的数据项。 77 | const imageItem = Array.from(items).find((item) => item.type.includes('image')) 78 | 79 | if (imageItem) { 80 | const file = imageItem.getAsFile() 81 | 82 | if (file) { 83 | handleUploadImage(file) 84 | } 85 | } 86 | }) 87 | 88 | $wrapper.get(0)?.addEventListener('drop', (ev) => { 89 | ev.preventDefault() 90 | 91 | if (!(ev instanceof DragEvent)) { 92 | return 93 | } 94 | 95 | const file = ev.dataTransfer?.files[0] 96 | 97 | if (file) { 98 | handleUploadImage(file) 99 | } 100 | }) 101 | 102 | $('.flex-one-row:last-of-type > .gray').text('') 103 | 104 | $uploadBar.on('click', () => { 105 | if (!$uploadBar.hasClass('v2p-reply-upload-bar-disabled')) { 106 | handleClickUploadImage() 107 | } 108 | }) 109 | 110 | $wrapper.append($uploadBar) 111 | 112 | return { 113 | uploadBar: $uploadBar, 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /website/src/app/share/components/ShareCardThemeBasic.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar } from '@radix-ui/themes' 2 | 3 | import type { TopicInfo } from '~/app/api/share/route' 4 | 5 | interface ShareCardProps { 6 | avatarRef: React.RefObject 7 | topicInfo: TopicInfo 8 | showSubtle?: boolean 9 | showQRCode?: boolean 10 | } 11 | 12 | export function ShareCardThemeBasic(props: ShareCardProps) { 13 | const { topicInfo, avatarRef, showSubtle, showQRCode } = props 14 | 15 | return ( 16 |
    17 |
    18 |
    V2EX
    19 | 20 |

    {topicInfo.title}

    21 | 22 |
    23 | 31 | {topicInfo.member.username} 32 | 33 | 34 | {topicInfo.time.year}-{topicInfo.time.month}-{topicInfo.time.day} 35 | 36 |
    37 | 38 | {topicInfo.content && ( 39 | <> 40 |
    41 | 42 |
    54 |
    55 | 56 | {showSubtle && 57 | topicInfo.supplements?.map((item, idx) => { 58 | return ( 59 |
    60 |
    61 |
    62 | ) 63 | })} 64 |
    65 | 66 | )} 67 | 68 | {showQRCode && ( 69 |
    70 |
    71 |
    72 |

    长按扫码

    73 |

    查看详情

    74 |
    75 | {/* eslint-disable @next/next/no-img-element */} 76 | 二维码 80 |
    81 |
    82 | )} 83 |
    84 |
    85 | ) 86 | } 87 | -------------------------------------------------------------------------------- /src/components/modal.ts: -------------------------------------------------------------------------------- 1 | import { createButton } from './button' 2 | 3 | interface ModalElements { 4 | $mask: JQuery 5 | $main: JQuery 6 | $header: JQuery 7 | $container: JQuery 8 | $title: JQuery 9 | $actions: JQuery 10 | $content: JQuery 11 | } 12 | 13 | interface ModalControl extends ModalElements { 14 | open: () => void 15 | close: () => void 16 | } 17 | 18 | interface CreateModalProps { 19 | root?: JQuery 20 | title?: string 21 | onMount?: (elements: ModalElements) => void 22 | onOpen?: (elements: ModalElements) => void 23 | onClose?: (elements: ModalElements) => void 24 | } 25 | 26 | /** 27 | * 创建 modal 框。 28 | */ 29 | export function createModal(props: CreateModalProps): ModalControl { 30 | const { root, title, onMount, onOpen, onClose } = props 31 | 32 | const $mask = $('
    ') 33 | 34 | const $content = $('
    ') 35 | 36 | const $closeBtn = createButton({ 37 | children: '关闭Esc', 38 | className: 'v2p-modal-close-btn', 39 | }) 40 | 41 | const $title = $(`
    ${title ?? ''}
    `) 42 | 43 | const $actions = $('
    ').append($closeBtn) 44 | 45 | const $header = $('
    ').append($title, $actions) 46 | 47 | const $main = $('
    ') 48 | .append($header, $content) 49 | .on('click', (ev) => { 50 | ev.stopPropagation() 51 | }) 52 | 53 | const $container = $mask.append($main).hide() 54 | 55 | const modalElements = { 56 | $mask, 57 | $main, 58 | $header, 59 | $container, 60 | $title, 61 | $actions, 62 | $content, 63 | } 64 | 65 | // 用于判定是否已经绑定了事件, 避免重复绑定。 66 | let boundEvent = false 67 | 68 | let mouseDownTarget: HTMLElement 69 | 70 | const mouseDownHandler = (ev: JQuery.MouseDownEvent) => { 71 | mouseDownTarget = ev.target 72 | } 73 | 74 | const mouseUpHandler = (ev: JQuery.MouseUpEvent) => { 75 | if ( 76 | mouseDownTarget === $mask.get(0) && 77 | ev.target === $mask.get(0) && 78 | ev.currentTarget === ev.target 79 | ) { 80 | // eslint-disable-next-line @typescript-eslint/no-use-before-define 81 | handleModalClose() 82 | } 83 | } 84 | 85 | const keyupHandler = (ev: JQuery.KeyDownEvent) => { 86 | if (ev.key === 'Escape') { 87 | // eslint-disable-next-line @typescript-eslint/no-use-before-define 88 | handleModalClose() 89 | } 90 | } 91 | 92 | const handleModalClose = () => { 93 | $mask.off('mousedown', mouseDownHandler) 94 | $mask.off('mouseup', mouseUpHandler) 95 | $(document).off('keydown', keyupHandler) 96 | boundEvent = false 97 | 98 | $container.fadeOut('fast') 99 | document.body.classList.remove('v2p-modal-open') 100 | 101 | onClose?.(modalElements) 102 | } 103 | 104 | const handleModalOpen = () => { 105 | // Hack: 为了防止 open 点击事件提前冒泡到 document 上,需要延迟绑定事件。 106 | setTimeout(() => { 107 | if (!boundEvent) { 108 | $mask.on('mousedown', mouseDownHandler) 109 | $mask.on('mouseup', mouseUpHandler) 110 | $(document).on('keydown', keyupHandler) 111 | boundEvent = true 112 | } 113 | }) 114 | 115 | $container.fadeIn('fast') 116 | document.body.classList.add('v2p-modal-open') 117 | 118 | onOpen?.(modalElements) 119 | } 120 | 121 | $closeBtn.on('click', handleModalClose) 122 | 123 | onMount?.(modalElements) 124 | 125 | if (root) { 126 | root.append($container) 127 | } 128 | 129 | return { ...modalElements, open: handleModalOpen, close: handleModalClose } 130 | } 131 | -------------------------------------------------------------------------------- /src/styles/v2ex-theme-dark.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | @mixin dark-mode { 3 | // 基础色: 4 | --v2p-color-main-50: unset; 5 | --v2p-color-main-100: #2d333b; 6 | --v2p-color-main-200: #374151; 7 | --v2p-color-main-300: #374151; 8 | --v2p-color-main-350: #6b7280cc; 9 | --v2p-color-main-400: #6b7280; 10 | --v2p-color-main-500: #9ca3af; 11 | --v2p-color-main-600: #9ca3af; 12 | --v2p-color-main-700: #d1d5db; 13 | --v2p-color-main-800: #e5e7eb; 14 | --v2p-color-main-900: #111827; 15 | --v2p-color-main-950: #030712; 16 | --v2p-color-accent-50: #064e3b; 17 | --v2p-color-accent-100: #065f46; 18 | --v2p-color-accent-200: #047857; 19 | --v2p-color-accent-300: #059669; 20 | --v2p-color-accent-400: #10b981; 21 | --v2p-color-accent-500: #34d399; 22 | --v2p-color-accent-600: #6ee7b7; 23 | --v2p-color-orange-50: #593600; 24 | --v2p-color-orange-100: #9a3412; 25 | --v2p-color-orange-400: #fbe090; 26 | 27 | // ==== 28 | --v2p-color-background: #1c2128; 29 | --v2p-color-foreground: #adbac7; 30 | --v2p-color-font-secondary: var(--v2p-color-main-600); 31 | 32 | // ==== 按钮 ==== 33 | --v2p-color-button-background: #373e47; 34 | --v2p-color-button-foreground: var(--v2p-color-foreground); 35 | --v2p-color-button-background-hover: #444c56; 36 | --v2p-color-button-foreground-hover: var(--v2p-color-foreground); 37 | // ---- 按钮 ---- 38 | 39 | // ==== 背景 ==== 40 | --v2p-color-bg-content: #22272e; 41 | --v2p-color-bg-subtle: rgb(6 78 59 / 30%); 42 | --v2p-color-bg-input: var(--v2p-color-background); 43 | --v2p-color-bg-search: var(--v2p-color-main-100); 44 | --v2p-color-bg-search-active: var(--v2p-color-main-200); 45 | --v2p-color-bg-widget: var(--v2p-color-bg-content); 46 | --v2p-color-bg-reply: var(--v2p-color-main-100); 47 | --v2p-color-bg-tooltip: var(--v2p-color-main-100); 48 | --v2p-color-bg-avatar: var(--v2p-color-main-300); 49 | --v2p-color-bg-block: #373e47; 50 | // ---- 背景 ---- 51 | 52 | --v2p-color-heart: #ef4444; 53 | --v2p-color-heart-fill: #fca5a5; 54 | --v2p-color-mask: rgb(99 110 123 / 40%); 55 | --v2p-color-border: #444c56; 56 | --v2p-color-input-border: #444c56; 57 | --v2p-color-border-darker: #444c56; 58 | 59 | // ==== 阴影 ==== 60 | --v2p-box-shadow: 0 0 0 1px var(--v2p-color-border); 61 | --v2p-toast-shadow: none; 62 | // ---- 阴影 ---- 63 | 64 | // 滚动条 65 | --v2p-scrollbar-background-color: #22303f; 66 | 67 | // V2EX 原有的 CSS 变量: 68 | --link-color: var(--v2p-color-foreground); 69 | --box-background-alt-color: var(--v2p-color-main-100); 70 | --box-background-hover-color: var(--v2p-color-main-300); 71 | --button-hover-color: var(--button-background-hover-color); 72 | --button-border-color: var(--v2p-color-border); 73 | --button-border-hover-color: #768390; 74 | 75 | visibility: visible; 76 | 77 | #Logo { 78 | background-image: url('https://www.v2ex.com/static/img/v2ex-alt@2x.png'); 79 | } 80 | 81 | ::selection { 82 | color: var(--v2p-color-background, #1c2128); 83 | background-color: var(--v2p-color-foreground, #adbac7); 84 | } 85 | 86 | img::selection { 87 | background-color: var(--v2p-color-foreground, #adbac7); 88 | } 89 | } 90 | 91 | body.v2p-theme-dark-default, 92 | .v2p-theme-dark-default, 93 | &[data-darkreader-scheme='dark'] body { 94 | @include dark-mode; 95 | } 96 | 97 | @supports selector(:has(*)) { 98 | body:has(#Wrapper.Night) { 99 | @include dark-mode; 100 | } 101 | } 102 | 103 | @supports not selector(:has(*)) { 104 | #Wrapper.Night { 105 | @include dark-mode; 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/contents/home/hot-topics.ts: -------------------------------------------------------------------------------- 1 | import { createPopup } from '../../components/popup' 2 | import { iconLoading } from '../../icons' 3 | import { getHotTopics } from '../../services' 4 | import type { HotTopic } from '../../types' 5 | import { $wrapper } from '../globals' 6 | 7 | export function handleHotTopics() { 8 | const $topicsHot = $('#TopicsHot') 9 | const $hotHeader = $topicsHot.find('> .cell:first-of-type').addClass('v2p-topics-hot-header') 10 | $hotHeader.find('.fade').text('热议主题') 11 | 12 | $hotHeader.nextAll('.cell').wrapAll('
    ') 13 | const $listWrapper = $('.v2p-topics-hot') 14 | 15 | let $todayCells = $listWrapper.find('> .cell') 16 | const $cell = $todayCells.eq(1).clone() 17 | $cell.find('.v2p-topic-preview-btn').remove() 18 | 19 | const $text = $('今日') 20 | const $trigger = $( 21 | '
    ' 22 | ) 23 | .prepend($text) 24 | .appendTo($hotHeader) 25 | 26 | const $dropdownContent = $(` 27 |
    28 |
    今日
    29 |
    近三日
    30 |
    近七日
    31 |
    近一月
    32 |
    33 | `) 34 | 35 | const popupControl = createPopup({ 36 | root: $wrapper, 37 | trigger: $trigger, 38 | content: $dropdownContent, 39 | offsetOptions: { mainAxis: 5, crossAxis: -5 }, 40 | }) 41 | 42 | let abortController: AbortController | null = null 43 | 44 | const now = Math.floor(Date.now() / 1000) 45 | const oneDay = 60 * 60 * 24 46 | const cache = new Map() 47 | 48 | const renderNewTopicList = (result: HotTopic[]) => { 49 | $listWrapper.empty() 50 | 51 | result.forEach((it) => { 52 | const $clonedCell = $cell.clone() 53 | const $user = $clonedCell.find('a[href^="/member"]') 54 | $user.attr('href', `/member/${it.member.username}`) 55 | $user.find('> img').attr('src', it.member.avatar_mini) 56 | $clonedCell.find('.item_hot_topic_title > a').text(it.title).attr('href', `/t/${it.id}`) 57 | $listWrapper.append($clonedCell) 58 | }) 59 | } 60 | 61 | $dropdownContent.find('.v2p-select-item').on('click', (ev) => { 62 | popupControl.close() 63 | 64 | const $target = $(ev.currentTarget) 65 | 66 | if ($target.hasClass('v2p-select-item-active')) { 67 | return 68 | } 69 | 70 | abortController?.abort() 71 | 72 | const { alias } = $target.data() 73 | 74 | $target.addClass('v2p-select-item-active').siblings().removeClass('v2p-select-item-active') 75 | $todayCells = $todayCells.detach() 76 | 77 | $listWrapper.empty().append(` 78 |
    79 |
    ${iconLoading}
    80 |
    81 | `) 82 | 83 | if (typeof alias === 'string') { 84 | $text.text(alias) 85 | 86 | switch (alias) { 87 | case '今日': 88 | $listWrapper.empty().append($todayCells) 89 | return 90 | 91 | case '近三日': 92 | case '近七日': 93 | case '近一月': { 94 | const cacheResult = cache.get(alias) 95 | 96 | if (cacheResult) { 97 | renderNewTopicList(cacheResult) 98 | } else { 99 | const days = alias === '近三日' ? 3 : alias === '近七日' ? 7 : 30 100 | 101 | abortController = new AbortController() 102 | 103 | getHotTopics({ 104 | startTime: now - days * oneDay, 105 | endTime: now, 106 | signal: abortController.signal, 107 | }).then(({ result }) => { 108 | cache.set(alias, result) 109 | renderNewTopicList(result) 110 | }) 111 | } 112 | return 113 | } 114 | } 115 | } 116 | }) 117 | } 118 | -------------------------------------------------------------------------------- /website/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import { Noto_Sans } from 'next/font/google' 3 | import Link from 'next/link' 4 | import { Theme } from '@radix-ui/themes' 5 | 6 | import { HoverButton } from '~/components/HoverButton' 7 | import { Initial } from '~/components/Initial' 8 | import { Logo } from '~/components/Logo' 9 | import { Nav } from '~/components/Nav' 10 | import { getPageTitle } from '~/utils' 11 | 12 | import '~/styles/globals.css' 13 | 14 | export const metadata: Metadata = { 15 | colorScheme: 'light', 16 | icons: [ 17 | { 18 | url: '/favicon.svg', 19 | type: 'image/svg+xml', 20 | media: '(prefers-color-scheme: light)', 21 | sizes: 'any', 22 | rel: 'icon', 23 | }, 24 | { 25 | url: '/favicon-dark.svg', 26 | type: 'image/svg+xml', 27 | media: '(prefers-color-scheme: dark)', 28 | sizes: 'any', 29 | rel: 'icon', 30 | }, 31 | ], 32 | title: getPageTitle(), 33 | description: '专为 V2EX 用户设计的浏览器插件,提供了丰富的扩展功能为你带来出色的体验。', 34 | openGraph: { 35 | type: 'website', 36 | title: 'V2EX Polish - 浏览器插件', 37 | description: '专为 V2EX 用户设计的浏览器插件,提供了丰富的扩展功能为你带来出色的体验。', 38 | url: 'https://www.v2p.app', 39 | images: 'https://i.imgur.com/q2minty.png', 40 | }, 41 | authors: [{ name: '陈梓聪 LeoKu', url: 'https://github.com/Codennnn' }], 42 | manifest: '/manifest.webmanifest', 43 | } 44 | 45 | const notoSans = Noto_Sans({ 46 | weight: ['400', '500', '600', '700', '900'], 47 | subsets: ['latin'], 48 | display: 'fallback', 49 | }) 50 | 51 | export default function RootLayout(props: React.PropsWithChildren) { 52 | return ( 53 | 54 | 55 | 56 | 57 | 58 |
    59 |
    61 | 62 |
    {props.children}
    63 | 64 |
    65 |
    66 |
    67 |
    68 |
    69 |
    70 | 71 |
    72 | 73 | V2EX Polish 74 | 75 |
    76 | 77 |
    78 | 79 | 83 | 应用商店 84 | 85 | 86 | 87 | · 88 | 89 | 90 | 94 | 使用反馈 95 | 96 | 97 | 98 | · 99 | 100 | 101 | 102 | 赞赏支持 103 | 104 | 105 |
    106 |
    107 |
    108 |
    109 |
    110 |
    111 | 112 | 113 | ) 114 | } 115 | -------------------------------------------------------------------------------- /src/components/popup.ts: -------------------------------------------------------------------------------- 1 | import { 2 | computePosition, 3 | type ComputePositionConfig, 4 | flip, 5 | offset, 6 | type OffsetOptions, 7 | type Placement, 8 | shift, 9 | } from '@floating-ui/dom' 10 | 11 | import { createToast } from './toast' 12 | 13 | export const hoverDelay = 350 14 | 15 | interface PopupElements { 16 | $content: JQuery 17 | } 18 | 19 | /** 用户信息弹出框控制。 */ 20 | export interface PopupControl extends PopupElements, Pick { 21 | /** 鼠标是否悬浮在弹出框上。 */ 22 | isOver: boolean 23 | /** 调用此方法在某个元素上打开弹出框。 */ 24 | open: (reference?: JQuery) => void 25 | /** 调用此方法关闭弹出框。 */ 26 | close: () => void 27 | } 28 | 29 | interface CreatePopupProps { 30 | /** 挂载在哪个节点下面 */ 31 | root: JQuery 32 | /** 触发 Popup 的元素 */ 33 | trigger?: JQuery 34 | /** 触发的方式 */ 35 | triggerType?: 'click' | 'hover' 36 | /** Popup 内部的渲染元素 */ 37 | content?: JQuery 38 | /** 计算定位方法的配置项 */ 39 | options?: Partial 40 | /** Popup 打开触发的回调 */ 41 | onOpen?: () => void 42 | /** Popup 关闭时触发的回调 */ 43 | onClose?: () => void 44 | 45 | placement?: Placement 46 | offsetOptions?: OffsetOptions 47 | } 48 | 49 | /** 50 | * 创建 Popup 框。 51 | */ 52 | export function createPopup(props: CreatePopupProps): PopupControl { 53 | const { 54 | root, 55 | trigger, 56 | triggerType = 'click', 57 | content, 58 | options, 59 | onOpen, 60 | onClose, 61 | placement = 'bottom-start', 62 | offsetOptions = { mainAxis: 5, crossAxis: 5 }, 63 | } = props 64 | 65 | const $popupContent = $('
    ') 66 | const $popup = $('
    ') 67 | .css('visibility', 'hidden') 68 | .append($popupContent) 69 | 70 | root.append($popup) 71 | 72 | if (content) { 73 | $popup.append(content) 74 | } 75 | 76 | const popup = $popup.get(0)! 77 | 78 | const handleClickOutside = (ev: JQuery.ClickEvent) => { 79 | if ($(ev.target).closest(popup).length === 0) { 80 | // eslint-disable-next-line @typescript-eslint/no-use-before-define 81 | handlePopupClose() 82 | } 83 | } 84 | 85 | const handlePopupClose = () => { 86 | $popup.css('visibility', 'hidden') 87 | $(document).off('click', handleClickOutside) 88 | onClose?.() 89 | // eslint-disable-next-line @typescript-eslint/no-use-before-define 90 | popupControl.onClose?.() 91 | } 92 | 93 | const handlePopupOpen = ($reference?: JQuery) => { 94 | if (!$reference) { 95 | return 96 | } 97 | 98 | // 为了避免点击外部区域立即关闭 Popup,需要延迟绑定 document 的 click 事件。 99 | setTimeout(() => { 100 | $(document).on('click', handleClickOutside) 101 | }) 102 | 103 | const referenceElement = $reference.get(0)! 104 | 105 | computePosition(referenceElement, popup, { 106 | placement, 107 | middleware: [offset(offsetOptions), flip(), shift({ padding: 8 })], 108 | ...options, 109 | }) 110 | .then(({ x, y }) => { 111 | Object.assign(popup.style, { 112 | left: `${x}px`, 113 | top: `${y}px`, 114 | }) 115 | $popup.css('visibility', 'visible') 116 | }) 117 | .catch(() => { 118 | handlePopupClose() 119 | createToast({ message: '❌ Popup 渲染失败' }) 120 | }) 121 | 122 | onOpen?.() 123 | } 124 | 125 | const popupControl: PopupControl = { 126 | $content: $popupContent, 127 | isOver: false, 128 | open: (reference) => { 129 | handlePopupOpen(reference) 130 | }, 131 | close: handlePopupClose, 132 | } 133 | 134 | if (triggerType === 'hover') { 135 | $popup.on('mouseover', () => { 136 | if (!popupControl.isOver) { 137 | popupControl.isOver = true 138 | 139 | $popup.off('mouseleave').on('mouseleave', () => { 140 | popupControl.isOver = false 141 | setTimeout(() => { 142 | if (!popupControl.isOver) { 143 | popupControl.close() 144 | } 145 | }, hoverDelay) 146 | }) 147 | } 148 | }) 149 | } 150 | 151 | trigger?.on('click', () => { 152 | if (popup.style.visibility !== 'hidden') { 153 | handlePopupClose() 154 | } else { 155 | handlePopupOpen(trigger) 156 | } 157 | }) 158 | 159 | return popupControl 160 | } 161 | -------------------------------------------------------------------------------- /website/src/components/icons/EdgeIcon.tsx: -------------------------------------------------------------------------------- 1 | export function EdgeIcon() { 2 | return ( 3 | 4 | 8 | 12 | 16 | 20 | 24 | 25 | 33 | 34 | 35 | 36 | 37 | 45 | 46 | 47 | 48 | 49 | 57 | 58 | 59 | 60 | 61 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | ) 76 | } 77 | -------------------------------------------------------------------------------- /website/src/components/support/SupportTable.tsx: -------------------------------------------------------------------------------- 1 | import { Table } from '@radix-ui/themes' 2 | 3 | interface DonationData { 4 | from?: string 5 | money: string 6 | time: string 7 | message?: string 8 | channel: '微信赞赏' | '微信转账' 9 | } 10 | 11 | export const donationList: DonationData[] = [ 12 | { 13 | from: '猎户星座', 14 | money: '6', 15 | time: '2024/05/11', 16 | message: '赞!!!', 17 | channel: '微信赞赏', 18 | }, 19 | { 20 | from: '*强', 21 | money: '6', 22 | time: '2024/05/07', 23 | message: 'v2插件👍', 24 | channel: '微信转账', 25 | }, 26 | { 27 | from: '余*', 28 | money: '66', 29 | time: '2024/02/28', 30 | message: '好看好用', 31 | channel: '微信转账', 32 | }, 33 | { 34 | from: 'Zhitao', 35 | money: '9', 36 | time: '2024/02/02', 37 | message: '感谢大佬开发好用的插件', 38 | channel: '微信赞赏', 39 | }, 40 | { 41 | from: 'Zryan', 42 | money: '6', 43 | time: '2024/01/10', 44 | message: '非常优秀的插件!', 45 | channel: '微信赞赏', 46 | }, 47 | { from: 'BurgerTown', money: '6', time: '2024/01/09', channel: '微信赞赏' }, 48 | { from: '匿名', money: '6', time: '2024/01/09', message: '🤙🤙', channel: '微信赞赏' }, 49 | { from: '自言姿语', money: '9', time: '2024/01/09', message: '感谢大佬', channel: '微信赞赏' }, 50 | { 51 | from: '匿名', 52 | money: '6', 53 | time: '2024/01/09', 54 | message: '来自 V2EX Polish', 55 | channel: '微信赞赏', 56 | }, 57 | { 58 | from: 'Will', 59 | money: '6', 60 | time: '2024/01/09', 61 | message: 'v2p 的 UI/UX 设计很用心', 62 | channel: '微信赞赏', 63 | }, 64 | { from: '🎃Jnan', money: '66', time: '2024/01/08', message: '你好棒呀!', channel: '微信赞赏' }, 65 | { from: '喜多🍒', money: '22', time: '2023/10/07', message: '感谢你的插件', channel: '微信赞赏' }, 66 | { from: '💿', money: '18', time: '2023/10/04', channel: '微信赞赏' }, 67 | { 68 | from: 'Xavier', 69 | money: '6', 70 | time: '2023/09/26', 71 | message: '非常不错的插件', 72 | channel: '微信赞赏', 73 | }, 74 | { from: '咸鱼', money: '9', time: '2023/09/26', channel: '微信赞赏' }, 75 | { from: '😐', money: '6', time: '2023/05/30', message: 'whilegreathair', channel: '微信赞赏' }, 76 | { from: '小人物', money: '10', time: '2023/05/30', channel: '微信赞赏' }, 77 | { 78 | from: '喜多🍒', 79 | money: '22', 80 | time: '2023/05/28', 81 | message: '插件非常棒,请你喝杯奶茶', 82 | channel: '微信赞赏', 83 | }, 84 | { 85 | from: '卷胖毛', 86 | money: '11', 87 | time: '2023/05/26', 88 | message: '喝杯奶茶继续加油!', 89 | channel: '微信赞赏', 90 | }, 91 | { 92 | from: '匿名', 93 | money: '22', 94 | time: '2023/05/26', 95 | message: '在国内的环境下,能做独立产品的开发者实在太少了,点个赞', 96 | channel: '微信赞赏', 97 | }, 98 | { from: '刘昆', money: '6', time: '2023/05/25', message: 'Polish 挺方便的', channel: '微信赞赏' }, 99 | { 100 | from: '宇宙的尽头是什么', 101 | money: '2', 102 | time: '2023/05/25', 103 | message: '希望插件越来越好', 104 | channel: '微信赞赏', 105 | }, 106 | { from: '陈洁', money: '22', time: '2023/05/25', message: '请你喝奶茶', channel: '微信赞赏' }, 107 | { 108 | from: '摄影铁手男', 109 | money: '11', 110 | time: '2023/05/25', 111 | message: '非常好用的 v2 插件,感谢', 112 | channel: '微信赞赏', 113 | }, 114 | ] 115 | 116 | export function SupportTable() { 117 | return ( 118 | 119 | 120 | 121 | 来自 122 | 金额(元) 123 | 留言 124 | 时间 125 | 方式 126 | 127 | 128 | 129 | 130 | {donationList.map((donation) => ( 131 | 132 | {donation.from} 133 | 134 | {donation.money} 135 | 136 | {donation.message || '-'} 137 | {donation.time} 138 | {donation.channel} 139 | 140 | ))} 141 | 142 | 143 | ) 144 | } 145 | -------------------------------------------------------------------------------- /src/styles/v2ex-theme-dawn.scss: -------------------------------------------------------------------------------- 1 | body.v2p-theme-dawn, 2 | .v2p-theme-dawn { 3 | // 色板: 4 | --v2p-color-base: 32deg 57% 95%; 5 | --v2p-color-surface: 35deg 100% 98%; 6 | --v2p-color-overlay: 33deg 43% 91%; 7 | --v2p-color-muted: 257deg 9% 61%; 8 | --v2p-color-subtle: 248deg 12% 52%; 9 | --v2p-color-text: 248deg 19% 40%; 10 | --v2p-color-love: 343deg 35% 55%; 11 | --v2p-color-gold: 35deg 81% 56%; 12 | --v2p-color-rose: 3deg 53% 67%; 13 | --v2p-color-pine: 197deg 53% 34%; 14 | --v2p-color-foam: 189deg 30% 48%; 15 | --v2p-color-iris: 268deg 21% 57%; 16 | 17 | // ==== 18 | --v2p-color-accent-50: hsl(var(--v2p-color-foam) / 10%); 19 | --v2p-color-accent-100: hsl(var(--v2p-color-foam) / 20%); 20 | --v2p-color-accent-200: hsl(var(--v2p-color-foam) / 30%); 21 | --v2p-color-accent-300: hsl(var(--v2p-color-foam) / 40%); 22 | --v2p-color-accent-400: hsl(var(--v2p-color-foam) / 50%); 23 | --v2p-color-accent-500: hsl(var(--v2p-color-foam) / 65%); 24 | --v2p-color-accent-600: hsl(var(--v2p-color-foam) / 80%); 25 | --v2p-color-orange-50: hsl(var(--v2p-color-gold) / 10%); 26 | --v2p-color-orange-100: hsl(var(--v2p-color-gold) / 20%); 27 | --v2p-color-orange-400: hsl(var(--v2p-color-gold)); 28 | 29 | // ==== 30 | --v2p-color-background: hsl(var(--v2p-color-base)); 31 | --v2p-color-foreground: hsl(var(--v2p-color-text)); 32 | --v2p-color-selection-foreground: var(--v2p-color-foreground); 33 | --v2p-color-selection-background: hsl(var(--v2p-color-muted) / 20%); 34 | --v2p-color-selection-background-img: hsl(var(--v2p-color-muted) / 60%); 35 | --v2p-color-font-secondary: hsl(var(--v2p-color-subtle)); 36 | --v2p-color-font-tertiary: hsl(var(--v2p-color-subtle)); 37 | --v2p-color-font-quaternary: hsl(var(--v2p-color-subtle)); 38 | 39 | // ==== 按钮 ==== 40 | --v2p-color-button-background: hsl(var(--v2p-color-text) / 10%); 41 | --v2p-color-button-foreground: var(--v2p-color-foreground); 42 | --v2p-color-button-background-hover: hsl(var(--v2p-color-text) / 15%); 43 | --v2p-color-button-foreground-hover: var(--v2p-color-foreground); 44 | // ---- 按钮 ---- 45 | 46 | // ==== 背景 ==== 47 | --v2p-color-bg-content: hsl(var(--v2p-color-surface)); 48 | --v2p-color-bg-footer: var(--v2p-color-bg-content); 49 | --v2p-color-bg-hover-btn: rgb(152 147 165 / 10%); 50 | --v2p-color-bg-subtle: hsl(var(--v2p-color-pine) / 10%); 51 | --v2p-color-bg-input: hsl(var(--v2p-color-overlay) / 40%); 52 | --v2p-color-bg-search: hsl(var(--v2p-color-overlay) / 60%); 53 | --v2p-color-bg-search-active: hsl(var(--v2p-color-overlay) / 90%); 54 | --v2p-color-bg-widget: rgb(255 255 255 / 70%); 55 | --v2p-color-bg-reply: #faf4ed; 56 | --v2p-color-bg-tooltip: var(--v2p-color-bg-content); 57 | --v2p-color-bg-tabs: hsl(var(--v2p-color-pine) / 10%); 58 | --v2p-color-bg-tpr: hsl(var(--v2p-color-text) / 10%); 59 | --v2p-color-bg-avatar: hsl(var(--v2p-color-overlay)); 60 | --v2p-color-bg-block: hsl(var(--v2p-color-text) / 10%); 61 | --v2p-color-bg-block-darker: hsl(var(--v2p-color-text) / 25%); 62 | --v2p-color-bg-link: hsl(var(--v2p-color-text) / 10%); 63 | --v2p-color-bg-link-hover: hsl(var(--v2p-color-text) / 15%); 64 | // ---- 背景 ---- 65 | 66 | --v2p-color-tabs: hsl(var(--v2p-color-pine)); 67 | --v2p-color-heart: #eb6f92; 68 | --v2p-color-heart-fill: rgb(235 111 146 / 50%); 69 | --v2p-color-mask: rgb(0 0 0 / 25%); 70 | --v2p-color-divider: hsl(var(--v2p-color-muted) / 20%); 71 | --v2p-color-border: hsl(var(--v2p-color-muted) / 20%); 72 | --v2p-color-input-border: rgb(152 147 165 / 20%); 73 | --v2p-color-border-darker: hsl(var(--v2p-color-muted) / 40%); 74 | --v2p-color-link-visited: hsl(var(--v2p-color-subtle)); 75 | --v2p-color-error: #eb6f92; 76 | --v2p-color-bg-error: #fee2e2; 77 | --v2p-color-cell-num: hsl(var(--v2p-color-subtle)); 78 | 79 | // ==== 阴影 ==== 80 | --v2p-box-shadow: 0 3px 5px 0 rgb(0 0 0 / 4%); 81 | --v2p-widget-shadow: 0 9px 24px -3px rgb(0 0 0 / 6%), 0 4px 8px -1px rgb(0 0 0 /12%); 82 | --v2p-toast-shadow: 0 6px 16px 0 rgb(0 0 0 / 8%), 0 3px 6px -4px rgb(0 0 0 / 12%), 83 | 0 9px 28px 8px rgb(0 0 0 / 5%); 84 | // ---- 阴影 ---- 85 | 86 | // V2EX 原有的 CSS 变量: 87 | --link-color: var(--v2p-color-foreground); 88 | --box-background-alt-color: var(--v2p-color-bg-block); 89 | --box-background-hover-color: var(--v2p-color-bg-link-hover); 90 | --button-hover-color: var(--v2p-color-button-background-hover); 91 | --button-border-color: var(--v2p-color-border); 92 | --button-border-hover-color: var(--v2p-color-border-darker); 93 | 94 | visibility: visible; 95 | 96 | .button { 97 | &.special { 98 | &:hover { 99 | &, 100 | &:enabled { 101 | text-shadow: none; 102 | } 103 | } 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /website/src/components/screens/ScreenTopicWrite.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowUpRightSquare, ChevronDownIcon, ChevronRightIcon, SendIcon } from 'lucide-react' 2 | 3 | import { Box, Content, Header, Paragraph, RightSide, Wrapper } from '~/components/ui' 4 | 5 | export function ScreenTopicWrite() { 6 | return ( 7 | 8 |
    9 | 10 | 11 | 12 |
    13 | V2EX 14 | 15 | 创作新主题 16 |
    17 | 18 |
    19 | 请输入主题标题,如果标题能够表达完整内容,则正文可以为空 20 |
    21 | 22 |
    23 |
    24 | 25 | 正文 26 | 27 | 28 | 预览 29 | 30 |
    31 | 32 |
    33 | Syntax 34 |
    35 | V2EX 原生格式 36 | 37 | Markdown 38 | 39 |
    40 |
    41 |
    42 | 43 |
    44 |
    45 | 选择、粘贴、拖放上传图片。 46 |
    47 |
    48 | 49 |
    50 |
    51 | 主题节点 52 |
    53 | 请选择一个节点 54 | 55 |
    56 |
    57 | 58 |
    59 | V2EX 节点使用说明 60 | 61 |
    62 |
    63 | 64 |
    65 | 66 |
    67 |
    68 | 69 | 发布主题 70 |
    71 |
    72 |
    73 | 74 | 75 | 76 |
    77 | 发帖提示 78 |
    79 | 80 |
    81 |
    主题标题
    82 |
    83 | 84 | 85 | 86 |
    87 | 88 |
    正文
    89 |
    90 | 91 | 92 | 93 | 94 |
    95 | 96 |
    选择节点
    97 |
    98 | 99 | 100 | 101 |
    102 | 103 |
    尊重原创
    104 |
    105 | 106 | 107 |
    108 |
    109 |
    110 |
    111 |
    112 | 113 | ) 114 | } 115 | -------------------------------------------------------------------------------- /website/src/components/Screenshot.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useEffect, useRef, useState } from 'react' 4 | 5 | import { MoonStarIcon, SunIcon } from 'lucide-react' 6 | 7 | import { ScreenHome } from '~/components/screens/ScreenHome' 8 | import { ScreenTopic } from '~/components/screens/ScreenTopic' 9 | import { ScreenTopicWrite } from '~/components/screens/ScreenTopicWrite' 10 | 11 | const screens = [ 12 | { name: 'home', page: }, 13 | { name: 'topic', page: }, 14 | { name: 'topic-write', page: }, 15 | ] as const 16 | 17 | type ScreenName = (typeof screens)[number]['name'] 18 | 19 | export function Screenshot() { 20 | const [mode, setMode] = useState() 21 | 22 | const isDarkTheme = mode === 'theme-dark' 23 | 24 | const [currentDisplay, setCurrentDisplay] = useState('home') 25 | 26 | const timer = useRef() 27 | const mouseOver = useRef(false) 28 | 29 | const setupInterval = () => { 30 | window.clearInterval(timer.current) 31 | 32 | timer.current = window.setInterval(() => { 33 | if (!mouseOver.current) { 34 | setCurrentDisplay((display) => { 35 | if (display === 'home') { 36 | return 'topic' 37 | } else if (display === 'topic') { 38 | return 'topic-write' 39 | } else { 40 | return 'home' 41 | } 42 | }) 43 | } 44 | }, 2500) 45 | } 46 | 47 | useEffect(() => { 48 | setupInterval() 49 | 50 | return () => { 51 | window.clearInterval(timer.current) 52 | } 53 | }, []) 54 | 55 | return ( 56 | <> 57 |
    64 |
    { 69 | mouseOver.current = true 70 | }} 71 | onMouseLeave={() => { 72 | mouseOver.current = false 73 | }} 74 | > 75 | 86 | 87 |
    91 | {screens.map(({ name, page }, idx) => ( 92 |
    105 | {page} 106 |
    107 | ))} 108 |
    109 |
    110 |
    111 | 112 |
    113 | {screens.map(({ name }, idx) => ( 114 |
    126 | 127 | ) 128 | } 129 | -------------------------------------------------------------------------------- /src/contents/topic/avatar.ts: -------------------------------------------------------------------------------- 1 | import { createButton } from '../../components/button' 2 | import { hoverDelay, type PopupControl } from '../../components/popup' 3 | import { fetchUserInfo } from '../../services' 4 | import type { CommentData, Member } from '../../types' 5 | import { formatTimestamp } from '../../utils' 6 | import { openTagsSetter } from './content' 7 | 8 | const banned = Symbol() 9 | 10 | export const memberDataCache = new Map() 11 | 12 | interface ProcessAvatar { 13 | /** 触发弹出框的元素。 */ 14 | $trigger: JQuery 15 | popupControl: PopupControl 16 | commentData: Pick 17 | /** 是否要包裹一层可点击链接。 */ 18 | shouldWrap?: boolean 19 | openInNewTab?: boolean 20 | /** 点击「添加用户标签」按钮的回调。 */ 21 | onSetTagsClick?: () => void 22 | } 23 | 24 | /** 25 | * 处理用户头像元素: 26 | * - 鼠标悬浮头像会展示该用户的信息。 27 | */ 28 | export function processAvatar(params: ProcessAvatar) { 29 | const { 30 | $trigger, 31 | popupControl, 32 | commentData, 33 | shouldWrap = true, 34 | openInNewTab = false, 35 | onSetTagsClick, 36 | } = params 37 | 38 | const { memberName, memberAvatar, memberLink } = commentData 39 | 40 | let abortController: AbortController | null = null 41 | 42 | const handleOver = () => { 43 | popupControl.close() 44 | popupControl.open($trigger) 45 | 46 | const $content = $(` 47 |
    48 |
    49 |
    50 | 51 | 52 | 53 |
    54 | 55 |
    56 |
    57 | ${memberName} 58 |
    59 |
    60 |
    61 |
    62 | 63 |
    64 | 65 |
    66 | 67 |
    68 |
    69 | `) 70 | 71 | popupControl.$content.empty().append($content) 72 | 73 | createButton({ children: '添加用户标签' }) 74 | .on('click', () => { 75 | popupControl.close() 76 | openTagsSetter({ memberName, memberAvatar }) 77 | onSetTagsClick?.() 78 | }) 79 | .appendTo($('.v2p-member-card-actions')) 80 | 81 | void (async () => { 82 | // 缓存用户卡片的信息,只有在无缓存时才请求远程数据。 83 | if (!memberDataCache.has(memberName)) { 84 | abortController = new AbortController() 85 | 86 | popupControl.onClose = () => { 87 | abortController?.abort() 88 | } 89 | 90 | try { 91 | const memberData = await fetchUserInfo(memberName, { 92 | signal: abortController.signal, 93 | }) 94 | 95 | memberDataCache.set(memberName, memberData) 96 | } catch (err) { 97 | if (err instanceof Error) { 98 | $content.html(`${err.message}`) 99 | 100 | if (err.cause === 404) { 101 | memberDataCache.set(memberName, banned) 102 | } 103 | } 104 | 105 | return null 106 | } 107 | } 108 | 109 | const data = memberDataCache.get(memberName) 110 | 111 | if (typeof data === 'object') { 112 | $content.find('.v2p-no').removeClass('v2p-loading').text(`V2EX 第 ${data.id} 号会员`) 113 | 114 | $content 115 | .find('.v2p-created-date') 116 | .removeClass('v2p-loading') 117 | .text(`加入于 ${formatTimestamp(data.created)}`) 118 | 119 | if (data.bio && data.bio.trim().length > 0) { 120 | $content.find('.v2p-bio').css('disply', 'block').text(data.bio) 121 | } 122 | } else if (typeof data === 'symbol' && data === banned) { 123 | $content.html('查无此用户,疑似已被封禁') 124 | } 125 | })() 126 | } 127 | 128 | let isOver = false 129 | 130 | $trigger 131 | .on('mouseover', () => { 132 | isOver = true 133 | setTimeout(() => { 134 | if (isOver) { 135 | handleOver() 136 | } 137 | }, hoverDelay) 138 | }) 139 | .on('mouseleave', () => { 140 | isOver = false 141 | setTimeout(() => { 142 | if (!popupControl.isOver && !isOver) { 143 | popupControl.close() 144 | } 145 | }, hoverDelay) 146 | }) 147 | 148 | if (shouldWrap) { 149 | // 点击头像跳转到该用户的主页。 150 | $trigger.wrap( 151 | `` 152 | ) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /scripts/build-manifest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 为了方便兼容 Chrome 和 Firefox,并且方便管理和修改 manifest.json, 3 | * 这个脚本会帮助生成 manifest.json。 4 | */ 5 | 6 | import fs from 'node:fs' 7 | import path from 'node:path' 8 | 9 | /** 扩展能够识别的 V2EX 域名列表。 */ 10 | export const HOSTS = [ 11 | 'v2ex.com', 12 | 'www.v2ex.com', 13 | 'cn.v2ex.com', 14 | 'jp.v2ex.com', 15 | 'de.v2ex.com', 16 | 'us.v2ex.com', 17 | 'hk.v2ex.com', 18 | 'global.v2ex.com', 19 | 'fast.v2ex.com', 20 | 's.v2ex.com', 21 | 'origin.v2ex.com', 22 | 'staging.v2ex.com', 23 | ] 24 | 25 | type Manifest = chrome.runtime.ManifestV3 26 | 27 | const generateManifest = (): Manifest => { 28 | const getMatches = (surrfix?: string) => 29 | HOSTS.map((host) => `https://${host}${surrfix ? `/${surrfix}` : ''}/*`) 30 | 31 | const matches = getMatches() 32 | const topicMatches = getMatches('t') 33 | const writeMatches = getMatches('write') 34 | const memberMatches = getMatches('member') 35 | 36 | return { 37 | manifest_version: 3, 38 | 39 | name: 'V2EX Polish', 40 | 41 | version: '1.11.10', // <- 在发布前,需要手动修改版本。 42 | 43 | description: '专为 V2EX 用户设计,提供了丰富的扩展功能。', 44 | 45 | permissions: ['scripting', 'contextMenus', 'storage', 'alarms', 'sidePanel'], 46 | 47 | host_permissions: matches, 48 | 49 | icons: { 50 | '16': 'images/icon-16.png', 51 | '32': 'images/icon-32.png', 52 | '48': 'images/icon-48.png', 53 | '128': 'images/icon-128.png', 54 | }, 55 | 56 | content_scripts: [ 57 | { 58 | matches: matches, 59 | css: [ 60 | 'css/v2ex-theme-var.css', 61 | 'css/v2ex-theme-default.css', 62 | 'css/v2ex-theme-dark.css', 63 | 'css/v2ex-theme-compact.css', 64 | 'css/v2ex-theme-dawn.css', 65 | 'css/v2ex-theme-mobile.css', 66 | ], 67 | run_at: 'document_start', 68 | all_frames: true, 69 | }, 70 | { 71 | matches: matches, 72 | js: ['scripts/polyfill.js'], 73 | run_at: 'document_end', 74 | all_frames: true, 75 | }, 76 | 77 | { 78 | matches: matches, 79 | css: ['css/v2ex-effect.css'], 80 | js: ['scripts/jquery.min.js', 'scripts/common.min.js'], 81 | all_frames: true, 82 | }, 83 | { 84 | matches: matches, 85 | exclude_matches: [ 86 | '*://*/t/*', 87 | '*://*/notes/*', 88 | '*://*/settings', 89 | '*://*/write', 90 | '*://*/member/*', 91 | ], 92 | js: ['scripts/jquery.min.js', 'scripts/v2ex-home.min.js'], 93 | all_frames: true, 94 | }, 95 | { 96 | matches: topicMatches, 97 | js: ['scripts/jquery.min.js', 'scripts/v2ex-topic.min.js'], 98 | all_frames: true, 99 | }, 100 | { 101 | matches: writeMatches, 102 | js: ['scripts/jquery.min.js', 'scripts/v2ex-write.min.js'], 103 | all_frames: true, 104 | }, 105 | { 106 | matches: memberMatches, 107 | js: ['scripts/jquery.min.js', 'scripts/v2ex-member.min.js'], 108 | all_frames: true, 109 | }, 110 | 111 | { 112 | matches: matches, 113 | js: ['scripts/toggle-icon.min.js'], 114 | all_frames: true, 115 | }, 116 | ], 117 | 118 | background: { 119 | service_worker: 'scripts/background.min.js', 120 | }, 121 | 122 | web_accessible_resources: [ 123 | { 124 | matches: matches, 125 | resources: ['scripts/web_accessible_resources.min.js'], 126 | }, 127 | ], 128 | 129 | options_ui: { 130 | page: 'pages/options.html', 131 | open_in_tab: true, 132 | }, 133 | 134 | action: { 135 | default_title: 'V2EX Polish 用户面板', 136 | default_popup: 'pages/popup.html', 137 | }, 138 | } 139 | } 140 | 141 | const manifest = generateManifest() 142 | 143 | fs.writeFile(path.join('extension', 'manifest.json'), JSON.stringify(manifest), 'utf8', (err) => { 144 | if (err) { 145 | console.error(err) 146 | } 147 | }) 148 | 149 | const manifestFirefox: Manifest = JSON.parse(JSON.stringify(manifest)) 150 | 151 | Object.assign(manifestFirefox, { 152 | browser_specific_settings: { 153 | gecko: { 154 | id: 'leokudev@gmail.com', 155 | }, 156 | }, 157 | }) 158 | 159 | manifestFirefox.permissions = manifestFirefox.permissions?.filter( 160 | (permission) => permission !== 'sidePanel' 161 | ) 162 | 163 | if (manifestFirefox.background) { 164 | const serviceWorker = manifestFirefox.background.service_worker 165 | 166 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 167 | /** @ts-expect-error */ 168 | delete manifestFirefox.background.service_worker 169 | 170 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 171 | /** @ts-expect-error */ 172 | manifestFirefox.background['scripts'] = [serviceWorker] 173 | } 174 | 175 | fs.writeFile( 176 | path.join('extension', 'manifest-firefox.json'), 177 | JSON.stringify(manifestFirefox), 178 | 'utf8', 179 | (err) => { 180 | if (err) { 181 | console.error(err) 182 | } 183 | } 184 | ) 185 | -------------------------------------------------------------------------------- /website/src/components/Logo.tsx: -------------------------------------------------------------------------------- 1 | export function Logo() { 2 | return ( 3 | 4 | 5 | 6 | 11 | 12 | 13 | 14 | 19 | 20 | 21 | 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/contents/topic/tool.ts: -------------------------------------------------------------------------------- 1 | import { createPopup } from '../../components/popup' 2 | import { createToast } from '../../components/toast' 3 | import { Links, StorageKey } from '../../constants' 4 | import { getStorageSync } from '../../utils' 5 | import { $infoCard, $replyBox, $replyTextArea, topicId } from '../globals' 6 | import { 7 | addToReadingList, 8 | decodeBase64TopicPage, 9 | focusReplyInput, 10 | insertTextToReplyInput, 11 | loadIcons, 12 | } from '../helpers' 13 | import { toggleTopicLayout } from './layout' 14 | 15 | /** 16 | * 右侧用户信息卡片中的工具栏。 17 | */ 18 | export function handleTools() { 19 | const storage = getStorageSync() 20 | const options = storage[StorageKey.Options] 21 | 22 | const $tools = $(` 23 |
    24 | 25 | 回复主题 26 | 27 | 28 | 稍后阅读 29 | 30 | 31 | 回到顶部 32 | 33 | 34 | 更多功能 35 | 36 |
    37 | `) 38 | 39 | $tools.find('.v2p-tool-reply').on('click', () => { 40 | $replyTextArea.trigger('focus') 41 | }) 42 | 43 | $tools.find('.v2p-tool-reading').on('click', () => { 44 | void addToReadingList({ 45 | url: window.location.href, 46 | title: document.title.replace(' - V2EX', ''), 47 | content: String($('head meta[property="og:description"]').prop('content')), 48 | }) 49 | }) 50 | 51 | $tools.find('.v2p-tool-scroll-top').on('click', () => { 52 | window.scrollTo({ top: 0, behavior: 'smooth' }) 53 | }) 54 | 55 | // 更多功能: 56 | { 57 | const $moreTool = $tools.find('.v2p-tool-more') 58 | 59 | const $toolContent = $(` 60 |
    61 |
    解析本页 Base64
    62 |
    文本转 Base64
    63 | 64 |
    65 | `) 66 | 67 | const toolsPopup = createPopup({ 68 | root: $replyBox, 69 | trigger: $moreTool, 70 | content: $toolContent, 71 | offsetOptions: { mainAxis: 5, crossAxis: -5 }, 72 | }) 73 | 74 | $toolContent.find('.v2p-reply-tool-decode').on('click', () => { 75 | decodeBase64TopicPage() 76 | }) 77 | 78 | $toolContent.find('.v2p-reply-tool-encode').on('click', () => { 79 | focusReplyInput() 80 | 81 | setTimeout(() => { 82 | // 加入下次事件循环,避免阻塞 Popup 关闭。 83 | const inputText = window.prompt('输入要加密的字符串,完成后将填写到回复框中:') 84 | 85 | if (inputText) { 86 | let encodedText: string | undefined 87 | 88 | try { 89 | encodedText = window.btoa(encodeURIComponent(inputText)) 90 | } catch (err) { 91 | const errorTip = '该文本无法编码为 Base64' 92 | console.error(err, `${errorTip},可能的错误原因:文本包含中文。`) 93 | createToast({ message: errorTip }) 94 | } 95 | 96 | if (encodedText) { 97 | insertTextToReplyInput(encodedText) 98 | } 99 | } 100 | }) 101 | }) 102 | 103 | $toolContent.find('.v2p-reply-tool-share').on('click', () => { 104 | if (topicId) { 105 | window.open(`${Links.Home}/share/${topicId}`, '_blank') 106 | } 107 | }) 108 | 109 | const canHideRefName = 110 | options.nestedReply.display === 'indent' && !!options.replyContent.hideRefName 111 | 112 | if (canHideRefName) { 113 | let isHidden = options.replyContent.hideRefName 114 | 115 | const $toolToggleDisplay = $('
    显示 @ 用户名
    ') 116 | 117 | $toolToggleDisplay.on('click', () => { 118 | if (isHidden) { 119 | isHidden = false 120 | $toolToggleDisplay.text('隐藏 @ 用户名') 121 | $('.v2p-member-ref').addClass('v2p-member-ref-show') 122 | } else { 123 | isHidden = true 124 | $toolToggleDisplay.text('显示 @ 用户名') 125 | $('.v2p-member-ref').removeClass('v2p-member-ref-show') 126 | } 127 | }) 128 | 129 | $toolContent.prepend($toolToggleDisplay) 130 | } 131 | 132 | const $toolToggleLayout = $( 133 | ` 134 |
    135 | ${options.reply.layout === 'horizontal' ? '切换为垂直布局' : '切换为水平布局'} 136 |
    137 | ` 138 | ) 139 | 140 | $toolToggleLayout.on('click', () => { 141 | toggleTopicLayout() 142 | }) 143 | 144 | $toolContent.prepend($toolToggleLayout) 145 | 146 | $toolContent.find('.v2p-select-item').on('click', () => { 147 | toolsPopup.close() 148 | }) 149 | } 150 | 151 | $infoCard.addClass('v2p-tool-box').append($tools) 152 | 153 | loadIcons() 154 | } 155 | -------------------------------------------------------------------------------- /src/styles/share.scss: -------------------------------------------------------------------------------- 1 | @mixin line-clamp($lines: 1, $line-height: 1.4) { 2 | overflow: hidden; 3 | display: -webkit-box; 4 | -webkit-box-orient: vertical; 5 | -webkit-line-clamp: $lines; 6 | line-height: $line-height; 7 | } 8 | 9 | @mixin common-button() { 10 | cursor: pointer; 11 | user-select: none; 12 | position: relative; 13 | display: inline-flex; 14 | gap: 5px; 15 | align-items: center; 16 | height: 28px; 17 | padding: 0 12px; 18 | font-family: inherit; 19 | font-size: 14px; 20 | font-weight: 500; 21 | line-height: 28px; 22 | color: var(--v2p-color-button-foreground); 23 | text-shadow: none; 24 | white-space: nowrap; 25 | background: var(--v2p-color-button-background); 26 | border: none; 27 | border-radius: 6px; 28 | outline: none; 29 | box-shadow: 30 | 0 1.8px 0 var(--box-background-hover-color), 31 | 0 1.8px 0 var(--button-background-color); 32 | transition: 33 | color 0.25s, 34 | background-color 0.25s, 35 | box-shadow 0.25s; 36 | 37 | &:is(:hover:enabled, :active:enabled) { 38 | font-weight: 500; 39 | color: var(--v2p-color-button-foreground-hover); 40 | text-shadow: none; 41 | background: var(--v2p-color-button-background-hover); 42 | border: none; 43 | box-shadow: var(--button-hover-shadow); 44 | } 45 | 46 | &:is(.hover_now, .disable_now) { 47 | color: var(--v2p-color-button-foreground) !important; 48 | text-shadow: none !important; 49 | background: var(--button-background-color) !important; 50 | border: none !important; 51 | box-shadow: 52 | 0 1.8px 0 var(--box-background-hover-color) !important, 53 | 0 1.8px 0 var(--button-background-color) !important; 54 | } 55 | 56 | &:is(.disable_now, :disabled) { 57 | pointer-events: none; 58 | cursor: default; 59 | font-weight: 500; 60 | color: var(--v2p-color-button-foreground); 61 | text-shadow: none; 62 | opacity: 0.8; 63 | background: var(--button-background-color); 64 | box-shadow: 65 | 0 1.8px 0 var(--box-background-hover-color), 66 | 0 1.8px 0 var(--button-background-color); 67 | } 68 | 69 | kbd { 70 | position: relative; 71 | right: -4px; 72 | padding: 0 3px; 73 | font-family: inherit; 74 | font-size: 90%; 75 | line-height: initial; 76 | border: 1px solid var(--button-border-color); 77 | border-radius: 4px; 78 | } 79 | } 80 | 81 | @mixin hover-button { 82 | cursor: pointer; 83 | user-select: none; 84 | position: relative; 85 | z-index: 1; 86 | margin: 0; 87 | text-decoration: none; 88 | white-space: nowrap; 89 | background: none; 90 | background-color: transparent; 91 | transition: color 0.2s; 92 | 93 | &::before { 94 | content: ''; 95 | position: absolute; 96 | z-index: -1; 97 | inset: 0 -5px; 98 | transform: scale(0.65); 99 | opacity: 0; 100 | background-color: var(--v2p-color-bg-hover-btn); 101 | border-radius: 5px; 102 | transition: 103 | background-color 0.2s, 104 | color 0.2s, 105 | transform 0.2s, 106 | opacity 0.2s; 107 | } 108 | 109 | &:hover { 110 | text-decoration: none; 111 | 112 | &::before { 113 | transform: scale(1); 114 | opacity: 1; 115 | } 116 | } 117 | 118 | @content; 119 | } 120 | 121 | @mixin input($minHeight: unset, $maxHeight: 800px) { 122 | resize: none; 123 | overflow: hidden; 124 | height: unset; 125 | min-height: $minHeight !important; 126 | max-height: $maxHeight !important; 127 | font-size: 15px; 128 | color: currentColor; 129 | background-color: var(--v2p-color-bg-input); 130 | border: 1px solid var(--button-border-color); 131 | border-radius: 8px; 132 | transition: opacity 0.25s; 133 | 134 | @content; 135 | 136 | &::placeholder { 137 | font-size: 15px; 138 | color: var(--v2p-color-font-tertiary); 139 | } 140 | 141 | &:is(:focus, :focus-within) { 142 | background-color: transparent; 143 | outline: none; 144 | box-shadow: 0 0 0 1px var(--button-border-color); 145 | } 146 | } 147 | 148 | @mixin tooltip { 149 | pointer-events: none; 150 | z-index: var(--zidx-tip); 151 | overflow: hidden; 152 | width: max-content; 153 | min-width: 30px; 154 | padding: 2px 5px; 155 | font-size: 12px; 156 | color: var(--v2p-color-foreground); 157 | text-align: center; 158 | white-space: nowrap; 159 | background-color: var(--v2p-color-bg-tooltip); 160 | border-radius: 4px; 161 | box-shadow: var(--v2p-widget-shadow); 162 | } 163 | 164 | @mixin link { 165 | text-decoration: underline 1.5px; 166 | text-underline-offset: 0.46ex; 167 | } 168 | 169 | @mixin popup { 170 | font-size: 14px; 171 | background: var(--v2p-color-bg-widget); 172 | backdrop-filter: blur(16px); 173 | border: 1px solid var(--box-border-color); 174 | border-radius: 8px; 175 | box-shadow: var(--v2p-widget-shadow); 176 | } 177 | 178 | @mixin underline-text { 179 | text-decoration: underline 1px; 180 | text-underline-offset: var(--v2p-underline-offset); 181 | } 182 | 183 | @mixin select-box { 184 | color: var(--v2p-color-foreground); 185 | background-color: var(--v2p-color-background); 186 | border: 1px solid var(--v2p-color-border); 187 | border-radius: 4px; 188 | } 189 | 190 | @mixin text-content { 191 | font-size: 15px; 192 | line-height: 1.6; 193 | } 194 | -------------------------------------------------------------------------------- /src/contents/dom.ts: -------------------------------------------------------------------------------- 1 | import type { CommentData, Options } from '../types' 2 | import { processReplyContent } from './topic/content' 3 | 4 | export function getCommentDataList({ 5 | options, 6 | $commentTableRows, 7 | $commentCells, 8 | }: { 9 | options: Options 10 | $commentTableRows: JQuery 11 | $commentCells: JQuery 12 | }) { 13 | return $commentTableRows 14 | .map((idx, tr) => { 15 | const id = $commentCells[idx].id 16 | 17 | const $tr = $(tr) 18 | const $td = $tr.find('> td:nth-child(3)') 19 | 20 | const thanked = $tr.find('> td:last-of-type > .fr').find('> .thank_area').hasClass('thanked') 21 | 22 | const $member = $td.find('> strong > a') 23 | const memberName = $member.text() 24 | const memberLink = $member.prop('href') 25 | const memberAvatar = $tr.find('.avatar').prop('src') 26 | 27 | const $content = $td.find('> .reply_content') 28 | const content = $content.text() 29 | 30 | const likes = Number($td.find('span.small').text()) 31 | const floor = $td.find('span.no').text() 32 | 33 | const memberNameMatches = Array.from(content.matchAll(/@([a-zA-Z0-9]+)/g)) 34 | const refMemberNames = 35 | memberNameMatches.length > 0 36 | ? memberNameMatches.map(([, name]) => { 37 | return name 38 | }) 39 | : undefined 40 | 41 | const floorNumberMatches = Array.from(content.matchAll(/#(\d+)/g)) 42 | const refFloors = 43 | floorNumberMatches.length > 0 44 | ? floorNumberMatches.map(([, floor]) => { 45 | return floor 46 | }) 47 | : undefined 48 | 49 | let contentHtml: CommentData['contentHtml'] = undefined 50 | 51 | if (refMemberNames) { 52 | const canHideRefName = 53 | options.nestedReply.display === 'indent' && !!options.replyContent.hideRefName 54 | 55 | // 如果设置了「隐藏 @ 用户名 」,则把 @ 用户名 提取出来,并使用 span 包裹。 56 | if (canHideRefName) { 57 | if (refMemberNames.length === 1) { 58 | contentHtml = $content.html() 59 | const pattern = /(@
    [\w\s]+<\/a>)\s+/g 60 | const replacement = '$1 ' 61 | 62 | contentHtml = contentHtml.replace(pattern, replacement) 63 | } 64 | } 65 | } 66 | 67 | return { 68 | id, 69 | memberName, 70 | memberLink, 71 | memberAvatar, 72 | content, 73 | contentHtml, 74 | likes, 75 | floor, 76 | index: idx, 77 | refMemberNames, 78 | refFloors, 79 | thanked, 80 | } 81 | }) 82 | .get() 83 | } 84 | 85 | export function handleNestedComment({ 86 | options, 87 | $commentCells, 88 | commentDataList, 89 | }: { 90 | options: Options 91 | $commentCells: JQuery 92 | commentDataList: readonly CommentData[] 93 | }) { 94 | const display = options.nestedReply.display 95 | 96 | if (display !== 'off') { 97 | $commentCells.each((i, cellDom) => { 98 | const $cellDom = $(cellDom) 99 | 100 | const dataFromIndex = commentDataList.at(i) 101 | 102 | if (options.replyContent.autoFold) { 103 | processReplyContent($cellDom) 104 | } 105 | 106 | // 先根据索引去找,如果能对应上就不需要再去 find 了,这样能加快处理速度。 107 | const currentComment = 108 | dataFromIndex?.id === cellDom.id 109 | ? dataFromIndex 110 | : commentDataList.find((data) => data.id === cellDom.id) 111 | 112 | if (currentComment) { 113 | const { refMemberNames, refFloors } = currentComment 114 | 115 | if (!refMemberNames || refMemberNames.length === 0) { 116 | return 117 | } 118 | 119 | const moreThanOneRefMember = refMemberNames.length > 1 120 | 121 | if (options.nestedReply.multipleInsideOne === 'off' && refMemberNames.length > 1) { 122 | return 123 | } 124 | 125 | for (const refName of moreThanOneRefMember ? refMemberNames.toReversed() : refMemberNames) { 126 | // 从当前评论往前找,找到第一个引用的用户的评论,然后把当前评论插入到那个评论的后面。 127 | for (let j = i - 1; j >= 0; j--) { 128 | const { memberName: compareName, floor: eachFloor } = commentDataList.at(j) || {} 129 | 130 | if (compareName === refName) { 131 | let refCommentIdx = j 132 | 133 | const firstRefFloor = moreThanOneRefMember 134 | ? refFloors?.toReversed().at(0) 135 | : refFloors?.at(0) 136 | 137 | // 找到了指定回复的用户后,发现跟指定楼层对不上,则继续寻找。 138 | // 如果手动指定了楼层,那么就以指定的楼层为准(一般来说,由用户指定会更精确),否则就以第一个引用的用户的评论的楼层为准。 139 | if (firstRefFloor && firstRefFloor !== eachFloor) { 140 | const targetIdx = commentDataList 141 | .slice(0, j) 142 | .findIndex((data) => data.floor === firstRefFloor && data.memberName === refName) 143 | 144 | if (targetIdx >= 0) { 145 | refCommentIdx = targetIdx 146 | } 147 | } 148 | 149 | if (display === 'indent') { 150 | cellDom.classList.add('v2p-indent') 151 | } 152 | 153 | $commentCells.eq(refCommentIdx).append(cellDom) 154 | return 155 | } 156 | } 157 | } 158 | } 159 | }) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /website/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 24 | 25 | 26 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /website/public/favicon-dark.svg: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 24 | 25 | 26 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /website/src/components/screens/ScreenTopic.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ChevronDown, 3 | ChevronRight, 4 | ChevronUp, 5 | EyeOffIcon, 6 | HeartIcon, 7 | StarIcon, 8 | TagIcon, 9 | TwitterIcon, 10 | } from 'lucide-react' 11 | 12 | import { 13 | Avatar, 14 | Box, 15 | Content, 16 | Header, 17 | ReplyItem, 18 | RightSide, 19 | UserPanel, 20 | Wrapper, 21 | } from '~/components/ui' 22 | 23 | export function ScreenTopic() { 24 | return ( 25 | 26 |
    27 | 28 | 29 |
    30 | 31 |
    32 |
    33 |
    34 | V2EX 35 | 36 | 分享创造 37 |
    38 |
    39 | ✨ V2EX 超强浏览器扩展:体验更先进的 V2EX! 40 |
    41 |
    42 | 43 | 44 | 45 | 46 | 47 | 48 | coolpace 49 | · 50 | 5 小时 25 分钟前 51 | · 52 | 4096 次点击 53 |
    54 |
    55 | 56 |
    57 | 58 |
    59 |

    60 | V2EX Polish 是一款专为 V2EX 用户设计的浏览器插件,提供了丰富的扩展功能,让你的 V2EX 61 | 页面焕然一新 !如果你愿意,请为我们的项目点个 Star ⭐️ 62 | 或分享给他人,让更多的人知道我们的存在。 63 |

    64 |
    65 | 66 |
    67 | 68 | 69 | 加入收藏 70 | 71 | 72 | 73 | Tweet 74 | 75 | 76 | 77 | 忽略主题 78 | 79 | 80 | 81 | 感谢 82 | 83 | 84 | 85 | 4096 次点击 86 | · 87 | 8 人收藏 88 | 89 |
    90 |
    91 | 92 | 93 |
    94 | 95 | 125 条回复 96 | · 97 | 34 条热门回复 98 | 99 | 100 | 101 | 102 | 103 | 插件 104 | 105 | 106 | 107 | 程序 108 | 109 | 110 | 111 | 开发 112 | 113 | 114 |
    115 | 116 |
    117 | 120 | } 121 | content="安装了一下,真的是现代化的体验!" 122 | floor="1" 123 | name="codennnn" 124 | time="21 天前" 125 | /> 126 |
    127 | } 131 | content={ 132 | <> 133 | 134 | @ 135 | codennnn 136 | 137 | 感谢你尝试 V2EX Polish ,我们希望打造一个超高质量的 V2EX 扩展。 138 | 139 | } 140 | floor="2" 141 | name="coolpace" 142 | time="21 天前" 143 | /> 144 |
    145 |
    146 |
    147 |
    148 | 149 | 150 | 151 | 152 |
    153 | 154 | ) 155 | } 156 | -------------------------------------------------------------------------------- /website/src/app/api/og/route.tsx: -------------------------------------------------------------------------------- 1 | import { ImageResponse } from 'next/og' 2 | 3 | import { OG_HEIGHT, OG_WIDTH } from '~/utils' 4 | 5 | export const runtime = 'edge' 6 | 7 | export async function GET() { 8 | await Promise.resolve() 9 | 10 | return new ImageResponse( 11 | ( 12 |
    13 |
    14 |
    15 | 16 | 17 | 18 | 19 | 24 | 25 | 26 | 27 | 32 | 33 | 34 | 35 | 36 | 37 |
    38 | 39 |
    V2EX Polish
    40 |
    41 | 专为 V2EX 用户设计的浏览器插件,提供了丰富的扩展功能为你带来出色的体验 42 |
    43 |
    44 |
    45 | ), 46 | { 47 | width: OG_WIDTH, 48 | height: OG_HEIGHT, 49 | } 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # V2EX Polish - 体验更现代化的 V2EX 2 | 3 | ![V2EX Polish 宣传封面图](https://cdn.jsdelivr.net/gh/Codennnn/static/preview/V2EX_Polish.jpg) 4 | 5 | 一款专为 V2EX 用户设计的轻量浏览器插件,提供了丰富的扩展功能,让原生页面焕然一新!✨ 6 | 7 | [![Badge CWS Version]][Link CWS] · 8 | [![Badge CWS Downloads]][Link CWS] · 9 | [![Badge CWS Rating]][Link CWS Rating] · 10 | [![Badge CWS Rating Count]][Link CWS Rating] 11 | 12 | ## 安装使用 13 | 14 | - Chrome、Edge、Arc 用户请在 [Chrome 商店中安装][Link CWS] 15 | - Firefox 用户[在此下载安装](https://addons.mozilla.org/zh-CN/firefox/addon/v2ex-polish/) 16 | - Safari 用户参见[安装教程](./website/src/content/docs/use-in-safari.md) 17 | - [油猴脚本](https://greasyfork.org/zh-CN/scripts/459848-v2ex-polish-%E4%BD%93%E9%AA%8C%E6%9B%B4%E7%8E%B0%E4%BB%A3%E5%8C%96%E7%9A%84-v2ex)(仅支持部分功能,文档后面介绍了功能差异) 18 | 19 | > [!IMPORTANT] 20 | > 使用其他类似的脚本或插件可能会导致冲突,如果在使用过程中发现网页内容有误,建议关闭其他插件以排查问题。 21 | 22 | ## 扩展功能 23 | 24 | ### 特色功能: 25 | 26 | - 🪄 界面美化:UI 设计更现代化,为你带来愉悦的视觉体验。 27 | 28 | - 📥 评论回复嵌套层级:主题下的评论回复支持层级展示,更轻松地追踪和回复他人的评论。 29 | 30 | - 🔥 热门回复展示:自动筛选出受欢迎的回复,热评先睹为快。 31 | 32 | - 😀 表情回复支持:评论输入框可以选择并插入表情,让回复更加生动和有趣。 33 | 34 | - 📃 长回复优化:智能折叠冗长回复,并可一键展开阅读完整内容。 35 | 36 | - 📰 内置主题列表:无需打开网页,在插件内即可快速获取最热、最新的主题列表和消息通知。 37 | 38 | - 📝 便捷回复操作:预览回复、文字转 Base64、上传图片。 39 | 40 |
    41 | 点击发现更多功能👇 42 | 43 | --- 44 | 45 | - 自动领取每日签到奖励。 46 | 47 | - 设置用户标签,快速标记各类用户。 48 | 49 | - 添加用户信息卡片,快捷查看用户信息。 50 | 51 | - 支持自动跟随系统切换浅色/深色主题。 52 | 53 | - 支持解析页面中所有 Base64 编码的内容。 54 | 55 | - 支持预加载多页回复,完美呈现嵌套回复。 56 | 57 | - 支持水平分区阅读主题内容。 58 | 59 | - “稍后阅读”:添加感兴趣的主题,方便日后浏览。 60 | 61 | - 无需访问主题页面,在主题列表中即可预览内容。 62 | 63 | - 一键生成主题分享图片。 64 | 65 | - 支持备份个人配置,方便跨设备同步。 66 | 67 | - 翻页后自动跳转到回复区。 68 | 69 |
    70 | 71 | ### 部分功能效果展示 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 |
    视觉友好的界面设计:丰富的个性化设置:
    在回复中快速上传图片:实时预览回复内容:
    设置用户的标签,标记用户:生成主题分享图片:
    隐藏回复中 @ 用户的名称:回复中插入热门流行表情:
    107 | 108 | ## 为什么选择 V2EX Polish? 109 | 110 | 尽管市面上早已存在众多增强 v2ex.com 的[脚本](https://greasyfork.org/zh-CN/scripts/by-site/v2ex.com)和[插件][Link CWS Search V2EX],但它们带来的体验良莠不齐,且大多数已经停止更新。 111 | 112 | V2EX Polish 的目标是提供一个更加完善的插件,并保证持续更新,快速响应 V2EX 用户的需求。我们志在打造最高质量的 V2EX 扩展,带给大家最佳的体验。 113 | 114 | ## 如何帮助我们 115 | 116 | 作为开发者,创造对他人有用的东西始终是我们的热情所在,这个项目也不例外。我们投入了大量的时间和精力,致力于为 V2EX 用户带来更好的体验。因此,如果我们的项目帮助了你,欢迎你为我们的项目: 117 | 118 | - 点个 Star ⭐️ 或分享给他人,让更多的人知道我们的存在。 119 | - [赞赏作者][Link Donation],这会激励作者投入更多的时间完善项目。 120 | - 提供反馈,帮助我们改进。 121 | - 改善代码,请阅读我们的[代码贡献指南](./.github/CONTRIBUTING.md)。 122 | 123 | ## 常见问题 124 | 125 |
    126 | 使用油猴脚本和浏览器扩展有什么区别? 127 | 128 | 油猴脚本不支持: 129 | 130 | - 所有个性化设置 131 | - 右键功能菜单 132 | - 用户标签设置 133 | 134 | 浏览器扩展支持全部功能,并且经过了更多的测试。为了达到最佳的功能体验,我们更推荐你安装扩展。担心扩展的体积太大?请放心,本扩展的安装体积还不到 **0.2 MB**⚡!我们十分关注扩展的体积和性能,努力减少资源占用。 135 | 136 |
    137 | 138 |
    139 | 为什么我的页面内容有误、样式异常? 140 | 141 | 如果你使用了其他与 V2EX 相关的插件,那么很可能会引发功能冲突,从而导致页面异常,建议关闭其他插件以排查问题。 142 | 143 |
    144 | 145 |
    146 | 为什么有的「楼中楼」回复的楼层不正确? 147 | 148 | 由于 V2EX 的原回复并没有记录回复的楼层,本扩展只能根据被回复的用户去寻找此用户的最近一条回复,然后嵌入到这后面去,这种方法并不能保证正确识别用户真正要回复的是哪一个楼层。 149 | 150 |
    151 | 152 |
    153 | 为什么需要设置「个人访问令牌(PAT)」? 154 | 155 | PAT 并不是必需的,只有当你想要使用诸如:主题内容预览、获取消息通知等功能时才需要设置,它是用来访问 [V2EX 开放 API](https://www.v2ex.com/help/api) 的。如果你还没有,请前往[这里创建](https://www.v2ex.com/settings/tokens)。 156 | 157 |
    158 | 159 | ## 问题反馈 160 | 161 | 我们需要你的建议和反馈,以持续完善 V2EX Polish。如果在使用中遇到任何问题,都可以[在这里](https://github.com/coolpace/V2EX_Polish/discussions/1?sort=new)提出。你也可以加入我们的 [Telegram 群组](https://t.me/+zH9GxA2DYLtjYjhl)向我们快速反馈。 162 | 163 | ## V2EX 相关主题 164 | 165 | - [V2EX 超强浏览器扩展:体验更先进的 V2EX](https://www.v2ex.com/t/930155) 166 | - [V2EX Polish 大量功能更新,即刻体验更好用的 V2EX](https://www.v2ex.com/t/935916) 167 | - [关于 V2EX Polish 意外从 Chrome 应用商店下架的说明](https://www.v2ex.com/t/940580) 168 | - [V2EX Polish 在 5 月份更新了什么?](https://www.v2ex.com/t/942786) 169 | - [V2EX 插件更新:预览回复、生成主题分享图片、隐藏回复用户名称、更多好玩的回复表情!](https://www.v2ex.com/t/977271) 170 | - [V2EX 插件更新:主题水平布局、选项页美化、更多功能...](https://www.v2ex.com/t/1007017) 171 | 172 | **喜欢我们的扩展吗?请在[应用商店][Link CWS]给我们好评!🥰** 173 | 174 | ## 赞赏支持 175 | 176 | 如果这个插件帮助你节省了时间,让你的生活更加愉快,可以给开发者一点小小的赞赏,这会帮助插件更持续地发展。对于你们的大方支持,我们感慨万分! 177 | 178 | ![赞赏码](./assets/appreciation-code.png) 179 | 180 | 181 | 182 | [Badge CWS Version]: https://img.shields.io/chrome-web-store/v/onnepejgdiojhiflfoemillegpgpabdm.svg?style=flat&colorA=232323&colorB=232323&label=最新版本&logo=hackthebox&logoColor=eeeeee 183 | [Badge CWS Downloads]: https://img.shields.io/chrome-web-store/users/onnepejgdiojhiflfoemillegpgpabdm.svg?style=flat&colorA=232323&colorB=232323&label=用户量 184 | [Badge CWS Rating]: https://img.shields.io/chrome-web-store/rating/onnepejgdiojhiflfoemillegpgpabdm.svg?style=flat&colorA=232323&colorB=232323&label=评分 185 | [Badge CWS Rating Count]: https://img.shields.io/chrome-web-store/rating-count/onnepejgdiojhiflfoemillegpgpabdm.svg?style=flat&colorA=232323&colorB=232323&label=评价数 186 | 187 | 188 | 189 | [Link CWS]: https://chromewebstore.google.com/detail/v2ex-polish/onnepejgdiojhiflfoemillegpgpabdm 190 | [Link CWS Search V2EX]: https://chromewebstore.google.com/search/V2EX?hl=zh-CN 191 | [Link CWS Rating]: https://chromewebstore.google.com/detail/v2ex-polish/onnepejgdiojhiflfoemillegpgpabdm/reviews?hl=zh-CN 192 | [Link Donation]: https://www.v2p.app/support 193 | -------------------------------------------------------------------------------- /src/styles/v2ex-theme-var.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --zidx-serach: 100; 3 | --zidx-tabs: 10; 4 | --zidx-tools-card: 10; 5 | --zidx-reply-box: 99; 6 | --zidx-modal-header: 50; 7 | --zidx-modal-mask: 888; 8 | --zidx-toast: 999; 9 | --zidx-tip: 99; 10 | --zidx-popup: 99; 11 | --zidx-expand-mask: 10; 12 | --zidx-expand-btn: 20; 13 | --v2p-underline-offset: 0.5ex; 14 | --v2p-layout-column-gap: 25px; 15 | --v2p-layout-row-gap: 20px; 16 | --v2p-nav-height: 55px; 17 | --v2p-tp-item-x: 20px; 18 | --v2p-tp-item-y: 10px; 19 | --v2p-tp-tabs-pd: 10px; 20 | --v2p-tp-nested-pd: 15px; 21 | 22 | @mixin light-mode { 23 | // 基础色(参考自 TailwindCSS Slate): 24 | --v2p-color-main-50: #f7f9fb; 25 | --v2p-color-main-100: #f1f5f9; 26 | --v2p-color-main-200: #e2e8f0; 27 | --v2p-color-main-300: #cbd5e1; 28 | --v2p-color-main-350: #94a3b8cc; 29 | --v2p-color-main-400: #94a3b8; 30 | --v2p-color-main-500: #64748b; 31 | --v2p-color-main-600: #475569; 32 | --v2p-color-main-700: #334155; 33 | --v2p-color-main-800: #1e293b; 34 | --v2p-color-accent-50: #ecfdf5; 35 | --v2p-color-accent-100: #d1fae5; 36 | --v2p-color-accent-200: #a7f3d0; 37 | --v2p-color-accent-300: #6ee7b7; 38 | --v2p-color-accent-400: #34d399; 39 | --v2p-color-accent-500: #10b981; 40 | --v2p-color-accent-600: #059669; 41 | --v2p-color-orange-50: #fff7ed; 42 | --v2p-color-orange-100: #ffedd5; 43 | --v2p-color-orange-400: #fb923c; 44 | 45 | // ==== 46 | --v2p-color-background: #f2f3f5; 47 | --v2p-color-foreground: var(--v2p-color-main-800); 48 | --v2p-color-selection-foreground: var(--v2p-color-main-100); 49 | --v2p-color-selection-background: var(--v2p-color-main-700); 50 | --v2p-color-selection-background-img: var(--v2p-color-main-500); 51 | --v2p-color-font-secondary: var(--v2p-color-main-600); 52 | --v2p-color-font-tertiary: var(--v2p-color-main-500); 53 | --v2p-color-font-quaternary: var(--v2p-color-main-400); 54 | 55 | // ==== 按钮 ==== 56 | --v2p-color-button-background: var(--v2p-color-main-100); 57 | --v2p-color-button-foreground: var(--v2p-color-main-500); 58 | --v2p-color-button-background-hover: var(--v2p-color-main-200); 59 | --v2p-color-button-foreground-hover: var(--v2p-color-main-600); 60 | // ---- 按钮 ---- 61 | 62 | // ==== 背景 ==== 63 | --v2p-color-bg-content: #fff; 64 | --v2p-color-bg-footer: var(--v2p-color-bg-content); 65 | --v2p-color-bg-hover-btn: var(--v2p-color-main-200); 66 | --v2p-color-bg-subtle: rgb(236 253 245 / 90%); 67 | --v2p-color-bg-input: var(--v2p-color-main-50); 68 | --v2p-color-bg-search: var(--v2p-color-main-100); 69 | --v2p-color-bg-search-active: var(--v2p-color-main-200); 70 | --v2p-color-bg-widget: rgb(255 255 255 / 70%); 71 | --v2p-color-bg-reply: var(--v2p-color-main-100); 72 | --v2p-color-bg-tooltip: var(--v2p-color-bg-content); 73 | --v2p-color-bg-tabs: var(--v2p-color-main-100); 74 | --v2p-color-bg-tpr: var(--v2p-color-main-100); 75 | --v2p-color-bg-avatar: var(--v2p-color-main-300); 76 | --v2p-color-bg-block: var(--v2p-color-main-100); 77 | --v2p-color-bg-block-darker: var(--v2p-color-main-300); 78 | --v2p-color-bg-link: var(--v2p-color-main-100); 79 | --v2p-color-bg-link-hover: var(--v2p-color-main-200); 80 | // ---- 背景 ---- 81 | 82 | --v2p-color-tabs: var(--v2p-color-foreground); 83 | --v2p-color-heart: #ef4444; 84 | --v2p-color-heart-fill: #fee2e2; 85 | --v2p-color-mask: rgb(0 0 0 / 25%); 86 | --v2p-color-divider: var(--v2p-color-main-200); 87 | --v2p-color-border: var(--v2p-color-main-200); 88 | --v2p-color-input-border: var(--v2p-color-main-300); 89 | --v2p-color-border-darker: var(--v2p-color-main-300); 90 | --v2p-color-link-visited: var(--v2p-color-main-400); 91 | --v2p-color-error: #ef4444; 92 | --v2p-color-bg-error: #fee2e2; 93 | --v2p-color-cell-num: var(--v2p-color-main-350); 94 | 95 | // ==== 阴影 ==== 96 | --v2p-box-shadow: 0 3px 5px 0 rgb(0 0 0 / 4%); 97 | --v2p-widget-shadow: 0 9px 24px -3px rgb(0 0 0 / 6%), 0 4px 8px -1px rgb(0 0 0 /12%); 98 | --v2p-toast-shadow: 0 6px 16px 0 rgb(0 0 0 / 8%), 0 3px 6px -4px rgb(0 0 0 / 12%), 99 | 0 9px 28px 8px rgb(0 0 0 / 5%); 100 | // ---- 阴影 ---- 101 | 102 | // 滚动条 103 | --v2p-scrollbar-background-color: #fcfcfc; 104 | 105 | // V2EX 原有的 CSS 变量: 106 | --color-fade: var(--v2p-color-font-secondary); 107 | --color-gray: var(--v2p-color-font-secondary); 108 | --link-color: var(--v2p-color-foreground); 109 | --link-darker-color: var(--v2p-color-main-600); 110 | --link-hover-color: var(--v2p-color-foreground); 111 | --link-caution-color: var(--v2p-color-orange-400); 112 | --box-border-color: var(--v2p-color-border); 113 | --box-foreground-color: var(--v2p-color-foreground); 114 | --box-background-color: var(--v2p-color-bg-content); 115 | --box-background-alt-color: var(--v2p-color-main-100); 116 | --box-background-hover-color: var(--v2p-color-main-200); 117 | --box-border-focus-color: var(--v2p-color-main-200); 118 | --box-border-radius: 10px; 119 | --button-background-color: var(--v2p-color-button-background); 120 | --button-foreground-color: var(--v2p-color-button-foreground); 121 | --button-hover-color: var(--v2p-color-button-background-hover); 122 | --button-background-hover-color: var(--v2p-color-button-background-hover); 123 | --button-foreground-hover-color: var(--v2p-color-button-foreground-hover); 124 | --button-border-color: var(--v2p-color-main-300); 125 | --button-border-hover-color: var(--v2p-color-main-400); 126 | } 127 | 128 | body { 129 | @include light-mode; 130 | 131 | font-family: system-ui, sans-serif; 132 | color: var(--v2p-color-foreground); 133 | visibility: hidden; 134 | background-color: var(--v2p-color-background); 135 | 136 | &.v2p-theme-light-default, 137 | .v2p-theme-light-default { 138 | @include light-mode; 139 | 140 | visibility: visible; 141 | } 142 | 143 | #Logo { 144 | background-image: url('https://www.v2ex.com/static/img/v2ex@2x.png'); 145 | } 146 | 147 | ::selection { 148 | color: var(--v2p-color-selection-foreground); 149 | background-color: var(--v2p-color-selection-background); 150 | } 151 | 152 | img::selection { 153 | background-color: var(--v2p-color-selection-background-img); 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/background/main.ts: -------------------------------------------------------------------------------- 1 | import { Menu, MessageKey, StorageKey } from '../constants' 2 | import type { StorageItems } from '../types' 3 | import { getRunEnv } from '../utils' 4 | import { checkIn } from './daily-check-in' 5 | 6 | interface Message { 7 | [MessageKey.action]?: 'openPopup' 8 | [MessageKey.colorScheme]?: 'dark' | 'light' 9 | [MessageKey.showOptions]?: true 10 | } 11 | 12 | chrome.runtime.onMessage.addListener((message: Message) => { 13 | if (Reflect.has(message, MessageKey.colorScheme)) { 14 | void chrome.action.setIcon({ 15 | path: 16 | message[MessageKey.colorScheme] === 'dark' 17 | ? { 18 | 16: '../images/icon-16-dark.png', 19 | 32: '../images/icon-32-dark.png', 20 | 48: '../images/icon-48-dark.png', 21 | 128: '../images/icon-128-dark.png', 22 | } 23 | : { 24 | 16: '../images/icon-16.png', 25 | 32: '../images/icon-32.png', 26 | 48: '../images/icon-48.png', 27 | 128: '../images/icon-128.png', 28 | }, 29 | }) 30 | } else if (Reflect.has(message, MessageKey.showOptions)) { 31 | if (message[MessageKey.showOptions] === true) { 32 | chrome.runtime.openOptionsPage() 33 | } 34 | } 35 | }) 36 | 37 | chrome.contextMenus.removeAll(() => { 38 | const runEnv = getRunEnv() 39 | 40 | chrome.contextMenus.create({ 41 | documentUrlPatterns: [ 42 | 'https://v2ex.com/*', 43 | 'https://www.v2ex.com/*', 44 | 'https://cn.v2ex.com/*', 45 | 'https://jp.v2ex.com/*', 46 | 'https://de.v2ex.com/*', 47 | 'https://us.v2ex.com/*', 48 | 'https://hk.v2ex.com/*', 49 | 'https://global.v2ex.com/*', 50 | 'https://fast.v2ex.com/*', 51 | 'https://s.v2ex.com/*', 52 | 'https://origin.v2ex.com/*', 53 | 'https://staging.v2ex.com/*', 54 | ], 55 | contexts: ['page'], 56 | title: 'V2EX Polish', 57 | visible: true, 58 | id: Menu.Root, 59 | }) 60 | 61 | if (runEnv === 'chrome' && 'sidePanel' in chrome && typeof chrome.sidePanel.open === 'function') { 62 | chrome.contextMenus.create({ 63 | documentUrlPatterns: [ 64 | 'https://v2ex.com/*', 65 | 'https://www.v2ex.com/*', 66 | 'https://cn.v2ex.com/*', 67 | 'https://jp.v2ex.com/*', 68 | 'https://de.v2ex.com/*', 69 | 'https://us.v2ex.com/*', 70 | 'https://hk.v2ex.com/*', 71 | 'https://global.v2ex.com/*', 72 | 'https://fast.v2ex.com/*', 73 | 'https://s.v2ex.com/*', 74 | 'https://origin.v2ex.com/*', 75 | 'https://staging.v2ex.com/*', 76 | ], 77 | contexts: ['page'], 78 | title: '选项设置', 79 | id: Menu.Options, 80 | parentId: Menu.Root, 81 | }) 82 | } 83 | 84 | chrome.contextMenus.create({ 85 | documentUrlPatterns: [ 86 | 'https://v2ex.com/t/*', 87 | 'https://www.v2ex.com/t/*', 88 | 'https://cn.v2ex.com/t/*', 89 | 'https://jp.v2ex.com/t/*', 90 | 'https://de.v2ex.com/t/*', 91 | 'https://us.v2ex.com/t/*', 92 | 'https://hk.v2ex.com/t/*', 93 | 'https://global.v2ex.com/t/*', 94 | 'https://fast.v2ex.com/t/*', 95 | 'https://s.v2ex.com/t/*', 96 | 'https://origin.v2ex.com/t/*', 97 | 'https://staging.v2ex.com/t/*', 98 | ], 99 | contexts: ['page'], 100 | title: '解析本页 Base64', 101 | id: Menu.Decode, 102 | parentId: Menu.Root, 103 | }) 104 | 105 | chrome.contextMenus.create({ 106 | documentUrlPatterns: [ 107 | 'https://v2ex.com/t/*', 108 | 'https://www.v2ex.com/t/*', 109 | 'https://cn.v2ex.com/t/*', 110 | 'https://jp.v2ex.com/t/*', 111 | 'https://de.v2ex.com/t/*', 112 | 'https://us.v2ex.com/t/*', 113 | 'https://hk.v2ex.com/t/*', 114 | 'https://global.v2ex.com/t/*', 115 | 'https://fast.v2ex.com/t/*', 116 | 'https://s.v2ex.com/t/*', 117 | 'https://origin.v2ex.com/t/*', 118 | 'https://staging.v2ex.com/t/*', 119 | ], 120 | contexts: ['page'], 121 | title: '添加进稍后阅读', 122 | id: Menu.Reading, 123 | parentId: Menu.Root, 124 | }) 125 | 126 | chrome.contextMenus.onClicked.addListener((info, tab) => { 127 | if (tab?.id) { 128 | switch (info.menuItemId) { 129 | case Menu.Options: { 130 | chrome.sidePanel.open({ tabId: tab.id }) 131 | break 132 | } 133 | 134 | case Menu.Decode: { 135 | void chrome.scripting.executeScript({ 136 | target: { tabId: tab.id }, 137 | files: ['scripts/decode-base64.min.js'], 138 | }) 139 | break 140 | } 141 | 142 | case Menu.Reading: { 143 | void chrome.scripting.executeScript({ 144 | target: { tabId: tab.id }, 145 | files: ['scripts/reading-list.min.js'], 146 | }) 147 | break 148 | } 149 | } 150 | } 151 | }) 152 | }) 153 | 154 | chrome.tabs.onUpdated.addListener((tabId, _, tab) => { 155 | if (!('sidePanel' in chrome)) { 156 | return 157 | } 158 | 159 | void (async () => { 160 | if (!tab.url) { 161 | return 162 | } 163 | 164 | const url = new URL(tab.url) 165 | 166 | if ( 167 | url.origin === 'https://www.v2ex.com' || 168 | url.origin === 'https://v2ex.com' || 169 | url.origin === 'https://cn.v2ex.com' || 170 | url.origin === 'https://jp.v2ex.com' || 171 | url.origin === 'https://de.v2ex.com' || 172 | url.origin === 'https://us.v2ex.com' || 173 | url.origin === 'https://hk.v2ex.com' || 174 | url.origin === 'https://global.v2ex.com' || 175 | url.origin === 'https://fast.v2ex.com' || 176 | url.origin === 'https://s.v2ex.com' || 177 | url.origin === 'https://origin.v2ex.com' || 178 | url.origin === 'https://staging.v2ex.com' 179 | ) { 180 | await chrome.sidePanel.setOptions({ 181 | tabId, 182 | path: 'pages/options.html', 183 | enabled: true, 184 | }) 185 | } else { 186 | await chrome.sidePanel.setOptions({ 187 | tabId, 188 | enabled: false, 189 | }) 190 | } 191 | })() 192 | }) 193 | 194 | const checkInAlarmName = 'dailyCheckIn' 195 | 196 | chrome.alarms.get(checkInAlarmName, (alarm) => { 197 | if (typeof alarm === 'undefined') { 198 | // background 脚本无法持久运行,在 Chrome 中 5 分钟内会关闭连接,所以需要使用 alarm 来保持定时任务。 199 | void chrome.alarms.create(checkInAlarmName, { 200 | periodInMinutes: 4.9, 201 | }) 202 | } 203 | }) 204 | 205 | chrome.alarms.onAlarm.addListener((alarm) => { 206 | if (alarm.name === checkInAlarmName) { 207 | chrome.storage.sync.get().then((items: StorageItems) => { 208 | const options = items[StorageKey.Options] 209 | 210 | if (options?.autoCheckIn.enabled) { 211 | void checkIn() 212 | } 213 | }) 214 | } 215 | }) 216 | --------------------------------------------------------------------------------