├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── .nycrc.json ├── .prettierrc.js ├── .travis.yml ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── assets └── img │ └── blobbycat │ ├── LICENSE │ ├── browseraction128.png │ ├── icon128.png │ ├── icon48.png │ └── logo.png ├── build_hook.sh ├── manifest.json ├── package-lock.json ├── package.json ├── scripts └── bundle.ts ├── src ├── app.html ├── app.ts ├── background.ts ├── components │ ├── app.tsx │ ├── group.tsx │ ├── group_row.tsx │ ├── options.tsx │ └── paginator.tsx ├── lib │ ├── archive.ts │ ├── brokers.ts │ ├── globals.ts │ ├── options.ts │ ├── tabs_store.ts │ ├── types.ts │ └── utils.ts └── style │ ├── app.scss │ ├── modal.scss │ └── montserrat.css ├── tests ├── archive.test.ts ├── options.test.ts ├── tabs.test.ts └── utils.ts ├── tsconfig.json └── webpack.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // Cribbed from https://dev.to/robertcoopercode/using-eslint-and-prettier-in-a-typescript-project-53jb 2 | 3 | module.exports = { 4 | parser: '@typescript-eslint/parser', // Specifies the ESLint parser 5 | extends: [ 6 | 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin 7 | 'prettier/@typescript-eslint', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier 8 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and displays prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 9 | ], 10 | parserOptions: { 11 | ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features 12 | sourceType: 'module', // Allows for the use of imports 13 | indent: 2, 14 | project: './tsconfig.json', 15 | }, 16 | rules: { 17 | '@typescript-eslint/consistent-type-assertions': ['error', { assertionStyle: 'as' }], 18 | '@typescript-eslint/no-floating-promises': ['error'], 19 | '@typescript-eslint/explicit-function-return-type': ['warn', { allowExpressions: true }], 20 | '@typescript-eslint/no-unused-vars': ['warn', { varsIgnorePattern: '(^h$|Component$)' }], 21 | // Use sparingly ;). 22 | '@typescript-eslint/no-explicit-any': false, 23 | '@typescript-eslint/ban-ts-ignore': false, 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .nyc_output/ 3 | coverage/ 4 | dist/ 5 | node_modules/ 6 | pack/ 7 | 8 | .DS_Store 9 | mix-manifest.json 10 | -------------------------------------------------------------------------------- /.nycrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extension": [ 3 | ".ts", 4 | ".tsx" 5 | ], 6 | "exclude": [ 7 | "src/app.ts", 8 | "src/background.ts", 9 | "src/tsxdom.ts" 10 | ], 11 | "reporter": [ 12 | "text", 13 | "lcov" 14 | ], 15 | "include": [ 16 | "src/**/*.ts", 17 | "src/**/*.tsx" 18 | ], 19 | "all": true, 20 | "cache": false 21 | } 22 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 100, 6 | tabWidth: 2, 7 | useTabs: false, 8 | }; 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: bionic 2 | language: node_js 3 | node_js: 4 | - 10.16.0 5 | before_install: 6 | - npm i -g npm@6.10.0 7 | # see https://docs.travis-ci.com/user/build-stages/ 8 | jobs: 9 | include: 10 | - stage: test 11 | - name: Test and Upload Coverage 12 | script: npm run test:coveralls 13 | - name: Bundle Extension 14 | script: npx ts-node scripts/bundle.ts 15 | - name: Lint 16 | script: npm run lint 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.preferences.quoteStyle": "single", 3 | "eslint.validate": [ 4 | "javascript", 5 | "javascriptreact", 6 | "typescript" 7 | ], 8 | "eslint.enable": true 9 | } 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Mori Bellamy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GrayTabby 2 | 3 | [![Build Status](https://travis-ci.com/moribellamy/graytabby.svg?branch=master)](https://travis-ci.com/moribellamy/graytabby) 4 | [![Coverage Status](https://coveralls.io/repos/github/moribellamy/graytabby/badge.svg?branch=master)](https://coveralls.io/github/moribellamy/graytabby?branch=master) 5 | 6 | GrayTabby is a tab archiver for Chrome, Brave, Firefox, and Opera. 7 | 8 | # Download 9 | 10 | 11 | 12 | 13 | 14 | # Why GrayTabby 15 | * GrayTabby is _simple_. It focuses around one core flow: archival. Most users will get all the value they need out of this tab archiver by clicking one button. 16 | * GrayTabby is _open source_. You can read for yourself that it doesn't share your data. You can contribute to it for everyone's benefit. 17 | * GrayTabby is _focused_. It only asks for permission to view and modify your tabs, because that is its purpose in life. 18 | 19 | # Demo 20 | [![GrayTabby demo](https://img.youtube.com/vi/24_mo9sSyjo/0.jpg)](https://youtu.be/24_mo9sSyjo) 21 | 22 | # Licensing 23 | The source code is [MIT](LICENSE). Some images are [only allowed for distribution with GrayTabby](assets/img/blobbycat/LICENSE), so derivative works may not use them. 24 | -------------------------------------------------------------------------------- /assets/img/blobbycat/LICENSE: -------------------------------------------------------------------------------- 1 | Images in this directory copyright Kirstin Kajita, all rights reserved. 2 | -------------------------------------------------------------------------------- /assets/img/blobbycat/browseraction128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moribellamy/graytabby/406684a49ab73c17f410076fdf153a4db5ac00ca/assets/img/blobbycat/browseraction128.png -------------------------------------------------------------------------------- /assets/img/blobbycat/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moribellamy/graytabby/406684a49ab73c17f410076fdf153a4db5ac00ca/assets/img/blobbycat/icon128.png -------------------------------------------------------------------------------- /assets/img/blobbycat/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moribellamy/graytabby/406684a49ab73c17f410076fdf153a4db5ac00ca/assets/img/blobbycat/icon48.png -------------------------------------------------------------------------------- /assets/img/blobbycat/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moribellamy/graytabby/406684a49ab73c17f410076fdf153a4db5ac00ca/assets/img/blobbycat/logo.png -------------------------------------------------------------------------------- /build_hook.sh: -------------------------------------------------------------------------------- 1 | LOGO=dist/assets/img/blobbycat/browseraction128.png 2 | 3 | if [[ "$NODE_ENV" == "development" ]]; then 4 | if [[ -f `which convert` ]]; then 5 | echo "development build; doing flippycat" 6 | convert -flip $LOGO $LOGO 7 | else 8 | echo "'convert' not found on path; skipping flippycat" 9 | fi 10 | else 11 | echo "production build; skipping flippycat" 12 | fi 13 | 14 | VERSION=`egrep -oe '.*"version":.*' manifest.json` 15 | VERSION=`echo "$VERSION" | egrep -oe '(\d+[.]?)+'` 16 | 17 | if [[ "$NODE_ENV" == "development" ]]; then 18 | VERSION="$VERSION-`date +"%T"`" 19 | fi 20 | 21 | # echo "stamping build with $VERSION" 22 | # sed -i.bak "s/!!VERSION!!/$VERSION/" dist/app.html 23 | # rm dist/app.html.bak 24 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "graytabby", 4 | "version": "20.7.30", 5 | "icons": { 6 | "128": "assets/img/blobbycat/browseraction128.png" 7 | }, 8 | "browser_action": { 9 | "default_icon": "assets/img/blobbycat/browseraction128.png", 10 | "default_title": "Archive into GrayTabby" 11 | }, 12 | "background": { 13 | "scripts": [ 14 | "background.js" 15 | ] 16 | }, 17 | "permissions": [ 18 | "tabs", 19 | "storage", 20 | "contextMenus" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "test": "mocha --require ts-node/register/transpile-only --recursive tests/**/*.test.ts", 4 | "test:coverage": "nyc npm run test", 5 | "test:coveralls": "npm run test:coverage && cat ./coverage/lcov.info | coveralls", 6 | "develop": "NODE_ENV=development webpack --watch", 7 | "develop:once": "NODE_ENV=development webpack", 8 | "build": "NODE_ENV=production webpack", 9 | "clean": "rm -rf dist coverage pack", 10 | "deps": "rm -rf node_modules; npm i", 11 | "bundle": "ts-node scripts/bundle.ts", 12 | "lint": "eslint src/**/*.ts tests/**/*.ts" 13 | }, 14 | "author": "Mori Bellamy", 15 | "license": "MIT", 16 | "devDependencies": { 17 | "@types/archiver": "^3.1.0", 18 | "@types/chai": "^4.2.11", 19 | "@types/chrome": "0.0.86", 20 | "@types/firefox-webext-browser": "^67.0.2", 21 | "@types/jquery": "^3.3.38", 22 | "@types/jsdom": "^12.2.4", 23 | "@types/mocha": "^5.2.7", 24 | "@types/recursive-readdir": "^2.2.0", 25 | "@types/rimraf": "^3.0.0", 26 | "@types/sinon-chrome": "^2.2.8", 27 | "@typescript-eslint/eslint-plugin": "^2.31.0", 28 | "@typescript-eslint/parser": "^2.31.0", 29 | "archiver": "^4.0.1", 30 | "autoprefixer": "^9.7.6", 31 | "chai": "^4.2.0", 32 | "copy-webpack-plugin": "^5.1.1", 33 | "coveralls": "^3.1.0", 34 | "css-loader": "^3.5.3", 35 | "eslint-config-prettier": "^6.11.0", 36 | "eslint-plugin-prettier": "^3.1.3", 37 | "file-loader": "^4.3.0", 38 | "jquery": "^3.5.1", 39 | "jsdom": "^15.2.1", 40 | "mini-css-extract-plugin": "^0.8.2", 41 | "mocha": "^6.2.3", 42 | "node-sass": "^4.14.1", 43 | "nyc": "^14.1.1", 44 | "object-sizeof": "^1.6.0", 45 | "postcss-loader": "^3.0.0", 46 | "prettier": "^1.19.1", 47 | "purecss": "^2.0.3", 48 | "recursive-readdir": "^2.2.2", 49 | "rimraf": "^3.0.2", 50 | "sass-loader": "^7.3.1", 51 | "sinon-chrome": "^3.0.1", 52 | "ts-loader": "^6.2.2", 53 | "ts-node": "^8.10.1", 54 | "ts-sinon": "^1.2.0", 55 | "tsx-dom": "^0.8.3", 56 | "typescript": "^3.8.3", 57 | "web-ext": "^4.2.0", 58 | "webextension-polyfill-ts": "^0.9.1", 59 | "webpack": "^4.43.0", 60 | "webpack-cli": "^3.3.11", 61 | "webpack-shell-plugin-next": "^1.1.9" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /scripts/bundle.ts: -------------------------------------------------------------------------------- 1 | import * as childProcess from 'child_process'; 2 | import * as archiver from 'archiver'; 3 | import * as fs from 'fs'; 4 | import * as path from 'path'; 5 | import readdir from 'recursive-readdir'; 6 | import rimraf from 'rimraf'; 7 | 8 | async function rmrf(target: string): Promise { 9 | return new Promise((resolve, reject) => { 10 | rimraf(target, error => { 11 | if (error) reject(error); 12 | resolve(); 13 | }); 14 | }); 15 | } 16 | 17 | async function rename(src: string, dst: string): Promise { 18 | return new Promise((resolve, reject) => { 19 | fs.rename(src, dst, err => { 20 | if (err) reject(err); 21 | resolve(); 22 | }); 23 | }); 24 | } 25 | 26 | async function subcommand(cmd: string): Promise { 27 | return new Promise((resolve, reject) => { 28 | childProcess.exec(cmd, (err, stdout, stderr) => { 29 | if (err) reject(err); 30 | resolve([stdout, stderr]); 31 | }); 32 | }); 33 | } 34 | 35 | function abspath(...parts: string[]): string { 36 | return path.join(process.cwd(), ...parts); 37 | } 38 | 39 | async function bundle(): Promise { 40 | process.chdir(path.join(__dirname, '..')); 41 | await rmrf(abspath('dist')); 42 | const build = subcommand('npm run build'); 43 | 44 | await rmrf(abspath('pack')); 45 | fs.mkdirSync(abspath('pack')); 46 | const zip = archiver.default('zip'); 47 | const output = fs.createWriteStream(abspath('pack', 'chrome.zip')); 48 | zip.pipe(output); 49 | 50 | await build; 51 | zip.directory(abspath('dist'), false); 52 | await zip.finalize(); 53 | 54 | process.chdir('dist'); 55 | await subcommand('web-ext build'); 56 | process.chdir('..'); 57 | let basename = ''; 58 | for (const f of await readdir(abspath('dist'))) { 59 | if (f.endsWith('.zip') && f.includes('dist/web-ext-artifacts')) { 60 | basename = path.basename(f); 61 | await rename(f, abspath('pack', basename)); 62 | } 63 | } 64 | if (basename === '') { 65 | throw 'Could not find firefox bundle.'; 66 | } 67 | 68 | basename = basename.substring(0, basename.length - '.zip'.length); 69 | await rename(abspath('pack', 'chrome.zip'), abspath('pack', `${basename}-chrome.zip`)); 70 | 71 | for (const f of await readdir(abspath('pack'))) { 72 | console.log(f); 73 | } 74 | } 75 | 76 | function run(promise: Promise): void { 77 | promise.then( 78 | () => null, 79 | err => { 80 | console.error(err); 81 | process.exit(1); 82 | }, 83 | ); 84 | } 85 | 86 | run(bundle()); 87 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | GrayTabby 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GT entry point. Not included in coverage reports, so don't put 3 | * non-trivial logic here. 4 | */ 5 | 6 | import { App } from './components/app'; 7 | import { DOCUMENT, PAGE_LOAD } from './lib/globals'; 8 | import './style/app.scss'; 9 | 10 | /** 11 | * The main entry point for GrayTabby. 12 | */ 13 | export async function graytabby(): Promise { 14 | const app = DOCUMENT.get().body.appendChild(App()); 15 | await app.initialRender; 16 | } 17 | 18 | graytabby().then( 19 | () => { 20 | PAGE_LOAD.get() 21 | .pub() 22 | .catch(() => { 23 | console.log('no listeners for page load'); 24 | }) 25 | .finally(() => { 26 | console.log('loaded graytabby frontend'); 27 | }); 28 | }, 29 | err => { 30 | console.error(err); 31 | }, 32 | ); 33 | -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Background script. See 3 | * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Anatomy_of_a_WebExtension 4 | * 5 | * No "long running logic" is implemented here. This is just the best place to register handlers. 6 | * 7 | * Not included in coverage reports, so don't put non-trivial logic here. 8 | */ 9 | 10 | import { bindArchivalHandlers } from './lib/archive'; 11 | 12 | bindArchivalHandlers().then( 13 | () => { 14 | console.log('loaded graytabby backend'); 15 | }, 16 | err => { 17 | console.error('could not load graytabby backend', err); 18 | }, 19 | ); 20 | -------------------------------------------------------------------------------- /src/components/app.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'tsx-dom'; 2 | import { ARCHIVAL } from '../lib/globals'; 3 | import { getOptions } from '../lib/options'; 4 | import { GrayTabGroup, loadAllTabGroups, saveTabGroup } from '../lib/tabs_store'; 5 | import { BrowserTab } from '../lib/types'; 6 | import { clamp, getOnlyChild, setOnlyChild } from '../lib/utils'; 7 | import { GroupComponent } from './group'; 8 | import { OptionsComponent, setVisible } from './options'; 9 | import { PaginatorComponent } from './paginator'; 10 | 11 | async function ingestTabs( 12 | tabSummaries: BrowserTab[], 13 | now = () => new Date().getTime(), 14 | ): Promise { 15 | if (tabSummaries.length == 0) return; 16 | let counter = 0; 17 | const group: GrayTabGroup = { 18 | tabs: tabSummaries.map(ts => { 19 | return { ...ts, key: counter++ }; 20 | }), 21 | date: now(), 22 | }; 23 | await saveTabGroup(group); 24 | } 25 | 26 | export interface AppElement extends HTMLDivElement { 27 | _signalWatcher: () => void; 28 | initialRender: Promise; 29 | } 30 | 31 | export function watchRender(app: AppElement): Promise { 32 | return new Promise(resolve => { 33 | app._signalWatcher = resolve; 34 | }); 35 | } 36 | 37 | export function App(): AppElement { 38 | const optionsWrapper =
; // Contains one modal. 39 | const paginatorWrapper =
; // Contains one paginator. 40 | const groupsWrapper = (
) as HTMLDivElement; // Contains N GroupComponents. 41 | 42 | const self = ( 43 |
44 | {optionsWrapper} 45 |
46 |

Welcome to GrayTabby!

47 | {paginatorWrapper} 48 |

49 | {groupsWrapper} 50 | setVisible(getOnlyChild(optionsWrapper) as HTMLDivElement, true)} 54 | /> 55 |
56 |
57 | ) as AppElement; 58 | 59 | let lastRenderPage = 0; 60 | async function render(pageNum?: number /* 0 indexed */): Promise { 61 | if (pageNum == null) pageNum = lastRenderPage; 62 | const options = await getOptions(); 63 | setOnlyChild(optionsWrapper, ); 64 | groupsWrapper.innerHTML = ''; 65 | 66 | const loaded = await loadAllTabGroups(); 67 | 68 | const totalPages = Math.max(1, Math.ceil(loaded.length / options.groupsPerPage)); 69 | pageNum = clamp(pageNum, 0, totalPages - 1); 70 | const firstIdx = pageNum * options.groupsPerPage; 71 | const lastIdx = firstIdx + options.groupsPerPage; 72 | 73 | setOnlyChild( 74 | paginatorWrapper, 75 | totalPages > 1 ? ( 76 | render(clicked)} 80 | /> 81 | ) : ( 82 |
83 | ), 84 | ); 85 | for (const group of loaded.slice(firstIdx, lastIdx)) { 86 | groupsWrapper.appendChild(); 87 | } 88 | lastRenderPage = pageNum; 89 | if (self._signalWatcher != null) { 90 | self._signalWatcher(); 91 | self._signalWatcher = null; 92 | } 93 | } 94 | 95 | self.initialRender = render(0); 96 | 97 | ARCHIVAL.get().sub(async summaries => { 98 | await ingestTabs(summaries); 99 | await render(0); 100 | }); 101 | 102 | return self; 103 | } 104 | -------------------------------------------------------------------------------- /src/components/group.tsx: -------------------------------------------------------------------------------- 1 | import { BaseProps, h } from 'tsx-dom'; 2 | import { 3 | dateFromKey, 4 | eraseTabGroup, 5 | GrayTab, 6 | GrayTabGroup, 7 | keyFromGroup, 8 | saveTabGroup, 9 | } from '../lib/tabs_store'; 10 | import { GroupRowComponent } from './group_row'; 11 | 12 | interface GroupProps extends BaseProps { 13 | group: GrayTabGroup; 14 | removeCallback: () => void; 15 | } 16 | 17 | function groupFromDiv(self: GroupElement): GrayTabGroup { 18 | const date = dateFromKey(self.id); 19 | const group: GrayTabGroup = { 20 | date: date, 21 | tabs: [], 22 | }; 23 | const lis = self.querySelectorAll('li'); 24 | lis.forEach(li => { 25 | const a: HTMLAnchorElement = li.querySelector('a'); 26 | const tab: GrayTab = { 27 | key: Number(a.attributes.getNamedItem('data-key').value), 28 | url: a.href, 29 | title: a.innerText, 30 | }; 31 | group.tabs.push(tab); 32 | }); 33 | return group; 34 | } 35 | 36 | async function syncGroupFromDOM(self: GroupElement): Promise { 37 | const group = groupFromDiv(self); 38 | if (group.tabs.length == 0) { 39 | await eraseTabGroup(group.date); 40 | } else { 41 | await saveTabGroup(group); 42 | } 43 | return group; 44 | } 45 | 46 | async function childClickCallback(event: MouseEvent): Promise { 47 | let target = event.target as HTMLElement; 48 | let tail: HTMLElement = null; 49 | while (!target.classList.contains('tabGroup')) { 50 | tail = target; 51 | target = target.parentElement; 52 | } 53 | // now target is a
    and tail is an
  • 54 | target.removeChild(tail); 55 | const self = target.parentElement as GroupElement; 56 | const group = await syncGroupFromDOM(self); 57 | if (group.tabs.length == 0) { 58 | self.remove(); 59 | self.removeCallback(); 60 | } 61 | } 62 | 63 | interface GroupElement extends HTMLDivElement { 64 | removeCallback: () => void; 65 | } 66 | 67 | export function GroupComponent({ group, removeCallback }: GroupProps): GroupElement { 68 | const rows =
      ; 69 | const self = ( 70 |
      71 | {new Date(group.date).toLocaleString()} 72 | {rows} 73 |
      74 | ) as GroupElement; 75 | 76 | self.removeCallback = removeCallback; 77 | 78 | for (const tab of group.tabs) { 79 | rows.appendChild(); 80 | } 81 | 82 | return self; 83 | } 84 | -------------------------------------------------------------------------------- /src/components/group_row.tsx: -------------------------------------------------------------------------------- 1 | import { BaseProps, h } from 'tsx-dom'; 2 | import { BROWSER } from '../lib/globals'; 3 | import { GrayTab } from '../lib/tabs_store'; 4 | 5 | interface FaviconProps { 6 | url: string; 7 | } 8 | 9 | function FaviconComponent({ url }: FaviconProps): HTMLImageElement { 10 | const domain = new URL(url).hostname; 11 | let location = ''; 12 | if (domain) location = `https://www.google.com/s2/favicons?domain=${domain}`; 13 | return () as HTMLImageElement; 14 | } 15 | 16 | interface GroupRowProps extends BaseProps { 17 | tab: GrayTab; 18 | clickCallback: (event: MouseEvent) => Promise; 19 | } 20 | 21 | export function GroupRowComponent({ tab, clickCallback }: GroupRowProps): HTMLLIElement { 22 | const removal = async (event: MouseEvent): Promise => { 23 | event.preventDefault(); 24 | await Promise.all([ 25 | clickCallback(event), 26 | BROWSER.get().tabs.create({ url: tab.url, active: false }), 27 | ]); 28 | }; 29 | 30 | return ( 31 |
    • 32 | 38 |
    • 39 | ) as HTMLLIElement; 40 | } 41 | -------------------------------------------------------------------------------- /src/components/options.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'tsx-dom'; 2 | import { Options, setOptions } from '../lib/options'; 3 | 4 | export function setVisible(self: HTMLDivElement, visible: boolean): void { 5 | self.style.display = visible ? 'block' : 'none'; 6 | } 7 | 8 | interface OptionsProps { 9 | options: Options; 10 | closeCallback: () => void; 11 | } 12 | 13 | export function OptionsComponent({ options, closeCallback }: OptionsProps): HTMLDivElement { 14 | const optionsDupesCheckbox = ( 15 | 16 | ) as HTMLInputElement; 17 | optionsDupesCheckbox.onchange = async () => { 18 | await setOptions({ 19 | archiveDupes: optionsDupesCheckbox.checked, 20 | }); 21 | }; 22 | 23 | const optionsLimitTextbox = ( 24 | 25 | ) as HTMLInputElement; 26 | optionsLimitTextbox.onkeydown = (e): boolean => { 27 | return ( 28 | e.ctrlKey || 29 | e.altKey || 30 | (47 < e.keyCode && e.keyCode < 58 && e.shiftKey == false) || 31 | (95 < e.keyCode && e.keyCode < 106) || 32 | e.keyCode == 8 || 33 | e.keyCode == 9 || 34 | (e.keyCode > 34 && e.keyCode < 40) || 35 | e.keyCode == 46 36 | ); 37 | }; 38 | optionsLimitTextbox.onkeyup = async () => { 39 | const newLimit = Number(optionsLimitTextbox.value); 40 | if (newLimit != NaN) { 41 | await setOptions({ 42 | tabLimit: newLimit, 43 | }); 44 | } 45 | }; 46 | 47 | // HACK 48 | const groupsPerPageTextbox = ( 49 | 50 | ) as HTMLInputElement; 51 | groupsPerPageTextbox.onkeydown = (e): boolean => { 52 | return ( 53 | e.ctrlKey || 54 | e.altKey || 55 | (47 < e.keyCode && e.keyCode < 58 && e.shiftKey == false) || 56 | (95 < e.keyCode && e.keyCode < 106) || 57 | e.keyCode == 8 || 58 | e.keyCode == 9 || 59 | (e.keyCode > 34 && e.keyCode < 40) || 60 | e.keyCode == 46 61 | ); 62 | }; 63 | groupsPerPageTextbox.onkeyup = async () => { 64 | const newLimit = Number(groupsPerPageTextbox.value); 65 | if (newLimit != NaN) { 66 | await setOptions({ 67 | groupsPerPage: newLimit, 68 | }); 69 | } 70 | }; 71 | 72 | const content = ( 73 |
      74 |
      75 |
      76 | Options 77 | 78 | {optionsLimitTextbox} 79 | 80 | How many tabs to keep. Older tab groups are removed to keep you under this limit. 81 | 82 | 83 | {groupsPerPageTextbox} 84 | How many tab groups to load per page. 85 | 86 | 92 | 93 | If checked, identical tabs will not de-duplicate on archival. 94 | 95 |
      96 | Info 97 | 98 | Source Code (on GitHub) 99 | 100 |
      101 |
      102 | ) as HTMLDivElement; 103 | 104 | const modal = ( 105 | 108 | ) as HTMLDivElement; 109 | 110 | modal.onclick = (event: MouseEvent) => { 111 | if (!content.contains(event.target as HTMLElement)) { 112 | setVisible(modal, false); 113 | closeCallback(); 114 | } 115 | }; 116 | return modal; 117 | } 118 | -------------------------------------------------------------------------------- /src/components/paginator.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'tsx-dom'; 2 | import { clamp } from '../lib/utils'; 3 | 4 | interface PaginatorButtonComponentProps { 5 | clickCallback: () => void; 6 | active: boolean; 7 | innerText: string; 8 | } 9 | 10 | function PaginatorButtonComponent({ 11 | clickCallback, 12 | active, 13 | innerText, 14 | }: PaginatorButtonComponentProps): HTMLAnchorElement { 15 | const click = (event: MouseEvent): void => { 16 | event.preventDefault(); 17 | clickCallback(); 18 | }; 19 | return ( 20 | 21 | {innerText} 22 | 23 | ) as HTMLAnchorElement; 24 | } 25 | 26 | interface PaginatorProps { 27 | pages: number; 28 | currentPage: number; // 0 indexed 29 | selectCallback: (page: number) => void; 30 | } 31 | 32 | export function PaginatorComponent({ 33 | pages, 34 | currentPage, 35 | selectCallback, 36 | }: PaginatorProps): HTMLDivElement { 37 | currentPage = clamp(currentPage, 0, pages - 1); 38 | const container = (