├── src ├── core │ ├── shared │ │ ├── utils │ │ │ ├── entity.ts │ │ │ ├── storageUtil.ts │ │ │ ├── valueObject.ts │ │ │ ├── cache.ts │ │ │ ├── importExportUtil.ts │ │ │ └── index.ts │ │ ├── constants │ │ │ └── index.ts │ │ └── typings │ │ │ └── index.ts │ ├── repos │ │ ├── index.ts │ │ ├── tabRepo.ts │ │ └── implementations │ │ │ └── tabRepo.ts │ ├── errors │ │ ├── chromeAction │ │ │ └── StoreTabsError.ts │ │ └── tab │ │ │ └── InvalidDescriptionError.ts │ ├── services │ │ ├── index.ts │ │ ├── chromeActionService.ts │ │ └── tabService.ts │ ├── useCase │ │ ├── chromeActionUseCase.ts │ │ └── tabUseCase.ts │ └── factory │ │ ├── tabList.ts │ │ └── tabSimple.ts ├── test │ ├── fixtures │ │ ├── tabLists.json │ │ ├── tabOriginalData.ts │ │ ├── oneTab.txt │ │ ├── tabListData.ts │ │ └── tabService │ │ │ └── uniqueAllTabList.ts │ ├── __mocks__ │ │ ├── styleMock.ts │ │ └── chromeMock.ts │ ├── setup.ts │ ├── build.spec.ts │ ├── utils │ │ └── helpers.ts │ └── unit │ │ ├── core │ │ ├── factory │ │ │ └── tabSimple.spec.ts │ │ └── services │ │ │ └── tabService.spec.ts │ │ └── shared │ │ └── utils.test.ts ├── assets │ ├── tabX.png │ ├── favicon.ico │ ├── TabX_dark.png │ ├── icons │ │ ├── 128.png │ │ ├── 16.png │ │ └── 48.png │ └── TabX_light.png ├── ui │ ├── utils │ │ ├── types.d.ts │ │ ├── index.ts │ │ ├── producer.ts │ │ └── tabListTitle.ts │ ├── components │ │ ├── Load.tsx │ │ ├── FaviconImage.tsx │ │ ├── DeleteButton.tsx │ │ ├── Settings │ │ │ ├── style.ts │ │ │ ├── Languages │ │ │ │ └── index.tsx │ │ │ └── Tabs │ │ │ │ └── index.tsx │ │ ├── Header │ │ │ ├── Menu │ │ │ │ ├── index.tsx │ │ │ │ ├── style.ts │ │ │ │ └── MenuContent │ │ │ │ │ └── index.tsx │ │ │ ├── style.ts │ │ │ └── index.tsx │ │ ├── List │ │ │ ├── TabList │ │ │ │ ├── style.ts │ │ │ │ ├── TabListContainer.tsx │ │ │ │ └── internal │ │ │ │ │ ├── TabListMenuContent.tsx │ │ │ │ │ └── TabListHeader.tsx │ │ │ ├── TabSimpleLink │ │ │ │ ├── TabLinkOps │ │ │ │ │ ├── style.ts │ │ │ │ │ └── index.tsx │ │ │ │ └── index.tsx │ │ │ └── TabLinks │ │ │ │ └── style.ts │ │ ├── MenuItem.tsx │ │ └── Layout │ │ │ ├── style.ts │ │ │ └── index.tsx │ ├── hooks │ │ ├── index.ts │ │ ├── useHasLoaded.ts │ │ ├── useMouseOver.ts │ │ ├── useLoadMore.ts │ │ ├── useFuse.ts │ │ └── useLocalStorage.ts │ ├── index.tsx │ ├── stores │ │ ├── lang.ts │ │ ├── tabs.ts │ │ ├── colorTheme.ts │ │ ├── tabList.ts │ │ └── tabLists.ts │ ├── constants │ │ ├── index.ts │ │ └── styles.ts │ ├── router │ │ └── Routes.tsx │ ├── App.tsx │ ├── locales │ │ ├── ja.json │ │ └── en.json │ └── pages │ │ ├── Settings │ │ └── index.tsx │ │ └── List │ │ └── index.tsx ├── shared │ └── @types │ │ └── globals.d.ts ├── public │ └── index.html ├── background.ts └── manifest.json ├── .firebaserc ├── .gitignore ├── functions ├── .gitignore ├── tsconfig.json ├── package.json ├── src │ └── index.ts └── tslint.json ├── generate_icons.sh ├── firebase.json ├── .stylelintrc ├── .github └── workflows │ └── main.yml ├── webpack.dev.js ├── .eslintrc.yml ├── .prettierrc.yml ├── LICENSE ├── webpack.config.js ├── CLAUDE.md ├── README.md ├── package.json ├── tsconfig.json └── jest.config.js /src/core/shared/utils/entity.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/test/fixtures/tabLists.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/test/__mocks__/styleMock.ts: -------------------------------------------------------------------------------- 1 | module.exports = {} 2 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "tabx-572fb" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/assets/tabX.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unvalley/TabX/HEAD/src/assets/tabX.png -------------------------------------------------------------------------------- /src/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unvalley/TabX/HEAD/src/assets/favicon.ico -------------------------------------------------------------------------------- /src/assets/TabX_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unvalley/TabX/HEAD/src/assets/TabX_dark.png -------------------------------------------------------------------------------- /src/assets/icons/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unvalley/TabX/HEAD/src/assets/icons/128.png -------------------------------------------------------------------------------- /src/assets/icons/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unvalley/TabX/HEAD/src/assets/icons/16.png -------------------------------------------------------------------------------- /src/assets/icons/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unvalley/TabX/HEAD/src/assets/icons/48.png -------------------------------------------------------------------------------- /src/assets/TabX_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unvalley/TabX/HEAD/src/assets/TabX_light.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | dist.zip 3 | node_modules 4 | lib 5 | .DS_Store 6 | yarn-error.log 7 | coverage 8 | .env -------------------------------------------------------------------------------- /src/core/repos/index.ts: -------------------------------------------------------------------------------- 1 | import { TabRepo } from './implementations/tabRepo' 2 | 3 | export const tabRepo = new TabRepo() 4 | -------------------------------------------------------------------------------- /src/ui/utils/types.d.ts: -------------------------------------------------------------------------------- 1 | export type DeepPartial = { 2 | [P in keyof T]?: T[P] extends Record ? DeepPartial : T[P] 3 | } 4 | -------------------------------------------------------------------------------- /functions/.gitignore: -------------------------------------------------------------------------------- 1 | ## Compiled JavaScript files 2 | **/*.js 3 | **/*.js.map 4 | 5 | # Typescript v1 declaration files 6 | typings/ 7 | 8 | node_modules/ -------------------------------------------------------------------------------- /generate_icons.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | for size in 16 48 128 4 | do 5 | convert tabX.png -resize ${size}x -unsharp 1.5x1+0.7+0.02 src/assets/icons/${size}.png 6 | done 7 | -------------------------------------------------------------------------------- /src/ui/components/Load.tsx: -------------------------------------------------------------------------------- 1 | import { Loading } from '@geist-ui/react' 2 | import React from 'react' 3 | 4 | export const Load: React.FC = () => 5 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": { 3 | "predeploy": [ 4 | "npm --prefix \"$RESOURCE_DIR\" run lint", 5 | "npm --prefix \"$RESOURCE_DIR\" run build" 6 | ] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "processors": ["stylelint-processor-styled-components"], 3 | "extends": [ 4 | "stylelint-config-recommended", 5 | "stylelint-config-styled-components" 6 | ] 7 | } -------------------------------------------------------------------------------- /src/test/__mocks__/chromeMock.ts: -------------------------------------------------------------------------------- 1 | export const chrome = { 2 | storage: { 3 | local: { 4 | get: jest.fn(), 5 | }, 6 | }, 7 | extension: { 8 | connect: jest.fn(), 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /src/shared/@types/globals.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | interface ProcessEnv { 3 | readonly NODE_ENV: 'development' | 'production' | 'test' 4 | 5 | readonly REACT_APP_API_BASE_URL: string 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/core/errors/chromeAction/StoreTabsError.ts: -------------------------------------------------------------------------------- 1 | export class StoreTabsError extends Error { 2 | constructor(err: any) { 3 | super(`Couldn't store tabs: ${err.message}`) 4 | this.name = 'StoraTabsError' 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/core/errors/tab/InvalidDescriptionError.ts: -------------------------------------------------------------------------------- 1 | export class InvalidDescriptionError extends Error { 2 | constructor() { 3 | super(`Over the limit description text length`) 4 | this.name = 'InvalidDescriptionError' 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/core/repos/tabRepo.ts: -------------------------------------------------------------------------------- 1 | import { TabList } from '../shared/typings' 2 | 3 | export interface ITabRepo { 4 | getAllTabList(): Promise 5 | setAllTabList(allTabList: TabList[]): Promise 6 | deleteAllTabList(): Promise 7 | } 8 | -------------------------------------------------------------------------------- /src/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | TabX 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /src/ui/hooks/index.ts: -------------------------------------------------------------------------------- 1 | import { useFuse } from './useFuse' 2 | import { useLoadMore } from './useLoadMore' 3 | import { useLocalStorage } from './useLocalStorage' 4 | import { useMouseOver } from './useMouseOver' 5 | 6 | export { useLoadMore, useLocalStorage, useMouseOver, useFuse } 7 | -------------------------------------------------------------------------------- /src/ui/hooks/useHasLoaded.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | export const useHasLoaded = () => { 4 | const [hasLoaded, setHasLoaded] = useState(false) 5 | 6 | useEffect(() => { 7 | setHasLoaded(true) 8 | }, []) 9 | 10 | return hasLoaded 11 | } 12 | -------------------------------------------------------------------------------- /src/ui/components/FaviconImage.tsx: -------------------------------------------------------------------------------- 1 | import { Image } from '@geist-ui/react' 2 | import React from 'react' 3 | 4 | type Props = { src: string; size?: number } 5 | export const FaviconImage: React.FC = ({ src, size = 25 }) => { 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /src/ui/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { RecoilRoot } from 'recoil' 4 | 5 | import { App } from './App' 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById('root'), 12 | ) 13 | -------------------------------------------------------------------------------- /src/core/services/index.ts: -------------------------------------------------------------------------------- 1 | import { tabRepo } from '../repos' 2 | import { ChromeActionService } from './chromeActionService' 3 | import { TabService } from './tabService' 4 | 5 | export const tabService = new TabService(tabRepo) 6 | export const chromeActionService = new ChromeActionService(tabService) 7 | -------------------------------------------------------------------------------- /src/test/setup.ts: -------------------------------------------------------------------------------- 1 | import chrome from 'sinon-chrome' 2 | 3 | export const fixChromeWebextensionPolyfill = () => { 4 | if (!chrome.runtime) chrome.runtime = {} as any 5 | if (!chrome.runtime.id) chrome.runtime.id = 'tabX' 6 | } 7 | 8 | fixChromeWebextensionPolyfill() 9 | ;(global as any).chrome = chrome 10 | -------------------------------------------------------------------------------- /src/core/shared/utils/storageUtil.ts: -------------------------------------------------------------------------------- 1 | import { Mutex } from 'async-mutex' 2 | import { browser } from 'webextension-polyfill-ts' 3 | 4 | export const mutex = new Mutex() 5 | 6 | export const getStorage = (key: string) => browser.storage.local.get(key) 7 | export const setStorage = (obj: Record) => browser.storage.local.set(obj) 8 | -------------------------------------------------------------------------------- /src/core/shared/utils/valueObject.ts: -------------------------------------------------------------------------------- 1 | export abstract class ValueObject { 2 | protected constructor(protected readonly val: T) {} 3 | 4 | get(): T { 5 | return this.val 6 | } 7 | 8 | equals(vo: ValueObject): boolean { 9 | if (this.constructor.name !== vo.constructor.name) return false 10 | return this.get() === vo.get() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/test/build.spec.ts: -------------------------------------------------------------------------------- 1 | import puppeteer from 'puppeteer' 2 | 3 | import { launchPuppeteerWithExtension } from './utils/helpers' 4 | 5 | describe('install', () => { 6 | test('it installs the extension', async () => { 7 | const browser = await launchPuppeteerWithExtension(puppeteer) 8 | expect(browser).toBeTruthy() 9 | browser.close() 10 | }, 5000) 11 | }) 12 | -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | import { browser } from 'webextension-polyfill-ts' 2 | 3 | import { chromeActionService } from './core/services' 4 | 5 | export const init = async () => { 6 | await Promise.all([ 7 | browser.browserAction.onClicked.addListener(async () => { 8 | await chromeActionService.storeAllTabs().catch(err => console.error(err)) 9 | }), 10 | ]) 11 | } 12 | 13 | init() 14 | -------------------------------------------------------------------------------- /functions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "noImplicitReturns": true, 5 | "noUnusedLocals": true, 6 | "outDir": "lib", 7 | "sourceMap": true, 8 | "strict": true, 9 | "target": "es2017", 10 | "typeRoots": ["node_modules/@types"] 11 | }, 12 | "compileOnSave": true, 13 | "include": ["src"], 14 | "exclude": ["node_modules"] 15 | } 16 | -------------------------------------------------------------------------------- /src/core/shared/utils/cache.ts: -------------------------------------------------------------------------------- 1 | import { TAB_LISTS } from '../constants' 2 | import { TabList } from '../typings' 3 | import { eq, when } from '.' 4 | 5 | export const cache = { tabLists: [] as TabList[] } 6 | export const saveCache = (storageKey: string, lists: TabList[]) => 7 | when(storageKey).on(eq(TAB_LISTS), () => (cache.tabLists = lists as TabList[])) 8 | 9 | export const loadCache = () => cache.tabLists 10 | -------------------------------------------------------------------------------- /src/ui/stores/lang.ts: -------------------------------------------------------------------------------- 1 | import { atom, selector } from 'recoil' 2 | 3 | import { Lang } from '~/ui/constants' 4 | 5 | export const langState = atom({ 6 | key: 'langState', 7 | default: selector({ 8 | key: 'langState/default', 9 | get: () => { 10 | const lang = localStorage.getItem('lang') 11 | if (lang === null) return Lang.ENGLISH 12 | return lang 13 | }, 14 | }), 15 | }) 16 | -------------------------------------------------------------------------------- /src/ui/components/DeleteButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@geist-ui/react' 2 | import { AlertCircle } from '@geist-ui/react-icons' 3 | import React from 'react' 4 | 5 | type Props = { onClick: () => void; label?: string } 6 | 7 | export const DeleteButton: React.VFC = ({ onClick, label = 'Delete All Tabs' }: Props) => ( 8 | 11 | ) 12 | -------------------------------------------------------------------------------- /src/ui/components/Settings/style.ts: -------------------------------------------------------------------------------- 1 | import { Toggle } from '@geist-ui/react' 2 | import styled from 'styled-components' 3 | 4 | import { Spacing } from '~/ui/constants/styles' 5 | 6 | export const ToggleWrapper = styled.div` 7 | display: inline-flex; 8 | flex-wrap: nowrap; 9 | align-items: center; 10 | justify-content: center; 11 | ` 12 | 13 | export const _Toggle = styled(Toggle).attrs({ 14 | size: 'large', 15 | })` 16 | margin: ${Spacing['2']}; 17 | ` 18 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | lint: 9 | name: Lint check 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@master 13 | - uses: actions/setup-node@v1 14 | with: 15 | node-version: 12 16 | - name: yarn install 17 | run: yarn install 18 | - name: run eslint 19 | run: yarn lint 20 | - name: run jest 21 | run: yarn jest 22 | -------------------------------------------------------------------------------- /src/ui/stores/tabs.ts: -------------------------------------------------------------------------------- 1 | import { atom, selector } from 'recoil' 2 | 3 | import { tabService } from '~/core/services' 4 | import { TabSimple } from '~/core/shared/typings' 5 | 6 | export const tabsState = atom({ 7 | key: 'tabsState', 8 | default: selector({ 9 | key: 'tabsState/Default', 10 | get: async () => { 11 | const lists = await tabService.getAllSimpleTab() 12 | if (typeof lists === 'undefined') return [] 13 | return lists 14 | }, 15 | }), 16 | }) 17 | -------------------------------------------------------------------------------- /src/ui/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const Lang = { 2 | ENGLISH: 'en', 3 | JAPANESE: 'ja', 4 | } as const 5 | 6 | export const REPO_NAME = 'unvalley/TabX' 7 | export const BUY_ME_A_COFFEE = 'Buy me a Coffee' 8 | 9 | export const URL = { 10 | ROOT: '/', 11 | SETTINGS: '/settings', 12 | } as const 13 | 14 | export const STORAGE_KEYS = { 15 | IS_VISIBLE_TAB_LIST_MENU: 'isVisibleTabListMenu', 16 | IS_VISIBLE_TAB_LIST_HEADER: 'isVisibleTabListHeader', 17 | SHOULD_DELETE_TAB_WHEN_CLICKED: 'shouldDeleteTabWhenClicked', 18 | } as const 19 | -------------------------------------------------------------------------------- /src/test/utils/helpers.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | 3 | const extensionPath = path.join(__dirname, '../dist') 4 | 5 | export const launchPuppeteerWithExtension = function (puppeteer: any) { 6 | const options = { 7 | headless: true, 8 | ignoreHTTPSErrors: true, 9 | devtools: true, 10 | args: [ 11 | `--disable-extensions-except=${extensionPath}`, 12 | `--load-extension=${extensionPath}`, 13 | '--no-sandbox', 14 | '--disable-setuid-sandbox', 15 | ], 16 | } 17 | 18 | return puppeteer.launch(options) 19 | } 20 | -------------------------------------------------------------------------------- /src/ui/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { IO } from 'fp-ts/lib/IO' 2 | 3 | import { APP_NAME } from '~/core/shared/constants' 4 | import { Themes } from '~/ui/constants/styles' 5 | 6 | export const getNowYMD: IO = () => { 7 | const dt = new Date() 8 | const y = dt.getFullYear() 9 | const m = ('00' + (dt.getMonth() + 1)).slice(-2) 10 | const d = ('00' + dt.getDate()).slice(-2) 11 | return y + m + d 12 | } 13 | 14 | export const exportedJSONFileName = `${APP_NAME}_${getNowYMD()}.json` 15 | export const isDark = (themeType: string) => themeType === Themes.DARK 16 | -------------------------------------------------------------------------------- /src/core/useCase/chromeActionUseCase.ts: -------------------------------------------------------------------------------- 1 | import { Tabs } from 'webextension-polyfill-ts' 2 | 3 | import { TabList, TabSimple } from '../shared/typings' 4 | 5 | export interface IChromeActionUseCase { 6 | getAllInWindow(windowId?: number): Promise 7 | getAllTabsInCurrentWindow(): Promise 8 | closeAllTabs(tabs: Tabs.Tab[]): Promise 9 | openTabLists(): Promise 10 | storeTabs(tabs: Tabs.Tab[]): Promise 11 | storeAllTabs(): Promise 12 | restoreTabs(tabs: TabSimple[]): Promise 13 | } 14 | -------------------------------------------------------------------------------- /src/ui/hooks/useMouseOver.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export const useMouseOver = () => { 4 | const [mouseOver, setMouseOver] = React.useState({ hover: false, index: 0 }) 5 | 6 | const handleMouseOver = (index: number) => { 7 | setMouseOver({ hover: true, index }) 8 | } 9 | 10 | const handleMouseOut = () => { 11 | setMouseOver({ hover: false, index: -1 }) 12 | } 13 | 14 | const isMouseOvered = (index: number) => mouseOver.hover === true && mouseOver.index === index 15 | 16 | return { handleMouseOver, handleMouseOut, isMouseOvered } 17 | } 18 | -------------------------------------------------------------------------------- /src/ui/components/Header/Menu/index.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from '@geist-ui/react' 2 | import React from 'react' 3 | 4 | import { MenuContent } from './MenuContent' 5 | import { _MoreVerticalIcon, _Popover } from './style' 6 | 7 | export const Menu: React.VFC = () => { 8 | const theme = useTheme() 9 | const popoverColor = theme.palette.foreground 10 | const popoverBgColor = theme.palette.accents_2 11 | 12 | return ( 13 | <_Popover content={} $color={popoverColor} $bgColor={popoverBgColor}> 14 | <_MoreVerticalIcon /> 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/ui/components/List/TabList/style.ts: -------------------------------------------------------------------------------- 1 | import { Row } from '@geist-ui/react' 2 | import styled from 'styled-components' 3 | 4 | export const TabListWrapper = styled.div` 5 | width: 100%; 6 | min-width: 80%; 7 | ` 8 | 9 | export const _Row = styled(Row).attrs({ 10 | component: 'span', 11 | })` 12 | padding-left: 100px !important; 13 | margin-left: -100px !important; 14 | width: calc(100% + 100px); 15 | ` 16 | export const HoveredMenu = styled.div` 17 | margin-left: -100px; 18 | float: left; 19 | width: 100px; 20 | text-align: right; 21 | vertical-align: top; 22 | ` 23 | -------------------------------------------------------------------------------- /src/ui/components/MenuItem.tsx: -------------------------------------------------------------------------------- 1 | import { Popover } from '@geist-ui/react' 2 | import React from 'react' 3 | import styled from 'styled-components' 4 | 5 | import { Spacing } from '~/ui/constants/styles' 6 | 7 | const Label = styled.span` 8 | margin-left: ${Spacing['2']} !important; 9 | ` 10 | 11 | type Props = { onClick: () => void; label: string; icon: JSX.Element } 12 | export const MenuItem: React.FC = props => ( 13 | 14 | {props.icon} 15 | 16 | 17 | ) 18 | -------------------------------------------------------------------------------- /src/ui/stores/colorTheme.ts: -------------------------------------------------------------------------------- 1 | import { atom, selector } from 'recoil' 2 | 3 | import { Themes } from '../constants/styles' 4 | 5 | const isSystemColorDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches 6 | 7 | export const colorThemeState = atom({ 8 | key: 'colorThemeState', 9 | default: selector({ 10 | key: 'colorThemeState/default', 11 | get: () => { 12 | const theme = localStorage.getItem('theme') 13 | if (theme === null) return isSystemColorDark ? Themes.DARK : Themes.LIGHT 14 | return theme 15 | }, 16 | }), 17 | }) 18 | -------------------------------------------------------------------------------- /src/ui/router/Routes.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { MemoryRouter, Route, Switch } from 'react-router-dom' 3 | 4 | import { ContentLayout } from '../components/Layout' 5 | import { List } from '../pages/List' 6 | import { Settings } from '../pages/Settings' 7 | 8 | export const Routes = () => { 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/ui/components/Layout/style.ts: -------------------------------------------------------------------------------- 1 | import { Grid } from '@geist-ui/react' 2 | import styled from 'styled-components' 3 | 4 | import { Spacing } from '~/ui/constants/styles' 5 | 6 | // FIXME 7 | export const ContentWrapper: any = styled(Grid.Container)<{ bg: string }>` 8 | margin: 0px 100px; 9 | display: flex; 10 | min-height: 100vh; 11 | position: relative; 12 | background-color: ${({ bg }) => bg}; 13 | ` 14 | 15 | export const MainContainer = styled.main` 16 | margin: ${Spacing['5']} auto; 17 | max-width: 70%; 18 | flex: 1 1 0%; 19 | order: 2; 20 | position: relative; 21 | flex-direction: column; 22 | padding-bottom: 8rem; 23 | ` 24 | -------------------------------------------------------------------------------- /src/core/shared/utils/importExportUtil.ts: -------------------------------------------------------------------------------- 1 | import { ImportedUrlObj } from '../typings' 2 | 3 | export const createImportedUrlObjs = (singleLines: string[]) => { 4 | const res: ImportedUrlObj[][] = [[]] 5 | const isEmptyLine = (s: string) => s === '' 6 | 7 | singleLines.forEach(singleLine => { 8 | const urlObj = genImportedUrlObj(singleLine) 9 | isEmptyLine(singleLine) ? res.push([]) : res[res.length - 1].push(urlObj) 10 | }) 11 | return res 12 | } 13 | 14 | const genImportedUrlObj = (singleLine: string): ImportedUrlObj => { 15 | const urlAndTitle = singleLine.split('|') 16 | return { url: urlAndTitle[0], title: urlAndTitle[1] } 17 | } 18 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge') 2 | const path = require('path') 3 | const base = require('./webpack.config.js') 4 | const ExtensionReloader = require('webpack-extension-reloader') 5 | 6 | module.exports = merge(base, { 7 | mode: 'development', 8 | watch: true, 9 | entry: { 10 | background: './src/background.ts', 11 | }, 12 | devtool: 'source-map', 13 | plugins: [ 14 | new ExtensionReloader({ 15 | manifest: path.resolve(__dirname, './src/manifest.json'), 16 | port: 3001, 17 | reloadPage: true, 18 | entries: { 19 | background: 'background', 20 | }, 21 | }), 22 | ], 23 | }) 24 | -------------------------------------------------------------------------------- /src/ui/components/List/TabSimpleLink/TabLinkOps/style.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | import { Spacing } from '~/ui/constants/styles' 4 | 5 | export const OpsWrapper = styled.section<{ opacity: number }>` 6 | opacity: ${({ opacity }) => opacity}; 7 | transition: all 0.3s ease; 8 | ` 9 | 10 | export const OpsElement = styled.span<{ bg: string }>` 11 | cursor: pointer; 12 | font-size: 12px; 13 | vertical-align: middle; 14 | padding: ${Spacing['0.5']}; 15 | margin-left: 2px; 16 | border-radius: 33px; 17 | z-index: 2; 18 | &:hover { 19 | background-color: ${({ bg }) => bg}; 20 | box-shadow: 10; 21 | opacity: 0.9; 22 | } 23 | ` 24 | -------------------------------------------------------------------------------- /src/ui/components/Header/Menu/style.ts: -------------------------------------------------------------------------------- 1 | import { Popover } from '@geist-ui/react' 2 | import { MoreVertical } from '@geist-ui/react-icons' 3 | import styled from 'styled-components' 4 | 5 | import { Spacing } from '~/ui/constants/styles' 6 | 7 | export const _MoreVerticalIcon = styled(MoreVertical)` 8 | border-radius: 20px; 9 | ` 10 | 11 | export const _Popover = styled(Popover)<{ $color: string; $bgColor: string }>` 12 | cursor: pointer; 13 | border-radius: 50%; 14 | padding: ${Spacing['2']}; 15 | transition: all 0.3s ease; 16 | line-height: 0; 17 | &:hover { 18 | color: ${props => props.color}; 19 | background-color: ${props => props.$bgColor}; 20 | } 21 | ` 22 | -------------------------------------------------------------------------------- /src/test/fixtures/tabOriginalData.ts: -------------------------------------------------------------------------------- 1 | import { Tabs } from 'webextension-polyfill-ts' 2 | 3 | export const FixutreTab = (url?: string) => { 4 | return { 5 | id: 1, 6 | index: 1, 7 | highlighted: false, 8 | active: true, 9 | pinned: false, 10 | lastAccessed: 1, 11 | url: url, 12 | title: 'example', 13 | favIconUrl: '', 14 | status: '', 15 | discarded: false, 16 | incognito: false, 17 | width: 1, 18 | height: 1, 19 | hidden: false, 20 | sessionId: '', 21 | cookieStoreId: '', 22 | isArticle: false, 23 | isInReaderMode: false, 24 | attention: false, 25 | successorTabId: 1, 26 | autoDiscardable: false, 27 | } as Tabs.Tab 28 | } 29 | -------------------------------------------------------------------------------- /src/core/useCase/tabUseCase.ts: -------------------------------------------------------------------------------- 1 | import { TabList, TabSimple } from '../shared/typings' 2 | 3 | export interface ITabUseCase { 4 | getAllTabList(): Promise 5 | getAllSimpleTab(): Promise 6 | setAllTabList(allTabList: TabList[]): Promise 7 | addTabList(newTabList: TabList): Promise 8 | addAllTabList(newAllTabList: TabList[]): Promise 9 | deleteTabSimple(tabListId: number, tabId: number): Promise 10 | deleteTabList(tabListId: number): Promise 11 | importFromText(urlText: string): Promise 12 | exportToText(): Promise 13 | uniqueAllTabList(): Promise 14 | saveTabListDescription(description: string, tabListId: number): Promise 15 | } 16 | -------------------------------------------------------------------------------- /src/ui/components/Header/style.ts: -------------------------------------------------------------------------------- 1 | import { Text } from '@geist-ui/react' 2 | import styled from 'styled-components' 3 | 4 | import { Spacing } from '~/ui/constants/styles' 5 | 6 | export const PageHeaderText = styled(Text).attrs({ 7 | size: '1.5rem', 8 | })` 9 | display: inline-block; 10 | font-weight: bold; 11 | &:hover { 12 | color: #0d7fe0; 13 | } 14 | ` 15 | 16 | export const _Div = styled.div<{ color: string; bgColor: string }>` 17 | cursor: pointer; 18 | border-radius: 50%; 19 | padding: ${Spacing['2']}; 20 | vertical-align: middle; 21 | line-height: 0; 22 | display: inline-block; 23 | transition: all 0.3s ease; 24 | &:hover { 25 | color: ${({ color }) => color}; 26 | background-color: ${({ bgColor }) => bgColor}; 27 | } 28 | ` 29 | -------------------------------------------------------------------------------- /src/ui/constants/styles.ts: -------------------------------------------------------------------------------- 1 | export const Colors = { 2 | BACKGROUND: '#232956', 3 | BUTTON_TEXT: '#232946', 4 | HEADLINE: '#fffffe', 5 | MOON_DARK: '#222', 6 | PARAGRAPH: '#222', 7 | SHADOW: '#2d81b121', 8 | SUN_LIGHT: '#fff', 9 | WHITE: '#fff', 10 | } as const 11 | 12 | export const Spacing = { 13 | '0': '0px', 14 | '0.5': '4px', 15 | '1': '8px', 16 | '2': '12px', 17 | '3': '16px', 18 | '4': '24px', 19 | '5': '32px', 20 | '6': '48px', 21 | } as const 22 | 23 | export const Rule = { 24 | MENU_ICON_SIZE: 20, 25 | SHOW_TAB_TITLE_LENGTH: 50, 26 | TAB_LINKS_ELEM_SIZE: 15, 27 | HEADER_TITLE_MAX_LENGTH: 35, 28 | TITLE_MAX_LENGTH: 50, 29 | } as const 30 | 31 | export const Themes = { 32 | DARK: 'dark', 33 | LIGHT: 'light', 34 | } as const 35 | -------------------------------------------------------------------------------- /src/ui/components/List/TabSimpleLink/TabLinkOps/index.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from '@geist-ui/react' 2 | import React from 'react' 3 | 4 | import { isDark } from '~/ui/utils' 5 | 6 | import { OpsElement, OpsWrapper } from './style' 7 | 8 | type Props = { 9 | tabId: number 10 | onClick: (tabId: number) => Promise 11 | isVisible: boolean 12 | } 13 | 14 | export const TabLinkOps: React.FC = ({ tabId, isVisible, onClick, children }) => { 15 | const theme = useTheme() 16 | const opsElemBg = isDark(theme.type) ? theme.palette.accents_2 : theme.palette.accents_1 17 | return ( 18 | 19 | onClick(tabId)}> 20 | {children} 21 | 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/core/shared/constants/index.ts: -------------------------------------------------------------------------------- 1 | // Validation for URLs 2 | export const ILLEGAL_URLS = ['about:', 'wss:', 'ws:', 'chrome:'] 3 | export const PICKED_TAB_PROPS = ['url', 'title', 'favIconUrl', 'pinned'] as const 4 | 5 | export const DEFAULT_TITLE = 'untitled' 6 | export const TAB_LISTS = 'tabLists' 7 | export const APP_NAME = 'TabX' 8 | 9 | const TWITTER_TEXT = 'TabX - 🪣 A simple tab management tool' 10 | const WEB_STORE_URL = 'https://chrome.google.com/webstore/detail/tabx/pnomgepiknocmkmncjkcchojfiookljb?hl=ja' 11 | export const TWITTER_URL = `https://twitter.com/share?text=${TWITTER_TEXT}&url=${WEB_STORE_URL}` 12 | 13 | export const FEEDBACK_URL = 'https://github.com/unvalley/TabX/discussions' 14 | export const DONATION_URL = '' 15 | 16 | export const API_BASE_URL = process.env.REACT_APP_API_BASE_URL 17 | -------------------------------------------------------------------------------- /src/core/shared/typings/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Basic Tab Type 3 | */ 4 | export type TabSimple = { 5 | id: number 6 | title: string 7 | /** 8 | * page description 9 | */ 10 | description?: string 11 | pinned: boolean 12 | favorite: boolean 13 | lastAccessed: number 14 | url: string 15 | favIconUrl: string 16 | /** 17 | * page ogp url 18 | */ 19 | ogImageUrl: string 20 | domain?: string 21 | } 22 | 23 | /** 24 | * Each Tab Group 25 | */ 26 | export type TabList = { 27 | id: number 28 | title: string 29 | description: string 30 | tabs: TabSimple[] 31 | hasPinned: boolean 32 | createdAt: number 33 | updatedAt: number 34 | } 35 | 36 | /** 37 | * for import (text) feature 38 | */ 39 | export type ImportedUrlObj = { 40 | url: string 41 | title: string 42 | } 43 | -------------------------------------------------------------------------------- /src/core/repos/implementations/tabRepo.ts: -------------------------------------------------------------------------------- 1 | import { TabList } from '~/core/shared/typings' 2 | import { getStorage, setStorage } from '~/core/shared/utils/storageUtil' 3 | 4 | import { ITabRepo } from '../tabRepo' 5 | 6 | type TabLists = 'tabLists' 7 | 8 | export class TabRepo implements ITabRepo { 9 | private keyName: TabLists = 'tabLists' 10 | 11 | public async getAllTabList() { 12 | const result = await getStorage(this.keyName).then(data => 13 | Array.isArray(data[this.keyName]) ? (data[this.keyName] as TabList[]) : [], 14 | ) 15 | return result 16 | } 17 | 18 | public setAllTabList(allTabList: TabList[]) { 19 | return setStorage({ [this.keyName]: allTabList }) 20 | } 21 | 22 | public deleteAllTabList() { 23 | return setStorage({ [this.keyName]: null }) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/ui/utils/producer.ts: -------------------------------------------------------------------------------- 1 | import produce, { Draft } from 'immer' 2 | 3 | import { TabList } from '~/core/shared/typings' 4 | 5 | /////////////////////////// 6 | // producer 7 | /////////////////////////// 8 | 9 | export const removeTabLink = (tabLists: TabList[], tabListId: number, tabId: number) => 10 | produce(tabLists, (draft: Draft) => { 11 | const targetTabList = draft.filter(list => list.id === tabListId)[0] 12 | const index = targetTabList.tabs.findIndex(({ id }) => id === tabId) 13 | targetTabList.tabs = targetTabList.tabs.filter((_, i) => i !== index) 14 | }) 15 | 16 | export const removeTab = (tabList: TabList, tabId: number): TabList => 17 | produce(tabList, (draft: Draft) => { 18 | const newTabs = draft.tabs.filter(tab => tab.id !== tabId) 19 | draft.tabs = newTabs 20 | }) 21 | -------------------------------------------------------------------------------- /src/ui/utils/tabListTitle.ts: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next' 2 | 3 | import { TabList } from '~/core/shared/typings' 4 | import { omitText } from '~/core/shared/utils' 5 | 6 | import { Lang } from '../constants' 7 | 8 | export const getDisplayTitle = (tabList: TabList, withLongText: boolean) => { 9 | const { i18n } = useTranslation() 10 | 11 | const maxLength = withLongText ? 40 : 25 12 | const firstTabLinkTitle = omitText(tabList.tabs[0] ? tabList.tabs[0].title : 'title')(maxLength)('…') 13 | const getTitle = (lang: string) => 14 | lang === Lang.JAPANESE 15 | ? `${firstTabLinkTitle}と${tabList.tabs.length - 1}件` 16 | : `${firstTabLinkTitle} & ${tabList.tabs.length - 1} tabs` 17 | 18 | const displayTitle = tabList.tabs.length > 1 ? getTitle(i18n.language) : `${firstTabLinkTitle}` 19 | return displayTitle 20 | } 21 | -------------------------------------------------------------------------------- /src/ui/components/Layout/index.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from '@geist-ui/react' 2 | import React from 'react' 3 | 4 | import { Spacing, Themes } from '~/ui/constants/styles' 5 | 6 | import { Load } from '../Load' 7 | import { ContentWrapper, MainContainer } from './style' 8 | 9 | export const ContentLayout: React.FC = props => { 10 | const theme = useTheme() 11 | const mainBgColor = theme.type === Themes.DARK ? theme.palette.background : '#f2f4fb' 12 | 13 | return ( 14 | 15 | 16 | 19 | 20 | 21 | } 22 | > 23 | {props.children} 24 | 25 | 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/test/fixtures/oneTab.txt: -------------------------------------------------------------------------------- 1 | https://github.com/unvalley/TabX | unvalley/TabX: TabX manages your all tabs. 2 | https://github.com/GoogleChrome/workbox | GoogleChrome/workbox: 📦 Workbox: JavaScript libraries for Progressive Web Apps 3 | https://github.com/aws/aws-sdk-go | aws/aws-sdk-go: AWS SDK for the Go programming language. 4 | 5 | https://qiita.com | Qiita 6 | https://dev.to/ | DEV Community 7 | https://zenn.dev | Zenn|エンジニアのための情報共有コミュニティ 8 | https://eh-career.com/engineerhub/ | エンジニアHub|若手Webエンジニアのキャリアを考える! 9 | 10 | https://doc.rust-jp.rs/rust-by-example-ja/ | Introduction - Rust By Example 日本語版 11 | https://www.rust-lang.org/ | Rust Programming Language 12 | https://crates.io/ | crates.io: The Rust community’s crate registry 13 | 14 | https://www.typescriptlang.org/ | TypeScript: Typed JavaScript at Any Scale. 15 | https://basarat.gitbook.io/typescript/ | Introduction - TypeScript Deep Dive -------------------------------------------------------------------------------- /src/core/factory/tabList.ts: -------------------------------------------------------------------------------- 1 | import { Tabs } from 'webextension-polyfill-ts' 2 | 3 | import { TabList, TabSimple } from '../shared/typings' 4 | import { genObjectId, nonNullable } from '../shared/utils' 5 | import { normalizeTab } from './tabSimple' 6 | 7 | export const createNewTabList = (tabs: Tabs.Tab[]): TabList => ({ 8 | id: genObjectId(), 9 | title: '', 10 | description: '', 11 | // has pinned on this extension? - default false 12 | hasPinned: false, 13 | createdAt: Date.now(), 14 | updatedAt: Date.now(), 15 | tabs: tabs.map(normalizeTab).filter(nonNullable) || [], 16 | }) 17 | 18 | export const createNewTabListFromImport = (tabs: TabSimple[]): TabList => ({ 19 | id: genObjectId(), 20 | title: '', 21 | description: '', 22 | // has pinned on this extension? - default false 23 | hasPinned: false, 24 | createdAt: Date.now(), 25 | updatedAt: Date.now(), 26 | tabs: tabs || [], 27 | }) 28 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | env: 3 | es2020: true 4 | browser: true 5 | jest/globals: true 6 | parser: '@typescript-eslint/parser' 7 | parserOptions: 8 | project: ./tsconfig.json 9 | ecmaFeatures: 10 | jsx: true 11 | plugins: 12 | - react 13 | - jest 14 | - '@typescript-eslint/eslint-plugin' 15 | - prettier 16 | - simple-import-sort 17 | settings: 18 | react: 19 | version: detect 20 | rules: 21 | '@typescript-eslint/explicit-function-return-type': off 22 | '@typescript-eslint/explicit-module-boundary-types': off 23 | '@typescript-eslint/no-unused-vars': off 24 | '@typescript-eslint/no-non-null-assertion': off 25 | react/prop-types: off 26 | simple-import-sort/imports: error 27 | extends: 28 | - eslint:recommended 29 | - plugin:@typescript-eslint/eslint-recommended 30 | - plugin:@typescript-eslint/recommended 31 | - plugin:react/recommended 32 | - plugin:jest/recommended 33 | - prettier 34 | - prettier/@typescript-eslint 35 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | 4 | "name": "TabX", 5 | "description": "🪣 A simple tab management tool", 6 | "version": "0.0.7", 7 | "icons": { 8 | "16": "assets/icons/16.png", 9 | "48": "assets/icons/48.png", 10 | "128": "assets/icons/128.png" 11 | }, 12 | 13 | "browser_action": { 14 | "default_icon": { 15 | "16": "assets/icons/16.png", 16 | "48": "assets/icons/48.png", 17 | "128": "assets/icons/128.png" 18 | } 19 | }, 20 | 21 | "background": { 22 | "scripts": ["background.js"] 23 | }, 24 | 25 | "commands": { 26 | "store-all-tabs": { 27 | "description": "__MSG_store_all_tabs__", 28 | "global": true 29 | } 30 | }, 31 | 32 | "permissions": ["storage", "tabs"], 33 | 34 | "content_security_policy": "script-src 'self' 'unsafe-eval' https://www.google-analytics.com https://apis.google.com __DEV_CSP__; object-src 'self'", 35 | "offline_enabled": true 36 | } 37 | -------------------------------------------------------------------------------- /src/ui/hooks/useLoadMore.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | export const useLoadMore = (loadCount = 5, items: T[]) => { 4 | const [limit, setLimit] = useState(loadCount) 5 | const [itemsToShow, setItemsToShow] = useState([]) 6 | const [loading, setLoading] = useState(false) 7 | 8 | const loopWithSlice = (start: number, end: number) => { 9 | const slicedItems = items.slice(start, end) 10 | const newItems = [...itemsToShow, ...slicedItems] 11 | setItemsToShow(newItems) 12 | } 13 | 14 | useEffect(() => { 15 | loopWithSlice(0, loadCount) 16 | }, []) 17 | 18 | const handleShowMoreItems = () => { 19 | setLoading(true) 20 | 21 | loopWithSlice(limit, limit + loadCount) 22 | setLimit(limit + loadCount) 23 | 24 | setLoading(false) 25 | } 26 | 27 | const isMaxLength = itemsToShow.length === items.length 28 | 29 | return { itemsToShow, handleShowMoreItems, isMaxLength, loading } 30 | } 31 | -------------------------------------------------------------------------------- /src/ui/stores/tabList.ts: -------------------------------------------------------------------------------- 1 | import { atomFamily, selector, selectorFamily } from 'recoil' 2 | 3 | import { TabList } from '~/core/shared/typings' 4 | 5 | import { sortTabListsState, tabListsState } from './tabLists' 6 | 7 | /** 8 | * Children TabListState 9 | */ 10 | export const tabListState = atomFamily({ 11 | key: 'tabListState', 12 | default: selectorFamily({ 13 | key: 'tabListState/Default', 14 | get: (index: number) => async ({ get }) => { 15 | const lists = await get(sortTabListsState) 16 | return lists[index] 17 | }, 18 | }), 19 | }) 20 | 21 | export const totalTabCountSelector = selector({ 22 | key: 'totalTabCount', 23 | get: async ({ get }) => { 24 | const tabLists = await get(tabListsState) 25 | if (!tabLists.length) return 0 26 | const tabCounts = tabLists.flatMap(e => e.tabs.length) 27 | const totalTabCount = tabCounts.reduce((prev, cur) => prev + cur) 28 | return totalTabCount 29 | }, 30 | }) 31 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # https://prettier.io/docs/en/options.html 3 | 4 | # parser 5 | parser: babel 6 | 7 | # main settings 8 | printWidth: 120 9 | tabWidth: 2 10 | useTabs: false 11 | semi: false 12 | singleQuote: true 13 | quoteProps: as-needed 14 | jsxSingleQuote: false 15 | trailingComma: all 16 | bracketSpacing: true 17 | jsxBracketSameLine: false 18 | arrowParens: avoid 19 | endOfLine: lf 20 | 21 | # process always 22 | requirePragma: false 23 | insertPragma: false 24 | 25 | # for Markdown 26 | proseWrap: preserve 27 | # for HTML 28 | htmlWhitespaceSensitivity: css 29 | 30 | overrides: 31 | - files: 32 | - '*.ts' 33 | - '*.tsx' 34 | options: 35 | parser: typescript 36 | - files: '*.json' 37 | options: 38 | parser: json 39 | - files: 40 | - '*.md' 41 | - '*.markdown' 42 | options: 43 | parser: markdown 44 | - files: '*.html' 45 | options: 46 | parser: html 47 | - files: 48 | - '*.yml' 49 | - '*.yaml' 50 | options: 51 | parser: yaml 52 | -------------------------------------------------------------------------------- /src/test/unit/core/factory/tabSimple.spec.ts: -------------------------------------------------------------------------------- 1 | import { normalizeTab } from '~/core/factory/tabSimple' 2 | import { FixutreTab } from '~/test/fixtures/tabOriginalData' 3 | 4 | describe('tabSimple', () => { 5 | describe('normalizeTab', () => { 6 | it('should return undefined when tab.url is empty', () => { 7 | const tab = FixutreTab() 8 | const actual = normalizeTab(tab) 9 | expect(actual).toBeUndefined() 10 | }) 11 | 12 | it('should create SimpleTab when tab.url exists', () => { 13 | const tab = FixutreTab('https://example.com') 14 | const actual = normalizeTab(tab) 15 | const expected = { 16 | id: 1, 17 | title: 'example', 18 | pinned: false, 19 | favorite: false, 20 | lastAccessed: tab.lastAccessed, 21 | url: tab.url, 22 | favIconUrl: tab.favIconUrl, 23 | ogImageUrl: '', 24 | description: '', 25 | domain: 'example.com', 26 | } 27 | 28 | expect(expected).toStrictEqual(actual) 29 | }) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/ui/components/List/TabLinks/style.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | import { Colors, Spacing } from '~/ui/constants/styles' 4 | 5 | export const TabLinkWrapper = styled.span<{ 6 | bg: string 7 | hoverShadow: string 8 | }>` 9 | margin: ${Spacing['0.5']}; 10 | padding: ${Spacing['0.5']} 6px ${Spacing['0.5']} ${Spacing['3']}; 11 | cursor: pointer; 12 | border-radius: 33px; 13 | box-shadow: 0px 20px 35px -16px ${Colors.SHADOW}; 14 | background-color: ${({ bg }) => bg}; 15 | justify-content: center; 16 | display: inline-flex; 17 | text-align: center; 18 | transition: all 0.4s ease; 19 | &:hover { 20 | box-shadow: ${({ hoverShadow }) => hoverShadow}; 21 | opacity: 0.9; 22 | } 23 | ` 24 | 25 | export const TabLinkButton = styled.a<{ color: string }>` 26 | color: ${({ color }) => color}; 27 | justify-content: center; 28 | text-align: center; 29 | text-decoration: none; 30 | line-height: 1.5; 31 | display: inline-flex; 32 | z-index: 1; 33 | ` 34 | 35 | export const Title = styled.span` 36 | word-break: break-all; 37 | font-size: 12px; 38 | ` 39 | -------------------------------------------------------------------------------- /src/ui/stores/tabLists.ts: -------------------------------------------------------------------------------- 1 | import { atom, selector } from 'recoil' 2 | 3 | import { tabService } from '~/core/services' 4 | import { TabList } from '~/core/shared/typings' 5 | 6 | /** 7 | * Root TabListsState 8 | */ 9 | export const tabListsState = atom({ 10 | key: 'tabListsState', 11 | default: selector({ 12 | key: 'tabListsState/Default', 13 | get: async () => { 14 | const lists = await tabService.getAllTabList() 15 | if (!lists.length) return [] 16 | return lists 17 | }, 18 | }), 19 | }) 20 | 21 | // default: newestAt 22 | export const tabListsSortState = atom({ 23 | key: 'tabListsSortState', 24 | default: true, 25 | }) 26 | 27 | export const sortTabListsState = selector({ 28 | key: 'sortTabListsState', 29 | get: async ({ get }) => { 30 | const sort = get(tabListsSortState) 31 | const lists = await get(tabListsState) 32 | 33 | switch (sort) { 34 | case true: 35 | return [...lists].reverse() 36 | case false: 37 | return lists 38 | } 39 | }, 40 | set: async ({ set }, newValue) => set(tabListsState, newValue), 41 | }) 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Hiroki.Ihoriya 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 | -------------------------------------------------------------------------------- /functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "scripts": { 4 | "lint": "tslint --project tsconfig.json", 5 | "build": "tsc --lib es5", 6 | "serve": "npm run build && firebase emulators:start --only functions", 7 | "shell": "npm run build && firebase functions:shell", 8 | "start": "npm run shell", 9 | "deploy": "firebase deploy --only functions", 10 | "logs": "firebase functions:log" 11 | }, 12 | "engines": { 13 | "node": "10" 14 | }, 15 | "main": "lib/index.js", 16 | "dependencies": { 17 | "@types/cors": "^2.8.9", 18 | "@types/express": "^4.17.9", 19 | "@types/normalize-url": "^4.2.0", 20 | "@types/url-metadata": "^2.1.0", 21 | "cors": "^2.8.5", 22 | "express": "^4.17.3", 23 | "firebase-admin": "^8.10.0", 24 | "firebase-functions": "^3.6.1", 25 | "normalize-url": "^5.3.1", 26 | "url-metadata": "^2.5.0" 27 | }, 28 | "devDependencies": { 29 | "@types/jest": "^26.0.19", 30 | "firebase-functions-test": "^0.2.0", 31 | "jest": "^26.6.3", 32 | "ts-jest": "^26.4.4", 33 | "tslint": "^5.12.0", 34 | "typescript": "^3.8.0" 35 | }, 36 | "private": true 37 | } 38 | -------------------------------------------------------------------------------- /src/ui/App.tsx: -------------------------------------------------------------------------------- 1 | import { CssBaseline, GeistProvider } from '@geist-ui/react' 2 | import i18n from 'i18next' 3 | import React, { useEffect } from 'react' 4 | import { initReactI18next } from 'react-i18next' 5 | import { useRecoilValue } from 'recoil' 6 | 7 | import { langState } from '~/ui/stores/lang' 8 | 9 | import enJson from './locales/en.json' 10 | import jaJson from './locales/ja.json' 11 | import { Routes } from './router/Routes' 12 | import { colorThemeState } from './stores/colorTheme' 13 | 14 | i18n.use(initReactI18next).init({ 15 | resources: { 16 | en: { 17 | translation: enJson, 18 | }, 19 | ja: { 20 | translation: jaJson, 21 | }, 22 | }, 23 | lng: 'en', 24 | fallbackLng: 'en', 25 | interpolation: { escapeValue: false }, 26 | returnEmptyString: false, 27 | }) 28 | 29 | export const App = () => { 30 | const colorTheme = useRecoilValue(colorThemeState) 31 | const lang = useRecoilValue(langState) 32 | 33 | useEffect(() => { 34 | i18n.changeLanguage(lang) 35 | }, [lang, i18n]) 36 | 37 | return ( 38 | 39 | 40 | 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /src/test/fixtures/tabListData.ts: -------------------------------------------------------------------------------- 1 | import { TabList } from '~/core/shared/typings' 2 | 3 | export const allTabListData: TabList[] = [ 4 | { 5 | id: 1, 6 | title: 'title1', 7 | description: '', 8 | tabs: [ 9 | { 10 | id: 1, 11 | title: 'title1', 12 | description: 'description1', 13 | pinned: false, 14 | favorite: false, 15 | lastAccessed: 1630686258, 16 | url: 'https://example.com', 17 | favIconUrl: '', 18 | ogImageUrl: '', 19 | domain: 'example.com', 20 | }, 21 | ], 22 | hasPinned: false, 23 | createdAt: 1630686258, 24 | updatedAt: 1630686258, 25 | }, 26 | { 27 | id: 2, 28 | title: 'title2', 29 | description: '', 30 | tabs: [ 31 | { 32 | id: 1, 33 | title: 'title2', 34 | description: 'description2', 35 | pinned: false, 36 | favorite: false, 37 | lastAccessed: 1630686258, 38 | url: 'https://github.com', 39 | favIconUrl: '', 40 | ogImageUrl: '', 41 | domain: 'github.com', 42 | }, 43 | ], 44 | hasPinned: false, 45 | createdAt: 1630686258, 46 | updatedAt: 1630686258, 47 | }, 48 | ] 49 | -------------------------------------------------------------------------------- /src/ui/components/Settings/Languages/index.tsx: -------------------------------------------------------------------------------- 1 | import { Card, Divider, Radio, Text } from '@geist-ui/react' 2 | import React from 'react' 3 | import { useTranslation } from 'react-i18next' 4 | import { useRecoilState } from 'recoil' 5 | 6 | import { Lang } from '~/ui/constants' 7 | import { langState } from '~/ui/stores/lang' 8 | 9 | export const Languages: React.VFC<{ backgroundColor?: string }> = ({ backgroundColor }) => { 10 | const [t, i18n] = useTranslation() 11 | const [lang, setLang] = useRecoilState(langState) 12 | 13 | const handleChange = (val: React.ReactText) => { 14 | i18n.changeLanguage(lang) 15 | setLang(val.toString()) 16 | localStorage.setItem('lang', val.toString()) 17 | } 18 | 19 | return ( 20 | 21 | 22 | {t('LANGUAGE')} 23 | 24 | 25 | 26 | handleChange(val)}> 27 | English 28 | 日本語 29 | 30 | 31 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/ui/hooks/useFuse.ts: -------------------------------------------------------------------------------- 1 | import Fuse from 'fuse.js' 2 | import { useCallback, useMemo, useState } from 'react' 3 | import { debounce } from 'throttle-debounce' 4 | 5 | import { TabSimple } from '~/core/shared/typings' 6 | 7 | // ref: https://bit.ly/3uWJ3JY 8 | 9 | export const useFuse = (list: TabSimple[], fuseOptions: Fuse.IFuseOptions) => { 10 | const [query, updateQuery] = useState('') 11 | const fuse = useMemo(() => new Fuse(list, fuseOptions), [list, fuseOptions]) 12 | // memoize results whenever the query or options change 13 | // if query is empty and `matchAllOnEmptyQuery` is `true` then return all list 14 | // NOTE: we remap the results to match the return structure of `fuse.search()` 15 | const searchResults = useMemo(() => (query ? fuse.search(query, { limit: 20 }) : []), [fuse, query]) 16 | // debounce updateQuery and rename it `setQuery` so it's transparent 17 | const setQuery = useCallback(debounce(15, updateQuery), []) 18 | // pass a handling helper to speed up implementation 19 | const onSearch = useCallback(e => setQuery(e.target.value), [setQuery]) 20 | // still returning `setQuery` for custom handler implementations 21 | return { 22 | searchResults, 23 | onSearch, 24 | query, 25 | setQuery, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/core/factory/tabSimple.ts: -------------------------------------------------------------------------------- 1 | import { Tabs } from 'webextension-polyfill-ts' 2 | 3 | import { ImportedUrlObj, TabSimple } from '../shared/typings' 4 | import { genObjectId } from '../shared/utils' 5 | 6 | export const normalizeTab = (tab: Tabs.Tab) => { 7 | if (!tab.url) return undefined 8 | const res = tab.url.match(/^https?:\/{2,}(.*?)(?:\/|\?|#|$)/) 9 | const normalizedTab: TabSimple = { 10 | id: tab.id || genObjectId(), 11 | title: tab.title || '', 12 | pinned: tab.pinned || false, 13 | favorite: false, 14 | lastAccessed: tab.lastAccessed || Date.now(), 15 | url: tab.url || '', 16 | favIconUrl: tab.favIconUrl || '', 17 | ogImageUrl: '', 18 | description: '', 19 | domain: (res && res[1]) || '', 20 | } 21 | return normalizedTab 22 | } 23 | 24 | export const normalizeUrlText = (urlObj: ImportedUrlObj) => { 25 | const res = urlObj.url.match(/^https?:\/{2,}(.*?)(?:\/|\?|#|$)/) 26 | const normalized: TabSimple = { 27 | id: genObjectId(), 28 | title: urlObj.title || '', 29 | pinned: false, 30 | favorite: false, 31 | lastAccessed: Date.now(), 32 | url: urlObj.url, 33 | favIconUrl: `https://www.google.com/s2/favicons?domain=${urlObj.url}`, 34 | ogImageUrl: '', 35 | description: '', 36 | domain: (res && res[1]) || '', 37 | } 38 | return normalized 39 | } 40 | -------------------------------------------------------------------------------- /src/ui/hooks/useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | import { isString } from '~/core/shared/utils' 4 | 5 | /** 6 | * localStorageを扱うHook 7 | * Ref: https://github.com/streamich/react-use/blob/master/docs/useLocalStorage.md 8 | * @param key 9 | * @param initialValue 10 | * @param raw JSONシリアライズせずに,生で取得したい場合にtrueを渡す 11 | */ 12 | export const useLocalStorage = (key: string, initialValue?: T, raw?: boolean): [T, (value: T) => void] => { 13 | const [state, setState] = useState(() => { 14 | try { 15 | const localStorageValue = localStorage.getItem(key) 16 | 17 | if (isString(localStorageValue)) { 18 | return raw ? localStorageValue : JSON.parse(localStorageValue || 'null') 19 | } else { 20 | const val = isString(initialValue) ? JSON.stringify(initialValue) : String(initialValue) 21 | 22 | localStorage.setItem(key, val) 23 | return initialValue 24 | } 25 | } catch (err) { 26 | console.error(err) 27 | return initialValue 28 | } 29 | }) 30 | 31 | useEffect(() => { 32 | try { 33 | const serializedState = raw ? String(state) : JSON.stringify(state) 34 | localStorage.setItem(key, serializedState) 35 | } catch (err) { 36 | console.error(err) 37 | } 38 | }) 39 | 40 | return [state, setState] 41 | } 42 | -------------------------------------------------------------------------------- /src/ui/locales/ja.json: -------------------------------------------------------------------------------- 1 | { 2 | "HOME": "ホーム", 3 | "SETTINGS": "設定", 4 | "LANGUAGE": "言語", 5 | "CURRENT_LANGUAGE": "現在の言語", 6 | "TABS": "タブ", 7 | "DANGER_ZONE": "Danger Zone", 8 | "DANGER_ZONE_MESSAGE": "タブを全て消去すると,元に戻すことはできません", 9 | "DELETE_ALL_TABS_BUTTON": "全て消去する", 10 | "DELETE_MESSAGE": "️消去しますか?", 11 | "DELETED_ALL_TABS": "タブを全て消去しました", 12 | "COLOR_THEMES": "カラーテーマ", 13 | "CONTRIBUTION": "貢献する", 14 | 15 | "SETTING_SHOW_TAB_GROUP_MENU": "グループ内のタブメニューを表示する", 16 | "SETTING_SHOW_TAB_GROUP_COUNT": "グループ内のタブ数を表示する", 17 | "SETTING_DELETE_TAB_WHEN_CLICKED": "タブをクリックした際に削除する", 18 | "SETTING_EXPORT": "タブをExportする", 19 | "SETTING_IMPORT": "タブをImportする", 20 | "EXPORT_BUTTON": "Export", 21 | "HIDE_EXPORT_BUTTON": "隠す", 22 | 23 | "TOTAL_TAB": "現在の合計タブ数", 24 | "TAB_LISTS_EMPTY_MESSAGE": "タブがありません。TabXアイコンをクリックすると、開かれたタブを格納します!", 25 | 26 | "PIN_TABS": "ピン留めする", 27 | "SHARE_LINKS": "リンクを共有", 28 | "OPEN_TABS": "グループ内のタブを全て開く", 29 | "EDIT_DESCRIPTION": "グループの説明を書く", 30 | "DELETE_TABS": "グループ内のタブを全て削除する", 31 | "GEN_MARKDONW_LINKS": "MD用リンクを取得", 32 | "GEN_SCRAPBOX_LINKS": "Scrapbox用リンクを取得", 33 | 34 | "ONLY_PINNED": "ピン留めのみ表示", 35 | 36 | "COPY_MD_LINKS": "マークダウン用リンクをコピーしました", 37 | "COPY_SCRAPBOX_LINKS": "Scrapbox用リンクをコピーしました", 38 | "DELETED_SELECTED_TABS": "選択されたタブグループを削除しました", 39 | "SAVE_DESCRIPTION": "説明を保存しました", 40 | 41 | "IMPORT_SUCCESS": "タブのImportに成功しました", 42 | "IMPORT_ERROR": "予期せぬエラーにより,タブのImportに失敗しました", 43 | 44 | "SORT": "ソート", 45 | "UNIQUE": "ユニーク", 46 | "TWEET": "ツイート", 47 | "FEEDBACK": "フィードバック", 48 | "TWEET_MESSAGE": "TabX - シンプルなタブ管理ツール", 49 | "DONATE": "寄付" 50 | } 51 | -------------------------------------------------------------------------------- /src/ui/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "HOME": "Home", 3 | "SETTINGS": "Settings", 4 | "LANGUAGE": "Language", 5 | "CURRENT_LANGUAGE": "Current Language", 6 | "TABS": "Tabs", 7 | "DANGER_ZONE": "Danger Zone", 8 | "DANGER_ZONE_MESSAGE": "Once you delete all tabs, there is no going back.", 9 | "DELETE_ALL_TABS_BUTTON": "Delete All Tabs", 10 | "DELETE_MESSAGE": "Are you sure you want to delete?", 11 | "DELETED_ALL_TABS": "Deleted All Tabs", 12 | "COLOR_THEMES": "Color Themes", 13 | "CONTRIBUTION": "Contribution", 14 | 15 | "SETTING_SHOW_TAB_GROUP_MENU": "Show tab group menu", 16 | "SETTING_SHOW_TAB_GROUP_COUNT": "Show tab group count", 17 | "SETTING_DELETE_TAB_WHEN_CLICKED": "Delete tab when clicked", 18 | "SETTING_EXPORT": "Export Tabs", 19 | "SETTING_IMPORT": "Import Tabs", 20 | "EXPORT_BUTTON": "Export", 21 | "HIDE_EXPORT_BUTTON": "hide", 22 | 23 | "TOTAL_TAB": "Current Total Tabs", 24 | "TAB_LISTS_EMPTY_MESSAGE": "After clicking the TabX icon, it stores all opened tabs", 25 | 26 | "PIN_TABS": "Pin Tabs", 27 | "OPEN_TABS": "Open Tabs", 28 | "EDIT_DESCRIPTION": "Edit Description", 29 | "SHARE_LINKS": "Share Links", 30 | "DELETE_TABS": "Delete Tabs", 31 | "GEN_MARKDONW_LINKS": "Get Markdown Links", 32 | "GEN_SCRAPBOX_LINKS": "Get Scrapbox Links", 33 | "ONLY_PINNED": "Only Pinned", 34 | 35 | "COPY_MD_LINKS": "Copied markdown links to clipboard", 36 | "COPY_SCRAPBOX_LINKS": "Copied scrapbox links to clipboard", 37 | 38 | "DELETED_SELECTED_TABS": "Selected tab group deleted", 39 | "SAVE_DESCRIPTION": "Saved description", 40 | 41 | "IMPORT_SUCCESS": "Successfully imported tabs", 42 | "IMPORT_ERROR": "An unexpected error has occurred", 43 | 44 | "SORT": "Sort", 45 | "UNIQUE": "Unique", 46 | "TWEET": "Tweet", 47 | "FEEDBACK": "Feedback", 48 | "TWEET_MESSAGE": "TabX - A simple tab management tool", 49 | "DONATE": "Donate" 50 | } 51 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const CopyWebpackPlugin = require('copy-webpack-plugin') 3 | const HtmlWebPackPlugin = require('html-webpack-plugin') 4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 5 | const path = require('path') 6 | 7 | module.exports = { 8 | entry: { 9 | app: './src/ui/index.tsx', 10 | background: './src/background.ts', 11 | }, 12 | output: { 13 | path: __dirname + '/dist', 14 | }, 15 | optimization: { 16 | noEmitOnErrors: true, 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.tsx?$/, 22 | loader: 'awesome-typescript-loader', 23 | }, 24 | { 25 | test: /\.html$/, 26 | use: [ 27 | { 28 | loader: 'html-loader', 29 | options: { minimize: true }, 30 | }, 31 | ], 32 | }, 33 | { 34 | test: /\.css$/, 35 | use: [MiniCssExtractPlugin.loader, 'css-loader'], 36 | }, 37 | { 38 | test: /\.(jpg|png|gif|woff|woff2|eot|ttf|svg)$/, 39 | use: [{ loader: 'file-loader' }], 40 | }, 41 | ], 42 | }, 43 | 44 | resolve: { 45 | extensions: ['*', '.js', '.ts', '.tsx', '.json', '.mjs', '.wasm'], 46 | alias: { 47 | '~': path.resolve(__dirname, 'src'), 48 | }, 49 | }, 50 | 51 | plugins: [ 52 | new HtmlWebPackPlugin({ 53 | template: 'src/public/index.html', 54 | filename: 'index.html', 55 | chunks: ['app'], 56 | favicon: 'src/assets/favicon.ico', 57 | }), 58 | new MiniCssExtractPlugin({ 59 | filename: '[name].css', 60 | chunkFilename: '[id].css', 61 | }), 62 | new CopyWebpackPlugin([ 63 | { 64 | from: 'src/manifest.json', 65 | to: 'manifest.json', 66 | }, 67 | { 68 | from: 'src/assets/icons', 69 | to: 'assets/icons', 70 | }, 71 | ]), 72 | ], 73 | } 74 | -------------------------------------------------------------------------------- /functions/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as cors from 'cors' 2 | import * as express from 'express' 3 | import * as functions from 'firebase-functions' 4 | import * as normalizeUrl from 'normalize-url' 5 | import { Result } from 'url-metadata' 6 | const urlMeta = require('url-metadata') 7 | 8 | type TargetMeta = { ogImageUrl: string; description: string } 9 | type TargetMetaWithId = TargetMeta & { id: number } 10 | type Param = { id: number; url: string } 11 | 12 | const isValidOgImageUrl = (ogImageUrl: string) => !ogImageUrl.startsWith('/') 13 | const extractUntilFQDN = (url: string) => 14 | normalizeUrl(url, { 15 | stripHash: true, 16 | }) 17 | 18 | const pullMeta = async (param: Param): Promise => { 19 | try { 20 | const meta = (await urlMeta(param.url)) as Result 21 | const convertOgImageUrl = (ogImageUrl: string) => { 22 | const convUrl = extractUntilFQDN(meta['url']) + ogImageUrl 23 | return convUrl 24 | } 25 | 26 | return { 27 | id: param.id, 28 | ogImageUrl: isValidOgImageUrl(meta['og:image']) ? meta['og:image'] : convertOgImageUrl(meta['og:image']), 29 | description: meta['description'], 30 | } as TargetMetaWithId 31 | } catch (err) { 32 | console.error(err) 33 | return err 34 | } 35 | } 36 | 37 | const createMetaObjs = async (params: Param[]) => { 38 | const promises = params.map(param => { 39 | return pullMeta(param) 40 | }) 41 | const metaObjs = await Promise.all(promises).then(res => res) 42 | return metaObjs 43 | } 44 | 45 | const app = express() 46 | 47 | app.use(cors({ origin: true, credentials: true })) 48 | app.use(express.json()) 49 | app.use(express.urlencoded({ extended: true })) 50 | 51 | app.post('/metadatas', async (req, res) => { 52 | try { 53 | const obj = await createMetaObjs(req.body.params as Param[]) 54 | res.status(200).send(obj) 55 | } catch (err) { 56 | res.status(500).send(err) 57 | } 58 | }) 59 | 60 | const api = functions.https.onRequest(app) 61 | module.exports = { api } 62 | -------------------------------------------------------------------------------- /src/core/shared/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { IO } from 'fp-ts/lib/IO' 2 | 3 | export const genObjectId: IO = () => { 4 | const timestamp = (new Date().getTime() / 1000) | 0 5 | return timestamp + Math.random() * 16 6 | } 7 | 8 | export const zip = (arr1: T[], arr2: U[]) => { 9 | if (arr1.length !== arr2.length) { 10 | throw new Error('arrays must be same length') 11 | } 12 | return arr1.map((_, i) => [arr1[i], arr2[i]] as [T, U]) 13 | } 14 | 15 | export const omitText = (text: string) => (len: number) => (ellipsis: string) => 16 | text.length >= len ? text.slice(0, len - ellipsis.length) + ellipsis : text 17 | 18 | // eslint-disable-next-line 19 | export const isString = (val: any): val is string => typeof val === 'string' 20 | 21 | export const groupBy = (objects: T[], key: keyof T): { [key: string]: T[] } => 22 | objects.reduce((map, obj) => { 23 | map[obj[key]] = map[obj[key]] || [] 24 | map[obj[key]].push(obj) 25 | return map 26 | }, {} as { [key: string]: T[] }) 27 | 28 | /** 29 | * remove nullable elements 30 | */ 31 | export const nonNullable = (value: T): value is NonNullable => value != null 32 | 33 | // Ref: https://qiita.com/hatakoya/items/018afbfb1bd45136618a 34 | type ChainedWhen = { 35 | on: (pred: (v: T) => boolean, fn: () => A) => ChainedWhen 36 | otherwise: (fn: () => A) => R | A 37 | } 38 | 39 | export const match = (val: any): ChainedWhen => ({ 40 | // eslint-disable-next-line no-unused-vars 41 | on: (_pred: (v: T) => boolean, _fn: () => A) => match(val), 42 | // eslint-disable-next-line no-unused-vars 43 | otherwise: (_fn: () => A): A | R => val, 44 | }) 45 | 46 | export const chain = (val: T): ChainedWhen => ({ 47 | on: (pred: (v: T) => boolean, fn: () => A) => (pred(val) ? match(fn()) : chain(val)), 48 | otherwise: (fn: () => A) => fn(), 49 | }) 50 | 51 | export const when = (val: T) => ({ 52 | on: (pred: (v: T) => boolean, fn: () => A) => (pred(val) ? match(fn()) : chain(val)), 53 | }) 54 | 55 | export const eq = (val1: T) => (val2: T) => val1 === val2 56 | -------------------------------------------------------------------------------- /src/test/unit/shared/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { eq, groupBy, isString, nonNullable, when, zip } from '~/core/shared/utils' 2 | 3 | describe('zip', () => { 4 | it('should works', () => { 5 | const res = zip([1, 2, 3, 4], ['a', 'b', 'c', 'd']) 6 | expect(res).toStrictEqual([ 7 | [1, 'a'], 8 | [2, 'b'], 9 | [3, 'c'], 10 | [4, 'd'], 11 | ]) 12 | }) 13 | it('should not works when length are different', () => { 14 | expect(() => { 15 | zip([1, 2, 3, 4], ['a', 'b', 'c']) 16 | }).toThrow() 17 | }) 18 | }) 19 | 20 | describe('isString', () => { 21 | it('should work', () => { 22 | const str = 'string' 23 | expect(isString(str)).toBeTruthy() 24 | }) 25 | it('should be false when val is number', () => { 26 | const num = 0 27 | expect(isString(num)).toBeFalsy() 28 | }) 29 | }) 30 | 31 | describe('nonNullable', () => { 32 | it('shuld remove null', () => { 33 | const nonNullArr = nonNullable(['a', 'b', 'c', 'd', null]) 34 | expect(nonNullArr).toBeTruthy() 35 | }) 36 | it('should remove all null', () => { 37 | const nonNullArr = nonNullable([null, null, null]) 38 | expect(nonNullArr).toBeTruthy() 39 | }) 40 | }) 41 | 42 | describe('groupBy', () => { 43 | it('should work with domains', () => { 44 | const data = [ 45 | { id: 1, domain: 'test1.com' }, 46 | { id: 2, domain: 'test2.com' }, 47 | { id: 3, domain: 'test1.com' }, 48 | ] 49 | const res = groupBy(data, 'domain') 50 | const ans = { 51 | 'test2.com': [{ domain: 'test2.com', id: 2 }], 52 | 'test1.com': [ 53 | { domain: 'test1.com', id: 1 }, 54 | { domain: 'test1.com', id: 3 }, 55 | ], 56 | } 57 | expect(res).toStrictEqual(ans) 58 | }) 59 | }) 60 | 61 | describe('when function', () => { 62 | it('should return matched val', () => { 63 | const hoge = 1 64 | const res = when(hoge) 65 | .on(eq(1), () => 'A') 66 | .on(eq(2), () => 'B') 67 | .on(eq(3), () => 'C') 68 | .otherwise(() => 'default') 69 | expect(res).toBe('A') 70 | }) 71 | 72 | it('should return default when doesnt match', () => { 73 | const hoge = 4 74 | const res = when(hoge) 75 | .on(eq(1), () => 'A') 76 | .on(eq(2), () => 'B') 77 | .on(eq(3), () => 'C') 78 | .otherwise(() => 'default') 79 | expect(res).toBe('default') 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /src/ui/components/List/TabList/TabListContainer.tsx: -------------------------------------------------------------------------------- 1 | import { useMediaQuery } from '@geist-ui/react' 2 | import React, { memo } from 'react' 3 | import { useRecoilState } from 'recoil' 4 | import styled from 'styled-components' 5 | 6 | import { tabService } from '~/core/services' 7 | import { TabList } from '~/core/shared/typings' 8 | import { STORAGE_KEYS } from '~/ui/constants' 9 | import { useLocalStorage } from '~/ui/hooks' 10 | import { tabListState } from '~/ui/stores/tabList' 11 | import { removeTab } from '~/ui/utils/producer' 12 | 13 | import { TabSimpleLink } from '../TabSimpleLink' 14 | import { TabListHeader } from './internal/TabListHeader' 15 | import { TabListWrapper } from './style' 16 | 17 | type Props = { index: number; isVisibleTabListHeader: boolean } 18 | 19 | export const TabListContainer: React.VFC = memo(({ index, isVisibleTabListHeader }) => { 20 | const [shouldDeleteTabWhenClicked] = useLocalStorage(STORAGE_KEYS.SHOULD_DELETE_TAB_WHEN_CLICKED, true) 21 | const [tabList, setTabList] = useRecoilState(tabListState(index)) 22 | 23 | const isLG = useMediaQuery('lg') 24 | 25 | const handleTabDelete = async (tabId: number) => { 26 | await tabService.deleteTabSimple(tabList.id, tabId).then(() => { 27 | const newTabs = removeTab(tabList, tabId) 28 | // NOTE: handling for last tab deletion 29 | newTabs.tabs.length > 0 ? setTabList(newTabs) : setTabList({} as TabList) 30 | }) 31 | } 32 | 33 | // NOTE: handling for deleting a tabList from each menu 34 | if (!tabList || !tabList.tabs) return <> 35 | 36 | return ( 37 | 38 | {isVisibleTabListHeader && ( 39 | <> 40 | 41 | {tabList.description !== '' && {tabList.description}} 42 | 43 | )} 44 | 45 | {tabList.tabs.map((tab, index) => ( 46 | 54 | ))} 55 | 56 | ) 57 | }) 58 | 59 | TabListContainer.displayName = 'TabListContainer' 60 | 61 | const TabListDescription = styled.div` 62 | margin-bottom: 8px; 63 | word-break: break-all; 64 | ` 65 | -------------------------------------------------------------------------------- /src/test/fixtures/tabService/uniqueAllTabList.ts: -------------------------------------------------------------------------------- 1 | import { TabList } from '~/core/shared/typings' 2 | 3 | export const uniqueAllTabListTestDataBefore: TabList[] = [ 4 | { 5 | id: 1, 6 | title: 'TabList Title', 7 | description: '', 8 | tabs: [ 9 | { 10 | id: 1, 11 | title: 'tab title', 12 | description: '', 13 | pinned: false, 14 | favorite: false, 15 | lastAccessed: 1630686258, 16 | url: 'https://example.com', 17 | favIconUrl: '', 18 | ogImageUrl: '', 19 | domain: 'example.com', 20 | }, 21 | { 22 | id: 2, 23 | title: 'tab title', 24 | description: '', 25 | pinned: false, 26 | favorite: false, 27 | lastAccessed: 1630686258, 28 | url: 'https://test.com', 29 | favIconUrl: '', 30 | ogImageUrl: '', 31 | domain: 'test.com', 32 | }, 33 | ], 34 | hasPinned: false, 35 | createdAt: 1630686258, 36 | updatedAt: 1630686258, 37 | }, 38 | { 39 | id: 2, 40 | title: 'TabList Title', 41 | description: '', 42 | tabs: [ 43 | { 44 | id: 3, 45 | title: 'tab title', 46 | description: '', 47 | pinned: false, 48 | favorite: false, 49 | lastAccessed: 1630686258, 50 | url: 'https://test.com', 51 | favIconUrl: '', 52 | ogImageUrl: '', 53 | domain: 'test.com', 54 | }, 55 | { 56 | id: 4, 57 | title: 'tab title', 58 | description: '', 59 | pinned: false, 60 | favorite: false, 61 | lastAccessed: 1630686258, 62 | url: 'https://twitter.com', 63 | favIconUrl: '', 64 | ogImageUrl: '', 65 | domain: 'twitter.com', 66 | }, 67 | ], 68 | hasPinned: false, 69 | createdAt: 1630686258, 70 | updatedAt: 1630686258, 71 | }, 72 | { 73 | id: 3, 74 | title: 'TabList Title', 75 | description: '', 76 | tabs: [ 77 | { 78 | id: 5, 79 | title: 'tab title', 80 | description: '', 81 | pinned: false, 82 | favorite: false, 83 | lastAccessed: 1630686258, 84 | url: 'https://twitter.com', 85 | favIconUrl: '', 86 | ogImageUrl: '', 87 | domain: 'twitter.com', 88 | }, 89 | ], 90 | hasPinned: false, 91 | createdAt: 1630686258, 92 | updatedAt: 1630686258, 93 | }, 94 | ] 95 | -------------------------------------------------------------------------------- /src/ui/components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import { Col, Input, Row, Spacer, useMediaQuery, useTheme } from '@geist-ui/react' 2 | import { Moon, Search, Sun } from '@geist-ui/react-icons' 3 | import React from 'react' 4 | import { Link } from 'react-router-dom' 5 | import { useRecoilState } from 'recoil' 6 | 7 | import { Menu } from '~/ui/components/Header/Menu' 8 | import { Colors, Themes } from '~/ui/constants/styles' 9 | import { colorThemeState } from '~/ui/stores/colorTheme' 10 | import { isDark } from '~/ui/utils' 11 | 12 | import { _Div, PageHeaderText } from './style' 13 | 14 | type Props = { text?: string; onSearch?: (e: any) => void } 15 | 16 | export const Header: React.VFC = ({ text, onSearch }) => { 17 | const [colorTheme, setColorTheme] = useRecoilState(colorThemeState) 18 | const downSM = useMediaQuery('sm', { match: 'down' }) 19 | 20 | const theme = useTheme() 21 | const popoverColor = theme.palette.foreground 22 | const popoverBgColor = theme.palette.accents_2 23 | 24 | const changeColorTheme = (theme: string) => { 25 | setColorTheme(theme) 26 | localStorage.setItem('theme', theme) 27 | } 28 | 29 | const showSearchInput = !!onSearch && !downSM 30 | 31 | return ( 32 | 33 | 34 | 35 | 36 | {text} 37 | 38 | 39 | {showSearchInput && ( 40 | } 42 | status="secondary" 43 | placeholder="Search by title or url" 44 | clearable={true} 45 | onChange={onSearch} 46 | style={{ margin: '0px 10px 0px 2px' }} 47 | /> 48 | )} 49 | 50 | 51 | 52 | 53 | <_Div 54 | color={popoverColor} 55 | bgColor={popoverBgColor} 56 | role="button" 57 | onClick={() => changeColorTheme(isDark(colorTheme) ? Themes.LIGHT : Themes.DARK)} 58 | > 59 | {isDark(colorTheme) ? : } 60 | 61 | 62 | 63 | 64 | 65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /src/ui/components/List/TabSimpleLink/index.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from '@geist-ui/react' 2 | import { X } from '@geist-ui/react-icons' 3 | import React, { memo } from 'react' 4 | 5 | import { TabSimple } from '~/core/shared/typings' 6 | import { omitText } from '~/core/shared/utils' 7 | import { FaviconImage } from '~/ui/components/FaviconImage' 8 | import { Rule, Spacing } from '~/ui/constants/styles' 9 | import { useMouseOver } from '~/ui/hooks' 10 | import { isDark } from '~/ui/utils' 11 | 12 | import { TabLinkButton, TabLinkWrapper, Title } from '../TabLinks/style' 13 | import { TabLinkOps } from './TabLinkOps' 14 | 15 | type Props = { 16 | tab: TabSimple 17 | index: number 18 | isOpsVisible?: boolean 19 | shouldDeleteTabWhenClicked?: boolean 20 | onDelete?: (tabId: number) => Promise 21 | } 22 | 23 | export const TabSimpleLink: React.VFC = memo( 24 | ({ tab, index, isOpsVisible, shouldDeleteTabWhenClicked = true, onDelete }) => { 25 | const theme = useTheme() 26 | const { handleMouseOut, handleMouseOver, isMouseOvered } = useMouseOver() 27 | 28 | const tabLinkWrapperBg = isDark(theme.type) ? theme.palette.accents_2 : theme.palette.accents_1 29 | const tabTitle = omitText(tab.title)(Rule.TITLE_MAX_LENGTH)('…') 30 | const isDeletable = shouldDeleteTabWhenClicked && !!onDelete 31 | 32 | return ( 33 | handleMouseOver(index)} 37 | onMouseLeave={handleMouseOut} 38 | hoverShadow={theme.expressiveness.shadowSmall} 39 | bg={tabLinkWrapperBg} 40 | > 41 | isDeletable && onDelete!(tab.id)} 46 | color={theme.palette.foreground} 47 | > 48 | 49 | 50 | 51 | {tabTitle} 52 | 53 | {/* Ops show when the tab is hoverd */} 54 | {isOpsVisible && !!onDelete && ( 55 | 56 | 57 | 58 | )} 59 | 60 | ) 61 | }, 62 | ) 63 | 64 | TabSimpleLink.displayName = 'TabSimpleLink' 65 | -------------------------------------------------------------------------------- /src/test/unit/core/services/tabService.spec.ts: -------------------------------------------------------------------------------- 1 | import { mock, mockReset } from 'jest-mock-extended' 2 | 3 | import { ITabRepo } from '~/core/repos/tabRepo' 4 | import { TabService } from '~/core/services/tabService' 5 | import { allTabListData } from '~/test/fixtures/tabListData' 6 | import { uniqueAllTabListTestDataBefore } from '~/test/fixtures/tabService/uniqueAllTabList' 7 | 8 | describe('tabService', () => { 9 | const tabRepoMock = mock() 10 | 11 | beforeEach(() => { 12 | mockReset(tabRepoMock) 13 | }) 14 | 15 | it('getAllTabList', async () => { 16 | const expected = allTabListData 17 | tabRepoMock.getAllTabList.mockReturnValue(Promise.resolve(allTabListData)) 18 | const tabService = new TabService(tabRepoMock) 19 | const actual = await tabService.getAllTabList() 20 | 21 | expect(actual).toStrictEqual(expected) 22 | expect(tabRepoMock.getAllTabList).toBeCalled() 23 | }) 24 | 25 | it('uniqueAllTabList', async () => { 26 | const before = uniqueAllTabListTestDataBefore 27 | 28 | tabRepoMock.getAllTabList.mockReturnValue(Promise.resolve(before)) 29 | tabRepoMock.setAllTabList.mockImplementation() 30 | 31 | const tabService = new TabService(tabRepoMock) 32 | const hasProcessed = await tabService.uniqueAllTabList() 33 | 34 | expect(hasProcessed).toBeTruthy() 35 | expect(tabRepoMock.setAllTabList).toBeCalledWith( 36 | expect.not.objectContaining({ 37 | id: 3, 38 | title: 'TabList Title', 39 | description: '', 40 | tabs: [ 41 | { 42 | id: 5, 43 | title: 'tab title', 44 | description: '', 45 | pinned: false, 46 | favorite: false, 47 | lastAccessed: 1630686258, 48 | url: 'https://twitter.com', 49 | favIconUrl: '', 50 | ogImageUrl: '', 51 | domain: 'twitter.com', 52 | }, 53 | ], 54 | hasPinned: false, 55 | createdAt: 1630686258, 56 | updatedAt: 1630686258, 57 | }), 58 | ) 59 | }) 60 | 61 | it('exportToText', async () => { 62 | const original = allTabListData 63 | 64 | tabRepoMock.getAllTabList.mockReturnValue(Promise.resolve(original)) 65 | 66 | const tabService = new TabService(tabRepoMock) 67 | 68 | const actual = await tabService.exportToText() 69 | // TODO: increase tab counts 70 | const expected = 'https://example.com | title1\n\nhttps://github.com | title2' 71 | expect(tabRepoMock.getAllTabList).toBeCalled() 72 | expect(actual).toEqual(expected) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /src/ui/components/Header/Menu/MenuContent/index.tsx: -------------------------------------------------------------------------------- 1 | import { Popover, useToasts } from '@geist-ui/react' 2 | import { ChevronUpDown, Heart, Home, Settings, Twitter, Zap } from '@geist-ui/react-icons' 3 | import React from 'react' 4 | import { useTranslation } from 'react-i18next' 5 | import { useHistory } from 'react-router-dom' 6 | import { useRecoilState } from 'recoil' 7 | 8 | import { tabService } from '~/core/services' 9 | import { FEEDBACK_URL, TWITTER_URL } from '~/core/shared/constants' 10 | import { MenuItem } from '~/ui/components/MenuItem' 11 | import { Rule } from '~/ui/constants/styles' 12 | import { tabListsSortState, tabListsState } from '~/ui/stores/tabLists' 13 | 14 | const toTwitter = () => window.open(TWITTER_URL) 15 | const toFeedback = () => window.open(FEEDBACK_URL) 16 | 17 | export const MenuContent: React.VFC = () => { 18 | const { t } = useTranslation() 19 | const [, setToast] = useToasts() 20 | 21 | const [sort, setSort] = useRecoilState(tabListsSortState) 22 | const [_, setTabLists] = useRecoilState(tabListsState) 23 | 24 | const updateSort = () => { 25 | setSort(!sort) 26 | } 27 | const uniqueTabs = async () => { 28 | await tabService 29 | .uniqueAllTabList() 30 | .then(async res => { 31 | if (!res) return setToast({ type: 'default', text: 'Tabs are already unique' }) 32 | 33 | setToast({ type: 'success', text: 'Successfuly made the tabs unique (by url)' }) 34 | const tabLists = await tabService.getAllTabList() 35 | setTabLists(tabLists) 36 | }) 37 | .catch(() => setToast({ type: 'error', text: 'An unexpected error has occurred' })) 38 | } 39 | 40 | const history = useHistory() 41 | const toHome = () => history.push('/') 42 | const toSettings = () => history.push('/settings') 43 | 44 | return ( 45 | <> 46 | {/* FUNCTION */} 47 | } /> 48 | } /> 49 | 50 | {/* ROUTING */} 51 | } /> 52 | } /> 53 | 54 | {/* EXTERNAL LINKS */} 55 | } /> 56 | } /> 57 | 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /src/ui/pages/Settings/index.tsx: -------------------------------------------------------------------------------- 1 | import { Spacer, useTheme, useToasts } from '@geist-ui/react' 2 | import React, { useLayoutEffect, useState } from 'react' 3 | import { useTranslation } from 'react-i18next' 4 | import { SetterOrUpdater, useRecoilState } from 'recoil' 5 | 6 | import { tabService } from '~/core/services' 7 | import { APP_NAME } from '~/core/shared/constants' 8 | import { TabList } from '~/core/shared/typings' 9 | import { Header } from '~/ui/components/Header' 10 | import { Load } from '~/ui/components/Load' 11 | import { Spacing } from '~/ui/constants/styles' 12 | import { tabListsState } from '~/ui/stores/tabLists' 13 | 14 | import { Languages } from '../../components/Settings/Languages' 15 | import { Tabs } from '../../components/Settings/Tabs' 16 | 17 | const useDeleteAllTabs = (setTabLists: SetterOrUpdater) => { 18 | const { t } = useTranslation() 19 | const [, setToast] = useToasts() 20 | 21 | const deleteAllTabs = async () => { 22 | if (confirm(t('DELETE_MESSAGE'))) { 23 | await tabService 24 | .deleteAllTabList() 25 | .then(() => setTabLists([] as TabList[])) 26 | .then(() => { 27 | setToast({ 28 | text: t('DELETED_ALL_TABS'), 29 | }) 30 | }) 31 | .catch(err => console.error(err)) 32 | } 33 | } 34 | return deleteAllTabs 35 | } 36 | 37 | const useCountTotalTabs = (setTabLists: SetterOrUpdater) => { 38 | const [hasCountedTotalTabs, setHasCountedTotalTabs] = useState(false) 39 | useLayoutEffect(() => { 40 | try { 41 | const resetTabLists = async () => { 42 | const tabLists = await tabService.getAllTabList() 43 | setTabLists(tabLists) 44 | } 45 | resetTabLists().then(() => setHasCountedTotalTabs(true)) 46 | } catch (err) { 47 | console.error(err) 48 | } 49 | }, []) 50 | return { hasCountedTotalTabs } 51 | } 52 | 53 | export const Settings: React.FC = () => { 54 | const theme = useTheme() 55 | 56 | const [tabLists, setTabLists] = useRecoilState(tabListsState) 57 | // HACK: this is for count totalTabs. Not efficient, needs refactor 58 | const { hasCountedTotalTabs } = useCountTotalTabs(setTabLists) 59 | const deleteAllTabs = useDeleteAllTabs(setTabLists) 60 | 61 | if (!hasCountedTotalTabs) { 62 | return ( 63 |
64 | 65 |
66 | ) 67 | } 68 | 69 | return ( 70 | <> 71 |
72 | 73 | 74 | 75 | 76 | 77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Repository Overview 6 | 7 | TabX is a Chrome extension for tab management, inspired by OneTab. It's built with TypeScript, React, and Recoil for state management. The extension allows users to save their open tabs and manage them with features like fuzzy search, dark mode, and import/export capabilities. 8 | 9 | ## Common Commands 10 | 11 | ### Development 12 | - `npm install` - Install dependencies 13 | - `npm run dev` - Start development mode with hot reload 14 | - `npm run build` - Build production extension 15 | - `npm run test` - Run Jest tests 16 | - `npm run lint` - Run ESLint 17 | - `npm run lint:fix` - Auto-fix linting issues 18 | - `npm run lint:css` - Run Stylelint on styled-components 19 | 20 | ### Testing 21 | - Tests are located in `src/test/` 22 | - Run a specific test file: `npm run test -- src/test/unit/core/services/tabService.spec.ts` 23 | - Tests use Jest with TypeScript support via ts-jest 24 | - Mocks for Chrome API are in `src/test/__mocks__/chromeMock.ts` 25 | 26 | ## Code Architecture 27 | 28 | ### Extension Structure 29 | - **Background Script**: `src/background.ts` - Handles browser action clicks and manages tab storage 30 | - **Main UI**: `src/ui/` - React application for tab management interface 31 | - **Core Logic**: `src/core/` - Business logic organized in layers: 32 | - `services/` - Core business logic (tabService, chromeActionService) 33 | - `useCase/` - Application use cases 34 | - `repos/` - Storage layer using Chrome storage API 35 | - `factory/` - Entity creation factories 36 | - `errors/` - Custom error types 37 | 38 | ### State Management 39 | - Uses Recoil for state management (`src/ui/stores/`) 40 | - Key atoms: tabLists, tabs, colorTheme, lang 41 | - State is persisted using Chrome storage API 42 | 43 | ### Key Dependencies 44 | - React 17 with TypeScript 45 | - Recoil for state management 46 | - Styled-components for styling 47 | - Fuse.js for fuzzy search 48 | - i18next for internationalization (English and Japanese) 49 | - Webpack for bundling 50 | 51 | ### Build Output 52 | - Development build: Extension files are output to `dist/` directory 53 | - The manifest.json is copied from `src/manifest.json` 54 | - Background script and UI are built as separate bundles 55 | 56 | ### Chrome Extension Permissions 57 | - `storage` - For persisting tab data 58 | - `tabs` - For accessing and managing browser tabs 59 | 60 | ### Import Aliases 61 | - `~/` maps to `src/` directory (configured in TypeScript and Jest) 62 | 63 | ## Extension Loading 64 | After building, load the extension in Chrome: 65 | 1. Go to chrome://extensions/ 66 | 2. Enable Developer mode 67 | 3. Click "Load unpacked" 68 | 4. Select the `dist` directory -------------------------------------------------------------------------------- /src/ui/components/List/TabList/internal/TabListMenuContent.tsx: -------------------------------------------------------------------------------- 1 | import { useToasts } from '@geist-ui/react' 2 | import { Box } from '@geist-ui/react-icons' 3 | import { Clipboard } from '@geist-ui/react-icons' 4 | import { Delete } from '@geist-ui/react-icons' 5 | import { ExternalLink } from '@geist-ui/react-icons' 6 | import { Edit3 } from '@geist-ui/react-icons' 7 | import React from 'react' 8 | import { useTranslation } from 'react-i18next' 9 | import { SetterOrUpdater } from 'recoil' 10 | 11 | import { chromeActionService, tabService } from '~/core/services' 12 | import { TabList } from '~/core/shared/typings' 13 | import { MenuItem } from '~/ui/components/MenuItem' 14 | import { Rule } from '~/ui/constants/styles' 15 | 16 | type Props = { tabList: TabList; setTabList: SetterOrUpdater; openEditDescriptionModal: () => void } 17 | 18 | export const TabListMenuContent: React.VFC = ({ tabList, setTabList, openEditDescriptionModal }) => { 19 | const { t } = useTranslation() 20 | const [, setToast] = useToasts() 21 | 22 | const handleDelete = async (tabListId: number) => { 23 | if (!window.confirm(t('DELETE_MESSAGE'))) return 24 | 25 | await tabService.deleteTabList(tabListId).then(() => setTabList({} as TabList)) 26 | setToast({ 27 | text: t('DELETED_SELECTED_TABS'), 28 | }) 29 | } 30 | 31 | const genMarkdownLink = async () => { 32 | const tabsText = tabList.tabs.map(tab => `[${tab.title}](${tab.url})`) 33 | setToast({ 34 | text: t('COPY_MD_LINKS'), 35 | type: 'success', 36 | }) 37 | return navigator.clipboard.writeText(tabsText.join('\n')) 38 | } 39 | 40 | const genScrapboxLink = async () => { 41 | const tabsText = tabList.tabs.map(tab => `\t[${tab.title} ${tab.url}]`) 42 | setToast({ 43 | text: t('COPY_SCRAPBOX_LINKS'), 44 | type: 'success', 45 | }) 46 | return navigator.clipboard.writeText(tabsText.join('\n')) 47 | } 48 | 49 | const handleOpen = async (tabListId: number) => { 50 | await chromeActionService 51 | .restoreTabList(tabListId) 52 | .then(() => setTabList({} as TabList)) 53 | .catch(err => console.error(err)) 54 | } 55 | 56 | return ( 57 | <> 58 | handleOpen(tabList.id)} 60 | label={t('OPEN_TABS')} 61 | icon={} 62 | /> 63 | } 67 | /> 68 | } 72 | /> 73 | } /> 74 | handleDelete(tabList.id)} 76 | label={t('DELETE_TABS')} 77 | icon={} 78 | /> 79 | 80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 |

6 |

TabX

7 |

TabX is a simple tab management tool 🪣

8 | 9 |

10 | GitHub Workflow Status 11 | 12 | Install Chrome 13 | 14 |

15 | 16 | ## Application Image (v0.0.6) 17 | 18 | | ![TabX_light](https://user-images.githubusercontent.com/38400669/133642613-118dc73f-3135-4c50-86b2-612bdb8e1e3a.png) | ![TabX_dark](https://user-images.githubusercontent.com/38400669/133642626-a4ac5004-0825-4597-8021-7c68d7daef44.png) | 19 | | :------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------: | 20 | | Light Mode | Dark Mode | 21 | 22 | ## Instlattion 23 | 24 | - Install from [Chrome Web Store](https://chrome.google.com/webstore/detail/tabx/pnomgepiknocmkmncjkcchojfiookljb?hl=en&authuser=1) 25 | - Please wait for Firefox. 26 | 27 | ## Motivation 28 | 29 | > The problem of having too many tabs open occurs when we use a browser. Opening too many tabs affect memory consumption and prevent comfortable browsing. 30 | > Recently, some extensions and browsers have approached this problem, but they are a bit overloaded, at least for me. 31 | > This extension is inspired by OneTab. 32 | > The goal of TabX is to create a simpler and easier to use tab management tool. 33 | 34 | ## How to use 35 | 36 | 1. After install [TabX](https://chrome.google.com/webstore/detail/tabx/pnomgepiknocmkmncjkcchojfiookljb?hl=en&authuser=1), you can show the icon in extension area (beside search bar). 37 | 2. When you click the icon, TabX stores your opend tabs (exclude pinned tabs). 38 | 3. Manage your tabs at TabX page with below features! 39 | 40 | ## Features 41 | 42 | - ✅ Store your tabs 43 | - 💎 Simple UI 44 | - 🌌 Built-in dark mode 45 | - 🔍 Fuzzy search (thanks to [Fuse.js](https://github.com/krisk/Fuse)! ) 46 | - ⛓ Import & Export (OneTab compatible) 47 | - 📝 Edit description of tab group 48 | - 🌏 i18n support (only English & Japanese) 49 | - ⚡️ Remove duplicated stored tabs (We call this "Unique" feature) 50 | 51 | ## FutureWork 52 | 53 | - 🛠 Add tab filtering option (date, domain, etc..) 54 | - 🔦 Search from anywhere 55 | - 👨‍💻 Keyboard shortcuts 56 | 57 | ## Build 58 | 59 | 1. Fork and clone this repository. 60 | 2. Open terminal in the cloned root folder and run: 61 | - `npm run install` 62 | - `npm run build` 63 | 3. Zip the dist folder that created by `npm run build` 64 | 4. Load the `dist.zip` at chrome://extensions/ (click "Load unpacked" and open the dist.zip) 65 | 5. After run `npm run dev`, you can use TabX dev-mode. 66 | 67 | ## License 68 | 69 | [MIT](LICENSE.md) 70 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tabx", 3 | "version": "0.0.7", 4 | "description": "TabX manages your tabs", 5 | "scripts": { 6 | "build": "webpack --mode production --config webpack.config.js", 7 | "dev": "webpack --config webpack.dev.js --watch -O '{\"noUnusedLocals\":false, \"noUnusedParameters\":false}'", 8 | "lint": "eslint --ext .ts,.tsx src", 9 | "lint:fix": "eslint --fix './src/**/*.{ts,tsx}' && prettier --write './src/**/*.{ts,tsx}'", 10 | "test": "jest --config jest.config.js", 11 | "lint:css": "stylelint src/**/*.tsx", 12 | "postinstall": "patch-package" 13 | }, 14 | "author": "Hiroki Ihoriya", 15 | "license": "MIT", 16 | "devDependencies": { 17 | "@babel/preset-react": "^7.12.10", 18 | "@geist-ui/react-icons": "^1.0.1", 19 | "@testing-library/jest-dom": "^5.11.9", 20 | "@testing-library/react": "^11.2.2", 21 | "@testing-library/user-event": "^12.7.1", 22 | "@types/axios": "^0.14.0", 23 | "@types/chrome": "^0.0.60", 24 | "@types/jest": "^26.0.15", 25 | "@types/puppeteer": "^5.4.3", 26 | "@types/react": "^16.9.32", 27 | "@types/react-dom": "^16.9.6", 28 | "@types/react-i18next": "^8.1.0", 29 | "@types/react-paginate": "^6.2.1", 30 | "@types/react-router-dom": "^5.1.6", 31 | "@types/recoil": "^0.0.1", 32 | "@types/sinon-chrome": "^2.2.10", 33 | "@types/styled-components": "^5.1.4", 34 | "@types/stylelint": "^9.10.1", 35 | "@typescript-eslint/eslint-plugin": "^4.15.0", 36 | "@typescript-eslint/parser": "^4.15.0", 37 | "async-mutex": "^0.2.6", 38 | "awesome-typescript-loader": "^5.2.1", 39 | "axios": "^0.21.2", 40 | "babel-loader": "^8.2.2", 41 | "copy-webpack-plugin": "4.5.1", 42 | "css-loader": "^5.0.0", 43 | "eslint": "^7.16.0", 44 | "eslint-config-prettier": "^7.2.0", 45 | "eslint-plugin-jest": "^24.1.3", 46 | "eslint-plugin-prettier": "^3.3.1", 47 | "eslint-plugin-react": "^7.22.0", 48 | "eslint-plugin-simple-import-sort": "^7.0.0", 49 | "file-loader": "^6.2.0", 50 | "fuse.js": "^6.4.6", 51 | "google-cloud-functions-typescript": "^0.0.4", 52 | "html-loader": "^1.3.2", 53 | "html-webpack-plugin": "3.2.0", 54 | "i18next": "^19.8.4", 55 | "immer": "^9.0.6", 56 | "jest": "^26.6.2", 57 | "jest-mock-extended": "^2.0.2-beta2", 58 | "mini-css-extract-plugin": "^1.2.1", 59 | "node-sass": "^7.0.0", 60 | "patch-package": "^6.2.2", 61 | "postinstall-postinstall": "^2.1.0", 62 | "prettier": "^2.2.1", 63 | "puppeteer": "^7.0.1", 64 | "react-i18next": "^11.8.5", 65 | "react-router-dom": "^5.2.0", 66 | "recoil": "^0.1.2", 67 | "sass-loader": "^6.0.7", 68 | "sinon-chrome": "^3.0.1", 69 | "style-loader": "^0.20.3", 70 | "styled-components": "^5.2.1", 71 | "stylelint": "^13.8.0", 72 | "stylelint-config-recommended": "^3.0.0", 73 | "stylelint-config-styled-components": "^0.1.1", 74 | "stylelint-processor-styled-components": "^1.10.0", 75 | "throttle-debounce": "^3.0.1", 76 | "ts-jest": "^26.5.0", 77 | "tsconfig-paths-webpack-plugin": "^3.3.0", 78 | "typescript": "^4.2.4", 79 | "webextension-polyfill-ts": "^0.21.0", 80 | "webpack": "4.6.0", 81 | "webpack-cli": "3.1.2", 82 | "webpack-extension-reloader": "^1.1.4", 83 | "webpack-merge": "^5.3.0" 84 | }, 85 | "dependencies": { 86 | "@geist-ui/react": "^2.1.5", 87 | "@types/throttle-debounce": "^2.1.0", 88 | "fp-ts": "^2.11.1", 89 | "react": "17.0.1", 90 | "react-dom": "^16.13.1" 91 | }, 92 | "keywords": ["react", "typescript"] 93 | } 94 | -------------------------------------------------------------------------------- /src/core/services/chromeActionService.ts: -------------------------------------------------------------------------------- 1 | import { browser, Tabs } from 'webextension-polyfill-ts' 2 | 3 | import { StoreTabsError } from '../errors/chromeAction/StoreTabsError' 4 | import { createNewTabList } from '../factory/tabList' 5 | import { ILLEGAL_URLS } from '../shared/constants' 6 | import { TabSimple } from '../shared/typings' 7 | import { IChromeActionUseCase } from '../useCase/chromeActionUseCase' 8 | import { ITabUseCase } from '../useCase/tabUseCase' 9 | 10 | export class ChromeActionService implements IChromeActionUseCase { 11 | constructor(private readonly tabService: ITabUseCase) {} 12 | 13 | public getAllInWindow(windowId?: number) { 14 | return browser.tabs.query({ windowId }) 15 | } 16 | 17 | public async getAllTabsInCurrentWindow() { 18 | const currentWindow = await browser.windows.getCurrent() 19 | return this.getAllInWindow(currentWindow.id) 20 | } 21 | 22 | public closeAllTabs(tabs: Tabs.Tab[]) { 23 | return browser.tabs.remove(tabs.map(tab => tab.id!)) 24 | } 25 | 26 | /** 27 | * openTabLists 28 | * - check TabX page is already opend or not 29 | * - if opend, move the TabX page, else open new TabX page 30 | */ 31 | public async openTabLists() { 32 | const openTabs = await this.getAllTabsInCurrentWindow() 33 | const appUrl = browser.runtime.getURL('index.html#/app/') 34 | 35 | const hasFoundAppTab = openTabs.find(tab => tab.url === appUrl) 36 | if (hasFoundAppTab) { 37 | // NOTE: change tab to app and reload 38 | return await browser.tabs 39 | .update(hasFoundAppTab.id, { active: true }) 40 | .then(() => browser.tabs.reload(hasFoundAppTab.id)) 41 | } 42 | return await browser.tabs.create({ url: appUrl, pinned: true }) 43 | } 44 | 45 | public async storeTabs(tabs: Tabs.Tab[]) { 46 | const newList = createNewTabList(tabs) 47 | if (!tabs.length) return 48 | 49 | try { 50 | const lists = await this.tabService.getAllTabList() 51 | typeof lists === 'undefined' || lists === null 52 | ? await this.tabService.setAllTabList([newList]) 53 | : await this.tabService.addTabList(newList) 54 | } catch (err) { 55 | throw new StoreTabsError(err) 56 | } 57 | await this.closeAllTabs(tabs).catch(err => console.error(err)) 58 | return newList 59 | } 60 | 61 | public async storeAllTabs() { 62 | const tabs = await this.getAllTabsInCurrentWindow() 63 | const sanitizedTabs = tabs.filter(isValidTab) 64 | 65 | // `res[1]` is storing TabList 66 | await Promise.all([this.openTabLists(), this.storeTabs(sanitizedTabs)]).then(res => res[1]) 67 | } 68 | 69 | public async restoreTabList(tabListId: number) { 70 | // SELECT 71 | const allTabLists = await this.tabService.getAllTabList() 72 | const targetTabListElem = allTabLists.filter(list => list.id === tabListId)[0] 73 | // OPEN 74 | // TODO: refactor 75 | await this.restoreTabs(targetTabListElem.tabs).then(async () => { 76 | // DELETE 77 | await this.tabService.deleteTabList(tabListId) 78 | }) 79 | } 80 | 81 | public async restoreTabs(tabs: TabSimple[]) { 82 | const promises = tabs.map( 83 | async tab => 84 | await browser.tabs.create({ 85 | url: tab.url, 86 | pinned: tab.pinned, 87 | }), 88 | ) 89 | Promise.all(promises) 90 | } 91 | } 92 | 93 | const isLegalURL = (url: string) => ILLEGAL_URLS.every(prefix => !url.startsWith(prefix)) 94 | 95 | const isValidTab = (tab: Tabs.Tab) => { 96 | const appUrl = browser.runtime.getURL('index.html#app/') 97 | if (!tab.url) return false 98 | return !tab.pinned && !tab.url.startsWith(appUrl) && isLegalURL(tab.url) 99 | } 100 | -------------------------------------------------------------------------------- /src/ui/pages/List/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Spacer } from '@geist-ui/react' 2 | import Fuse from 'fuse.js' 3 | import React, { useMemo } from 'react' 4 | import { useTranslation } from 'react-i18next' 5 | import { useRecoilValue } from 'recoil' 6 | 7 | import { APP_NAME } from '~/core/shared/constants' 8 | import { TabList, TabSimple } from '~/core/shared/typings' 9 | import { Header } from '~/ui/components/Header' 10 | import { TabListContainer } from '~/ui/components/List/TabList/TabListContainer' 11 | import { TabSimpleLink } from '~/ui/components/List/TabSimpleLink' 12 | import { STORAGE_KEYS } from '~/ui/constants' 13 | import { useLoadMore, useLocalStorage } from '~/ui/hooks' 14 | import { useFuse } from '~/ui/hooks/useFuse' 15 | import { useHasLoaded } from '~/ui/hooks/useHasLoaded' 16 | import { tabListsState } from '~/ui/stores/tabLists' 17 | import { tabsState } from '~/ui/stores/tabs' 18 | 19 | const listFuseSearchOptions = { 20 | minMatchCharLength: 1, 21 | shouldSort: true, 22 | threshold: 0.3, 23 | findAllMatches: true, 24 | useExtendedSearch: true, 25 | keys: ['title', 'url'], 26 | } 27 | 28 | export const List: React.FC = () => { 29 | const { t } = useTranslation() 30 | const tabLists = useRecoilValue(tabListsState) 31 | const tabs = useRecoilValue(tabsState) 32 | const { searchResults, query, onSearch } = useFuse(tabs, listFuseSearchOptions) 33 | const [isVisibleTabListHeader] = useLocalStorage(STORAGE_KEYS.IS_VISIBLE_TAB_LIST_HEADER) 34 | const [shouldDeleteTabWhenClicked] = useLocalStorage(STORAGE_KEYS.SHOULD_DELETE_TAB_WHEN_CLICKED, true) 35 | 36 | const perLoadCount = useMemo(() => (isVisibleTabListHeader ? 6 : 10), [isVisibleTabListHeader]) 37 | const { itemsToShow, handleShowMoreItems, isMaxLength } = useLoadMore(perLoadCount, tabLists) 38 | const hasLoaded = useHasLoaded() 39 | 40 | return ( 41 | <> 42 |
43 | {!tabLists.length && {t('TAB_LISTS_EMPTY_MESSAGE')}} 44 | {hasLoaded && ( 45 | <> 46 | {query !== '' && searchResults.length > 0 ? ( 47 | 48 | ) : ( 49 | <> 50 | {query === '' ? ( 51 | 57 | ) : ( 58 |

No results found, Try with another query

59 | )} 60 | 61 | )} 62 | 63 | )} 64 | 65 | ) 66 | } 67 | 68 | type BaseTabListProps = { 69 | tabLists: TabList[] 70 | handleShowMoreItems: () => void 71 | isVisibleTabListHeader: boolean 72 | isMaxLength: boolean 73 | } 74 | 75 | const BaseTabList: React.FC = ({ 76 | tabLists, 77 | isMaxLength, 78 | handleShowMoreItems, 79 | isVisibleTabListHeader, 80 | }) => { 81 | return ( 82 | <> 83 | {tabLists.map((item, index) => ( 84 | 85 | ))} 86 | 87 | {!isMaxLength && } 88 | 89 | ) 90 | } 91 | 92 | type SearchResultProps = { 93 | searchResults: Fuse.FuseResult[] 94 | shouldDeleteTabWhenClicked: boolean 95 | } 96 | 97 | const SearchResult: React.FC = ({ searchResults, shouldDeleteTabWhenClicked }) => { 98 | return ( 99 | <> 100 | {searchResults.map((tab, index) => ( 101 | 108 | ))} 109 | 110 | ) 111 | } 112 | -------------------------------------------------------------------------------- /src/ui/components/List/TabList/internal/TabListHeader.tsx: -------------------------------------------------------------------------------- 1 | import { Modal, Popover, Textarea, useInput, useModal, useTheme, useToasts } from '@geist-ui/react' 2 | import { Menu } from '@geist-ui/react-icons' 3 | import React from 'react' 4 | import { useTranslation } from 'react-i18next' 5 | import { SetterOrUpdater } from 'recoil' 6 | import styled from 'styled-components' 7 | 8 | import { tabService } from '~/core/services' 9 | import { TabList } from '~/core/shared/typings' 10 | import { Spacing } from '~/ui/constants/styles' 11 | import { useMouseOver } from '~/ui/hooks' 12 | import { getDisplayTitle } from '~/ui/utils/tabListTitle' 13 | 14 | import { _Row, HoveredMenu } from '../style' 15 | import { TabListMenuContent } from './TabListMenuContent' 16 | 17 | type Props = { 18 | index: number 19 | tabList: TabList 20 | setTabList: SetterOrUpdater 21 | isLG?: boolean 22 | } 23 | 24 | const MAX_INPUT_LENGTH = 1000 25 | 26 | export const TabListHeader: React.VFC = ({ index, tabList, setTabList, isLG }) => { 27 | const { handleMouseOver, handleMouseOut } = useMouseOver() 28 | const displayTitle = getDisplayTitle(tabList, isLG || false) 29 | const [, setToast] = useToasts() 30 | const { t } = useTranslation() 31 | 32 | // theme 33 | const theme = useTheme() 34 | const popoverColor = theme.palette.foreground 35 | const popoverBgColor = theme.palette.accents_2 36 | 37 | const { setVisible: setModalVisible, bindings: modalBindings } = useModal() 38 | const { state: inputState, bindings: textAreaBindings } = useInput(tabList.description || '') 39 | 40 | const saveDescription = async () => { 41 | setModalVisible(false) 42 | 43 | if (inputState.length > MAX_INPUT_LENGTH) { 44 | alert('Over the limit text length! (文字数が1000文字の制限を超えています)') 45 | return 46 | } 47 | 48 | await tabService 49 | .saveTabListDescription(inputState, tabList.id) 50 | .then(() => { 51 | setToast({ type: 'success', text: t('SAVE_DESCRIPTION') }) 52 | const newTabList = { ...tabList, description: inputState } 53 | setTabList(newTabList) 54 | }) 55 | .catch(e => setToast({ type: 'error', text: e.message })) 56 | } 57 | 58 | return ( 59 | <> 60 | <_Row 61 | style={{ minHeight: '50px' }} 62 | onMouseOver={() => handleMouseOver(index)} 63 | onMouseLeave={() => handleMouseOut()} 64 | > 65 | 66 | <_Popover 67 | placement={'bottomStart'} 68 | leaveDelay={2} 69 | offset={12} 70 | content={ 71 | setModalVisible(true)} 73 | tabList={tabList} 74 | setTabList={setTabList} 75 | /> 76 | } 77 | style={{ 78 | padding: Spacing['2'], 79 | }} 80 | $color={popoverColor} 81 | $bgColor={popoverBgColor} 82 | > 83 | 90 | 91 | 92 | {displayTitle} 93 | 94 | 95 | 96 | Edit Description 97 | 98 |