├── .github ├── dependabot.yml └── workflows │ ├── lint.yaml │ ├── release.yaml │ └── test.yaml ├── .gitignore ├── Makefile ├── README.md ├── eslint.config.js ├── package.json ├── pnpm-lock.yaml ├── public ├── icon-with-shadow.svg └── icon │ ├── 128.png │ ├── 16.png │ ├── 32.png │ ├── 48.png │ └── 96.png ├── src ├── application.test.ts ├── application.ts ├── browser-extension │ ├── background │ │ └── background.ts │ ├── content-scripts │ │ ├── index.tsx │ │ └── twitter.ts │ ├── options │ │ ├── index.html │ │ └── index.tsx │ └── setupForTest.ts ├── components │ ├── Checkbox.tsx │ ├── FormSwitch.tsx │ ├── GlobalSuspense.tsx │ ├── Input.tsx │ └── MenuItem.tsx ├── constants.ts ├── events │ ├── event.ts │ ├── open_popup_event.ts │ └── readong_event.ts ├── manifest.ts ├── pages │ ├── popup │ │ ├── Popup.tsx │ │ ├── PopupFooter.tsx │ │ ├── PopupForm.tsx │ │ ├── PopupHeader.tsx │ │ ├── PopupInput.tsx │ │ └── PopupTextarea.tsx │ └── setting │ │ ├── GeneralConfig.tsx │ │ ├── Header.tsx │ │ ├── Logo.tsx │ │ ├── SettingProvider.tsx │ │ └── Settings.tsx ├── storages │ ├── bear │ │ ├── BearStorage.ts │ │ └── Setting.tsx │ ├── bookmark │ │ ├── BookmarkStorage.ts │ │ └── Setting.tsx │ ├── obsidian │ │ ├── ObsidianStorage.ts │ │ └── Setting.tsx │ ├── storage.ts │ └── tg │ │ ├── Setting.tsx │ │ └── TelegramStorage.ts ├── supports │ ├── browser.ts │ ├── enableDevHMR.ts │ ├── popup.ts │ ├── storage.ts │ ├── time.ts │ └── tweet.ts ├── types.ts └── vite-env.d.ts ├── tsconfig.json ├── vite.config.ts └── vitest.config.ts /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: github-actions # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | - package-ecosystem: npm 13 | directory: / 14 | schedule: 15 | interval: weekly 16 | 17 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | # typos-check: 13 | # name: Spell Check with Typos 14 | # runs-on: ubuntu-latest 15 | # steps: 16 | # - name: Checkout Actions Repository 17 | # uses: actions/checkout@v4 18 | # - name: Check spelling with custom config file 19 | # uses: crate-ci/typos@v1.16.10 20 | # with: 21 | # config: ./typos.toml 22 | eslint: 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | 28 | - uses: pnpm/action-setup@v2 29 | with: 30 | version: 8.6.0 31 | 32 | - uses: actions/setup-node@v4 33 | with: 34 | node-version: 18 35 | cache: 'pnpm' 36 | 37 | - name: Install packages 38 | run: pnpm install --no-frozen-lockfile 39 | 40 | - name: Run eslint 41 | run: pnpm lint 42 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: [ v\d+\.\d+\.\d+ ] 6 | 7 | jobs: 8 | create-release: 9 | permissions: 10 | contents: write 11 | runs-on: ubuntu-20.04 12 | outputs: 13 | release_id: ${{ steps.create-release.outputs.id }} 14 | release_upload_url: ${{ steps.create-release.outputs.upload_url }} 15 | release_body: "${{ steps.tag.outputs.message }}" 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Get version 21 | id: get_version 22 | uses: battila7/get-version-action@v2 23 | 24 | - name: Get tag message 25 | id: tag 26 | run: | 27 | git fetch --depth=1 origin +refs/tags/*:refs/tags/* 28 | echo "message<> $GITHUB_OUTPUT 29 | echo "$(git tag -l --format='%(contents)' ${{ steps.get_version.outputs.version }})" >> $GITHUB_OUTPUT 30 | echo "EOF" >> $GITHUB_OUTPUT 31 | 32 | - name: Create Release 33 | id: create-release 34 | uses: ncipollo/release-action@v1 35 | with: 36 | draft: true 37 | name: ${{ steps.get_version.outputs.version }} 38 | tag: ${{ steps.get_version.outputs.version }} 39 | body: "${{ steps.tag.outputs.message }}" 40 | 41 | build-browser-extension: 42 | runs-on: ubuntu-22.04 43 | 44 | permissions: 45 | contents: write 46 | packages: write 47 | 48 | needs: create-release 49 | 50 | steps: 51 | - uses: actions/checkout@v4 52 | 53 | - uses: pnpm/action-setup@v2 54 | with: 55 | version: 8.6.0 56 | 57 | - name: setup node 58 | uses: actions/setup-node@v4 59 | with: 60 | node-version: 18 61 | cache: 'pnpm' 62 | 63 | - name: Get version 64 | id: get_version 65 | uses: battila7/get-version-action@v2 66 | 67 | - name: Install dependencies 68 | run: pnpm install --no-frozen-lockfile 69 | 70 | - name: Build browser extension 71 | run: make build-browser-extension 72 | env: 73 | VERSION: "${{ steps.get_version.outputs.version-without-v }}" 74 | 75 | - name: Package plugin 76 | run: | 77 | mkdir release 78 | mv chromium.zip release/readog-${{ steps.get_version.outputs.version-without-v }}.zip 79 | 80 | - name: Upload extensions to release 81 | id: upload-release-asset 82 | uses: actions/upload-release-asset@v1 83 | env: 84 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 85 | with: 86 | upload_url: ${{ needs.create-release.outputs.release_upload_url }} 87 | asset_path: release/readog-${{ steps.get_version.outputs.version-without-v }}.zip 88 | asset_name: readog-chrome-extension-${{ steps.get_version.outputs.version-without-v }}.zip 89 | asset_content_type: application/zip 90 | 91 | publish-release: 92 | permissions: 93 | contents: write 94 | runs-on: ubuntu-20.04 95 | needs: [create-release, build-browser-extension] 96 | 97 | steps: 98 | - name: publish release 99 | id: publish-release 100 | uses: actions/github-script@v7 101 | env: 102 | release_id: ${{ needs.create-release.outputs.release_id }} 103 | with: 104 | script: | 105 | github.rest.repos.updateRelease({ 106 | owner: context.repo.owner, 107 | repo: context.repo.repo, 108 | release_id: process.env.release_id, 109 | draft: false, 110 | prerelease: false 111 | }) 112 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Unit Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | unit-test: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - uses: pnpm/action-setup@v2 19 | with: 20 | version: 8.6.0 21 | 22 | - uses: actions/setup-node@v4 23 | with: 24 | node-version: 18 25 | cache: 'pnpm' 26 | 27 | - name: Install packages 28 | run: pnpm install --no-frozen-lockfile 29 | 30 | - name: Run test 31 | run: pnpm test 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # Config files 27 | .webextrc 28 | .webextrc.* 29 | 30 | .eslintcache 31 | package.json-e 32 | 33 | chromium.zip -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION ?= 0.1.0 2 | 3 | clean: 4 | rm -rf dist 5 | 6 | change-version: 7 | sed -i -e "s/\"version\": \".*\"/\"version\": \"$(VERSION)\"/" src-tauri/tauri.conf.json 8 | 9 | change-package-version: 10 | sed -i -e "s/\"version\": \".*\"/\"version\": \"$(VERSION)\"/" package.json 11 | 12 | build-browser-extension: change-package-version 13 | pnpm build 14 | cd dist && zip -r ../chromium.zip . 15 | 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Readog is browser extension that can save your link to any platform

2 | 3 | https://github.com/godruoyi/readog/assets/16079222/a1fd44a0-90d0-44e5-95d0-f1381131833f 4 | 5 | ## Description 6 | 7 | Readog allows you to save links with a single click to any platform that support Open API. 8 | Currently, it supports Telegram and Bookmark, we are plans for additional more support in the future, PR/Issue are welcome. 9 | 10 | ## Features 11 | 12 | - [x] 🌰 Click once and save to any platform 13 | - [x] 🌰 Support browser Bookmark 14 | - [x] 🍏 Support Telegram 15 | - [x] 🐛 Support Bear 16 | - [x] 🐕‍🦺 Support Obsidian 17 | - [ ] 🚧 Support Notion 18 | - [ ] 🚧 Support Flomo 19 | 20 | ## Installation 21 | 22 | ### Install Manually 23 | 24 | 1. Download readog-chrome-extension-{version}.zip from [Latest Release](https://github.com/godruoyi/readog/releases) 25 | 2. Unzip it 26 | 3. Open the Extension page of Chrome & Arc browsers 27 | 4. Enable `Developer mode` and click `Load unpacked` button to import Readog 28 | 5. Configure the service provider you want to save data(tg, bookmark, bear, etc.) 29 | 6. Enjoy. 30 | 31 | ![Chrome Extension](https://github.com/godruoyi/readog/assets/16079222/c1022503-9bb9-4ab3-b31d-37c331238b56) 32 | 33 | ### Install from source code 34 | 35 | 1. Clone the repository 36 | 2. Install dependencies: `pnpm install` 37 | 3. Build the extension: `pnpm build` 38 | 4. Open Chrome Developer Tools and load the extension from the `dist` directory 39 | 40 | ## Development 41 | 42 | 1. Clone the repository 43 | 2. Install dependencies: `pnpm install` 44 | 3. Run the development server: `pnpm dev` 45 | 4. Open Chrome Developer Tools and load the extension from the `dist` directory 46 | 5. Make changes to the code and the extension will be reloaded automatically 47 | 48 | ## License 49 | MIT 50 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu({ 4 | // Enable stylistic formatting rules 5 | // stylistic: true, 6 | 7 | // Or customize the stylistic rules 8 | stylistic: { 9 | indent: 4, // 4, or 'tab' 10 | quotes: 'single', // or 'double' 11 | }, 12 | 13 | rules: { 14 | 'no-console': 'off', 15 | 'curly': ['error', 'all'], 16 | 'node/prefer-global/process': 'off', 17 | }, 18 | 19 | // TypeScript and Vue are auto-detected, you can also explicitly enable them: 20 | typescript: true, 21 | vue: true, 22 | 23 | // Disable jsonc and yaml support 24 | jsonc: false, 25 | yaml: false, 26 | 27 | // `.eslintignore` is no longer supported in Flat config, use `ignores` instead 28 | ignores: [ 29 | './fixtures', 30 | // ...globs 31 | ], 32 | }) 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "anylater", 3 | "private": true, 4 | "version": "1.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "test": "vitest", 8 | "dev": "vite", 9 | "build": "tsc && vite build", 10 | "lint": "eslint \"src/**/*.{ts,tsx}\" --cache", 11 | "lint:fix": "eslint --fix \"src/**/*.{ts,tsx}\"", 12 | "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,md,json}\" --config ./.prettierrc --cache" 13 | }, 14 | "dependencies": { 15 | "baseui": "^14.0.0", 16 | "clsx": "^2.1.0", 17 | "install": "^0.13.0", 18 | "jss": "^10.10.0", 19 | "jss-preset-default": "^10.10.0", 20 | "react": "^18.2.0", 21 | "react-dom": "^18.2.0", 22 | "react-error-boundary": "^4.0.11", 23 | "react-jss": "^10.10.0", 24 | "styletron-engine-atomic": "^1.6.2", 25 | "styletron-react": "^6.1.1" 26 | }, 27 | "devDependencies": { 28 | "@antfu/eslint-config": "1.0.0-beta.29", 29 | "@samrum/vite-plugin-web-extension": "^5.0.0", 30 | "@types/chrome": "0.0.258", 31 | "@types/react": "^18.2.48", 32 | "@types/react-dom": "^18.2.18", 33 | "@types/webextension-polyfill": "^0.10.0", 34 | "@vitejs/plugin-react": "^4.0.4", 35 | "element-ready": "^7.0.0", 36 | "eslint": "^8.52.0", 37 | "jsdom": "^23.2.0", 38 | "typescript": "^4.9.3", 39 | "vite": "^4.5.1", 40 | "vite-tsconfig-paths": "^4.2.0", 41 | "vitest": "^1.1.3", 42 | "vitest-chrome": "^0.1.0", 43 | "webextension-polyfill": "^0.10.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /public/icon-with-shadow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /public/icon/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godruoyi/readog/dfa9ca8dc5f69bea5974830e9b38bba1030d3d76/public/icon/128.png -------------------------------------------------------------------------------- /public/icon/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godruoyi/readog/dfa9ca8dc5f69bea5974830e9b38bba1030d3d76/public/icon/16.png -------------------------------------------------------------------------------- /public/icon/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godruoyi/readog/dfa9ca8dc5f69bea5974830e9b38bba1030d3d76/public/icon/32.png -------------------------------------------------------------------------------- /public/icon/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godruoyi/readog/dfa9ca8dc5f69bea5974830e9b38bba1030d3d76/public/icon/48.png -------------------------------------------------------------------------------- /public/icon/96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godruoyi/readog/dfa9ca8dc5f69bea5974830e9b38bba1030d3d76/public/icon/96.png -------------------------------------------------------------------------------- /src/application.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from 'vitest' 2 | 3 | // import { app as app1, app as app2 } from './application' 4 | 5 | it('app should be a singleton', async () => { 6 | expect(true).toBe(true) 7 | 8 | // todo fix error: This script should only be loaded in a browser extension 9 | // const app3 = (await import(`./application`)).app 10 | // 11 | // expect(app1).toBe(app2) 12 | // expect(app1).toBe(app3) 13 | // expect(app2).toBe(app3) 14 | // 15 | // expect(app1.isBooted()).toBe(true) 16 | // expect(app1 === app2).toBe(true) 17 | // expect(app2 === app3).toBe(true) 18 | }) 19 | -------------------------------------------------------------------------------- /src/application.ts: -------------------------------------------------------------------------------- 1 | import type { IServiceProvider } from './types' 2 | import { EventServiceProvider } from './events/event' 3 | import type { StorageManager } from './storages/storage' 4 | import { StorageServiceProvider } from './storages/storage' 5 | import type { EventDispatcher } from './events/event' 6 | 7 | class Application { 8 | /** 9 | * Event manager instance, it will be initialized after application booted. 10 | * 11 | * @public 12 | */ 13 | public event: EventDispatcher | null = null 14 | 15 | /** 16 | * Storage manager instance, it will be initialized after application booted. 17 | * 18 | * @public 19 | */ 20 | public storage: StorageManager | null = null 21 | 22 | /** 23 | * Indicates if the application has been bootstrapped. 24 | * 25 | * @private 26 | */ 27 | private static booted: boolean = false 28 | 29 | /** 30 | * The application singleton instance. 31 | * 32 | * @private 33 | */ 34 | private static instance: Application | null = null 35 | 36 | /** 37 | * The bootstrap providers for the application. 38 | * 39 | * @private 40 | */ 41 | private providers: IServiceProvider[] = [ 42 | new EventServiceProvider(), 43 | new StorageServiceProvider(), 44 | ] 45 | 46 | /** 47 | * Private constructor to enforce singleton. 48 | * 49 | * @private 50 | * @throws {IError} if the application has already been booted 51 | * @throws {IError} registering the providers failed 52 | */ 53 | private constructor() {} 54 | 55 | /** 56 | * Determine if the application has been booted. 57 | * 58 | * @returns {boolean} true if the application has been booted, false otherwise 59 | */ 60 | public static isBooted(): boolean { 61 | return this.booted 62 | } 63 | 64 | /** 65 | * Get the application singleton instance. 66 | * 67 | * @returns {Application} application instance 68 | * @throws {IError} if the application has already been booted 69 | * @throws {IError} registering the providers failed 70 | * @throws {IError} booting the providers failed 71 | * @throws {IError} get config from storage failed 72 | */ 73 | public static getInstance(): Application { 74 | if (Application.instance) { 75 | return Application.instance 76 | } 77 | 78 | return (new Application()).launch() 79 | } 80 | 81 | /** 82 | * Launch the application. 83 | * 84 | * @private 85 | */ 86 | private launch(): Application { 87 | console.log('launch application...') 88 | 89 | this.registerServiceProviders() 90 | 91 | this.bootstrap() 92 | 93 | return this 94 | } 95 | 96 | /** 97 | * Bootstrap the application. 98 | * 99 | * @private 100 | * @throws {IError} bootstrapping the providers failed 101 | */ 102 | private bootstrap(): void { 103 | this.boot() 104 | 105 | this.bootServiceProviders() 106 | } 107 | 108 | /** 109 | * Boot the application, the application will be booted only once. 110 | * 111 | * @private 112 | */ 113 | private boot(): void { 114 | if (Application.booted) { 115 | throw new Error('Application has already been booted.') 116 | } 117 | 118 | Application.booted = true 119 | Application.instance = this 120 | } 121 | 122 | /** 123 | * Register the service providers. 124 | * 125 | * @private 126 | * @throws {IError} registering the providers failed 127 | */ 128 | private registerServiceProviders() { 129 | for (const provider of this.providers) { 130 | provider.register(this) 131 | } 132 | } 133 | 134 | /** 135 | * Boot the service providers. 136 | * 137 | * @throws {IError} booting the providers failed 138 | * @private 139 | */ 140 | private bootServiceProviders() { 141 | for (const provider of this.providers) { 142 | provider.boot() 143 | } 144 | } 145 | } 146 | 147 | const app = Application.getInstance() 148 | 149 | export { Application, app } 150 | -------------------------------------------------------------------------------- /src/browser-extension/background/background.ts: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill' 2 | import { isSystemPage, transformBrowserTabToLink } from '../../supports/browser' 3 | import { 4 | EVENT_OPEN_OPTION, 5 | EVENT_OPEN_POPUP, 6 | EVENT_SAVED_READOG, 7 | EVENT_SAVE_READOG, 8 | EVENT_SAVING_READOG, 9 | } from '../../events/event' 10 | import { app } from '../../application' 11 | import { OpenOptionEventListener } from '../../events/open_popup_event' 12 | import { SaveReadogEvent, SavedEvent, SavingEvent } from '../../events/readong_event' 13 | 14 | app.event?.registerAll({ 15 | [EVENT_OPEN_OPTION]: [ 16 | new OpenOptionEventListener(), 17 | ], 18 | [EVENT_SAVING_READOG]: [ 19 | new SavingEvent(), 20 | ], 21 | [EVENT_SAVED_READOG]: [ 22 | new SavedEvent(), 23 | ], 24 | [EVENT_SAVE_READOG]: [ 25 | new SaveReadogEvent(), 26 | ], 27 | }) 28 | 29 | browser.contextMenus.create( 30 | { 31 | id: 'readog', 32 | type: 'normal', 33 | title: 'Save via Readog', 34 | contexts: ['page', 'selection', 'link', 'image', 'video', 'audio', 'editable'], 35 | }, 36 | ) 37 | 38 | browser.action.onClicked.addListener(async (tab, _info) => { 39 | if (isSystemPage(tab)) { 40 | browser.runtime.openOptionsPage().then(() => {}) 41 | 42 | return 43 | } 44 | 45 | app.event?.sendEventToContentScript(tab.id as number, EVENT_OPEN_POPUP, transformBrowserTabToLink(tab)).then(() => {}) 46 | }) 47 | 48 | browser.contextMenus?.onClicked.addListener(async (info, tab) => { 49 | if (!tab) { 50 | return 51 | } 52 | 53 | if (isSystemPage(tab)) { 54 | browser.runtime.openOptionsPage().then(() => {}) 55 | return 56 | } 57 | 58 | app.event?.sendEventToContentScript(tab.id as number, EVENT_OPEN_POPUP, transformBrowserTabToLink(tab, info)).then(() => {}) 59 | }) 60 | 61 | browser.runtime.onMessage.addListener((request, sender) => { 62 | const { tab } = sender 63 | const { type, payload } = request 64 | 65 | const event = { 66 | tabID: tab?.id as number, 67 | type, 68 | payload, 69 | } 70 | 71 | app.event?.fire(event) 72 | }) 73 | -------------------------------------------------------------------------------- /src/browser-extension/content-scripts/index.tsx: -------------------------------------------------------------------------------- 1 | import '../../supports/enableDevHMR' 2 | import type { Root } from 'react-dom/client' 3 | import { createRoot } from 'react-dom/client' 4 | import React from 'react' 5 | import { JssProvider, createGenerateId } from 'react-jss' 6 | import { create } from 'jss' 7 | import preset from 'jss-preset-default' 8 | import { Client as Styletron } from 'styletron-engine-atomic' 9 | import { Provider as StyletronProvider } from 'styletron-react' 10 | import { BaseProvider, LightTheme } from 'baseui' 11 | import { createPopupCardElement, queryPopupCardElement } from '../../supports/popup' 12 | import { GlobalSuspense } from '../../components/GlobalSuspense' 13 | import { Popup } from '../../pages/popup/Popup' 14 | import { EVENT_OPEN_POPUP } from '../../events/event' 15 | import type { ILink } from '../../types' 16 | import { app } from '../../application' 17 | 18 | let root: Root | null = null 19 | const generateId = createGenerateId() 20 | 21 | export async function showPopup(link: ILink) { 22 | let popup = await queryPopupCardElement() 23 | if (!popup) { 24 | popup = await createPopupCardElement() 25 | } 26 | 27 | const jss = create().setup({ 28 | ...preset(), 29 | insertionPoint: popup.parentElement ?? undefined, 30 | }) 31 | const JSS = JssProvider 32 | const engine = new Styletron({ 33 | container: popup.parentElement ?? undefined, 34 | prefix: `x-styletron-`, 35 | }) 36 | 37 | if (root) { 38 | root.unmount() 39 | } 40 | 41 | root = createRoot(popup) 42 | root.render( 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | , 54 | ) 55 | } 56 | 57 | async function main() { 58 | app.event?.listen((e) => { 59 | showPopup(e.payload as ILink) 60 | }, EVENT_OPEN_POPUP) 61 | } 62 | 63 | main() 64 | -------------------------------------------------------------------------------- /src/browser-extension/content-scripts/twitter.ts: -------------------------------------------------------------------------------- 1 | import * as twitterUtil from '../../supports/tweet' 2 | import { showPopup } from './index' 3 | 4 | async function main() { 5 | console.log('Twitter content script loaded') 6 | 7 | for await (const tweet of twitterUtil.observeTweets()) { 8 | console.log('Tweet found!', tweet) 9 | // some guys tweet cannot be shareable, find a better way to insert the READ LATER button 10 | const shareButton = await twitterUtil.findShareElementButton(tweet.element) 11 | 12 | shareButton?.addEventListener('click', () => { 13 | const readLater = twitterUtil.createReadLaterMenuItem() 14 | 15 | readLater.onclick = () => { 16 | showPopup({ 17 | url: tweet.url, 18 | title: 'Twitter', 19 | }) 20 | 21 | twitterUtil.findShareMenuContainer().then((c) => { 22 | // hide the menu after user click the READ LATER button 23 | c?.style.setProperty('display', 'none') 24 | }) 25 | } 26 | 27 | twitterUtil.findShareMenuContainer().then(c => c?.appendChild(readLater)) 28 | }) 29 | } 30 | } 31 | 32 | main() 33 | -------------------------------------------------------------------------------- /src/browser-extension/options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | Options - Reader 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /src/browser-extension/options/index.tsx: -------------------------------------------------------------------------------- 1 | import '../../supports/enableDevHMR' 2 | import { createRoot } from 'react-dom/client' 3 | import React from 'react' 4 | import { Settings } from '../../pages/setting/Settings' 5 | 6 | const root = createRoot(document.getElementById('root') as HTMLElement) 7 | root.render( 8 | , 9 | ) 10 | -------------------------------------------------------------------------------- /src/browser-extension/setupForTest.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll, vi } from 'vitest' 2 | 3 | beforeAll(() => { 4 | // not work now 5 | vi.stubGlobal('chrome', { 6 | storage: { 7 | local: { 8 | get: vi.fn(() => ({})), 9 | set: vi.fn(), 10 | remove: vi.fn(), 11 | }, 12 | sync: { 13 | get: vi.fn(() => ({})), 14 | set: vi.fn(), 15 | remove: vi.fn(), 16 | }, 17 | session: { 18 | get: vi.fn(() => ({})), 19 | set: vi.fn(), 20 | remove: vi.fn(), 21 | }, 22 | }, 23 | runtime: { 24 | getURL: (url: string) => `https://local.io/${url}`, 25 | }, 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /src/components/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import { Checkbox as BaseCheckbox, STYLE_TYPE } from 'baseui/checkbox' 2 | import { useEffect, useState } from 'react' 3 | 4 | interface CheckboxProps { 5 | onChange?: (checked: boolean) => void 6 | checked?: boolean 7 | } 8 | 9 | export function Checkbox(props: CheckboxProps) { 10 | const [checked, setChecked] = useState(props.checked ?? false) 11 | useEffect(() => { 12 | setChecked(props.checked ?? false) 13 | }, [props.checked]) 14 | 15 | const onChange = (v: boolean) => { 16 | setChecked(v) 17 | if (props.onChange) { 18 | props.onChange(v) 19 | } 20 | } 21 | 22 | return ( 23 | { onChange(e.currentTarget.checked) }} 26 | checkmarkType={STYLE_TYPE.toggle_round} 27 | overrides={{ 28 | Root: { 29 | style: ({ _$theme }) => ({}), 30 | }, 31 | Toggle: { 32 | style: ({ _$theme }) => ({ 33 | backgroundColor: '#FFFFFF', 34 | height: '20px', 35 | }), 36 | }, 37 | ToggleTrack: { 38 | style: ({ _$theme }) => ({ 39 | backgroundColor: checked ? '#3170E2' : '#717171', 40 | height: '17px', 41 | }), 42 | }, 43 | ToggleInner: { 44 | style: ({ _$theme }) => ({ 45 | height: '17px', 46 | }), 47 | }, 48 | }} 49 | > 50 | 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /src/components/FormSwitch.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { FormControl } from 'baseui/form-control' 3 | import { getSettings, syncSettings } from '../supports/storage' 4 | import { Checkbox } from './Checkbox' 5 | 6 | interface SwitchProps { 7 | label?: string 8 | caption?: string 9 | checked?: boolean 10 | autosave?: string 11 | onChange?: (checked: boolean) => void 12 | } 13 | 14 | export function FormSwitch(props: SwitchProps) { 15 | const [checked, setChecked] = useState(props.checked ?? false) 16 | 17 | useEffect(() => { 18 | setChecked(props.checked ?? false) 19 | }, [props.checked]) 20 | 21 | useEffect(() => { 22 | if (props.autosave) { 23 | ;(async () => { 24 | const settings = await getSettings() 25 | const key = props.autosave ?? 'checked' 26 | // eslint-disable-next-line ts/ban-ts-comment 27 | // @ts-expect-error 28 | const v = settings[key] as boolean 29 | setChecked(v) 30 | })() 31 | } 32 | }, []) 33 | 34 | const onChange = async (v: boolean) => { 35 | setChecked(v) 36 | if (props.onChange) { 37 | props.onChange(v) 38 | 39 | return 40 | } 41 | 42 | if (props.autosave) { 43 | const settings = await getSettings() 44 | await syncSettings({ 45 | ...settings, 46 | [props.autosave ?? 'checked']: v, 47 | }) 48 | } 49 | } 50 | 51 | return ( 52 | props.label} 54 | caption={() => props.caption} 55 | overrides={{ 56 | Label: { 57 | style: ({ _$theme }) => ({ 58 | color: '#D1D1D1', 59 | fontSize: '16px', 60 | }), 61 | }, 62 | }} 63 | > 64 |
70 | 71 |
72 |
73 | ) 74 | } 75 | -------------------------------------------------------------------------------- /src/components/GlobalSuspense.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react' 2 | 3 | export function GlobalSuspense({ children }: { children: React.ReactNode }) { 4 | // TODO: a global loading fallback 5 | return {children} 6 | } 7 | -------------------------------------------------------------------------------- /src/components/Input.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import type { InputOverrides } from 'baseui/input' 3 | import { Input as BaseInput, SIZE } from 'baseui/input' 4 | import type { FormControlOverrides } from 'baseui/form-control' 5 | import { FormControl } from 'baseui/form-control' 6 | 7 | export interface InputProps { 8 | label?: string 9 | type?: string 10 | placeholder?: string 11 | caption?: string 12 | value?: string 13 | onChange?: (v: string) => void 14 | 15 | formControlOverrides?: FormControlOverrides 16 | inputOverrides?: InputOverrides 17 | } 18 | 19 | export function Input(props: InputProps) { 20 | const [value, setValue] = useState(props.value) 21 | useEffect(() => { 22 | setValue(props.value) 23 | }, [props.value]) 24 | 25 | return ( 26 | props.label} 28 | caption={() => props.caption} 29 | overrides={ 30 | !props.formControlOverrides 31 | ? { 32 | Label: { 33 | style: ({ _$theme }) => ({ 34 | color: '#D1D1D1', 35 | fontSize: '16px', 36 | }), 37 | }, 38 | } 39 | : props.formControlOverrides 40 | } 41 | > 42 | { 44 | setValue(event.currentTarget.value) 45 | if (props.onChange) { 46 | props.onChange(event.currentTarget.value) 47 | } 48 | }} 49 | type={props.type ?? 'input'} 50 | placeholder={props.placeholder} 51 | size={SIZE.mini} 52 | value={value} 53 | overrides={props.inputOverrides ?? {}} 54 | /> 55 | 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /src/components/MenuItem.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { createUseStyles } from 'react-jss' 3 | import { clsx } from 'clsx' 4 | 5 | interface IMenuItemProps { 6 | id: string 7 | logo?: React.ReactNode 8 | title: string 9 | selected: boolean 10 | onClick?: (id: string) => void 11 | } 12 | 13 | const useStyles = createUseStyles({ 14 | container: (selected: boolean) => ({ 15 | 'width': '220px', 16 | 'height': '40px', 17 | 'color': '#FFFFFF', 18 | 'display': 'flex', 19 | 'padding': '3px 10px', 20 | 'marginBottom': '5px', 21 | '&:hover': { 22 | backgroundColor: selected ? '#0670EB' : '#1A2037', 23 | borderRadius: '4px', 24 | }, 25 | }), 26 | logo: { 27 | alignItems: 'center', 28 | display: 'flex', 29 | justifyContent: 'center', 30 | }, 31 | title: { 32 | fontSize: '18px', 33 | alignItems: 'center', 34 | display: 'flex', 35 | marginLeft: '12px', 36 | }, 37 | selected: { 38 | backgroundColor: '#0670EB', 39 | borderRadius: '4px', 40 | }, 41 | }) 42 | 43 | export function MenuItem(props: IMenuItemProps) { 44 | // const [selected, setSelected] = useState(props.selected) 45 | const styles = useStyles(props.selected) 46 | 47 | return ( 48 |
{ 51 | console.log('item click', props) 52 | // setSelected(true) 53 | if (props.onClick) { 54 | props.onClick(props.id) 55 | } 56 | }} 57 | > 58 |
59 | {props.logo ?? DefaultSettingLogo()} 60 |
61 |
{props.title}
62 |
63 | ) 64 | } 65 | 66 | function DefaultSettingLogo() { 67 | return ( 68 | 78 | 79 | 80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const TweetMainCss: string = 'css-175oi2r' 2 | export const TweetSelector: string = `section.${TweetMainCss}>div>div>div` 3 | export const TweetUrlRegexp: RegExp = /\/status\/[0-9]+$/ 4 | 5 | export const ShowReader: string = 'Show Reader' 6 | 7 | export const ExtensionContainerId: string = '__readhub-extension-root' 8 | export const PopupCardId: string = '__readhub-extension-popup-card' 9 | -------------------------------------------------------------------------------- /src/events/event.ts: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill' 2 | import type { IServiceProvider } from '../types' 3 | import type { Application } from '../application' 4 | 5 | export interface IEventPayload extends Record {} 6 | 7 | export interface IEvent { 8 | type: IEventType 9 | payload?: IEventPayload 10 | tabID?: number 11 | } 12 | 13 | export interface IListener { 14 | handle(event: IEvent, app: Application): Promise 15 | } 16 | 17 | export type IEventType = string 18 | export const EVENT_OPEN_POPUP: IEventType = 'open_popup' 19 | export const EVENT_SAVE_STATUS: IEventType = 'save_status' 20 | 21 | export const EVENT_OPEN_OPTION: IEventType = 'open_option' 22 | export const EVENT_SAVING_READOG: IEventType = 'saving_readog' 23 | export const EVENT_SAVE_READOG: IEventType = 'save_readog' 24 | export const EVENT_SAVED_READOG: IEventType = 'saved_readog' 25 | 26 | export class EventServiceProvider implements IServiceProvider { 27 | boot(): void {} 28 | 29 | /** 30 | * Register event manager. 31 | * 32 | * @param app 33 | */ 34 | register(app: Application): void { 35 | app.event = new EventDispatcher(app) 36 | } 37 | } 38 | 39 | export class EventDispatcher { 40 | public constructor(protected app: Application) {} 41 | 42 | /** 43 | * All registered listeners. 44 | * 45 | * @protected 46 | */ 47 | protected listeners: Record = {} 48 | 49 | /** 50 | * Register an event listener. 51 | * 52 | * @param name 53 | * @param listener 54 | */ 55 | public register(name: IEventType, listener: IListener) { 56 | if (!this.listeners[name]) { 57 | this.listeners[name] = [] 58 | } 59 | 60 | this.listeners[name].push(listener) 61 | } 62 | 63 | /** 64 | * Register listener of all events. 65 | * 66 | * @param listeners 67 | */ 68 | public registerAll(listeners: Record) { 69 | for (const name in listeners) { 70 | for (const listener of listeners[name]) { 71 | this.register(name, listener) 72 | } 73 | } 74 | } 75 | 76 | /** 77 | * Fire an event. 78 | * 79 | * @param event 80 | */ 81 | public async fire(event: IEvent) { 82 | if (!this.listeners[event.type]) { 83 | return 84 | } 85 | 86 | for (const listener of this.listeners[event.type]) { 87 | await listener.handle(event, this.app) 88 | } 89 | } 90 | 91 | /** 92 | * Listen an event that from the background script or content script. 93 | * 94 | * @param callback 95 | * @param name 96 | * @returns a function that can be used to remove the listener 97 | */ 98 | public listen(callback: (event: IEvent) => void, name?: IEventType): () => void { 99 | let callbackWrapper = function (event: IEvent) { 100 | callback(event) 101 | } 102 | 103 | if (name) { 104 | callbackWrapper = function (event: IEvent) { 105 | if (event.type === name) { 106 | callback(event) 107 | } 108 | } 109 | } 110 | 111 | browser.runtime.onMessage.addListener(callbackWrapper) 112 | 113 | return () => { 114 | browser.runtime.onMessage.removeListener(callbackWrapper) 115 | } 116 | } 117 | 118 | /** 119 | * Send an event to the background script. 120 | * 121 | * @param type 122 | * @param payload 123 | */ 124 | async sendEventToBackground(type: IEventType, payload?: IEventPayload): Promise { 125 | return await browser.runtime.sendMessage({ 126 | type, 127 | payload, 128 | }) 129 | } 130 | 131 | /** 132 | * Send an event to the content script. 133 | * 134 | * @param tabID 135 | * @param type 136 | * @param payload 137 | */ 138 | async sendEventToContentScript(tabID: number, type: IEventType, payload?: IEventPayload): Promise { 139 | return await browser.tabs.sendMessage(tabID, { 140 | type, 141 | payload, 142 | }) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/events/open_popup_event.ts: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill' 2 | import type { Application } from '../application' 3 | import type { IEvent, IListener } from './event' 4 | 5 | export class OpenOptionEventListener implements IListener { 6 | public async handle(_event: IEvent, _app: Application): Promise { 7 | await browser.runtime.openOptionsPage() 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/events/readong_event.ts: -------------------------------------------------------------------------------- 1 | // import { app } from '../application' 2 | import type { Application } from '../application' 3 | import type { ILink } from '../types' 4 | import type { IEvent, IListener } from './event' 5 | import { EVENT_SAVE_STATUS } from './event' 6 | 7 | export class SavingEvent implements IListener { 8 | async handle(event: IEvent): Promise { 9 | console.log('saving event', event) 10 | } 11 | } 12 | 13 | export class SavedEvent implements IListener { 14 | async handle(event: IEvent): Promise { 15 | console.log('saved event', event) 16 | } 17 | } 18 | 19 | export class SaveReadogEvent implements IListener { 20 | async handle(event: IEvent, app: Application): Promise { 21 | console.log('save readog event', event) 22 | 23 | const errors = await app.storage?.dispatch(event.payload as ILink) 24 | 25 | const { tabID } = event 26 | 27 | // notify content script that the link has been saved 28 | app.event?.sendEventToContentScript(tabID as number, EVENT_SAVE_STATUS, { 29 | errors, 30 | }) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/manifest.ts: -------------------------------------------------------------------------------- 1 | import { version } from '../package.json' 2 | 3 | export function getManifest() { 4 | const manifest: chrome.runtime.Manifest = { 5 | manifest_version: 3, 6 | 7 | name: 'Readog', 8 | description: 'Readog is a browser extension that can save your links to any platform.', 9 | version, 10 | 11 | icons: { 12 | 16: 'icon/16.png', 13 | 32: 'icon/32.png', 14 | 48: 'icon/48.png', 15 | 96: 'icon/96.png', 16 | 128: 'icon/128.png', 17 | }, 18 | 19 | action: { 20 | default_icon: 'icon/32.png', 21 | default_title: 'Save via Readog', 22 | }, 23 | 24 | options_ui: { 25 | page: 'src/browser-extension/options/index.html', 26 | open_in_tab: true, 27 | }, 28 | 29 | background: { 30 | service_worker: 'src/browser-extension/background/background.ts', 31 | }, 32 | 33 | content_scripts: [ 34 | { 35 | matches: ['https://twitter.com/*'], 36 | all_frames: true, 37 | js: ['src/browser-extension/content-scripts/twitter.ts'], 38 | }, 39 | { 40 | matches: [''], 41 | all_frames: true, 42 | js: ['src/browser-extension/content-scripts/index.tsx'], 43 | }, 44 | ], 45 | 46 | permissions: [ 47 | 'tabs', 48 | 'bookmarks', 49 | 'contextMenus', 50 | 'storage', 51 | 'nativeMessaging', 52 | ], 53 | } 54 | 55 | return manifest 56 | } 57 | -------------------------------------------------------------------------------- /src/pages/popup/Popup.tsx: -------------------------------------------------------------------------------- 1 | import { createUseStyles } from 'react-jss' 2 | import { useEffect, useRef, useState } from 'react' 3 | import { clsx } from 'clsx' 4 | import type { ILink } from '../../types' 5 | import { ExtensionContainerId } from '../../constants' 6 | import { getSettings } from '../../supports/storage' 7 | import { sleep } from '../../supports/time' 8 | import { PopupHeader } from './PopupHeader' 9 | import { PopupForm } from './PopupForm' 10 | 11 | export interface IReaderBoxProps extends ILink {} 12 | 13 | const useStyles = createUseStyles({ 14 | container: { 15 | position: 'fixed', 16 | zIndex: 2147483647, // todo use constant 17 | minWidth: '460px', 18 | maxWidth: '460px', 19 | lineHeight: '1.6', 20 | top: '10px', 21 | right: '10px', 22 | width: 'max-content', 23 | backgroundColor: '#262626', 24 | borderRadius: '4px', 25 | border: '1.2px solid #404040', 26 | transform: 'translateY(-150%)', 27 | transition: 'all ease-in-out 100ms', 28 | }, 29 | open: { 30 | transform: 'translateY(0)', 31 | }, 32 | }) 33 | 34 | export function Popup(props: IReaderBoxProps) { 35 | const styles = useStyles() 36 | const appTarget = useRef(null) 37 | const [isOpen, setIsOpen] = useState(true) 38 | const [isSubmit, setSubmit] = useState(false) 39 | 40 | const keyPress = (e: any) => { 41 | if (e.keyCode === 27) { 42 | setIsOpen(false) 43 | } 44 | } 45 | 46 | const handleDocumentClick = (e: any) => { 47 | if (e.target?.id === ExtensionContainerId) { 48 | return 49 | } 50 | setIsOpen(false) 51 | } 52 | 53 | useEffect(() => { 54 | setIsOpen(true) 55 | 56 | document.addEventListener('keyup', keyPress) 57 | document.addEventListener('click', handleDocumentClick) 58 | 59 | return () => { 60 | document.removeEventListener('keyup', keyPress) 61 | document.removeEventListener('click', handleDocumentClick) 62 | } 63 | }, []) 64 | 65 | useEffect(() => { 66 | ;(async () => { 67 | if (!isSubmit) { 68 | return 69 | } 70 | 71 | const settings = await getSettings() 72 | const x = settings.closeWhenSaved as boolean 73 | await sleep(2000) 74 | setIsOpen(!x) 75 | })() 76 | }, [isSubmit]) 77 | 78 | return ( 79 |
80 | 81 | setSubmit(true)} 86 | /> 87 |
88 | ) 89 | } 90 | -------------------------------------------------------------------------------- /src/pages/popup/PopupFooter.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from 'baseui/button' 2 | import React, { useEffect, useState } from 'react' 3 | import type { IError } from '../../types' 4 | 5 | export function PopupFooter(props: { onSubmit: () => void; loading?: boolean; errors?: IError[]; finished?: boolean }) { 6 | const [loading, setLoading] = useState(props.loading || false) 7 | const [saveText, setSaveText] = useState('SAVE') 8 | const [errors, setErrors] = useState(props.errors || []) 9 | const saveFailed: string = 'SAVE FAILED' 10 | 11 | useEffect(() => { 12 | setLoading(props.loading || false) 13 | }, [props.loading]) 14 | 15 | useEffect(() => { 16 | if (props.errors) { 17 | setErrors(props.errors || []) 18 | } 19 | 20 | if (props.finished) { 21 | setSaveText(props?.errors?.length ? saveFailed : 'SAVE SUCCESS') 22 | } 23 | }, [props.finished, props.errors]) 24 | 25 | return ( 26 |
27 | { 28 | errors.length > 0 29 | ? React.createElement('div', { style: { 30 | color: '#6A6969', 31 | fontSize: '10px', 32 | marginTop: '8px', 33 | marginBottom: '5px', 34 | } }, [ 35 | errors.map((e: IError, index: number) => React.createElement('div', { 36 | key: index, 37 | style: { 38 | lineHeight: '12px', 39 | }, 40 | }, [e.message])), 41 | ]) 42 | : undefined 43 | } 44 |
0 ? '0px' : '12px', 49 | }} 50 | > 51 | 76 |
77 |
78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /src/pages/popup/PopupForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { createUseStyles } from 'react-jss' 3 | import { app } from '../../application' 4 | import type { IEvent } from '../../events/event' 5 | import { EVENT_SAVE_READOG, EVENT_SAVE_STATUS } from '../../events/event' 6 | import { sleep } from '../../supports/time' 7 | import type { IError } from '../../types' 8 | import { PopupInput } from './PopupInput' 9 | import { PopupTextarea } from './PopupTextarea' 10 | import { PopupFooter } from './PopupFooter' 11 | 12 | interface ReaderBoxFormProps { 13 | link: string 14 | title?: string 15 | selectionText?: string 16 | onSubmitted?: () => void 17 | } 18 | 19 | const useStyles = createUseStyles({ 20 | container: { 21 | padding: '5px 20px 0 20px', 22 | height: '100%', 23 | }, 24 | links: { 25 | border: '1.2px solid #404040', 26 | borderRadius: '4px', 27 | marginTop: '12px', 28 | padding: '6px', 29 | }, 30 | }) 31 | 32 | export function PopupForm(props: ReaderBoxFormProps) { 33 | const [link, setLink] = useState(props.link) 34 | const [title, setTitle] = useState(props.title) 35 | const [remark, setRemark] = useState(props.selectionText) 36 | const [loading, setLoading] = useState(false) 37 | const [saveFinished, setSaveFinished] = useState(false) 38 | const [errors, setErrors] = useState([]) 39 | const styles = useStyles() 40 | 41 | const onSava = async () => { 42 | setLoading(true) 43 | await app.event?.sendEventToBackground(EVENT_SAVE_READOG, { 44 | url: link, 45 | title, 46 | selectionText: remark, 47 | }) 48 | } 49 | 50 | const saved = (event: IEvent) => { 51 | sleep(1000).then(() => { 52 | const errors = event?.payload?.errors?.map((e: any) => { 53 | return { message: e?.message ?? '' } as IError 54 | }).filter((e: IError) => e && e.message) ?? [] 55 | setErrors(errors) 56 | setLoading(false) 57 | setSaveFinished(true) 58 | props.onSubmitted?.() 59 | }) 60 | } 61 | 62 | useEffect(() => { 63 | const clean = app.event?.listen(saved, EVENT_SAVE_STATUS) 64 | return () => { 65 | clean?.() 66 | } 67 | }, []) 68 | 69 | return ( 70 |
71 |
72 | setLink(v)} 77 | /> 78 | setTitle(v)} 83 | /> 84 |
85 | setRemark(v)}> 86 | 87 |
88 | ) 89 | } 90 | -------------------------------------------------------------------------------- /src/pages/popup/PopupHeader.tsx: -------------------------------------------------------------------------------- 1 | import { createUseStyles } from 'react-jss' 2 | import { Application } from '../../application' 3 | import { EVENT_OPEN_OPTION } from '../../events/event' 4 | 5 | const useStyles = createUseStyles({ 6 | header: { 7 | color: '#fff', 8 | display: 'flex', 9 | height: '40px', 10 | lineHeight: '40px', 11 | justifyContent: 'space-between', 12 | borderBottom: '1px solid #404040', 13 | padding: '0 20px', 14 | }, 15 | logo: { 16 | fontSize: '22px', 17 | }, 18 | setting: { 19 | alignItems: 'center', 20 | display: 'flex', 21 | }, 22 | }) 23 | 24 | export function PopupHeader() { 25 | const styles = useStyles() 26 | 27 | // todo change setting icon 28 | return ( 29 |
30 |
31 | Readog 32 |
33 |
{ 36 | const app = await Application.getInstance() 37 | app.event?.sendEventToBackground(EVENT_OPEN_OPTION) 38 | }} 39 | > 40 | 49 | 50 | 51 |
52 |
53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /src/pages/popup/PopupInput.tsx: -------------------------------------------------------------------------------- 1 | import type { InputProps } from '../../components/Input' 2 | import { Input } from '../../components/Input' 3 | 4 | interface PopupInputProps extends InputProps {} 5 | 6 | export function PopupInput(props: PopupInputProps) { 7 | return ( 8 | { 13 | if (props.onChange) { 14 | props.onChange(v) 15 | } 16 | }} 17 | formControlOverrides={{ 18 | LabelContainer: { 19 | style: ({ _$theme }) => ({ 20 | marginBottom: '0', 21 | marginTop: '4px', 22 | }), 23 | }, 24 | Label: { 25 | style: ({ _$theme }) => ({ 26 | color: '#B9ABFD', 27 | paddingLeft: '4px', 28 | }), 29 | }, 30 | ControlContainer: { 31 | style: ({ _$theme }) => ({ 32 | marginBottom: '0', 33 | }), 34 | }, 35 | }} 36 | inputOverrides={{ 37 | Root: { 38 | style: ({ _$theme }) => ({ 39 | borderTopColor: '#262626', 40 | borderRightColor: '#262626', 41 | borderLeftColor: '#262626', 42 | borderBottomColor: '#262626', 43 | paddingRight: '0', 44 | paddingLeft: '0', 45 | paddingTop: '0', 46 | paddingBottom: '0', 47 | outline: '0 !important', 48 | }), 49 | }, 50 | Input: { 51 | style: ({ _$theme }) => ({ 52 | 'backgroundColor': '#262626', 53 | 'paddingRight': '4px', 54 | 'paddingLeft': '4px', 55 | 'color': '#FFFFFF', 56 | '::placeholder': { 57 | color: '#545454', 58 | }, 59 | ':hover': { 60 | cursor: 'text', 61 | backgroundColor: '#233A5A', 62 | color: '#FDFDFD', 63 | }, 64 | }), 65 | }, 66 | InputContainer: { 67 | style: ({ _$theme }) => ({ 68 | marginBottom: '0', 69 | paddingBottom: '0', 70 | }), 71 | }, 72 | }} 73 | /> 74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /src/pages/popup/PopupTextarea.tsx: -------------------------------------------------------------------------------- 1 | import { FormControl } from 'baseui/form-control' 2 | import { Textarea } from 'baseui/textarea' 3 | import { SIZE } from 'baseui/input' 4 | 5 | export function PopupTextarea(props: { value?: string; onChange: (v: string) => void }) { 6 | return ( 7 |
11 | ({ 16 | color: '#6B6B6B', 17 | paddingLeft: '4px', 18 | paddingTop: '0px', 19 | marginTop: '0px', 20 | fontSize: '11px', 21 | }), 22 | }, 23 | ControlContainer: { 24 | style: ({ _$theme }) => ({ 25 | marginBottom: '0', 26 | }), 27 | }, 28 | }} 29 | > 30 |