├── .github └── workflows │ └── build.yml ├── .gitignore ├── .million └── store.json ├── .prettierrc.mjs ├── .vscode └── settings.json ├── debug_proxy.html ├── eslint.config.mjs ├── index.html ├── package.json ├── patches └── @tanstack__react-virtual@3.10.9.patch ├── plugins ├── css-plugin.js └── eslint-recursive-sort.js ├── pnpm-lock.yaml ├── postcss.config.cjs ├── readme.md ├── renovate.json ├── setup-file.ts ├── src ├── App.tsx ├── assets │ └── fonts │ │ └── GeistVF.woff2 ├── atoms │ ├── app.ts │ ├── context-menu.ts │ ├── dom.ts │ ├── helper │ │ └── setting.ts │ ├── route.ts │ ├── settings │ │ ├── general.ts │ │ └── ui.ts │ ├── sidebar.ts │ └── user.ts ├── components │ ├── common │ │ ├── ErrorElement.tsx │ │ ├── LoadRemixAsyncComponent.tsx │ │ ├── NotFound.tsx │ │ └── ProviderComposer.tsx │ ├── layout │ │ └── sidebar │ │ │ ├── atoms.ts │ │ │ └── index.tsx │ └── ui │ │ ├── avatar │ │ └── index.tsx │ │ ├── button │ │ ├── Button.tsx │ │ ├── MotionButton.tsx │ │ └── index.ts │ │ ├── context-menu │ │ ├── context-menu.tsx │ │ └── index.ts │ │ ├── datetime │ │ └── index.tsx │ │ ├── divider │ │ ├── Divider.tsx │ │ ├── PanelSpliter.tsx │ │ └── index.ts │ │ ├── dropdown-menu │ │ └── DropdownMenu.tsx │ │ ├── icons │ │ └── ActivityType.tsx │ │ ├── input │ │ ├── Input.tsx │ │ ├── TextArea.tsx │ │ └── index.ts │ │ ├── kbd │ │ └── Kbd.tsx │ │ ├── loading.tsx │ │ ├── markdown │ │ └── index.tsx │ │ ├── portal │ │ ├── index.tsx │ │ └── provider.tsx │ │ ├── scroll-area │ │ ├── ScrollArea.tsx │ │ ├── ctx.ts │ │ ├── hooks.ts │ │ ├── index.module.css │ │ └── index.ts │ │ ├── tabs │ │ └── index.tsx │ │ ├── toast │ │ └── index.tsx │ │ └── tooltip │ │ ├── index.tsx │ │ └── styles.ts ├── database │ ├── constants.ts │ ├── db.ts │ ├── db_schema.ts │ ├── global.d.ts │ ├── index.ts │ ├── schemas │ │ ├── base.ts │ │ └── index.ts │ └── services │ │ ├── base.ts │ │ ├── issue.ts │ │ ├── meta.ts │ │ ├── notification.ts │ │ ├── pull-request.ts │ │ ├── repo-pin.ts │ │ ├── repo.ts │ │ └── user.ts ├── framer-lazy-feature.ts ├── global.d.ts ├── hooks │ ├── biz │ │ └── useRouter.ts │ └── common │ │ ├── index.ts │ │ ├── useDark.ts │ │ ├── useInputComposition.ts │ │ ├── useIsOnline.ts │ │ ├── useMeasure.ts │ │ ├── usePrevious.ts │ │ ├── useRefValue.ts │ │ ├── useTitle.ts │ │ └── useTypescriptHappyCallback.ts ├── initialize │ ├── hydrate.ts │ ├── index.ts │ └── jobs │ │ ├── index.ts │ │ └── polling.ts ├── lib │ ├── cn.ts │ ├── dev.tsx │ ├── dom.ts │ ├── gh.ts │ ├── i18n.ts │ ├── jotai.ts │ ├── log.ts │ ├── ns.ts │ ├── octokit.ts │ ├── parser.ts │ ├── query-client.ts │ ├── route-builder.ts │ └── utils.ts ├── main.tsx ├── modules │ └── notification │ │ ├── IssueDetail.tsx │ │ ├── NotificationItem.tsx │ │ ├── PullRequestDetail.tsx │ │ ├── atom.ts │ │ ├── list.tsx │ │ └── peek.tsx ├── pages │ └── (main) │ │ ├── index.tsx │ │ ├── layout.tsx │ │ └── notifications │ │ ├── (type) │ │ ├── [repo].tsx │ │ └── all.tsx │ │ └── layout.tsx ├── providers │ ├── context-menu-provider.tsx │ ├── root-providers.tsx │ ├── setting-sync.tsx │ └── stable-router-provider.tsx ├── router.tsx ├── scan.ts ├── store │ ├── issue │ │ ├── hooks.ts │ │ └── store.ts │ ├── notification │ │ ├── helper.ts │ │ ├── hooks.ts │ │ ├── selectors.ts │ │ └── store.ts │ ├── pull-request │ │ ├── hooks.ts │ │ └── store.ts │ ├── repo-pin │ │ ├── getters.ts │ │ ├── hooks.ts │ │ └── store.ts │ ├── repo │ │ ├── getters.ts │ │ ├── hooks.ts │ │ ├── selectors.ts │ │ └── store.ts │ └── utils │ │ ├── helper.test.ts │ │ ├── helper.ts │ │ └── queue.ts ├── styles │ ├── index.css │ ├── layer.css │ ├── tailwind.css │ └── theme.css └── vite-env.d.ts ├── tailwind.config.ts ├── todo.md ├── tsconfig.json ├── vercel.json ├── vite.config.ts └── vitest.config.ts /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Build 5 | 6 | on: 7 | push: 8 | branches: [main, master] 9 | pull_request: 10 | branches: [main, master] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [20.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | 27 | - name: Cache pnpm modules 28 | uses: actions/cache@v4 29 | env: 30 | cache-name: cache-pnpm-modules 31 | with: 32 | path: ~/.pnpm-store 33 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-${{ hashFiles('**/pnpm-lock.yaml') }} 34 | restore-keys: | 35 | ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}- 36 | 37 | - name: Setup pnpm 38 | uses: pnpm/action-setup@v4 39 | with: 40 | run_install: true 41 | - run: pnpm run build 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | 7 | # Million Lint 8 | .million 9 | -------------------------------------------------------------------------------- /.prettierrc.mjs: -------------------------------------------------------------------------------- 1 | import { factory } from '@innei/prettier' 2 | 3 | export default factory({ 4 | importSort: false, 5 | }) 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "[javascript][javascriptreact][typescript][typescriptreact][json][jsonc]": { 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": "explicit" 6 | } 7 | }, 8 | // If you do not want to autofix some rules on save 9 | // You can put this in your user settings or workspace settings 10 | "eslint.codeActionsOnSave.rules": [ 11 | "!unused-imports/no-unused-imports", 12 | "*" 13 | ], 14 | 15 | // If you want to silent stylistic rules 16 | // You can put this in your user settings or workspace settings 17 | "eslint.rules.customizations": [ 18 | { "rule": "@stylistic/*", "severity": "off", "fixable": true }, 19 | { "rule": "antfu/consistent-list-newline", "severity": "off" }, 20 | { "rule": "hyoban/jsx-attribute-spacing", "severity": "off" }, 21 | { "rule": "simple-import-sort/*", "severity": "off" }, 22 | { "rule": "unused-imports/no-unused-imports", "severity": "off" } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /debug_proxy.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Debug Proxy 5 | 6 | 89 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { defineConfig } from 'eslint-config-hyoban' 3 | 4 | import recursiveSort from './plugins/eslint-recursive-sort.js' 5 | 6 | export default defineConfig( 7 | { 8 | formatting: false, 9 | lessOpinionated: true, 10 | ignores: ['dist/**'], 11 | preferESM: false, 12 | }, 13 | { 14 | settings: { 15 | tailwindcss: { 16 | whitelist: ['center'], 17 | }, 18 | }, 19 | rules: { 20 | 'unicorn/prefer-math-trunc': 'off', 21 | 'unicorn/expiring-todo-comments': 0, 22 | '@eslint-react/no-clone-element': 0, 23 | '@eslint-react/hooks-extra/no-direct-set-state-in-use-effect': 0, 24 | // NOTE: Disable this temporarily 25 | 'react-compiler/react-compiler': 0, 26 | 'no-restricted-syntax': 0, 27 | 'package-json/valid-name': 0, 28 | 'no-restricted-globals': [ 29 | 'error', 30 | { 31 | name: 'location', 32 | message: 33 | "Since you don't use the same router instance in electron and browser, you can't use the global location to get the route info. \n\n" + 34 | 'You can use `useLocaltion` or `getReadonlyRoute` to get the route info.', 35 | }, 36 | ], 37 | }, 38 | }, 39 | { 40 | files: ['**/*.tsx'], 41 | rules: { 42 | '@stylistic/jsx-self-closing-comp': 'error', 43 | }, 44 | }, 45 | { 46 | files: ['locales/**/*.json'], 47 | plugins: { 48 | 'recursive-sort': recursiveSort, 49 | }, 50 | rules: { 51 | 'recursive-sort/recursive-sort': 'error', 52 | }, 53 | }, 54 | { 55 | files: ['package.json'], 56 | rules: { 57 | 'package-json/valid-name': 0, 58 | }, 59 | }, 60 | ) 61 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Linear 8 | 39 | 54 | 55 | 56 |
57 |
58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Linear", 3 | "type": "module", 4 | "version": "0.0.0", 5 | "packageManager": "pnpm@10.8.0", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://" 9 | }, 10 | "scripts": { 11 | "build": "tsc && vite build", 12 | "dev": "vite", 13 | "format": "prettier --write \"src/**/*.ts\" ", 14 | "lint": "eslint --fix", 15 | "prepare": "simple-git-hooks", 16 | "serve": "vite preview" 17 | }, 18 | "dependencies": { 19 | "@headlessui/react": "2.2.1", 20 | "@infinite-list/react": "3.0.1", 21 | "@million/lint": "1.0.14", 22 | "@octokit/core": "6.1.4", 23 | "@radix-ui/react-avatar": "1.1.3", 24 | "@radix-ui/react-context-menu": "2.2.6", 25 | "@radix-ui/react-dialog": "1.1.6", 26 | "@radix-ui/react-dropdown-menu": "2.1.6", 27 | "@radix-ui/react-scroll-area": "1.2.3", 28 | "@radix-ui/react-slot": "1.1.2", 29 | "@radix-ui/react-tabs": "1.1.3", 30 | "@radix-ui/react-tooltip": "1.1.8", 31 | "@remixicon/react": "4.6.0", 32 | "@tanstack/react-query": "5.72.1", 33 | "@tanstack/react-virtual": "3.13.6", 34 | "@types/react-syntax-highlighter": "15.5.13", 35 | "clsx": "2.1.1", 36 | "date-fns": "4.1.0", 37 | "dexie": "4.0.11", 38 | "dexie-export-import": "4.1.4", 39 | "es-toolkit": "1.34.1", 40 | "foxact": "0.2.45", 41 | "github-markdown-css": "5.8.1", 42 | "immer": "10.1.1", 43 | "jotai": "2.12.2", 44 | "motion": "12.6.3", 45 | "octokit": "4.1.2", 46 | "ofetch": "1.4.1", 47 | "pluralize": "8.0.0", 48 | "react": "19.1.0", 49 | "react-context-selector": "1.0.3", 50 | "react-diff-viewer": "3.1.1", 51 | "react-dom": "19.1.0", 52 | "react-markdown": "10.1.0", 53 | "react-resizable-layout": "npm:@innei/react-resizable-layout@0.7.3-fork.1", 54 | "react-router": "7.5.0", 55 | "react-scan": "0.3.3", 56 | "react-syntax-highlighter": "15.6.1", 57 | "rehype-raw": "7.0.0", 58 | "remark-gfm": "4.0.1", 59 | "sonner": "2.0.3", 60 | "tailwind-merge": "3.2.0", 61 | "use-sync-external-store": "1.5.0", 62 | "usehooks-ts": "3.1.1", 63 | "vaul": "1.1.2", 64 | "zustand": "5.0.3" 65 | }, 66 | "devDependencies": { 67 | "@egoist/tailwindcss-icons": "1.9.0", 68 | "@iconify-json/mingcute": "1.2.3", 69 | "@iconify-json/octicon": "1.2.5", 70 | "@innei/prettier": "^0.15.0", 71 | "@octokit/openapi-types": "24.0.0", 72 | "@tailwindcss/container-queries": "0.1.1", 73 | "@tailwindcss/typography": "0.5.16", 74 | "@types/node": "22.14.0", 75 | "@types/pluralize": "0.0.33", 76 | "@types/react": "19.1.0", 77 | "@types/react-dom": "19.1.1", 78 | "@vitejs/plugin-react": "^4.3.4", 79 | "autoprefixer": "10.4.21", 80 | "click-to-react-component": "1.1.2", 81 | "daisyui": "^4", 82 | "eslint": "9.24.0", 83 | "eslint-config-hyoban": "4.0.2", 84 | "fake-indexeddb": "6.0.0", 85 | "happy-dom": "17.4.4", 86 | "kolorist": "1.8.0", 87 | "lint-staged": "15.5.0", 88 | "postcss": "8.5.3", 89 | "postcss-import": "16.1.0", 90 | "postcss-js": "4.0.1", 91 | "prettier": "3.5.3", 92 | "simple-git-hooks": "2.12.1", 93 | "tailwind-scrollbar": "4.0.1", 94 | "tailwind-variants": "0.3.1", 95 | "tailwindcss": "^3", 96 | "tailwindcss-animate": "1.0.7", 97 | "tailwindcss-animated": "2.0.0", 98 | "tailwindcss-motion": "1.1.0", 99 | "tailwindcss-uikit-colors": "0.3.10", 100 | "typescript": "5.8.3", 101 | "unplugin-ast": "0.14.4", 102 | "vite": "6.2.5", 103 | "vite-plugin-checker": "0.9.1", 104 | "vite-tsconfig-paths": "5.1.4", 105 | "vitest": "3.1.1" 106 | }, 107 | "simple-git-hooks": { 108 | "pre-commit": "pnpm exec lint-staged" 109 | }, 110 | "lint-staged": { 111 | "*.{js,jsx,ts,tsx}": [ 112 | "prettier --ignore-path ./.gitignore --write " 113 | ], 114 | "*.{js,ts,cjs,mjs,jsx,tsx,json}": [ 115 | "eslint --fix" 116 | ] 117 | } 118 | } -------------------------------------------------------------------------------- /patches/@tanstack__react-virtual@3.10.9.patch: -------------------------------------------------------------------------------- 1 | diff --git a/dist/esm/index.js b/dist/esm/index.js 2 | index 13b1ae504e8b55efcd9d8cecd30df8ab234edc18..a8549c560b0d6b7a65b4dc58e8fb134d04dda037 100644 3 | --- a/dist/esm/index.js 4 | +++ b/dist/esm/index.js 5 | @@ -22,6 +22,9 @@ function useVirtualizerBase(options) { 6 | ); 7 | instance.setOptions(resolvedOptions); 8 | React.useEffect(() => { 9 | + if (!instance.scrollElement) { 10 | + instance._willUpdate() 11 | + } 12 | return instance._didMount(); 13 | }, []); 14 | useIsomorphicLayoutEffect(() => { 15 | -------------------------------------------------------------------------------- /plugins/css-plugin.js: -------------------------------------------------------------------------------- 1 | // https://github.com/tailwindlabs/tailwindcss-intellisense/issues/227#issuecomment-1462034856 2 | // cssAsPlugin.js 3 | const postcss = require('postcss') 4 | const postcssJs = require('postcss-js') 5 | const { readFileSync } = require('node:fs') 6 | 7 | require.extensions['.css'] = function (module, filename) { 8 | module.exports = ({ addBase, addComponents, addUtilities }) => { 9 | const css = readFileSync(filename, 'utf8') 10 | const root = postcss.parse(css) 11 | const jss = postcssJs.objectify(root) 12 | 13 | if ('@layer base' in jss) { 14 | addBase(jss['@layer base']) 15 | } 16 | if ('@layer components' in jss) { 17 | addComponents(jss['@layer components']) 18 | } 19 | if ('@layer utilities' in jss) { 20 | addUtilities(jss['@layer utilities']) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /plugins/eslint-recursive-sort.js: -------------------------------------------------------------------------------- 1 | const sortObjectKeys = (obj) => { 2 | if (typeof obj !== 'object' || obj === null) { 3 | return obj 4 | } 5 | 6 | if (Array.isArray(obj)) { 7 | return obj.map((element) => sortObjectKeys(element)) 8 | } 9 | 10 | return Object.keys(obj) 11 | .sort() 12 | .reduce((acc, key) => { 13 | acc[key] = sortObjectKeys(obj[key]) 14 | return acc 15 | }, {}) 16 | } 17 | /** 18 | * @type {import("eslint").ESLint.Plugin} 19 | */ 20 | export default { 21 | rules: { 22 | 'recursive-sort': { 23 | meta: { 24 | type: 'layout', 25 | fixable: 'code', 26 | }, 27 | create(context) { 28 | return { 29 | Program(node) { 30 | if (context.getFilename().endsWith('.json')) { 31 | const sourceCode = context.getSourceCode() 32 | const text = sourceCode.getText() 33 | 34 | try { 35 | const json = JSON.parse(text) 36 | const sortedJson = sortObjectKeys(json) 37 | const sortedText = JSON.stringify(sortedJson, null, 2) 38 | 39 | if (text.trim() !== sortedText.trim()) { 40 | context.report({ 41 | node, 42 | message: 'JSON keys are not sorted recursively', 43 | fix(fixer) { 44 | return fixer.replaceText(node, sortedText) 45 | }, 46 | }) 47 | } 48 | } catch (error) { 49 | context.report({ 50 | node, 51 | message: `Invalid JSON: ${error.message}`, 52 | }) 53 | } 54 | } 55 | }, 56 | } 57 | }, 58 | }, 59 | }, 60 | } 61 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'postcss-import': {}, 4 | 'tailwindcss/nesting': {}, 5 | tailwindcss: {}, 6 | autoprefixer: {}, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Linear 2 | 3 | A more user-friendly GitHub notification management tool (WIP) 4 | 5 | ![CleanShot 2025-02-18 at 12  03 56@2x](https://github.com/user-attachments/assets/069fb577-5ef6-4768-89e5-957725b14204) 6 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /setup-file.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import 'fake-indexeddb/auto' 3 | 4 | import { enableMapSet } from 'immer' 5 | 6 | globalThis.window = { 7 | location: new URL('https://example.com'), 8 | __dbIsReady: true, 9 | addEventListener: () => {}, 10 | get navigator() { 11 | return globalThis.navigator 12 | }, 13 | } 14 | 15 | if (!globalThis.navigator) { 16 | globalThis.navigator = { 17 | onLine: true, 18 | userAgent: 'node', 19 | } 20 | } 21 | enableMapSet() 22 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { 3 | Fragment, 4 | useEffect, 5 | useInsertionEffect, 6 | useRef, 7 | } from 'react' 8 | import { Outlet } from 'react-router' 9 | 10 | import { setGHToken, useAppIsReady, useGHToken } from './atoms/app' 11 | import { useUISettingKey } from './atoms/settings/ui' 12 | import { Button } from './components/ui/button' 13 | import { Input } from './components/ui/input' 14 | import { appLog } from './lib/log' 15 | import { RootProviders } from './providers/root-providers' 16 | 17 | export const App: FC = () => { 18 | return ( 19 | 20 | 21 | 22 | ) 23 | } 24 | 25 | const removeAppSkeleton = () => { 26 | const skeleton = document.querySelector('#skeleton') 27 | if (skeleton) { 28 | skeleton.remove() 29 | } 30 | } 31 | 32 | const AppLayer = () => { 33 | const appIsReady = useAppIsReady() 34 | 35 | useInsertionEffect(() => { 36 | removeAppSkeleton() 37 | }, []) 38 | useEffect(() => { 39 | const doneTime = Math.trunc(performance.now()) 40 | appLog('App is ready', `${doneTime}ms`) 41 | // applyAfterReadyCallbacks() 42 | }, [appIsReady]) 43 | 44 | return appIsReady ? : 45 | } 46 | 47 | const AppSkeleton = () => { 48 | const sidebarColWidth = useUISettingKey('sidebarColWidth') 49 | const ghtoken = useGHToken() 50 | return ( 51 | 52 |
58 | {!ghtoken && ( 59 |
60 | 61 |
62 | )} 63 | 64 | ) 65 | } 66 | 67 | const GHTokenForm = () => { 68 | const tokenRef = useRef('') 69 | return ( 70 |
71 |
GitHub Token
72 |
73 | Please enter your GitHub token 74 |
75 | 76 | { 81 | tokenRef.current = e.target.value 82 | }} 83 | /> 84 | 85 | 94 |
95 | ) 96 | } 97 | 98 | export default App 99 | -------------------------------------------------------------------------------- /src/assets/fonts/GeistVF.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Innei/linear/427685985fb1d0f59db725e83de57057c167d1b5/src/assets/fonts/GeistVF.woff2 -------------------------------------------------------------------------------- /src/atoms/app.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | import { atomWithStorage } from 'jotai/utils' 3 | 4 | import { createAtomHooks } from '~/lib/jotai' 5 | import { getStorageNS } from '~/lib/ns' 6 | 7 | export const [, , useAppIsReady, , appIsReady, setAppIsReady] = createAtomHooks( 8 | atom(false), 9 | ) 10 | 11 | export const [, , useAppSearchOpen, , , setAppSearchOpen] = createAtomHooks( 12 | atom(false), 13 | ) 14 | 15 | export const [ 16 | , 17 | , 18 | useAppPollingInterval, 19 | , 20 | getAppPollingInterval, 21 | setAppPollingInterval, 22 | ] = createAtomHooks(atom(60 * 1000)) 23 | 24 | export const [, , useGHToken, , getGHToken, setGHToken] = createAtomHooks( 25 | atomWithStorage(getStorageNS('gh-token'), '', undefined, { 26 | getOnInit: true, 27 | }), 28 | ) 29 | 30 | export const clearGHToken = () => { 31 | setGHToken('') 32 | localStorage.removeItem(getStorageNS('gh-token')) 33 | } 34 | -------------------------------------------------------------------------------- /src/atoms/context-menu.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | import { useCallback } from 'react' 3 | 4 | import { createAtomHooks } from '~/lib/jotai' 5 | 6 | // Atom 7 | 8 | type ContextMenuState = 9 | | { open: false } 10 | | { 11 | open: true 12 | position: { x: number; y: number } 13 | menuItems: FollowMenuItem[] 14 | // Just for abort callback 15 | // Also can be optimized by using the `atomWithListeners` 16 | abortController: AbortController 17 | } 18 | 19 | export const [ 20 | contextMenuAtom, 21 | useContextMenuState, 22 | useContextMenuValue, 23 | useSetContextMenu, 24 | ] = createAtomHooks(atom({ open: false })) 25 | 26 | const useShowWebContextMenu = () => { 27 | const setContextMenu = useSetContextMenu() 28 | 29 | const showWebContextMenu = useCallback( 30 | async ( 31 | menuItems: Array, 32 | e: MouseEvent | React.MouseEvent, 33 | ) => { 34 | const abortController = new AbortController() 35 | const resolvers = Promise.withResolvers() 36 | setContextMenu({ 37 | open: true, 38 | position: { x: e.clientX, y: e.clientY }, 39 | menuItems, 40 | abortController, 41 | }) 42 | 43 | abortController.signal.addEventListener('abort', () => { 44 | resolvers.resolve() 45 | }) 46 | return resolvers.promise 47 | }, 48 | [setContextMenu], 49 | ) 50 | 51 | return showWebContextMenu 52 | } 53 | 54 | // Menu 55 | 56 | export type BaseMenuItemText = { 57 | type: 'text' 58 | label: string 59 | click?: () => void 60 | /** only work in web app */ 61 | icon?: React.ReactNode 62 | shortcut?: string 63 | disabled?: boolean 64 | checked?: boolean 65 | supportMultipleSelection?: boolean 66 | } 67 | 68 | type BaseMenuItemSeparator = { 69 | type: 'separator' 70 | disabled?: boolean 71 | } 72 | 73 | type BaseMenuItem = BaseMenuItemText | BaseMenuItemSeparator 74 | 75 | export type FollowMenuItem = BaseMenuItem & { 76 | submenu?: FollowMenuItem[] 77 | } 78 | 79 | export type MenuItemInput = 80 | | (BaseMenuItemText & { hide?: boolean; submenu?: MenuItemInput[] }) 81 | | (BaseMenuItemSeparator & { hide?: boolean }) 82 | | null 83 | | undefined 84 | | false 85 | | '' 86 | 87 | function sortShortcutsString(shortcut: string) { 88 | const order = ['Shift', 'Ctrl', 'Meta', 'Alt'] 89 | const nextShortcut = shortcut 90 | 91 | const arr = nextShortcut.split('+') 92 | 93 | const sortedModifiers = arr 94 | .filter((key) => order.includes(key)) 95 | .sort((a, b) => order.indexOf(a) - order.indexOf(b)) 96 | 97 | const otherKeys = arr.filter((key) => !order.includes(key)) 98 | 99 | return [...sortedModifiers, ...otherKeys].join('+') 100 | } 101 | 102 | function normalizeMenuItems(items: MenuItemInput[]): FollowMenuItem[] { 103 | return items 104 | .filter( 105 | (item) => 106 | item !== null && item !== undefined && item !== false && item !== '', 107 | ) 108 | .filter((item) => !item.hide) 109 | .map((item) => { 110 | if (item.type === 'separator') { 111 | return item 112 | } 113 | 114 | return { 115 | ...item, 116 | shortcut: item.shortcut 117 | ? sortShortcutsString(item.shortcut) 118 | : undefined, 119 | submenu: item.submenu ? normalizeMenuItems(item.submenu) : undefined, 120 | } 121 | }) 122 | } 123 | 124 | export const useShowContextMenu = () => { 125 | const showWebContextMenu = useShowWebContextMenu() 126 | 127 | const showContextMenu = useCallback( 128 | async ( 129 | inputMenu: Array, 130 | e: MouseEvent | React.MouseEvent, 131 | ) => { 132 | const menuItems = normalizeMenuItems(inputMenu) 133 | 134 | await showWebContextMenu(menuItems, e) 135 | }, 136 | [showWebContextMenu], 137 | ) 138 | 139 | return showContextMenu 140 | } 141 | -------------------------------------------------------------------------------- /src/atoms/dom.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | 3 | import { createAtomHooks } from '~/lib/jotai' 4 | 5 | export const [ 6 | , 7 | , 8 | useMainContainerElement, 9 | , 10 | getMainContainerElement, 11 | setMainContainerElement, 12 | ] = createAtomHooks(atom(null)) 13 | 14 | export const [ 15 | , 16 | , 17 | useRootContainerElement, 18 | , 19 | getRootContainerElement, 20 | setRootContainerElement, 21 | ] = createAtomHooks(atom(null)) 22 | -------------------------------------------------------------------------------- /src/atoms/helper/setting.ts: -------------------------------------------------------------------------------- 1 | import { atom as jotaiAtom, useAtomValue } from 'jotai' 2 | import { atomWithStorage, selectAtom } from 'jotai/utils' 3 | import { useMemo } from 'react' 4 | 5 | import { useRefValue } from '~/hooks/common' 6 | import { createAtomHooks } from '~/lib/jotai' 7 | import { getStorageNS } from '~/lib/ns' 8 | 9 | export const createSettingAtom = ( 10 | settingKey: string, 11 | createDefaultSettings: () => T, 12 | ) => { 13 | const atom = atomWithStorage( 14 | getStorageNS(settingKey), 15 | createDefaultSettings(), 16 | undefined, 17 | { 18 | getOnInit: true, 19 | }, 20 | ) 21 | 22 | const [, , useSettingValue, , getSettings, setSettings] = 23 | createAtomHooks(atom) 24 | 25 | const initializeDefaultSettings = () => { 26 | const currentSettings = getSettings() 27 | const defaultSettings = createDefaultSettings() 28 | if (typeof currentSettings !== 'object') setSettings(defaultSettings) 29 | const newSettings = { ...defaultSettings, ...currentSettings } 30 | setSettings(newSettings) 31 | } 32 | 33 | const selectAtomCacheMap = {} as Record< 34 | keyof ReturnType, 35 | any 36 | > 37 | 38 | const noopAtom = jotaiAtom(null) 39 | 40 | const useMaybeSettingKey = >( 41 | key: Nullable, 42 | ) => { 43 | // @ts-expect-error 44 | let selectedAtom: Record[T] | null = null 45 | if (key) { 46 | selectedAtom = selectAtomCacheMap[key] 47 | if (!selectedAtom) { 48 | selectedAtom = selectAtom(atom, (s) => s[key]) 49 | selectAtomCacheMap[key] = selectedAtom 50 | } 51 | } else { 52 | selectedAtom = noopAtom 53 | } 54 | 55 | return useAtomValue(selectedAtom) as ReturnType[T] 56 | } 57 | 58 | const useSettingKey = >( 59 | key: T, 60 | ) => { 61 | return useMaybeSettingKey(key) as ReturnType[T] 62 | } 63 | 64 | function useSettingKeys< 65 | T extends keyof ReturnType, 66 | K1 extends T, 67 | K2 extends T, 68 | K3 extends T, 69 | K4 extends T, 70 | K5 extends T, 71 | K6 extends T, 72 | K7 extends T, 73 | K8 extends T, 74 | K9 extends T, 75 | K10 extends T, 76 | >(keys: [K1, K2?, K3?, K4?, K5?, K6?, K7?, K8?, K9?, K10?]) { 77 | return [ 78 | useMaybeSettingKey(keys[0]), 79 | useMaybeSettingKey(keys[1]), 80 | useMaybeSettingKey(keys[2]), 81 | useMaybeSettingKey(keys[3]), 82 | useMaybeSettingKey(keys[4]), 83 | useMaybeSettingKey(keys[5]), 84 | useMaybeSettingKey(keys[6]), 85 | useMaybeSettingKey(keys[7]), 86 | useMaybeSettingKey(keys[8]), 87 | useMaybeSettingKey(keys[9]), 88 | ] as [ 89 | ReturnType[K1], 90 | ReturnType[K2], 91 | ReturnType[K3], 92 | ReturnType[K4], 93 | ReturnType[K5], 94 | ReturnType[K6], 95 | ReturnType[K7], 96 | ReturnType[K8], 97 | ReturnType[K9], 98 | ReturnType[K10], 99 | ] 100 | } 101 | 102 | const useSettingSelector = < 103 | T extends keyof ReturnType, 104 | S extends ReturnType, 105 | R = S[T], 106 | >( 107 | selector: (s: S) => R, 108 | ): R => { 109 | const stableSelector = useRefValue(selector) 110 | 111 | return useAtomValue( 112 | // @ts-expect-error 113 | useMemo(() => selectAtom(atom, stableSelector.current), [stableSelector]), 114 | ) 115 | } 116 | 117 | const setSetting = >( 118 | key: K, 119 | value: ReturnType[K], 120 | ) => { 121 | const updated = Date.now() 122 | setSettings({ 123 | ...getSettings(), 124 | [key]: value, 125 | 126 | updated, 127 | }) 128 | } 129 | 130 | const clearSettings = () => { 131 | setSettings(createDefaultSettings()) 132 | } 133 | 134 | Object.defineProperty(useSettingValue, 'select', { 135 | value: useSettingSelector, 136 | }) 137 | 138 | return { 139 | useSettingKey, 140 | useSettingSelector, 141 | setSetting, 142 | clearSettings, 143 | initializeDefaultSettings, 144 | 145 | useSettingValue, 146 | useSettingKeys, 147 | getSettings, 148 | 149 | settingAtom: atom, 150 | } as { 151 | useSettingKey: typeof useSettingKey 152 | useSettingSelector: typeof useSettingSelector 153 | setSetting: typeof setSetting 154 | clearSettings: typeof clearSettings 155 | initializeDefaultSettings: typeof initializeDefaultSettings 156 | useSettingValue: typeof useSettingValue & { 157 | select: T>>(key: T) => Awaited 158 | } 159 | useSettingKeys: typeof useSettingKeys 160 | getSettings: typeof getSettings 161 | settingAtom: typeof atom 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/atoms/route.ts: -------------------------------------------------------------------------------- 1 | import { atom, useAtomValue } from 'jotai' 2 | import { selectAtom } from 'jotai/utils' 3 | import { useMemo } from 'react' 4 | import type { Location, NavigateFunction, Params } from 'react-router' 5 | 6 | import { createAtomHooks } from '~/lib/jotai' 7 | 8 | interface RouteAtom { 9 | params: Readonly> 10 | searchParams: URLSearchParams 11 | location: Location 12 | } 13 | 14 | export const [routeAtom, , , , getReadonlyRoute, setRoute] = createAtomHooks( 15 | atom({ 16 | params: {}, 17 | searchParams: new URLSearchParams(), 18 | location: { 19 | pathname: '', 20 | search: '', 21 | hash: '', 22 | state: null, 23 | key: '', 24 | }, 25 | }), 26 | ) 27 | 28 | const noop = [] 29 | export const useReadonlyRouteSelector = ( 30 | selector: (route: RouteAtom) => T, 31 | deps: any[] = noop, 32 | ): T => 33 | useAtomValue( 34 | useMemo(() => selectAtom(routeAtom, (route) => selector(route)), deps), 35 | ) 36 | 37 | // Vite HMR will create new router instance, but RouterProvider always stable 38 | 39 | const [, , , , navigate, setNavigate] = createAtomHooks( 40 | atom<{ fn: NavigateFunction | null }>({ fn() {} }), 41 | ) 42 | const getStableRouterNavigate = () => navigate().fn 43 | export { getStableRouterNavigate, setNavigate } 44 | -------------------------------------------------------------------------------- /src/atoms/settings/general.ts: -------------------------------------------------------------------------------- 1 | 2 | import { createSettingAtom } from '../helper/setting' 3 | 4 | export interface GeneralSettings { 5 | token: string 6 | } 7 | 8 | const createDefaultSettings = (): GeneralSettings => ({ 9 | token: '', 10 | }) 11 | 12 | export const { 13 | useSettingKey: useGeneralSettingKey, 14 | useSettingSelector: useGeneralSettingSelector, 15 | useSettingKeys: useGeneralSettingKeys, 16 | setSetting: setGeneralSetting, 17 | clearSettings: clearGeneralSettings, 18 | initializeDefaultSettings: initializeDefaultGeneralSettings, 19 | getSettings: getGeneralSettings, 20 | useSettingValue: useGeneralSettingValue, 21 | 22 | settingAtom: __generalSettingAtom, 23 | } = createSettingAtom('general', createDefaultSettings) 24 | -------------------------------------------------------------------------------- /src/atoms/settings/ui.ts: -------------------------------------------------------------------------------- 1 | 2 | import { createSettingAtom } from '../helper/setting' 3 | 4 | export interface UISettings { 5 | // Sidebar 6 | sidebarColWidth: number 7 | } 8 | 9 | export const createDefaultSettings = (): UISettings => ({ 10 | sidebarColWidth: 256, 11 | }) 12 | 13 | export const { 14 | useSettingKey: useUISettingKey, 15 | useSettingSelector: useUISettingSelector, 16 | useSettingKeys: useUISettingKeys, 17 | setSetting: setUISetting, 18 | clearSettings: clearUISettings, 19 | initializeDefaultSettings: initializeDefaultUISettings, 20 | getSettings: getUISettings, 21 | useSettingValue: useUISettingValue, 22 | settingAtom: __uiSettingAtom, 23 | } = createSettingAtom('ui', createDefaultSettings) 24 | 25 | export const uiServerSyncWhiteListKeys: (keyof UISettings)[] = [] 26 | -------------------------------------------------------------------------------- /src/atoms/sidebar.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | 3 | import { createAtomHooks } from '~/lib/jotai' 4 | 5 | const [ 6 | , 7 | , 8 | internal_useSidebarColumnShow, 9 | , 10 | internal_getSidebarColumnShow, 11 | setSidebarColumnShow, 12 | ] = createAtomHooks(atom(true)) 13 | 14 | export { setSidebarColumnShow } 15 | export const getSidebarColumnShow = internal_getSidebarColumnShow 16 | 17 | export const useSidebarColumnShow = internal_useSidebarColumnShow 18 | 19 | export const [ 20 | , 21 | , 22 | useSidebarColumnTempShow, 23 | , 24 | getSidebarColumnTempShow, 25 | setSidebarColumnTempShow, 26 | ] = createAtomHooks(atom(false)) 27 | -------------------------------------------------------------------------------- /src/atoms/user.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | 3 | import type { DB_User } from '~/database' 4 | import { createAtomHooks } from '~/lib/jotai' 5 | 6 | export const [, , useUser, , getUser, setUser] = createAtomHooks( 7 | atom(null), 8 | ) 9 | -------------------------------------------------------------------------------- /src/components/common/ErrorElement.tsx: -------------------------------------------------------------------------------- 1 | import { repository } from '@pkg' 2 | import { useEffect, useRef } from 'react' 3 | import { isRouteErrorResponse, useRouteError } from 'react-router' 4 | 5 | import { attachOpenInEditor } from '~/lib/dev' 6 | 7 | import { Button } from '../ui/button' 8 | 9 | export function ErrorElement() { 10 | const error = useRouteError() 11 | const message = isRouteErrorResponse(error) 12 | ? `${error.status} ${error.statusText}` 13 | : error instanceof Error 14 | ? error.message 15 | : JSON.stringify(error) 16 | const stack = error instanceof Error ? error.stack : null 17 | 18 | useEffect(() => { 19 | console.error('Error handled by React Router default ErrorBoundary:', error) 20 | }, [error]) 21 | 22 | const reloadRef = useRef(false) 23 | if ( 24 | message.startsWith('Failed to fetch dynamically imported module') && 25 | window.sessionStorage.getItem('reload') !== '1' 26 | ) { 27 | if (reloadRef.current) return null 28 | window.sessionStorage.setItem('reload', '1') 29 | window.location.reload() 30 | reloadRef.current = true 31 | return null 32 | } 33 | 34 | return ( 35 |
36 |
37 |
38 | 39 |

40 | Sorry, the app has encountered an error 41 |

42 |
43 |

{message}

44 | {import.meta.env.DEV && stack ? ( 45 |
46 | {attachOpenInEditor(stack)} 47 |
48 | ) : null} 49 | 50 |

51 | The App has a temporary problem, click the button below to try reloading 52 | the app or another solution? 53 |

54 | 55 |
56 | 57 |
58 | 59 |

60 | Still having this issue? Please give feedback in Github, thanks! 61 | 71 | Submit Issue 72 | 73 |

74 |
75 |
76 | ) 77 | } 78 | -------------------------------------------------------------------------------- /src/components/common/LoadRemixAsyncComponent.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, ReactNode } from 'react' 2 | import { createElement, useEffect, useState } from 'react' 3 | 4 | import { LoadingCircle } from '../ui/loading' 5 | 6 | export const LoadRemixAsyncComponent: FC<{ 7 | loader: () => Promise 8 | Header: FC<{ loader: () => any; [key: string]: any }> 9 | }> = ({ loader, Header }) => { 10 | const [loading, setLoading] = useState(true) 11 | 12 | const [Component, setComponent] = useState<{ c: () => ReactNode }>({ 13 | c: () => null, 14 | }) 15 | 16 | useEffect(() => { 17 | let isUnmounted = false 18 | setLoading(true) 19 | loader() 20 | .then((module) => { 21 | if (!module.Component) { 22 | return 23 | } 24 | if (isUnmounted) return 25 | 26 | const { loader } = module 27 | setComponent({ 28 | c: () => ( 29 | <> 30 |
31 | 32 | 33 | ), 34 | }) 35 | }) 36 | .finally(() => { 37 | setLoading(false) 38 | }) 39 | return () => { 40 | isUnmounted = true 41 | } 42 | }, [Header, loader]) 43 | 44 | if (loading) { 45 | return ( 46 |
47 | 48 |
49 | ) 50 | } 51 | 52 | return createElement(Component.c) 53 | } 54 | -------------------------------------------------------------------------------- /src/components/common/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import { useLocation, useNavigate } from 'react-router' 2 | 3 | import { Button } from '../ui/button' 4 | 5 | export const NotFound = () => { 6 | const location = useLocation() 7 | 8 | const navigate = useNavigate() 9 | return ( 10 |
11 |
12 |

13 | You have come to a desert of knowledge where there is nothing. 14 |

15 |

16 | Current path: {location.pathname} 17 |

18 | 19 |

20 | 21 |

22 |
23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/components/common/ProviderComposer.tsx: -------------------------------------------------------------------------------- 1 | import type { JSX } from 'react' 2 | import { cloneElement } from 'react' 3 | 4 | export const ProviderComposer: Component<{ 5 | contexts: JSX.Element[] 6 | }> = ({ contexts, children }) => 7 | contexts.reduceRight( 8 | (kids: any, parent: any) => cloneElement(parent, { children: kids }), 9 | children, 10 | ) 11 | -------------------------------------------------------------------------------- /src/components/layout/sidebar/atoms.ts: -------------------------------------------------------------------------------- 1 | import { atomWithStorage } from 'jotai/utils' 2 | 3 | import { getStorageNS } from '~/lib/ns' 4 | 5 | export const groupItemsAtom = atomWithStorage( 6 | getStorageNS('sidebar-group-items'), 7 | true, 8 | undefined, 9 | { 10 | getOnInit: true, 11 | }, 12 | ) 13 | -------------------------------------------------------------------------------- /src/components/ui/avatar/index.tsx: -------------------------------------------------------------------------------- 1 | import * as Avatar from '@radix-ui/react-avatar' 2 | 3 | import { cn } from '~/lib/cn' 4 | 5 | export const AvatarBase = ({ 6 | avatarUrl, 7 | login, 8 | className, 9 | }: { 10 | avatarUrl: string 11 | login: string 12 | className?: string 13 | }) => { 14 | return ( 15 | 16 | 17 | 21 | {login.slice(0, 2)} 22 | 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/components/ui/button/Button.tsx: -------------------------------------------------------------------------------- 1 | // Tremor Button [v0.2.0] 2 | 3 | import { Slot } from '@radix-ui/react-slot' 4 | import { RiLoader2Fill } from '@remixicon/react' 5 | import { m } from 'motion/react' 6 | import * as React from 'react' 7 | import type { VariantProps } from 'tailwind-variants' 8 | import { tv } from 'tailwind-variants' 9 | 10 | import { cx, focusRing } from '~/lib/cn' 11 | 12 | const buttonVariants = tv({ 13 | base: [ 14 | 'relative inline-flex items-center justify-center whitespace-nowrap rounded-md text-center font-medium transition-all duration-100 ease-in-out', 15 | 'disabled:pointer-events-none', 16 | focusRing, 17 | ], 18 | variants: { 19 | variant: { 20 | primary: [ 21 | 'border-transparent', 22 | 'text-white dark:text-white', 23 | 'bg-accent dark:bg-accent', 24 | 'hover:bg-accent/90 dark:hover:bg-accent/90', 25 | 'disabled:bg-accent/50 disabled:text-white/70', 26 | 'disabled:dark:bg-accent/30 disabled:dark:text-white/50', 27 | ], 28 | secondary: [ 29 | 'border border-gray-200 dark:border-gray-700', 30 | 'text-gray-700 dark:text-gray-200', 31 | 'bg-gray-50 dark:bg-gray-800', 32 | 'hover:bg-gray-100 dark:hover:bg-gray-750', 33 | 'disabled:bg-gray-50 disabled:text-gray-400', 34 | 'disabled:dark:bg-gray-800 disabled:dark:text-gray-500', 35 | ], 36 | light: [ 37 | 'shadow-none', 38 | 'border-transparent', 39 | 'text-gray-900 dark:text-gray-50', 40 | 'bg-gray-200 dark:bg-gray-900', 41 | 'hover:bg-gray-300/70 dark:hover:bg-gray-800/80', 42 | 'disabled:bg-gray-100 disabled:text-gray-400', 43 | 'disabled:dark:bg-gray-800 disabled:dark:text-gray-600', 44 | ], 45 | ghost: [ 46 | 'shadow-none', 47 | 'border-transparent', 48 | 'text-gray-900 dark:text-gray-50', 49 | 'bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800/80', 50 | 'disabled:text-gray-400', 51 | 'disabled:dark:text-gray-600', 52 | ], 53 | destructive: [ 54 | 'text-white', 55 | 'border-transparent', 56 | 'bg-red-600 dark:bg-red-700', 57 | 'hover:bg-red-700 dark:hover:bg-red-600', 58 | 'disabled:bg-red-300 disabled:text-white', 59 | 'disabled:dark:bg-red-950 disabled:dark:text-red-400', 60 | ], 61 | }, 62 | size: { 63 | xs: 'h-6 px-2 text-xs', 64 | sm: 'h-8 px-3 text-sm', 65 | md: 'h-10 px-4 text-sm', 66 | lg: 'h-11 px-8 text-base', 67 | xl: 'h-12 px-8 text-base', 68 | }, 69 | flat: { 70 | true: 'shadow-none', 71 | false: 'shadow-sm', 72 | }, 73 | }, 74 | defaultVariants: { 75 | variant: 'primary', 76 | size: 'sm', 77 | flat: false, 78 | }, 79 | }) 80 | 81 | interface ButtonProps 82 | extends React.ComponentPropsWithoutRef<'button'>, 83 | VariantProps { 84 | asChild?: boolean 85 | isLoading?: boolean 86 | loadingText?: string 87 | } 88 | 89 | const Button = ( 90 | { ref: forwardedRef, asChild, isLoading = false, loadingText, className, disabled, variant, size, flat, children, ...props }: ButtonProps & { ref?: React.RefObject }, 91 | ) => { 92 | const Component = asChild ? Slot : m.button 93 | return ( 94 | // @ts-expect-error 95 | 103 | {isLoading ? ( 104 | 105 | 115 | ) : ( 116 | children 117 | )} 118 | 119 | ) 120 | } 121 | 122 | Button.displayName = 'Button' 123 | 124 | export { Button, type ButtonProps, buttonVariants } 125 | -------------------------------------------------------------------------------- /src/components/ui/button/MotionButton.tsx: -------------------------------------------------------------------------------- 1 | import type { HTMLMotionProps } from 'motion/react' 2 | import { m } from 'motion/react' 3 | 4 | export const MotionButtonBase = ({ ref, children, ...rest }: HTMLMotionProps<'button'> & { ref?: React.RefObject }) => { 5 | return ( 6 | 14 | {children} 15 | 16 | ) 17 | } 18 | 19 | MotionButtonBase.displayName = 'MotionButtonBase' 20 | -------------------------------------------------------------------------------- /src/components/ui/button/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Button' 2 | export * from './MotionButton' 3 | -------------------------------------------------------------------------------- /src/components/ui/context-menu/index.ts: -------------------------------------------------------------------------------- 1 | export * from './context-menu' 2 | -------------------------------------------------------------------------------- /src/components/ui/datetime/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | differenceInDays, 3 | differenceInHours, 4 | differenceInMinutes, 5 | differenceInSeconds, 6 | format, 7 | formatDistance, 8 | } from 'date-fns' 9 | import type { FC } from 'react' 10 | import { useEffect, useRef, useState } from 'react' 11 | 12 | import { stopPropagation } from '~/lib/dom' 13 | 14 | import { 15 | Tooltip, 16 | TooltipContent, 17 | TooltipPortal, 18 | TooltipTrigger, 19 | } from '../tooltip' 20 | 21 | const formatTemplateString = 'MMM d, yyyy h:mm a' 22 | 23 | const formatTime = ( 24 | date: string | Date, 25 | relativeBeforeDay?: number, 26 | template = formatTemplateString, 27 | ) => { 28 | const dateObj = typeof date === 'string' ? new Date(date) : date 29 | 30 | if ( 31 | relativeBeforeDay && 32 | Math.abs(differenceInDays(dateObj, new Date())) > relativeBeforeDay 33 | ) { 34 | return format(dateObj, template) 35 | } 36 | return formatDistance(dateObj, new Date(), { addSuffix: false }) 37 | } 38 | 39 | const getUpdateInterval = (date: string | Date, relativeBeforeDay?: number) => { 40 | if (!relativeBeforeDay) return null 41 | const dateObj = typeof date === 'string' ? new Date(date) : date 42 | const diffInSeconds = Math.abs(differenceInSeconds(dateObj, new Date())) 43 | if (diffInSeconds <= 60) { 44 | return 1000 // Update every second 45 | } 46 | const diffInMinutes = Math.abs(differenceInMinutes(dateObj, new Date())) 47 | if (diffInMinutes <= 60) { 48 | return 60000 // Update every minute 49 | } 50 | const diffInHours = Math.abs(differenceInHours(dateObj, new Date())) 51 | if (diffInHours <= 24) { 52 | return 3600000 // Update every hour 53 | } 54 | const diffInDays = Math.abs(differenceInDays(dateObj, new Date())) 55 | if (diffInDays <= relativeBeforeDay) { 56 | return 86400000 // Update every day 57 | } 58 | return null // No need to update 59 | } 60 | 61 | export const RelativeTime: FC<{ 62 | date: string | Date 63 | displayAbsoluteTimeAfterDay?: number 64 | dateFormatTemplate?: string 65 | }> = (props) => { 66 | const { 67 | displayAbsoluteTimeAfterDay = 29, 68 | dateFormatTemplate = formatTemplateString, 69 | } = props 70 | const [relative, setRelative] = useState(() => 71 | formatTime(props.date, displayAbsoluteTimeAfterDay, dateFormatTemplate), 72 | ) 73 | 74 | const timerRef = useRef(null) 75 | 76 | useEffect(() => { 77 | const updateRelativeTime = () => { 78 | setRelative( 79 | formatTime(props.date, displayAbsoluteTimeAfterDay, dateFormatTemplate), 80 | ) 81 | const updateInterval = getUpdateInterval( 82 | props.date, 83 | displayAbsoluteTimeAfterDay, 84 | ) 85 | 86 | if (updateInterval !== null) { 87 | timerRef.current = setTimeout(updateRelativeTime, updateInterval) 88 | } 89 | } 90 | 91 | updateRelativeTime() 92 | 93 | return () => { 94 | clearTimeout(timerRef.current) 95 | } 96 | }, [props.date, displayAbsoluteTimeAfterDay, dateFormatTemplate]) 97 | const formated = format(props.date, dateFormatTemplate) 98 | 99 | if (formated === relative) { 100 | return <>{relative} 101 | } 102 | return ( 103 | 104 | {/* https://github.com/radix-ui/primitives/issues/2248#issuecomment-2147056904 */} 105 | 106 | {relative.replace('about ', '')} ago 107 | 108 | 109 | 110 | {formated} 111 | 112 | 113 | ) 114 | } 115 | -------------------------------------------------------------------------------- /src/components/ui/divider/Divider.tsx: -------------------------------------------------------------------------------- 1 | import type { DetailedHTMLProps, FC, HTMLAttributes } from 'react' 2 | 3 | import { clsxm } from '~/lib/cn' 4 | 5 | export const Divider: FC< 6 | DetailedHTMLProps, HTMLHRElement> 7 | > = (props) => { 8 | const { className, ...rest } = props 9 | return ( 10 |
17 | ) 18 | } 19 | 20 | export const DividerVertical: FC< 21 | DetailedHTMLProps, HTMLSpanElement> 22 | > = (props) => { 23 | const { className, ...rest } = props 24 | return ( 25 | 32 | w 33 | 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/components/ui/divider/PanelSpliter.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { useMeasure } from '~/hooks/common/useMeasure' 4 | import { clsxm } from '~/lib/cn' 5 | 6 | import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip' 7 | 8 | export const PanelSplitter = ( 9 | props: React.DetailedHTMLProps< 10 | React.HTMLAttributes, 11 | HTMLDivElement 12 | > & { 13 | isDragging?: boolean 14 | cursor?: string 15 | 16 | tooltip?: React.ReactNode 17 | }, 18 | ) => { 19 | const { isDragging, cursor, tooltip, ...rest } = props 20 | 21 | React.useEffect(() => { 22 | if (!isDragging) return 23 | const $css = document.createElement('style') 24 | 25 | $css.innerHTML = ` 26 | * { 27 | cursor: ${cursor} !important; 28 | } 29 | ` 30 | 31 | document.head.append($css) 32 | return () => { 33 | $css.remove() 34 | } 35 | }, [cursor, isDragging]) 36 | 37 | const [ref, { height }] = useMeasure() 38 | 39 | const El = ( 40 |
49 | ) 50 | 51 | return ( 52 |
53 | {tooltip ? ( 54 | 55 | {El} 56 | {tooltip} 57 | 58 | ) : ( 59 | El 60 | )} 61 |
62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /src/components/ui/divider/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Divider' 2 | export * from './PanelSpliter' 3 | -------------------------------------------------------------------------------- /src/components/ui/dropdown-menu/DropdownMenu.tsx: -------------------------------------------------------------------------------- 1 | import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' 2 | import * as React from 'react' 3 | 4 | import { cn } from '~/lib/cn' 5 | 6 | import { RootPortal } from '../portal' 7 | 8 | const DropdownMenu: typeof DropdownMenuPrimitive.Root = (props) => { 9 | return 10 | } 11 | 12 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 13 | 14 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 15 | 16 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 17 | 18 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 19 | 20 | const DropdownMenuContent = ({ 21 | ref, 22 | className, 23 | sideOffset = 4, 24 | ...props 25 | }: React.ComponentPropsWithoutRef & { 26 | ref?: React.RefObject | null> 29 | }) => { 30 | return ( 31 | 32 | 43 | 44 | ) 45 | } 46 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 47 | 48 | const DropdownMenuItem = ({ 49 | ref, 50 | className, 51 | inset, 52 | icon, 53 | active, 54 | ...props 55 | }: React.ComponentPropsWithoutRef & { 56 | inset?: boolean 57 | icon?: React.ReactNode | ((props?: { isActive?: boolean }) => React.ReactNode) 58 | active?: boolean 59 | } & { 60 | ref?: React.RefObject | null> 63 | }) => ( 64 | 77 | {!!icon && ( 78 | 79 | {typeof icon === 'function' ? icon({ isActive: active }) : icon} 80 | 81 | )} 82 | {props.children} 83 | {/* Justify Fill */} 84 | {!!icon && } 85 | 86 | ) 87 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 88 | 89 | const DropdownMenuCheckboxItem = ({ 90 | ref, 91 | className, 92 | children, 93 | checked, 94 | ...props 95 | }: React.ComponentPropsWithoutRef & { 96 | ref?: React.RefObject | null> 99 | }) => ( 100 | 111 | 112 | 113 | 114 | 115 | 116 | {children} 117 | 118 | ) 119 | DropdownMenuCheckboxItem.displayName = 120 | DropdownMenuPrimitive.CheckboxItem.displayName 121 | 122 | const DropdownMenuLabel = ({ 123 | ref, 124 | className, 125 | inset, 126 | ...props 127 | }: React.ComponentPropsWithoutRef & { 128 | inset?: boolean 129 | } & { 130 | ref?: React.RefObject | null> 133 | }) => ( 134 | 143 | ) 144 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 145 | 146 | const DropdownMenuSeparator = ({ 147 | ref, 148 | className, 149 | ...props 150 | }: React.ComponentPropsWithoutRef & { 151 | ref?: React.RefObject | null> 154 | }) => ( 155 | 160 | ) 161 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 162 | 163 | const DropdownMenuShortcut = ({ 164 | className, 165 | ...props 166 | }: React.HTMLAttributes) => ( 167 | 171 | ) 172 | DropdownMenuShortcut.displayName = 'DropdownMenuShortcut' 173 | 174 | export { 175 | DropdownMenu, 176 | DropdownMenuCheckboxItem, 177 | DropdownMenuContent, 178 | DropdownMenuGroup, 179 | DropdownMenuItem, 180 | DropdownMenuLabel, 181 | DropdownMenuPortal, 182 | DropdownMenuRadioGroup, 183 | DropdownMenuSeparator, 184 | DropdownMenuShortcut, 185 | DropdownMenuTrigger, 186 | } 187 | -------------------------------------------------------------------------------- /src/components/ui/icons/ActivityType.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | 3 | export const ActivityType = ({ 4 | type, 5 | state, 6 | }: { 7 | type: string 8 | state?: string 9 | }) => { 10 | switch (type) { 11 | case 'Issue': { 12 | return ( 13 | 20 | ) 21 | } 22 | case 'PullRequest': { 23 | return ( 24 | 34 | ) 35 | } 36 | case 'Discussion': { 37 | return 38 | } 39 | case 'CheckSuite': { 40 | return 41 | } 42 | case 'Release': { 43 | return 44 | } 45 | default: { 46 | return null 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/components/ui/input/Input.tsx: -------------------------------------------------------------------------------- 1 | import type { DetailedHTMLProps, InputHTMLAttributes } from 'react' 2 | 3 | import { useInputComposition } from '~/hooks/common/useInputComposition' 4 | import { clsxm } from '~/lib/cn' 5 | 6 | // This composition handler is not perfect 7 | // @see https://foxact.skk.moe/use-composition-input 8 | export const Input = ({ 9 | ref, 10 | className, 11 | ...props 12 | }: Omit< 13 | DetailedHTMLProps, HTMLInputElement>, 14 | 'ref' 15 | > & { ref?: React.RefObject }) => { 16 | const inputProps = useInputComposition(props) 17 | return ( 18 | 33 | ) 34 | } 35 | Input.displayName = 'Input' 36 | -------------------------------------------------------------------------------- /src/components/ui/input/TextArea.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import clsx from 'clsx' 4 | import { m, useMotionTemplate, useMotionValue } from 'motion/react' 5 | import type { 6 | DetailedHTMLProps, 7 | PropsWithChildren, 8 | TextareaHTMLAttributes, 9 | } from 'react' 10 | import { useCallback, useState } from 'react' 11 | 12 | import { useInputComposition } from '~/hooks/common/useInputComposition' 13 | import { clsxm } from '~/lib/cn' 14 | 15 | const roundedMap = { 16 | sm: 'rounded-sm', 17 | md: 'rounded-md', 18 | lg: 'rounded-lg', 19 | xl: 'rounded-xl', 20 | '2xl': 'rounded-2xl', 21 | '3xl': 'rounded-3xl', 22 | default: 'rounded', 23 | } 24 | export const TextArea = ({ 25 | ref, 26 | ...props 27 | }: DetailedHTMLProps< 28 | TextareaHTMLAttributes, 29 | HTMLTextAreaElement 30 | > & 31 | PropsWithChildren<{ 32 | wrapperClassName?: string 33 | onCmdEnter?: (e: React.KeyboardEvent) => void 34 | rounded?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'default' 35 | bordered?: boolean 36 | }> & { ref?: React.RefObject }) => { 37 | const { 38 | className, 39 | wrapperClassName, 40 | children, 41 | rounded = 'xl', 42 | bordered = true, 43 | onCmdEnter, 44 | onKeyDown, 45 | ...rest 46 | } = props 47 | const mouseX = useMotionValue(0) 48 | const mouseY = useMotionValue(0) 49 | const handleMouseMove = useCallback( 50 | ({ clientX, clientY, currentTarget }: React.MouseEvent) => { 51 | const bounds = currentTarget.getBoundingClientRect() 52 | mouseX.set(clientX - bounds.left) 53 | mouseY.set(clientY - bounds.top) 54 | }, 55 | [mouseX, mouseY], 56 | ) 57 | const handleKeyDown = useCallback( 58 | (e: React.KeyboardEvent) => { 59 | if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { 60 | onCmdEnter?.(e) 61 | } 62 | onKeyDown?.(e) 63 | }, 64 | [onCmdEnter, onKeyDown], 65 | ) 66 | const background = useMotionTemplate`radial-gradient(320px circle at ${mouseX}px ${mouseY}px, var(--spotlight-color) 0%, transparent 85%)` 67 | 68 | const inputProps = useInputComposition( 69 | Object.assign({}, props, { onKeyDown: handleKeyDown }), 70 | ) 71 | const [isFocus, setIsFocus] = useState(false) 72 | return ( 73 |
86 |