diff --git a/.changeset/open-llamas-chew.md b/.changeset/open-llamas-chew.md new file mode 100644 index 0000000..34ae68a --- /dev/null +++ b/.changeset/open-llamas-chew.md @@ -0,0 +1,20 @@ +--- +"headertweaker": minor +--- + +## New Features + +- **URL filter bar** — Filter the header list by URL scope to quickly find scoped headers +- **Global tab wizard** — Batch-target multiple headers to a specific URL via a guided flow +- **Drag & drop URL targeting** — Reassign a header's URL target by dragging it to a new scope +- **i18n groundwork** — All UI copy is now externalized to `src/i18n` (English only for now, contributions welcome!) + +## Improvements + +- Consistently match scoped headers against domains, subdomains, paths, and wildcards +- Group headers on the URL-specific tab by their exact scoped URL +- Reuse previously used URLs via the new `Select` component when targeting a header +- Focus the header key input automatically after creating a new header +- Clearer empty states for the header list +- Prominent alert on the Global tab clarifying that those headers apply everywhere +- New `Toast` component — saving header info now gives instant feedback \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..88df955 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,33 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + react: + patterns: + - react + - react-dom + - "@types/react" + - "@types/react-dom" + dev-dependencies: + dependency-type: development + update-types: + - minor + - patch + production-dependencies: + dependency-type: production + update-types: + - minor + - patch + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: + - "*" diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index d85d8a4..f63120e 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -19,7 +19,7 @@ jobs: git fetch origin ${{ github.base_ref }}:${{ github.base_ref }} - name: Check for changeset - if: github.actor != 'github-actions[bot]' + if: github.actor != 'github-actions[bot]' && github.actor != 'dependabot[bot]' shell: bash run: | CHANGES=$(git diff --name-status origin/${{ github.base_ref }}...HEAD \ @@ -33,19 +33,20 @@ jobs: echo "$CHANGES" fi - - name: Use Node.js + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node uses: actions/setup-node@v4 with: - node-version: "20" - - - name: Install pnpm - run: | - corepack enable - corepack prepare pnpm@latest --activate - pnpm --version + node-version-file: .nvmrc + cache: pnpm - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile + + - name: Audit + run: pnpm audit --audit-level=high --prod - name: Lint run: pnpm lint diff --git a/AGENTS.md b/AGENTS.md index 59e484f..2102748 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,23 +27,66 @@ pnpm format-n-lint:fix # Auto-fix lint and format issues - **`src/background.ts`** — Extension background script (service worker) - **`src/headertweaker.tsx`** — Main UI entry point - **`src/components/`** — Feature components, each co-located with its `.module.scss` -- **`src/helpers/`** — Pure utility functions +- **`src/helpers/`** — Pure utility functions; every function must live in its own dedicated helper file - **`src/contexts/`** — React context providers - **`src/interfaces/`** — Shared TypeScript types (`Header`, `Status`) +- **`src/i18n/`** — Localization: `config.ts` initializes i18next, `locales/en-US.json` holds the nested translation keys - **`public/manifest.json`** — Firefox manifest (base); `manifests/chrome.json` for Chrome overrides - The Vite `sync-manifest` plugin writes the current `package.json` version into the built manifests at bundle time ## Conventions - Use **path aliases** for cross-directory imports (never relative `../../`): - `@components/*`, `@helpers/*`, `@contexts/*`, `@interfaces/*`, `@constants/*`, `@styles/*` -- CSS: **SCSS modules** (`.module.scss`) co-located with each component + `@components/*`, `@helpers/*`, `@contexts/*`, `@interfaces/*`, `@constants/*`, `@styles/*`, `@i18n/*` +- **Localization**: never hardcode user-facing text (labels, placeholders, aria-labels, messages). Add a key to the matching group in `src/i18n/locales/en-US.json` and render it with `const { t } = useTranslation()` from `react-i18next`. Interpolate values with `t('group.key', { name })` matching `{{name}}` in the translation, and use i18next `_one` / `_other` suffixed keys with `{ count }` for plurals. Non-component code (helpers, constants) returns a `TranslationKey` instead of translated text, so the component can translate it. +- **SCSS styling**: Use SCSS modules (`.module.scss`) co-located with each component. Import design tokens using `@use '@styles/variables' as vars;` and reference tokens via `vars.$colors-*`, `vars.$spacing-core-*`, `vars.$border-radius-primary`, etc. Never hardcode colors or spacing values — always use design tokens from `src/styles/variables.scss`. - Components use named arrow-function exports typed as `FC`. +- Always render text through the `Text` component (`@components/text/text`); never place raw strings in bare DOM elements such as `` or `

`. - Declare all TypeScript types with `type`; do not use `interface`. Export a component prop type when it is shared. +- Constants belong in `src/constants/`, never inline in components. Define sets of options as a `SCREAMING_SNAKE_CASE` object with `as const` and derive the type from it: + + ```ts + export const SCOPES = { + ALL: 'all', + NO_SCOPE: 'no-scope', + } as const; + + export type Scope = (typeof SCOPES)[keyof typeof SCOPES]; + ``` + - Type DOM-wrapping components with `ComponentPropsWithoutRef<'element'>` and wrappers with `PropsWithChildren`. - Do not use `as any` or `as unknown as T` to silence TypeScript errors; narrow values to the required type instead. - Import `clsx` as `classnames`: `import classnames from 'clsx'`. Compose class names as `classnames(css.header, className)`. -- Keep compound-component subcomponents in the parent component's `elements/` folder. The root component module must re-export each element directly; do not add an `elements/index.ts` barrel. For example, `modal.tsx` should contain `export { ModalHeader } from './elements/modal-header';`. +- **Composition over configuration**: pass content as `children` rather than through content props (`label`, `icon`, `text`). `Button`, `IconButton`, and `Text` follow this. +- **Compound components**: Build multi-part components using composition, not props. Keep subcomponents in an `elements/` folder and re-export them from the parent module (no `elements/index.ts` barrel). The parent manages state and shares it with its subcomponents through a context. This pattern is used for `Modal` and `Steps`. + +```tsx +// src/components/steps/steps.tsx + + + Content + + +``` + +- **Contexts**: Create all contexts in `src/contexts/` with the naming convention `*.context.tsx`. A context file exports the context value type, the context, a dedicated provider component, and a `use*Context` consumer hook that throws when used outside its provider. Always consume a context through its hook — never `useContext` directly. + +```tsx +// src/contexts/steps.context.tsx +export type StepsContextValue = { /* ... */ }; +export const StepsContext = createContext(undefined); +export const StepsProvider: FC> = ({ value, children }) => ( + {children} +); +export const useStepsContext = (): StepsContextValue => { + const context = useContext(StepsContext); + + if (!context) throw new Error('useStepsContext must be used within a StepsProvider'); + + return context; +}; +``` + - Linting/formatting: **Biome** for JS/TS/JSON, **Stylelint** for SCSS — both run in CI ## Testing diff --git a/package.json b/package.json index df05a55..1e25286 100644 --- a/package.json +++ b/package.json @@ -13,15 +13,18 @@ }, "packageManager": "pnpm@10.17.1", "engines": { - "node": ">=20", + "node": ">=24", "pnpm": ">=10" }, "dependencies": { "@heroicons/react": "2.2.0", "clsx": "2.1.1", + "i18next": "26.3.6", "react": "18.2.0", - "react-compiler-runtime": "^1.0.0", + "react-compiler-runtime": "1.0.0", "react-dom": "18.2.0", + "react-i18next": "17.0.11", + "tldts": "7.4.10", "uuid": "11.1.0" }, "devDependencies": { @@ -34,7 +37,9 @@ "@types/react-dom": "19.1.9", "@vitejs/plugin-react": "5.0.2", "babel-plugin-react-compiler": "^1.0.0", + "concurrently": "9.2.1", "husky": "9.1.7", + "jsdom": "24.1.3", "rimraf": "6.0.1", "sass": "1.70.0", "stylelint": "16.23.1", @@ -44,15 +49,14 @@ "typescript": "5.9.2", "vite": "5.0.0", "vitest": "1.6.1", - "jsdom": "24.1.3", "web-ext": "8.9.0" }, "scripts": { - "dev:firefox": "BROWSER=firefox vite build && web-ext run --source-dir=dist/firefox --watch-files=dist/firefox/*", - "dev:firefox:console": "BROWSER=firefox vite build && web-ext run --source-dir=dist/firefox --watch-files=dist/firefox/* --browser-console", + "dev:firefox": "BROWSER=firefox vite build && concurrently --kill-others-on-fail \"BROWSER=firefox vite build --watch\" \"web-ext run --source-dir=dist/firefox\"", + "dev:firefox:console": "BROWSER=firefox vite build && concurrently --kill-others-on-fail \"BROWSER=firefox vite build --watch\" \"web-ext run --source-dir=dist/firefox --browser-console\"", "build:firefox": "BROWSER=firefox vite build", - "dev:chrome": "BROWSER=chrome vite build && web-ext run --target=chromium --source-dir=dist/chrome --watch-files=dist/chrome/*", - "dev:chrome:console": "BROWSER=chrome vite build && web-ext run --target=chromium --source-dir=dist/chrome --watch-files=dist/chrome/* --browser-console", + "dev:chrome": "BROWSER=chrome vite build && concurrently --kill-others-on-fail \"BROWSER=chrome vite build --watch\" \"web-ext run --target=chromium --source-dir=dist/chrome\"", + "dev:chrome:console": "BROWSER=chrome vite build && concurrently --kill-others-on-fail \"BROWSER=chrome vite build --watch\" \"web-ext run --target=chromium --source-dir=dist/chrome --browser-console\"", "build:chrome": "BROWSER=chrome vite build", "build:all": "pnpm run build:firefox && pnpm run build:chrome", "change": "changeset", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a39aad..01386a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,15 +14,24 @@ importers: clsx: specifier: 2.1.1 version: 2.1.1 + i18next: + specifier: 26.3.6 + version: 26.3.6(typescript@5.9.2) react: specifier: 18.2.0 version: 18.2.0 react-compiler-runtime: - specifier: ^1.0.0 + specifier: 1.0.0 version: 1.0.0(react@18.2.0) react-dom: specifier: 18.2.0 version: 18.2.0(react@18.2.0) + react-i18next: + specifier: 17.0.11 + version: 17.0.11(i18next@26.3.6(typescript@5.9.2))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.2) + tldts: + specifier: 7.4.10 + version: 7.4.10 uuid: specifier: 11.1.0 version: 11.1.0 @@ -54,6 +63,9 @@ importers: babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 + concurrently: + specifier: 9.2.1 + version: 9.2.1 husky: specifier: 9.1.7 version: 9.1.7 @@ -175,6 +187,10 @@ packages: resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -1107,6 +1123,11 @@ packages: resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} engines: {'0': node >= 0.8} + concurrently@9.2.1: + resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==} + engines: {node: '>=18'} + hasBin: true + confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} @@ -1616,6 +1637,9 @@ packages: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} + html-parse-stringify@4.0.1: + resolution: {integrity: sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==} + html-tags@3.3.1: resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} engines: {node: '>=8'} @@ -1644,6 +1668,14 @@ packages: engines: {node: '>=18'} hasBin: true + i18next@26.3.6: + resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==} + peerDependencies: + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + typescript: + optional: true + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -2303,6 +2335,22 @@ packages: peerDependencies: react: ^18.2.0 + react-i18next@17.0.11: + resolution: {integrity: sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==} + peerDependencies: + i18next: '>= 26.2.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -2388,6 +2436,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -2436,6 +2487,10 @@ packages: shell-quote@1.7.3: resolution: {integrity: sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==} + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + shellwords@0.1.1: resolution: {integrity: sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==} @@ -2588,6 +2643,10 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + supports-hyperlinks@3.2.0: resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} engines: {node: '>=14.18'} @@ -2626,6 +2685,13 @@ packages: resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} engines: {node: '>=14.0.0'} + tldts-core@7.4.10: + resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==} + + tldts@7.4.10: + resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==} + hasBin: true + tmp@0.2.3: resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} engines: {node: '>=14.14'} @@ -2642,6 +2708,13 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -2708,6 +2781,11 @@ packages: url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -3031,6 +3109,8 @@ snapshots: '@babel/runtime@7.28.4': {} + '@babel/runtime@7.29.7': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -3983,6 +4063,15 @@ snapshots: readable-stream: 2.3.8 typedarray: 0.0.6 + concurrently@9.2.1: + dependencies: + chalk: 4.1.2 + rxjs: 7.8.2 + shell-quote: 1.8.3 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + confbox@0.1.8: {} config-chain@1.1.13: @@ -4538,6 +4627,8 @@ snapshots: dependencies: whatwg-encoding: 3.1.1 + html-parse-stringify@4.0.1: {} + html-tags@3.3.1: {} htmlparser2@8.0.2: @@ -4567,6 +4658,10 @@ snapshots: husky@9.1.7: {} + i18next@26.3.6(typescript@5.9.2): + optionalDependencies: + typescript: 5.9.2 + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -5165,6 +5260,17 @@ snapshots: react: 18.2.0 scheduler: 0.23.2 + react-i18next@17.0.11(i18next@26.3.6(typescript@5.9.2))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.2): + dependencies: + '@babel/runtime': 7.29.7 + html-parse-stringify: 4.0.1 + i18next: 26.3.6(typescript@5.9.2) + react: 18.2.0 + use-sync-external-store: 1.6.0(react@18.2.0) + optionalDependencies: + react-dom: 18.2.0(react@18.2.0) + typescript: 5.9.2 + react-is@18.3.1: {} react-refresh@0.17.0: {} @@ -5263,6 +5369,10 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + safe-buffer@5.1.2: {} safe-stable-stringify@2.5.0: {} @@ -5299,6 +5409,8 @@ snapshots: shell-quote@1.7.3: {} + shell-quote@1.8.3: {} + shellwords@0.1.1: {} siginfo@2.0.0: {} @@ -5483,6 +5595,10 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + supports-hyperlinks@3.2.0: dependencies: has-flag: 4.0.0 @@ -5516,6 +5632,12 @@ snapshots: tinyspy@2.2.1: {} + tldts-core@7.4.10: {} + + tldts@7.4.10: + dependencies: + tldts-core: 7.4.10 + tmp@0.2.3: {} to-regex-range@5.0.1: @@ -5533,6 +5655,10 @@ snapshots: dependencies: punycode: 2.3.1 + tree-kill@1.2.2: {} + + tslib@2.8.1: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -5589,6 +5715,10 @@ snapshots: querystringify: 2.2.0 requires-port: 1.0.0 + use-sync-external-store@1.6.0(react@18.2.0): + dependencies: + react: 18.2.0 + util-deprecate@1.0.2: {} uuid@11.1.0: {} diff --git a/public/background.html b/public/background.html new file mode 100644 index 0000000..909b783 --- /dev/null +++ b/public/background.html @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/public/manifest.json b/public/manifest.json index 4844bef..6272249 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -13,7 +13,7 @@ "" ], "background": { - "scripts": ["js/background.js"] + "page": "background.html" }, "browser_action": { "default_popup": "headertweaker.html", diff --git a/src/background.ts b/src/background.ts index 599d26c..1fcd8ad 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,4 +1,7 @@ // Keep in sync with STATUS_KEY in headertweaker.helper.ts +import { createChromeUrlRestriction } from '@helpers/scope/chrome-url-restriction.helper'; +import { matchUrlRestriction } from '@helpers/scope/match-url-restriction.helper'; + const STATUS_KEY = 'isDisabled'; type Header = { name: string; value: string; enabled: boolean; urls?: string[] }; @@ -15,6 +18,8 @@ const getHeaders = async (): Promise => { return (result.headers as Header[]) || []; }; +const isString = (value: string | null): value is string => value !== null; + if (__BROWSER__ === 'chrome') { // Chrome MV3: use declarativeNetRequest to modify outgoing request headers const { ResourceType } = chrome.declarativeNetRequest; @@ -46,9 +51,9 @@ if (__BROWSER__ === 'chrome') { const enabledHeaders = headers.filter(({ enabled }) => enabled); let ruleId = 1; enabledHeaders.forEach(({ name, value, urls }) => { - const hasUrls = urls && urls.length > 0; - if (hasUrls) { - urls.forEach((urlFilter) => { + const urlRestrictions = urls?.map(createChromeUrlRestriction).filter(isString) ?? []; + if (urlRestrictions.length) { + urlRestrictions.forEach((regexFilter) => { addRules.push({ id: ruleId++, priority: 1, @@ -57,7 +62,7 @@ if (__BROWSER__ === 'chrome') { requestHeaders: [{ header: name, operation: 'set', value }], }, condition: { - urlFilter, + regexFilter, resourceTypes: ALL_RESOURCE_TYPES, }, }); @@ -88,13 +93,6 @@ if (__BROWSER__ === 'chrome') { }); } else { // Firefox MV2: use blocking webRequest to modify outgoing request headers - const matchesUrl = (url: string, patterns: string[]): boolean => { - return patterns.some((pattern) => { - const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); - return new RegExp(`^${escaped}$`).test(url); - }); - }; - const onBeforeSendHeaders = async ( details: browser.webRequest._OnBeforeSendHeadersDetails ): Promise => { @@ -109,7 +107,9 @@ if (__BROWSER__ === 'chrome') { const requestHeaders = details.requestHeaders.slice(); enabledHeaders.forEach(({ name, value, urls }) => { - if (urls && urls.length > 0 && !matchesUrl(details.url, urls)) return; + if (urls && urls.length > 0 && !urls.some((url) => matchUrlRestriction(details.url, url))) { + return; + } for (let i = requestHeaders.length - 1; i >= 0; i--) { if (requestHeaders[i].name.toLowerCase() === name.toLowerCase()) { requestHeaders.splice(i, 1); diff --git a/src/components/alert/alert.module.scss b/src/components/alert/alert.module.scss new file mode 100644 index 0000000..a1e08a4 --- /dev/null +++ b/src/components/alert/alert.module.scss @@ -0,0 +1,36 @@ +.root { + border-style: solid; + border-width: 1px; + border-radius: vars.$border-radius-primary; + margin: vars.$spacing-core-2 0; + display: flex; + align-items: center; + justify-content: flex-start; + + &.warning { + background-color: rgba(vars.$colors-primary-warning, .25); + border-color: rgba(vars.$colors-primary-warning, .5); + } +} + +.icon { + width: 40px; + align-self: stretch; + display: flex; + justify-content: center; + align-items: center; + + svg { + width: 20px; + height: 20px; + color: vars.$colors-primary-warning; + } +} + +.info { + padding: vars.$spacing-core-2 0; +} + +.title { + margin-bottom: vars.$spacing-core-2; +} \ No newline at end of file diff --git a/src/components/alert/alert.tsx b/src/components/alert/alert.tsx new file mode 100644 index 0000000..4ec5f28 --- /dev/null +++ b/src/components/alert/alert.tsx @@ -0,0 +1,61 @@ +import { Children, type FC, isValidElement, type PropsWithChildren } from 'react'; +import { InformationCircleIcon } from '@heroicons/react/24/outline'; +import classnames from 'clsx'; +import { AlertIcon } from './elements/alert-icon'; + +export { AlertContent } from './elements/alert-content'; +export { AlertIcon } from './elements/alert-icon'; +export { AlertTitle } from './elements/alert-title'; + +import css from './alert.module.scss'; + +type AlertProps = { + variant?: AlertVariant; +}; + +export type AlertVariant = 'positive' | 'neutral' | 'negative' | 'warning'; + +const getIcon = (variant: AlertProps['variant']) => { + switch (variant) { + default: + return InformationCircleIcon; + } +}; + +export const Alert: FC> = ({ variant = 'neutral', children }) => { + const Icon = getIcon(variant); + + return ( +

+
+ {!children || + !Children.toArray(children).some( + (child) => isValidElement(child) && (child.type as FC).displayName === 'AlertIcon' + ) ? ( + + + + ) : ( + Children.map(children, (child) => { + if (isValidElement(child) && (child.type as FC).displayName === 'AlertIcon') { + return child; + } + }) + )} +
+ +
+ {Children.map(children, (child) => { + if (isValidElement(child) && (child.type as FC).displayName === 'AlertTitle') { + return child; + } + })} + {Children.map(children, (child) => { + if (isValidElement(child) && (child.type as FC).displayName === 'AlertContent') { + return child; + } + })} +
+
+ ); +}; diff --git a/src/components/alert/elements/alert-content.tsx b/src/components/alert/elements/alert-content.tsx new file mode 100644 index 0000000..399194d --- /dev/null +++ b/src/components/alert/elements/alert-content.tsx @@ -0,0 +1,9 @@ +import type { FC, PropsWithChildren } from 'react'; + +import css from '../alert.module.scss'; + +export const AlertContent: FC = ({ children }) => { + return
{children}
; +}; + +AlertContent.displayName = 'AlertContent'; diff --git a/src/components/alert/elements/alert-icon.tsx b/src/components/alert/elements/alert-icon.tsx new file mode 100644 index 0000000..914ffe9 --- /dev/null +++ b/src/components/alert/elements/alert-icon.tsx @@ -0,0 +1,9 @@ +import type { FC, PropsWithChildren } from 'react'; + +import css from '../alert.module.scss'; + +export const AlertIcon: FC = ({ children }) => { + return
{children}
; +}; + +AlertIcon.displayName = 'AlertIcon'; diff --git a/src/components/alert/elements/alert-title.tsx b/src/components/alert/elements/alert-title.tsx new file mode 100644 index 0000000..49ab259 --- /dev/null +++ b/src/components/alert/elements/alert-title.tsx @@ -0,0 +1,20 @@ +import type { FC, PropsWithChildren } from 'react'; +import { Text, type TextProps } from '@components/text/text'; + +import css from '../alert.module.scss'; + +export const AlertTitle: FC> = ({ + children, + variant = 'h3', + ...textProps +}) => { + return ( +
+ + {children} + +
+ ); +}; + +AlertTitle.displayName = 'AlertTitle'; diff --git a/src/components/app/app-footer.tsx b/src/components/app/app-footer.tsx index 02e984d..cd5887e 100644 --- a/src/components/app/app-footer.tsx +++ b/src/components/app/app-footer.tsx @@ -1,18 +1,25 @@ -import { type ChangeEvent, type FC, type KeyboardEvent, useEffect, useState } from 'react'; +import { type ChangeEvent, type FC, type KeyboardEvent, useEffect, useRef, useState } from 'react'; import { Button } from '@components/button/button'; -import { Input } from '@components/input/input'; +import { TextInput } from '@components/input/text-input'; +import { ToastItem } from '@components/toast/toast-item'; import { useHeaderTweakerContext } from '@contexts/headertweaker.context'; +import { useToastContext } from '@contexts/toast.context'; import { cleanupHeaderKey } from '@helpers/validation.helper'; import { PlusCircleIcon } from '@heroicons/react/24/solid'; import type { Header } from '@interfaces/index'; +import { useTranslation } from 'react-i18next'; import css from './app.module.scss'; type AppFooterProps = Record; export const AppFooter: FC = () => { + const { t } = useTranslation(); const [header, setHeader] = useState
(); const [disabledButton, setDisabledButton] = useState(true); + const headerKeyRef = useRef(null); + + const { addToast } = useToastContext(); const { isDisabled, updateHeader } = useHeaderTweakerContext(); const handleInputChange = (e: ChangeEvent) => { @@ -48,6 +55,13 @@ export const AppFooter: FC = () => { if (header) { await updateHeader({ header, action: 'add' }); setHeader(undefined); + headerKeyRef.current?.focus(); + addToast( + + ); } }; @@ -60,10 +74,10 @@ export const AppFooter: FC = () => { return (
- = () => { />
- = () => {
); diff --git a/src/components/app/app-header.tsx b/src/components/app/app-header.tsx index 049db4e..d75ddb4 100644 --- a/src/components/app/app-header.tsx +++ b/src/components/app/app-header.tsx @@ -1,11 +1,18 @@ import { type FC, useState } from 'react'; +import { BulkScopeChange } from '@components/bulk-scope-change/bulk-scope-change'; +import { Button } from '@components/button/button'; import { IconButton } from '@components/button/icon-button'; import { Drawer } from '@components/drawer/drawer'; +import { HeaderFilters } from '@components/header-filters/header-filters'; import { Settings } from '@components/settings/settings'; import { Status } from '@components/status/status'; import { Text } from '@components/text/text'; +import { SCOPES } from '@constants/scopes'; import { useHeaderTweakerContext } from '@contexts/headertweaker.context'; +import { filterHeadersByScope } from '@helpers/scope/filter-headers-by-scope.helper'; +import { CursorArrowRippleIcon } from '@heroicons/react/16/solid'; import { Cog6ToothIcon } from '@heroicons/react/24/solid'; +import { useTranslation } from 'react-i18next'; import packageJson from '../../../package.json'; import css from './app.module.scss'; @@ -15,33 +22,62 @@ type AppHeaderProps = { }; export const AppHeader: FC = ({ withoutSettings = false }) => { + const { t } = useTranslation(); const [showSettings, setShowSettings] = useState(false); - const { isDisabled } = useHeaderTweakerContext(); + const [showBulkScopeChange, setShowBulkScopeChange] = useState(false); + const { scope, isDisabled, headers } = useHeaderTweakerContext(); + const headersWithoutScope = filterHeadersByScope(headers, SCOPES.NO_SCOPE); return ( -
-
- - Header - Tweaker - - v{packageJson.version} - -
- {!withoutSettings && ( - <> - setShowSettings(true)}> - - + <> +
+
+
+ + Header + Tweaker + + v{packageJson.version} + +
+ {!withoutSettings && ( + <> + setShowSettings(true)}> + + - setShowSettings(false)} title="Settings"> - - - - )} -
+ setShowSettings(false)} + title={t('title.settings')} + > + + + + )} + + +
+
+ +
+ {scope === SCOPES.NO_SCOPE ? ( + + ) : null} +
+
+ + ); }; diff --git a/src/components/app/app.module.scss b/src/components/app/app.module.scss index 3c7051b..fba472e 100644 --- a/src/components/app/app.module.scss +++ b/src/components/app/app.module.scss @@ -4,7 +4,7 @@ height: 100vh; } -.header { +.main { padding: 0 vars.$spacing-core-4; gap: vars.$spacing-core-2; display: flex; @@ -36,6 +36,16 @@ p { font-size: .8rem } } +.scopes { + padding: 0 vars.$spacing-core-4; + display: flex; + align-items: center; + margin-bottom: vars.$spacing-core-1; + + .filters { flex-grow: 1 } + .scopeButton { width: auto } +} + .content { flex-grow: 1; overflow-y: auto; @@ -45,9 +55,10 @@ display: flex; align-items: center; margin: 0; - gap: vars.$spacing-core-3; + gap: vars.$spacing-core-2; padding: 0 vars.$spacing-core-4; height: 72px; + flex-shrink: 0; .inputWrapper { flex-grow: 1 } button { width: auto } diff --git a/src/components/app/app.tsx b/src/components/app/app.tsx index 9b474b5..c36bda5 100644 --- a/src/components/app/app.tsx +++ b/src/components/app/app.tsx @@ -1,8 +1,10 @@ import type { FC } from 'react'; import { HeaderList } from '@components/header-list/header-list'; import { ImportHeaders } from '@components/import-headers/import-headers'; +import { Toast } from '@components/toast/toast'; import { IMPORT_PARAM } from '@constants/index'; import { HeaderTweakerProvider } from '@contexts/headertweaker.context'; +import { ToastProvider } from '@contexts/toast.context'; import { AppFooter } from './app-footer'; import { AppHeader } from './app-header'; @@ -16,12 +18,15 @@ export const App: FC = () => { const isImportWindow = params.get(IMPORT_PARAM) === 'true'; return ( - -
- -
{isImportWindow ? : }
- {!isImportWindow && } -
-
+ + +
+ +
{isImportWindow ? : }
+ {!isImportWindow && } +
+
+ +
); }; diff --git a/src/components/bulk-scope-change/bulk-scope-change.tsx b/src/components/bulk-scope-change/bulk-scope-change.tsx new file mode 100644 index 0000000..1f418e2 --- /dev/null +++ b/src/components/bulk-scope-change/bulk-scope-change.tsx @@ -0,0 +1,39 @@ +import type { Dispatch, FC, SetStateAction } from 'react'; +import { Modal, ModalClose, ModalContent, ModalTitle } from '@components/modal/modal'; +import { FinalStep, Step, StepIndicators, StepNavigation, Steps } from '@components/steps/steps'; +import { BulkScopeChangeProvider } from '@contexts/bulk-scope-change.context'; +import { useTranslation } from 'react-i18next'; +import { SaveButton } from './elements/save-button'; +import { SelectHeaders } from './elements/select-headers'; +import { SelectUrls } from './elements/select-urls'; + +type BulkScopeChangeProps = { + showModal: boolean; + setShowModal: Dispatch>; +}; + +export const BulkScopeChange: FC = ({ showModal, setShowModal }) => { + const { t } = useTranslation(); + const closeModal = () => setShowModal(false); + + return ( + + {t('title.scope.wizard')} + + + + + + + + + + + + } /> + + + + + ); +}; diff --git a/src/components/bulk-scope-change/elements/save-button.tsx b/src/components/bulk-scope-change/elements/save-button.tsx new file mode 100644 index 0000000..6c2fc7b --- /dev/null +++ b/src/components/bulk-scope-change/elements/save-button.tsx @@ -0,0 +1,61 @@ +import { type Dispatch, type FC, type SetStateAction, useState } from 'react'; +import { Button } from '@components/button/button'; +import { ToastItem } from '@components/toast/toast-item'; +import { useBulkScopeChangeContext } from '@contexts/bulk-scope-change.context'; +import { useHeaderTweakerContext } from '@contexts/headertweaker.context'; +import { useToastContext } from '@contexts/toast.context'; +import { CheckCircleIcon } from '@heroicons/react/24/solid'; +import { useTranslation } from 'react-i18next'; + +type SaveButtonProps = { + closeModal: Dispatch>; +}; + +export const SaveButton: FC = ({ closeModal }) => { + const { t } = useTranslation(); + const { addToast } = useToastContext(); + const { headers, updateHeader } = useHeaderTweakerContext(); + const { pendingHeaders, setError, setIsCompleted } = useBulkScopeChangeContext(); + + const [loading, setLoading] = useState(false); + + const newScopeUrls = [...new Set(Object.values(pendingHeaders).flat())]; + + const hasUrl = Object.values(pendingHeaders).some((urls) => + urls.some((url) => url.trim().length > 0) + ); + + const saveHeaders = async () => { + try { + for (const [id, urls] of Object.entries(pendingHeaders)) { + const header = headers.find((header) => header.id === id); + + if (!header) continue; + + setLoading(true); + await updateHeader({ header: { ...header, urls }, action: 'update' }); + } + } catch { + setError(t('feedback.error.scopeChange')); + } finally { + setIsCompleted(true); + setLoading(false); + closeModal(true); + addToast( + + ); + } + }; + + return ( + + ); +}; diff --git a/src/components/bulk-scope-change/elements/select-headers.tsx b/src/components/bulk-scope-change/elements/select-headers.tsx new file mode 100644 index 0000000..9336054 --- /dev/null +++ b/src/components/bulk-scope-change/elements/select-headers.tsx @@ -0,0 +1,64 @@ +import { Checkbox, INTERMEDIATE_INDICATOR } from '@components/input/checkbox'; +import { Text } from '@components/text/text'; +import { SCOPES } from '@constants/scopes'; +import { type PendingHeader, useBulkScopeChangeContext } from '@contexts/bulk-scope-change.context'; +import { useHeaderTweakerContext } from '@contexts/headertweaker.context'; +import { filterHeadersByScope } from '@helpers/scope/filter-headers-by-scope.helper'; +import { useTranslation } from 'react-i18next'; + +export const SelectHeaders = () => { + const { t } = useTranslation(); + const { headers } = useHeaderTweakerContext(); + const { pendingHeaders, setPendingHeaders } = useBulkScopeChangeContext(); + + const headersWithoutScope = filterHeadersByScope(headers, SCOPES.NO_SCOPE); + + const pendingHeadersCount = Object.keys(pendingHeaders).length; + const headersWithoutScopeIds = headersWithoutScope.map(({ id }) => id); + const allHeadersSelected = + headersWithoutScopeIds.length > 0 && pendingHeadersCount === headersWithoutScopeIds.length; + + return ( +
+ {t('description.scope.headerSelect')} + { + setPendingHeaders( + allHeadersSelected + ? {} + : headersWithoutScope.reduce((acc, { id }) => { + acc[id] = []; + return acc; + }, {}) + ); + }} + checked={allHeadersSelected ? true : pendingHeadersCount ? INTERMEDIATE_INDICATOR : false} + /> + {headersWithoutScope.map(({ id, name }) => ( +
+ + setPendingHeaders((currentHeaders) => { + if (currentHeaders[id]) { + const { [id]: _, ...rest } = currentHeaders; + return rest; + } + + return { ...currentHeaders, [id]: [] }; + }) + } + /> +
+ ))} +
+ ); +}; diff --git a/src/components/bulk-scope-change/elements/select-urls.tsx b/src/components/bulk-scope-change/elements/select-urls.tsx new file mode 100644 index 0000000..bba6492 --- /dev/null +++ b/src/components/bulk-scope-change/elements/select-urls.tsx @@ -0,0 +1,26 @@ +import { ScopeSelector } from '@components/scope-selector/scope-selector'; +import { Text } from '@components/text/text'; +import { useBulkScopeChangeContext } from '@contexts/bulk-scope-change.context'; +import { useTranslation } from 'react-i18next'; + +export const SelectUrls = () => { + const { t } = useTranslation(); + const { pendingHeaders, setPendingHeaders, isCompleted } = useBulkScopeChangeContext(); + + const [urls = []] = Object.values(pendingHeaders); + + return isCompleted ? ( + {t('feedback.success.scopeChange')} + ) : ( +
+ + setPendingHeaders((currentHeaders) => + Object.fromEntries(Object.keys(currentHeaders).map((id) => [id, updatedUrls])) + ) + } + /> +
+ ); +}; diff --git a/src/components/button/button.module.scss b/src/components/button/button.module.scss index 2b61c43..780602e 100644 --- a/src/components/button/button.module.scss +++ b/src/components/button/button.module.scss @@ -5,12 +5,12 @@ align-items: center; color: vars.$colors-primary-foreground; transition: all .1s; - position: relative; overflow: hidden; z-index: 1; cursor: pointer; line-height: normal; border-radius: 2rem; + position: relative; svg { width: 22px; @@ -152,3 +152,18 @@ } } } + +.loading { + position: absolute; + inset: 0; + background-color: rgb(black, .5); + display: flex; + align-items: center; + justify-content: center; + + .loadingIcon { animation: spinner 0.8s linear infinite } +} + +@keyframes spinner { + to { transform: rotate(360deg) } +} \ No newline at end of file diff --git a/src/components/button/button.tsx b/src/components/button/button.tsx index 051ad46..a20322b 100644 --- a/src/components/button/button.tsx +++ b/src/components/button/button.tsx @@ -1,10 +1,12 @@ import type { ComponentPropsWithoutRef, FC, ReactNode } from 'react'; +import { ArrowPathIcon } from '@heroicons/react/16/solid'; import classnames from 'clsx'; import css from './button.module.scss'; export type ButtonProps = ComponentPropsWithoutRef<'button'> & { children: ReactNode; + loading?: boolean; variant?: 'default' | 'ghost'; }; @@ -12,6 +14,8 @@ export const Button: FC = ({ children, className, 'aria-label': ariaLabel, + disabled = false, + loading = false, variant = 'default', ...props }: ButtonProps) => { @@ -21,8 +25,14 @@ export const Button: FC = ({ [css.ghost]: variant === 'ghost', })} aria-label={ariaLabel} + disabled={loading || disabled} {...props} > + {loading && ( +
+ +
+ )} {children} ); diff --git a/src/components/edit-header/edit-header.module.scss b/src/components/edit-header/edit-header.module.scss index 019ed6c..27b9773 100644 --- a/src/components/edit-header/edit-header.module.scss +++ b/src/components/edit-header/edit-header.module.scss @@ -1,27 +1,5 @@ .root { display: flex; flex-direction: column; - gap: vars.$spacing-core-4; + gap: vars.$spacing-core-3; } - -.urlSection { - display: flex; - flex-direction: column; - gap: vars.$spacing-core-2; -} - -.urlEntry { - display: flex; - flex-direction: column; - gap: vars.$spacing-core-1; -} - -.urlRow { - display: flex; - gap: vars.$spacing-core-2; - align-items: center; -} - -.urlError { - color: vars.$colors-primary-error; -} \ No newline at end of file diff --git a/src/components/edit-header/edit-header.tsx b/src/components/edit-header/edit-header.tsx index f7ac5da..68fd926 100644 --- a/src/components/edit-header/edit-header.tsx +++ b/src/components/edit-header/edit-header.tsx @@ -1,14 +1,17 @@ -import { type ChangeEvent, type FC, type KeyboardEvent, useState } from 'react'; +import { type ChangeEvent, type FC, useState } from 'react'; import { Button } from '@components/button/button'; -import { IconButton } from '@components/button/icon-button'; -import { Input } from '@components/input/input'; +import { TextInput } from '@components/input/text-input'; +import { ScopeSelector } from '@components/scope-selector/scope-selector'; import { Switch } from '@components/switch/switch'; import { Text } from '@components/text/text'; +import { ToastItem } from '@components/toast/toast-item'; import { useHeaderTweakerContext } from '@contexts/headertweaker.context'; -import { isDuplicateUrl, normalizeUrlRestriction } from '@helpers/scope.helper'; +import { useToastContext } from '@contexts/toast.context'; +import { getDuplicateUrlIndexes } from '@helpers/scope/get-duplicate-url.helper'; import { cleanupHeaderKey } from '@helpers/validation.helper'; -import { CheckCircleIcon, PlusIcon, XMarkIcon } from '@heroicons/react/24/solid'; +import { CheckCircleIcon } from '@heroicons/react/24/solid'; import type { Header } from '@interfaces/index'; +import { useTranslation } from 'react-i18next'; import css from './edit-header.module.scss'; @@ -17,15 +20,12 @@ type EditHeaderProps = { }; export const EditHeader: FC = ({ closePanel }) => { + const { t } = useTranslation(); + const { addToast } = useToastContext(); const { updateHeader, selectedHeader, useLabels, setUseLabels } = useHeaderTweakerContext(); const [header, setHeader] = useState
(selectedHeader); - const [focusedUrlIndex, setFocusedUrlIndex] = useState(null); - const [duplicateUrlIndex, setDuplicateUrlIndex] = useState(null); - const hasDuplicateUrls = (() => { - const urls = (header?.urls ?? []).map(normalizeUrlRestriction).filter(Boolean); - return new Set(urls).size !== urls.length; - })(); + const hasDuplicateUrls = getDuplicateUrlIndexes(header?.urls ?? []).length > 0; const handleInputChange = (e: ChangeEvent) => { const { target } = e; @@ -53,107 +53,36 @@ export const EditHeader: FC = ({ closePanel }) => { })); }; - const handleUrlChange = (index: number, value: string) => { - setDuplicateUrlIndex((currentIndex) => (currentIndex === index ? null : currentIndex)); - setHeader((prev) => { - if (!prev) return prev; - const urls = [...(prev.urls ?? [])]; - urls[index] = value; - return { ...prev, urls }; - }); - }; - - const addUrl = () => { - setHeader((prev) => { - if (!prev) return prev; - - const urls = [...(prev.urls ?? []), '']; - setFocusedUrlIndex(urls.length - 1); - return { ...prev, urls }; - }); - }; - - const handleUrlKeyDown = (event: KeyboardEvent, index: number) => { - if (event.key !== 'Enter') return; - - event.preventDefault(); - if (isDuplicateUrl(header, index)) { - setDuplicateUrlIndex(index); - return; - } - - setDuplicateUrlIndex(null); - addUrl(); - }; - - const removeUrl = (index: number) => { - setHeader((prev) => { - if (!prev) return prev; - return { ...prev, urls: (prev.urls ?? []).filter((_, i) => i !== index) }; - }); - }; - if (!header) return null; return (
setHeader((prev) => prev && { ...prev, enabled: state })} /> - - + - -
- - URL restrictions - - {(header.urls ?? []).map((url, index) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: order is stable, no reordering -
-
- handleUrlChange(index, e.target.value)} - onKeyDown={(event) => handleUrlKeyDown(event, index)} - autoFocus={focusedUrlIndex === index} - onFocus={() => setFocusedUrlIndex(null)} - /> - removeUrl(index)}> - - -
- {duplicateUrlIndex === index && ( - - This scope already exists - - )} -
- ))} - -
+ setHeader((prev) => prev && { ...prev, urls })} + />
); diff --git a/src/components/feedback/confirm.tsx b/src/components/feedback/confirm.tsx index 4b2ad66..729ffaf 100644 --- a/src/components/feedback/confirm.tsx +++ b/src/components/feedback/confirm.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react'; import { Button } from '@components/button/button'; import { ButtonGroup } from '@components/button/button-group'; import { Modal, ModalContent, ModalFooter, ModalTitle } from '@components/modal/modal'; +import { useTranslation } from 'react-i18next'; import type { ConfirmProps } from './interfaces'; export const Confirm: FC = ({ @@ -9,20 +10,24 @@ export const Confirm: FC = ({ onConfirm, onCancel, title, - confirmText = 'OK', - cancelText = 'Cancel', + confirmText, + cancelText, ...modalProps -}: ConfirmProps) => ( - - {title} - {message} - - - - - - - -); +}: ConfirmProps) => { + const { t } = useTranslation(); + + return ( + + {title} + {message} + + + + + + + + ); +}; diff --git a/src/components/feedback/success.tsx b/src/components/feedback/success.tsx index c7c92db..a4ecbee 100644 --- a/src/components/feedback/success.tsx +++ b/src/components/feedback/success.tsx @@ -3,24 +3,29 @@ import { Button } from '@components/button/button'; import { ButtonGroup } from '@components/button/button-group'; import { Modal, ModalContent, ModalFooter, ModalIcon, ModalTitle } from '@components/modal/modal'; import { CheckCircleIcon } from '@heroicons/react/24/solid'; +import { useTranslation } from 'react-i18next'; import type { SuccessProps } from './interfaces'; export const Success: FC = ({ message, onConfirm, - confirmText = 'OK', + confirmText, ...modalProps -}: SuccessProps) => ( - - - - - Success - {message} - - - - - - -); +}: SuccessProps) => { + const { t } = useTranslation(); + + return ( + + + + + {t('title.feedback.success.default')} + {message} + + + + + + + ); +}; diff --git a/src/components/header-filters/header-filters.module.scss b/src/components/header-filters/header-filters.module.scss new file mode 100644 index 0000000..89d1e2d --- /dev/null +++ b/src/components/header-filters/header-filters.module.scss @@ -0,0 +1,62 @@ +.root { + position: relative; + display: inline-flex; + align-items: center; + padding: vars.$spacing-core-1; + border-radius: 999px; + background-color: rgb(255 255 255 / 4%); +} + +.indicator { + position: absolute; + top: vars.$spacing-core-1; + left: 0; + bottom: vars.$spacing-core-1; + border-radius: 999px; + background-color: vars.$colors-primary-action-disabled; + transition: transform 0.25s ease, width 0.25s ease; + pointer-events: none; +} + +.tab { + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + gap: vars.$spacing-core-2; + padding: vars.$spacing-core-2 vars.$spacing-core-4; + border: none; + border-radius: 999px; + background: none; + cursor: pointer; + white-space: nowrap; + font: inherit; + color: vars.$colors-tertiary-foreground; + transition: color 0.25s ease; + + .icon { + color: rgba(vars.$colors-primary-warning, .5); + width: 20px; + height: 20px; + transition: color 0.25s ease; + } + + &:hover { color: vars.$colors-primary-foreground } + + &.active { + color: vars.$colors-primary-foreground; + + .icon { color: vars.$colors-primary-warning } + } + + &:disabled { + cursor: not-allowed; + color: vars.$colors-tertiary-foreground; + } +} + +.disabled { + opacity: 0.5; + + .indicator { background-color: transparent } +} diff --git a/src/components/header-filters/header-filters.tsx b/src/components/header-filters/header-filters.tsx new file mode 100644 index 0000000..7f914fa --- /dev/null +++ b/src/components/header-filters/header-filters.tsx @@ -0,0 +1,89 @@ +import { type ComponentPropsWithoutRef, type FC, useEffect, useRef, useState } from 'react'; +import { Text } from '@components/text/text'; +import { SCOPE_LABEL_KEYS, SCOPES, type Scope } from '@constants/scopes'; +import { useHeaderTweakerContext } from '@contexts/headertweaker.context'; +import { getCurrentTabUrl } from '@helpers/get-current-tab.helper'; +import { filterHeadersByScope } from '@helpers/scope/filter-headers-by-scope.helper'; +import { InformationCircleIcon } from '@heroicons/react/24/outline'; +import classnames from 'clsx'; +import { useTranslation } from 'react-i18next'; + +import css from './header-filters.module.scss'; + +const scopes = Object.values(SCOPES); + +const getHost = (url?: string) => { + if (!url) return undefined; + + try { + return new URL(url).host.replace(/^www\./i, ''); + } catch { + return undefined; + } +}; + +export const HeaderFilters: FC> = ({ className }) => { + const { t } = useTranslation(); + const { headers, scope, setscope } = useHeaderTweakerContext(); + const [currentUrl, setCurrentUrl] = useState(undefined); + const [indicator, setIndicator] = useState({ left: 0, width: 0 }); + const listRef = useRef(null); + + const currentHost = getHost(currentUrl); + const isDisabled = !headers.length; + + useEffect(() => { + getCurrentTabUrl().then(setCurrentUrl); + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: re-measure when the active tab or its label changes + useEffect(() => { + const active = listRef.current?.querySelector('[data-active="true"]'); + if (!active) return; + + setIndicator({ left: active.offsetLeft, width: active.offsetWidth }); + }, [scope, currentHost, headers]); + + return ( +
+ + {scopes.map((currentScope: Scope) => { + const isActive = currentScope === scope; + const count = filterHeadersByScope(headers, currentScope, currentUrl).length; + + return ( + + ); + })} +
+ ); +}; diff --git a/src/components/header-list/header-item.tsx b/src/components/header-list/header-item.tsx index cf164d3..31b80a8 100644 --- a/src/components/header-list/header-item.tsx +++ b/src/components/header-list/header-item.tsx @@ -3,13 +3,13 @@ import { IconButton } from '@components/button/icon-button'; import { Confirm } from '@components/feedback/confirm'; import { HeaderContent } from '@components/header-content/header-content'; import { Switch } from '@components/switch/switch'; -import { Text } from '@components/text/text'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@components/tooltip/tooltip'; +import { ToastItem } from '@components/toast/toast-item'; import { useHeaderTweakerContext } from '@contexts/headertweaker.context'; -import { matchesUrl } from '@helpers/header.helper'; -import { Bars3Icon, GlobeAltIcon, PencilSquareIcon, TrashIcon } from '@heroicons/react/24/solid'; +import { useToastContext } from '@contexts/toast.context'; +import { Bars3Icon, PencilSquareIcon, TrashIcon } from '@heroicons/react/24/solid'; import type { Header } from '@interfaces/index'; import classnames from 'clsx'; +import { useTranslation } from 'react-i18next'; import css from './header-list.module.scss'; @@ -18,10 +18,9 @@ type HeaderItemProps = Header & { index: number; isDragOver: boolean; showLabel: boolean; - currentUrl?: string; onDragStart: (index: number) => void; - onDragOver: (e: React.DragEvent, index: number) => void; - onDrop: (index: number) => void; + onDragOver: (e: React.DragEvent, index: number, urls?: string[]) => void; + onDrop: (index: number, urls?: string[]) => void; onDragEnd: () => void; }; @@ -36,17 +35,16 @@ export const HeaderItem: FC = ({ index, isDragOver, showLabel, - currentUrl, onDragStart, onDragOver, onDrop, onDragEnd, }: HeaderItemProps) => { + const { t } = useTranslation(); const [headerToDelete, setHeaderToDelete] = useState
(null); - const { isDisabled, setSelectedHeader, updateHeader } = useHeaderTweakerContext(); - const isScoped = urls && urls.length >= 1; - const isCurrentUrl = !!(isScoped && currentUrl && matchesUrl(currentUrl, urls)); + const { addToast } = useToastContext(); + const { isDisabled, setSelectedHeader, updateHeader } = useHeaderTweakerContext(); return ( <> @@ -54,8 +52,8 @@ export const HeaderItem: FC = ({ draggable className={classnames({ [css.disabled]: isDisabled, [css.dragOver]: isDragOver })} onDragStart={() => onDragStart(index)} - onDragOver={(e) => onDragOver(e, index)} - onDrop={() => onDrop(index)} + onDragOver={(e) => onDragOver(e, index, urls ?? [])} + onDrop={() => onDrop(index, urls ?? [])} onDragEnd={onDragEnd} > @@ -87,66 +85,42 @@ export const HeaderItem: FC = ({ - - - - - - - - {!isScoped && This header is not scoped to a specific url} - - {isScoped ? ( - isCurrentUrl ? ( - This header is scoped to the current url - ) : ( - The header is scoped to the following url's: {urls?.join(', ')} - ) - ) : null} - - - { setSelectedHeader({ id, name, value, enabled, urls, label }); openDrawer(true); }} > - + setHeaderToDelete({ id, name, value, enabled, urls, label })} > - + { if (headerToDelete) { await updateHeader({ header: headerToDelete, action: 'remove' }); setSelectedHeader(null); setHeaderToDelete(null); + addToast( + + ); } }} onCancel={() => setHeaderToDelete(null)} diff --git a/src/components/header-list/header-list.module.scss b/src/components/header-list/header-list.module.scss index eb45490..ad0f84b 100644 --- a/src/components/header-list/header-list.module.scss +++ b/src/components/header-list/header-list.module.scss @@ -8,6 +8,10 @@ .notFound { text-align: center; } +.tableWrapper { + position: relative; +} + .tableFixed { table-layout: fixed; width: 100%; @@ -20,7 +24,7 @@ .headerDragHandle { width: 28px; } -.headerSwitch { width: 70px; } +.headerSwitch { width: 54px; } .headerActions { width: 100px; @@ -28,16 +32,6 @@ padding-right: 0; } -.headerScope { width: 20px } - -.headerNameTh { - position: relative; -} - -.headerLabelTh { - position: relative; -} - .labelCell { overflow: hidden; } @@ -53,13 +47,11 @@ position: absolute; top: 0; bottom: 0; - right: -4px; width: 8px; + margin-left: -4px; cursor: col-resize; z-index: 2; - &.hidden { display: none } - &::before { content: ''; position: absolute; @@ -73,16 +65,9 @@ transition: opacity 0.15s, background-color 0.15s; } - &:hover { - &::before { - opacity: 1; - background-color: vars.$colors-primary-action; - } - - &::after { - opacity: 1; - border-color: vars.$colors-primary-action; - } + &:hover::before { + opacity: 1; + background-color: vars.$colors-primary-action; } } @@ -91,11 +76,6 @@ opacity: 1; background-color: vars.$colors-primary-action; } - - &::after { - opacity: 1; - border-color: vars.$colors-primary-action; - } } .buttonWrapper { @@ -138,15 +118,15 @@ outline-offset: -1px; } -.scopedIcon { - cursor: help; - - &.inactive { opacity: .5 } - &.currentUrl { color: vars.$colors-primary-action } +.groupRow { + td { + padding-top: vars.$spacing-core-4; + padding-bottom: vars.$spacing-core-2; + } } -.scopedCell { - vertical-align: middle; - - .scopedIcon { display: block; } +.groupItems { + display: flex; + align-items: center; + gap: vars.$spacing-core-2; } \ No newline at end of file diff --git a/src/components/header-list/header-list.tsx b/src/components/header-list/header-list.tsx index 298ab13..59e5504 100644 --- a/src/components/header-list/header-list.tsx +++ b/src/components/header-list/header-list.tsx @@ -1,18 +1,31 @@ import { type FC, useEffect, useRef, useState } from 'react'; +import { Alert, AlertContent } from '@components/alert/alert'; import { Drawer } from '@components/drawer/drawer'; import { EditHeader } from '@components/edit-header/edit-header'; import { HeaderItem } from '@components/header-list/header-item'; +import { Pill } from '@components/pill/pill'; +import { NoHeaders } from '@components/placeholders/no-headers'; import { Text } from '@components/text/text'; import { storage } from '@constants/index'; +import { SCOPES } from '@constants/scopes'; import { useHeaderTweakerContext } from '@contexts/headertweaker.context'; -import { getCurrentTabUrl } from '@helpers/header.helper'; +import { getCurrentTabUrl } from '@helpers/get-current-tab.helper'; +import { groupHeaders } from '@helpers/header/group-headers.helper'; +import { filterHeadersByScope } from '@helpers/scope/filter-headers-by-scope.helper'; +import { getScopeErrorMessageKey } from '@helpers/scope/get-scoped-error.helper'; import classnames from 'clsx'; +import { useTranslation } from 'react-i18next'; import css from './header-list.module.scss'; type HeaderListProps = Record; +type DropTargetUrls = string[] | undefined; + +const DRAG_HANDLE_WIDTH = 28; +const SWITCH_WIDTH = 54; export const HeaderList: FC = () => { + const { t } = useTranslation(); const [open, setOpen] = useState(false); const [dropIndex, setDropIndex] = useState(null); const [nameColWidth, setNameColWidth] = useState(275); @@ -21,7 +34,10 @@ export const HeaderList: FC = () => { const [currentUrl, setCurrentUrl] = useState(undefined); const dragIndexRef = useRef(null); const tableRef = useRef(null); - const { headers, selectedHeader, reorderHeaders, useLabels } = useHeaderTweakerContext(); + const { headers, selectedHeader, reorderHeaders, useLabels, scope } = useHeaderTweakerContext(); + + const visibleHeaders = filterHeadersByScope(headers, scope, currentUrl); + const groupedHeaders = groupHeaders(visibleHeaders); useEffect(() => { storage.local.get(['nameColWidth', 'labelColWidth']).then((result) => { @@ -44,6 +60,21 @@ export const HeaderList: FC = () => { dragIndexRef.current = index; }; + const moveHeader = async (from: number, insertIndex: number, targetUrls: DropTargetUrls) => { + const fromIndex = headers.findIndex(({ id }) => id === visibleHeaders[from]?.id); + if (fromIndex === -1 || insertIndex === -1) return; + + const newHeaders = [...headers]; + const [moved] = newHeaders.splice(fromIndex, 1); + const nextHeader = + targetUrls === undefined + ? moved + : { ...moved, urls: targetUrls.length ? targetUrls : undefined }; + + newHeaders.splice(insertIndex, 0, nextHeader); + await reorderHeaders(newHeaders); + }; + const handleDragOver = (e: React.DragEvent, index: number) => { e.preventDefault(); if (dragIndexRef.current !== index) { @@ -51,15 +82,30 @@ export const HeaderList: FC = () => { } }; - const handleDrop = async (index: number) => { + const handleDrop = async (index: number, targetUrls?: string[]) => { const from = dragIndexRef.current; dragIndexRef.current = null; setDropIndex(null); if (from === null || from === index) return; - const newHeaders = [...headers]; - const [moved] = newHeaders.splice(from, 1); - newHeaders.splice(index, 0, moved); - await reorderHeaders(newHeaders); + + const toIndex = headers.findIndex(({ id }) => id === visibleHeaders[index]?.id); + await moveHeader(from, toIndex, scope === SCOPES.ALL ? targetUrls : undefined); + }; + + const handleGroupDrop = async ( + targetUrls: string[], + targetHeaders: ReadonlyArray<{ id: string }> + ) => { + const from = dragIndexRef.current; + dragIndexRef.current = null; + setDropIndex(null); + if (from === null) return; + + const targetIndexes = targetHeaders.map((header) => + headers.findIndex(({ id }) => id === header.id) + ); + const insertIndex = Math.max(...targetIndexes) + 1; + await moveHeader(from, insertIndex, targetUrls); }; const handleDragEnd = () => { @@ -72,7 +118,8 @@ export const HeaderList: FC = () => { const startX = e.clientX; const startWidth = nameColWidth; const tableWidth = tableRef.current?.offsetWidth ?? 600; - const maxWidth = tableWidth - 218 - 80; + const maxWidth = + tableWidth - DRAG_HANDLE_WIDTH - SWITCH_WIDTH - (useLabels ? labelColWidth : 0) - 180; let currentWidth = startWidth; setIsResizing(true); @@ -99,7 +146,7 @@ export const HeaderList: FC = () => { const startX = e.clientX; const startWidth = labelColWidth; const tableWidth = tableRef.current?.offsetWidth ?? 600; - const maxWidth = tableWidth - nameColWidth - 218 - 80; + const maxWidth = tableWidth - nameColWidth - DRAG_HANDLE_WIDTH - SWITCH_WIDTH - 180; let currentWidth = startWidth; setIsResizing(true); @@ -121,80 +168,135 @@ export const HeaderList: FC = () => { document.addEventListener('mouseup', handleMouseUp); }; + if (!headers.length) { + return ; + } + + if (!visibleHeaders.length) { + return ; + } + + const dividerBaseOffset = DRAG_HANDLE_WIDTH + SWITCH_WIDTH + (useLabels ? labelColWidth : 0); + const labelDividerOffset = DRAG_HANDLE_WIDTH + SWITCH_WIDTH + labelColWidth; + const nameDividerOffset = dividerBaseOffset + nameColWidth; + return (
- - - - - {useLabels && } - - - - - - - -
- - {useLabels && ( - - Label -
+ + + + {t('label.scope.noScopeWarning', { count: visibleHeaders.length })} + + + + + )} + +
+ + + + + {useLabels && } + + + + + {scope === SCOPES.ALL ? ( + groupedHeaders.map((group) => { + const groupKey = group.urls.length ? group.urls.join(',') : 'global'; + + return ( + + visibleHeaders[dropIndex ?? -1]?.id === header.id + ), + })} + onDragOver={(event) => event.preventDefault()} + onDrop={() => handleGroupDrop(group.urls, group.headers)} + > + + + {group.headers.map((header) => { + const index = visibleHeaders.findIndex(({ id }) => id === header.id); + + return ( + + ); })} - onMouseDown={handleLabelResizeMouseDown} - aria-hidden="true" - /> - - )} - - - - - - {!headers.length ? ( - - - + + ); + }) ) : ( - headers.map((header, index) => ( - - )) + + {visibleHeaders.map((header, index) => ( + + ))} + )} - -
+ {group.urls.length ? ( +
+ + {t('label.scope.target', { count: group.urls.length })} + + {group.urls.map((url) => ( + {url} + ))} +
+ ) : ( + {t('label.scope.noScope')} + )} +
- Key - - Value - - -
- No headers to display yet -
- setOpen(false)}> +
+ {useLabels && ( + diff --git a/src/components/import-headers/import-headers.tsx b/src/components/import-headers/import-headers.tsx index 0ea7913..536e7e9 100644 --- a/src/components/import-headers/import-headers.tsx +++ b/src/components/import-headers/import-headers.tsx @@ -2,14 +2,16 @@ import { type FC, useRef, useState } from 'react'; import { Success } from '@components/feedback/success'; import { Text } from '@components/text/text'; import { useHeaderTweakerContext } from '@contexts/headertweaker.context'; -import { validateHeaderImport } from '@helpers/import.helper'; +import { validateHeaderImport } from '@helpers/validate-header-import.helper'; import classnames from 'clsx'; +import { useTranslation } from 'react-i18next'; import css from './import-headers.module.scss'; type ImportHeadersProps = Record; export const ImportHeaders: FC = () => { + const { t } = useTranslation(); const inputRef = useRef(null); const [importedHeaders, setImportedHeaders] = useState(0); const [dragActive, setDragActive] = useState(false); @@ -53,7 +55,7 @@ export const ImportHeaders: FC = () => { handleFiles(e.dataTransfer.files); } }} - aria-label="File upload form" + aria-label={t('a11y.ariaLabel.import.form')} tabIndex={-1} > 0} - title="Headers imported" - message={`${importedHeaders} headers are successfully imported.`} + title={t('title.feedback.success.import')} + message={t('feedback.success.import.successMessage', { count: importedHeaders })} onConfirm={() => window.close()} onClose={() => setImportedHeaders(0)} /> diff --git a/src/components/input/checkbox.tsx b/src/components/input/checkbox.tsx new file mode 100644 index 0000000..1847eb2 --- /dev/null +++ b/src/components/input/checkbox.tsx @@ -0,0 +1,58 @@ +import { + type ComponentPropsWithoutRef, + forwardRef, + useEffect, + useId, + useImperativeHandle, + useRef, +} from 'react'; +import { CheckIcon, MinusIcon } from '@heroicons/react/24/solid'; +import classnames from 'clsx'; + +import css from './input.module.scss'; + +export const INTERMEDIATE_INDICATOR = '-'; + +export type CheckboxState = boolean | typeof INTERMEDIATE_INDICATOR; + +export type CheckboxProps = Omit, 'checked' | 'type'> & { + label?: string; + checked?: CheckboxState; +}; + +export const Checkbox = forwardRef( + ({ label, id: providedId, checked, 'aria-checked': ariaChecked, ...props }, ref) => { + const inputRef = useRef(null); + const generatedId = useId(); + const id = providedId ?? generatedId; + + useImperativeHandle(ref, () => inputRef.current as HTMLInputElement); + + useEffect(() => { + if (inputRef.current) inputRef.current.indeterminate = checked === INTERMEDIATE_INDICATOR; + }, [checked]); + + return ( +
+
+ + {checked === INTERMEDIATE_INDICATOR ? ( +
+ {label && } +
+ ); + } +); + +Checkbox.displayName = 'Checkbox'; diff --git a/src/components/input/input.module.scss b/src/components/input/input.module.scss index e64b5e2..f77f3c1 100644 --- a/src/components/input/input.module.scss +++ b/src/components/input/input.module.scss @@ -1,4 +1,4 @@ -.root { +.text { display: block; width: 100%; position: relative; @@ -37,14 +37,72 @@ transition: border-color 0.1s ease-in-out; box-sizing: border-box; height: 100%; - - &:disabled { - cursor: not-allowed; - } } &:focus-within input { border-color: vars.$colors-primary-action; - background-color: color.scale(vars.$colors-primary-action-disabled, $lightness: -20%); + background-color: color.scale(vars.$colors-primary-action-disabled, $lightness: -20%); + } +} + +.root { + input:disabled { + cursor: not-allowed; } } + +.checkIcon { + position: absolute; + inset: 2px; + color: vars.$colors-primary-foreground; + opacity: 0; + pointer-events: none; + transition: opacity 0.1s ease-in-out; +} + +.checkbox { + display: inline-flex; + align-items: center; + gap: vars.$spacing-core-2; +} + +.inputWrapper { + position: relative; + flex-shrink: 0; + width: 20px; + height: 20px; + + input[type='checkbox'] { + appearance: none; + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + padding: 0; + border: 2px solid vars.$colors-primary-action-disabled; + border-radius: vars.$border-radius-primary; + background-color: vars.$colors-primary-action-disabled; + cursor: pointer; + transition: background-color 0.1s ease-in-out, border-color 0.1s ease-in-out; + + &:checked { + border-color: vars.$colors-primary-action; + background-color: vars.$colors-primary-action; + } + + &:focus-visible { + outline: 2px solid vars.$colors-primary-foreground; + outline-offset: 2px; + } + + &:disabled { opacity: 0.6 } + + &:hover:not(:disabled) { + border-color: vars.$colors-primary-action-hover; + background-color: vars.$colors-primary-action-hover; + } + } + + input:is(:checked, :indeterminate) + .checkIcon { opacity: 1 } +} \ No newline at end of file diff --git a/src/components/input/input.tsx b/src/components/input/input.tsx deleted file mode 100644 index 32a3b00..0000000 --- a/src/components/input/input.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { type ComponentPropsWithoutRef, type FC, useId } from 'react'; - -import css from './input.module.scss'; - -export const Input: FC> = ({ - type, - placeholder, - 'aria-label': ariaLabel, - ...props -}: ComponentPropsWithoutRef<'input'>) => { - const id = useId(); - - return ( - - ); -}; diff --git a/src/components/input/text-input.tsx b/src/components/input/text-input.tsx new file mode 100644 index 0000000..de9d28f --- /dev/null +++ b/src/components/input/text-input.tsx @@ -0,0 +1,27 @@ +import { type ComponentPropsWithoutRef, forwardRef, useId } from 'react'; + +import css from './input.module.scss'; + +export type TextInputProps = ComponentPropsWithoutRef<'input'>; + +export const TextInput = forwardRef( + ({ id: providedId, placeholder, 'aria-label': ariaLabel, type = 'text', ...props }, ref) => { + const generatedId = useId(); + const id = providedId ?? generatedId; + + return ( + + ); + } +); + +TextInput.displayName = 'TextInput'; diff --git a/src/components/modal/elements/modal-close.tsx b/src/components/modal/elements/modal-close.tsx index 66b5828..9af68a9 100644 --- a/src/components/modal/elements/modal-close.tsx +++ b/src/components/modal/elements/modal-close.tsx @@ -1,6 +1,7 @@ import type { FC } from 'react'; import { IconButton, type IconButtonProps } from '@components/button/icon-button'; import { XCircleIcon } from '@heroicons/react/24/solid'; +import { useTranslation } from 'react-i18next'; import css from '../modal.module.scss'; @@ -10,9 +11,11 @@ type ModalCloseProps = { }; const ModalClose: FC = ({ onClose, size = 'large' }) => { + const { t } = useTranslation(); + return (
- +
diff --git a/src/components/modal/modal.module.scss b/src/components/modal/modal.module.scss index 9a7a912..dfa46e8 100644 --- a/src/components/modal/modal.module.scss +++ b/src/components/modal/modal.module.scss @@ -22,6 +22,8 @@ transition: transform 0.3s cubic-bezier(.4,0,.2,1), opacity 0.3s cubic-bezier(.4,0,.2,1); border-radius: vars.$border-radius-primary; + &.fullHeight { height: 90vh } + .header { padding: vars.$spacing-core-4 vars.$spacing-core-4; display: flex; diff --git a/src/components/modal/modal.tsx b/src/components/modal/modal.tsx index d22ea32..48b175b 100644 --- a/src/components/modal/modal.tsx +++ b/src/components/modal/modal.tsx @@ -23,9 +23,16 @@ export type ModalProps = PropsWithChildren<{ isOpen: boolean; onClose: () => void; type: 'modal' | 'drawer' | 'alert' | 'confirm' | 'success'; + withFullHeight?: boolean; }>; -export const Modal: FC = ({ type, isOpen, onClose, children }) => { +export const Modal: FC = ({ + type, + isOpen, + onClose, + withFullHeight = false, + children, +}) => { const modalRoot = document.body; const backdropRef = useRef(null); @@ -92,7 +99,7 @@ export const Modal: FC = ({ type, isOpen, onClose, children }) => { onClick={handleBackdropClick} aria-hidden={!isOpen} > -
+
{Children.map(children, (child) => { if (isValidElement(child) && (child.type as FC).displayName === 'ModalIcon') { diff --git a/src/components/pill/pill.module.scss b/src/components/pill/pill.module.scss new file mode 100644 index 0000000..63a719c --- /dev/null +++ b/src/components/pill/pill.module.scss @@ -0,0 +1,8 @@ +.root { + padding: vars.$spacing-core-1 vars.$spacing-core-2; + border: solid 1px vars.$colors-primary-action-disabled; + display: flex; + align-items: center; + justify-content: center; + border-radius: vars.$border-radius-small; +} \ No newline at end of file diff --git a/src/components/pill/pill.tsx b/src/components/pill/pill.tsx new file mode 100644 index 0000000..0e52df7 --- /dev/null +++ b/src/components/pill/pill.tsx @@ -0,0 +1,12 @@ +import type { FC, PropsWithChildren } from 'react'; +import { Text } from '@components/text/text'; + +import css from './pill.module.scss'; + +export const Pill: FC = ({ children }) => { + return ( + + {children} + + ); +}; diff --git a/src/components/placeholders/no-headers.tsx b/src/components/placeholders/no-headers.tsx new file mode 100644 index 0000000..0f415e5 --- /dev/null +++ b/src/components/placeholders/no-headers.tsx @@ -0,0 +1,12 @@ +import type { FC } from 'react'; +import { Text } from '@components/text/text'; + +import css from './placeholders.module.scss'; + +export const NoHeaders: FC<{ message: string }> = ({ message }) => { + return ( +
+ {message} +
+ ); +}; diff --git a/src/components/placeholders/placeholders.module.scss b/src/components/placeholders/placeholders.module.scss new file mode 100644 index 0000000..87d2fa1 --- /dev/null +++ b/src/components/placeholders/placeholders.module.scss @@ -0,0 +1,7 @@ +.page { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} \ No newline at end of file diff --git a/src/components/scope-selector/scope-selector.module.scss b/src/components/scope-selector/scope-selector.module.scss new file mode 100644 index 0000000..401b7ba --- /dev/null +++ b/src/components/scope-selector/scope-selector.module.scss @@ -0,0 +1,21 @@ +.root { + display: flex; + flex-direction: column; + gap: vars.$spacing-core-2; +} + +.entry { + display: flex; + flex-direction: column; + gap: vars.$spacing-core-1; +} + +.row { + display: flex; + gap: vars.$spacing-core-2; + align-items: center; +} + +.error { + color: vars.$colors-primary-error; +} diff --git a/src/components/scope-selector/scope-selector.tsx b/src/components/scope-selector/scope-selector.tsx new file mode 100644 index 0000000..9f916f9 --- /dev/null +++ b/src/components/scope-selector/scope-selector.tsx @@ -0,0 +1,119 @@ +import { type FC, type KeyboardEvent, useState } from 'react'; +import { Button } from '@components/button/button'; +import { IconButton } from '@components/button/icon-button'; +import { TextInput } from '@components/input/text-input'; +import { Select } from '@components/select/select'; +import { Text } from '@components/text/text'; +import { useHeaderTweakerContext } from '@contexts/headertweaker.context'; +import { getDuplicateUrlIndexes } from '@helpers/scope/get-duplicate-url.helper'; +import { getKnownUrls } from '@helpers/scope/get-known-urls.helper'; +import { normalizeUrlRestriction } from '@helpers/scope/normalize-url-restriction.helper'; +import { PlusIcon, XMarkIcon } from '@heroicons/react/24/solid'; +import { useTranslation } from 'react-i18next'; + +import css from './scope-selector.module.scss'; + +export type ScopeSelectorProps = { + urls: string[]; + onChange: (urls: string[]) => void; +}; + +export const ScopeSelector: FC = ({ urls, onChange }) => { + const { t } = useTranslation(); + const { headers } = useHeaderTweakerContext(); + const [focusedIndex, setFocusedIndex] = useState(null); + + const knownUrls = getKnownUrls(headers); + const duplicateIndexes = getDuplicateUrlIndexes(urls); + const hasEmptyUrl = urls.some((url) => !url.trim()); + + const getOptions = (index: number) => + knownUrls + .filter( + (url) => + !urls.some( + (value, i) => + i !== index && normalizeUrlRestriction(value) === normalizeUrlRestriction(url) + ) + ) + .map((url) => ({ label: url, value: url })); + + const addUrl = () => { + if (hasEmptyUrl) return; + + setFocusedIndex(urls.length); + onChange([...urls, '']); + }; + + const handleKeyDown = (event: KeyboardEvent, index: number) => { + if (event.key !== 'Enter') return; + + event.preventDefault(); + if (duplicateIndexes.includes(index) || !urls[index]?.trim()) return; + + addUrl(); + }; + + return ( +
+ + {t('label.scope.selector')} + + + {urls.map((url, index) => { + const options = getOptions(index); + + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: order is stable, no reordering +
+
+ {options.every((option) => option.label === url) ? ( + + onChange(urls.map((current, i) => (i === index ? event.target.value : current))) + } + onKeyDown={(event) => handleKeyDown(event, index)} + /> + ) : ( + & { +export type TextProps = ComponentPropsWithoutRef<'span'> & { children: ReactNode; variant?: TextVariant; + textStyle?: 'primary' | 'secondary'; as?: HTMLElementType; }; export const Text: FC = ({ children, + textStyle = 'primary', variant = TextVariant.BODY, as: Tag = VariantTags[variant], ...props }: TextProps) => { - return {children}; + return ( + + {children} + + ); }; diff --git a/src/components/toast/elements/toast-wrapper.tsx b/src/components/toast/elements/toast-wrapper.tsx new file mode 100644 index 0000000..a4b73ff --- /dev/null +++ b/src/components/toast/elements/toast-wrapper.tsx @@ -0,0 +1,24 @@ +import { type FC, type PropsWithChildren, useEffect, useState } from 'react'; +import classnames from 'clsx'; + +import css from '../toast.module.scss'; + +const TOAST_DURATION_MS = 5000; + +type ToastWrapperProps = PropsWithChildren<{ onDismiss: () => void }>; + +export const ToastWrapper: FC = ({ onDismiss, children }) => { + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + const animationFrame = requestAnimationFrame(() => setIsVisible(true)); + const timeout = setTimeout(onDismiss, TOAST_DURATION_MS); + + return () => { + cancelAnimationFrame(animationFrame); + clearTimeout(timeout); + }; + }, [onDismiss]); + + return
{children}
; +}; diff --git a/src/components/toast/toast-item.tsx b/src/components/toast/toast-item.tsx new file mode 100644 index 0000000..a996b08 --- /dev/null +++ b/src/components/toast/toast-item.tsx @@ -0,0 +1,69 @@ +import type { FC } from 'react'; +import type { AlertVariant } from '@components/alert/alert'; +import { IconButton } from '@components/button/icon-button'; +import { Text } from '@components/text/text'; +import { + CheckCircleIcon, + ExclamationTriangleIcon, + InformationCircleIcon, +} from '@heroicons/react/24/outline'; +import { XCircleIcon } from '@heroicons/react/24/solid'; +import classnames from 'clsx'; +import { useTranslation } from 'react-i18next'; + +import css from './toast.module.scss'; + +export type ToastItemProps = { + message: string; + isNotClosable?: boolean; + variant?: AlertVariant; + onClose?: () => void; +}; + +const getIcon = (variant: AlertVariant) => { + switch (variant) { + case 'positive': + return CheckCircleIcon; + case 'negative': + return XCircleIcon; + case 'warning': + return ExclamationTriangleIcon; + default: + return InformationCircleIcon; + } +}; + +export const ToastItem: FC = ({ + message, + onClose, + isNotClosable = false, + variant = 'neutral', +}) => { + const { t } = useTranslation(); + const Icon = getIcon(variant); + + return ( +
+
+ +
+ + {message} + + {!isNotClosable && ( +
+ + + +
+ )} +
+ ); +}; diff --git a/src/components/toast/toast.module.scss b/src/components/toast/toast.module.scss new file mode 100644 index 0000000..b94cfb0 --- /dev/null +++ b/src/components/toast/toast.module.scss @@ -0,0 +1,76 @@ +.root { + position: fixed; + bottom: vars.$spacing-core-4; + left: 50%; + display: flex; + flex-direction: column; + gap: vars.$spacing-core-2; + transform: translateX(-50%); + z-index: 1100; + pointer-events: none; +} + +.wrapper { + opacity: 0; + transform: translateY(40px); + transition: transform 0.3s cubic-bezier(.4, 0, .2, 1), opacity 0.3s cubic-bezier(.4, 0, .2, 1); + pointer-events: auto; + + &.visible { + opacity: 1; + transform: translateY(0); + } +} + +.item { + display: flex; + align-items: center; + gap: vars.$spacing-core-3; + min-width: 240px; + max-width: 360px; + padding: vars.$spacing-core-2 vars.$spacing-core-2 vars.$spacing-core-2 vars.$spacing-core-4; + background-color: vars.$colors-primary-background; + color: vars.$colors-primary-foreground; + border: 1px solid vars.$colors-secondary-foreground; + border-radius: vars.$border-radius-primary; + box-shadow: 0 4px 12px rgb(0 0 0 / 25%); + + .icon { + flex: 0 0 auto; + display: flex; + + svg { + width: 20px; + height: 20px; + } + } + + &.notClosable { padding-right: vars.$spacing-core-4 } + + &.positive { + border-color: rgba(vars.$colors-primary-success, .5); + + .icon svg { color: vars.$colors-primary-success; } + } + + &.negative { + border-color: rgba(vars.$colors-primary-error, .5); + + .icon svg { color: vars.$colors-primary-error; } + } + + &.warning { + border-color: rgba(vars.$colors-primary-warning, .5); + + .icon svg { color: vars.$colors-primary-warning; } + } +} + + + +.message { flex: 1 1 auto } + +.close { + flex: 0 0 auto; + white-space: nowrap; +} \ No newline at end of file diff --git a/src/components/toast/toast.tsx b/src/components/toast/toast.tsx new file mode 100644 index 0000000..21cb0fe --- /dev/null +++ b/src/components/toast/toast.tsx @@ -0,0 +1,23 @@ +import { cloneElement, type FC, isValidElement } from 'react'; +import { useToastContext } from '@contexts/toast.context'; +import { createPortal } from 'react-dom'; +import { ToastWrapper } from './elements/toast-wrapper'; + +import css from './toast.module.scss'; + +export const Toast: FC = () => { + const { toasts, removeToast } = useToastContext(); + + if (toasts.length === 0) return null; + + return createPortal( +
+ {toasts.map(({ id, node }) => ( + removeToast(id)}> + {isValidElement(node) ? cloneElement(node, { onClose: () => removeToast(id) }) : node} + + ))} +
, + document.body + ); +}; diff --git a/src/constants/scopes.ts b/src/constants/scopes.ts new file mode 100644 index 0000000..a2fe849 --- /dev/null +++ b/src/constants/scopes.ts @@ -0,0 +1,15 @@ +import type { TranslationKey } from '@i18n/config'; + +export const SCOPES = { + ALL: 'all', + NO_SCOPE: 'no-scope', + CURRENT_URL: 'current-url', +} as const; + +export type Scope = (typeof SCOPES)[keyof typeof SCOPES]; + +export const SCOPE_LABEL_KEYS: Record = { + [SCOPES.ALL]: 'label.scope.all', + [SCOPES.NO_SCOPE]: 'label.scope.noScope', + [SCOPES.CURRENT_URL]: 'label.scope.currentUrl', +}; diff --git a/src/constants/select.ts b/src/constants/select.ts new file mode 100644 index 0000000..69d0b27 --- /dev/null +++ b/src/constants/select.ts @@ -0,0 +1 @@ +export const SELECT_CREATE_VALUE = '__select-create__'; diff --git a/src/contexts/bulk-scope-change.context.tsx b/src/contexts/bulk-scope-change.context.tsx new file mode 100644 index 0000000..6aff0eb --- /dev/null +++ b/src/contexts/bulk-scope-change.context.tsx @@ -0,0 +1,49 @@ +import { + createContext, + type Dispatch, + type FC, + type PropsWithChildren, + type SetStateAction, + useContext, + useState, +} from 'react'; +import type { Header } from '@interfaces/index'; + +export type PendingHeader = Record; + +export type BulkScopeChangeContextValue = { + error: string | undefined; + setError: Dispatch>; + isCompleted: boolean; + setIsCompleted: Dispatch>; + pendingHeaders: PendingHeader; + setPendingHeaders: Dispatch>; +}; + +export const BulkScopeChangeContext = createContext( + undefined +); + +export const BulkScopeChangeProvider: FC = ({ children }) => { + const [error, setError] = useState(); + const [isCompleted, setIsCompleted] = useState(false); + const [pendingHeaders, setPendingHeaders] = useState({}); + + return ( + + {children} + + ); +}; + +export const useBulkScopeChangeContext = (): BulkScopeChangeContextValue => { + const context = useContext(BulkScopeChangeContext); + + if (!context) { + throw new Error('useBulkScopeChangeContext must be used within a BulkScopeChangeProvider'); + } + + return context; +}; diff --git a/src/contexts/headertweaker.context.tsx b/src/contexts/headertweaker.context.tsx index b61df7e..eef9bf9 100644 --- a/src/contexts/headertweaker.context.tsx +++ b/src/contexts/headertweaker.context.tsx @@ -8,20 +8,19 @@ import { useState, } from 'react'; import { storage } from '@constants/index'; -import { - activateHeader, - addHeader, - getHeaders, - importHeaders, - removeHeader, - reorderHeaders, - updateHeader, -} from '@helpers/header.helper'; +import { SCOPES, type Scope } from '@constants/scopes'; +import { activateHeader } from '@helpers/header/activate-header.helper'; +import { addHeader } from '@helpers/header/add-header.helper'; +import { getHeaders } from '@helpers/header/get-headers.helper'; +import { importHeaders } from '@helpers/header/import-headers.helper'; +import { removeHeader } from '@helpers/header/remove-header.helper'; +import { saveHeaders } from '@helpers/header/save-headers.helper'; +import { updateHeader } from '@helpers/header/update-headers.helper'; import { isDisabledGlobally, setStatus as setHeaderTweakerStatus, } from '@helpers/headertweaker.helper'; -import type { Header, Status } from '@interfaces/index'; +import type { Header } from '@interfaces/index'; type HeaderFn = { header: Header; @@ -31,16 +30,20 @@ type HeaderFn = { type HeaderTweakerContextValue = { loading: boolean; - headers: Header[]; + headers: ReadonlyArray
; isDisabled: boolean; useLabels: boolean; selectedHeader: Header | null; + scope: Scope; + showBulkScopeChange: boolean; updateHeader: (args: HeaderFn) => Promise; importHeaders: (headers: Header[]) => Promise; reorderHeaders: (headers: Header[]) => Promise; - setStatus: (status: Status) => Promise; + setStatus: (status: string) => Promise; setUseLabels: (show: boolean) => void; + setscope: Dispatch>; setSelectedHeader: Dispatch>; + setShowBulkScopeChange: Dispatch>; }; const initialState: HeaderTweakerContextValue = { @@ -49,12 +52,16 @@ const initialState: HeaderTweakerContextValue = { loading: false, isDisabled: false, useLabels: false, + scope: SCOPES.ALL, + showBulkScopeChange: false, updateHeader: async () => {}, importHeaders: async () => {}, reorderHeaders: async () => {}, setSelectedHeader: () => {}, setUseLabels: () => {}, + setscope: () => {}, setStatus: async () => {}, + setShowBulkScopeChange: () => {}, }; export const HeaderTweakerContext = createContext({ @@ -74,7 +81,9 @@ export const HeaderTweakerProvider = ({ children }: HeaderTweakerContextProps) = const [isDisabled, setIsDisabled] = useState(false); const [useLabels, setUseLabels] = useState(false); const [headerList, setHeaderList] = useState([]); + const [showBulkScopeChange, setShowBulkScopeChange] = useState(false); const [selectedHeader, setSelectedHeaderRaw] = useState
(null); + const [scope, setscope] = useState(SCOPES.ALL); const setSelectedHeader = (value: SetStateAction
) => { setSelectedHeaderRaw(value); @@ -87,7 +96,7 @@ export const HeaderTweakerProvider = ({ children }: HeaderTweakerContextProps) = const getStatus = async () => setIsDisabled(await isDisabledGlobally()); - const setStatus = async (status: Status) => { + const setStatus = async (status: string) => { const newStatus = await setHeaderTweakerStatus(status); setIsDisabled(newStatus === 'disabled'); }; @@ -107,7 +116,7 @@ export const HeaderTweakerProvider = ({ children }: HeaderTweakerContextProps) = }; const reorderHeadersFn = async (headers: Header[]) => { - await reorderHeaders(headers); + await saveHeaders(headers); setHeaderList(headers); }; @@ -152,8 +161,12 @@ export const HeaderTweakerProvider = ({ children }: HeaderTweakerContextProps) = isDisabled, useLabels, selectedHeader, + scope, + showBulkScopeChange, + setscope, setSelectedHeader, setStatus, + setShowBulkScopeChange, headers: headerList, setUseLabels: setUseLabelsFn, updateHeader: updateHeaderFn, diff --git a/src/contexts/steps.context.tsx b/src/contexts/steps.context.tsx new file mode 100644 index 0000000..852c2c5 --- /dev/null +++ b/src/contexts/steps.context.tsx @@ -0,0 +1,28 @@ +import { createContext, type FC, type PropsWithChildren, useContext } from 'react'; + +export type StepsContextValue = { + currentStep: number; + totalSteps: number; + onStepChange: (stepIndex: number) => void; + stepTitles: string[]; +}; + +export const StepsContext = createContext(undefined); + +type StepsProviderProps = { + value: StepsContextValue; +}; + +export const StepsProvider: FC> = ({ value, children }) => { + return {children}; +}; + +export const useStepsContext = (): StepsContextValue => { + const context = useContext(StepsContext); + + if (!context) { + throw new Error('useStepsContext must be used within a StepsProvider'); + } + + return context; +}; diff --git a/src/contexts/toast.context.tsx b/src/contexts/toast.context.tsx new file mode 100644 index 0000000..7f92cf9 --- /dev/null +++ b/src/contexts/toast.context.tsx @@ -0,0 +1,47 @@ +import { + createContext, + type PropsWithChildren, + type ReactElement, + useContext, + useState, +} from 'react'; +import type { ToastItemProps } from '@components/toast/toast-item'; + +type ToastEntry = { + id: string; + node: ReactElement; +}; + +type ToastContextValue = { + toasts: ReadonlyArray; + addToast: (node: ReactElement) => void; + removeToast: (id: string) => void; +}; + +const ToastContext = createContext(undefined); + +export const ToastProvider = ({ children }: PropsWithChildren) => { + const [toasts, setToasts] = useState([]); + + const addToast = (node: ReactElement) => { + setToasts((current) => [...current, { id: crypto.randomUUID(), node }]); + }; + + const removeToast = (id: string) => { + setToasts((current) => current.filter((toast) => toast.id !== id)); + }; + + return ( + + {children} + + ); +}; + +export const useToastContext = (): ToastContextValue => { + const context = useContext(ToastContext); + + if (!context) throw new Error('useToastContext must be used within a ToastProvider'); + + return context; +}; diff --git a/src/headertweaker.tsx b/src/headertweaker.tsx index 8f3f27d..dfcda70 100644 --- a/src/headertweaker.tsx +++ b/src/headertweaker.tsx @@ -1,5 +1,6 @@ import './styles/fonts.scss'; import './styles/global.scss'; +import '@i18n/config'; import { App } from '@components/app/app'; import { createRoot } from 'react-dom/client'; diff --git a/src/helpers/get-current-tab.helper.spec.ts b/src/helpers/get-current-tab.helper.spec.ts new file mode 100644 index 0000000..3714534 --- /dev/null +++ b/src/helpers/get-current-tab.helper.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getCurrentTabUrl } from './get-current-tab.helper'; + +const { tabsQuery } = vi.hoisted(() => ({ tabsQuery: vi.fn() })); + +vi.mock('@constants/index', () => ({ + tabs: { query: tabsQuery }, +})); + +describe('getCurrentTabUrl', () => { + it('gets the active tab URL when it is available', async () => { + tabsQuery.mockResolvedValue([{ url: 'https://example.com' }]); + await expect(getCurrentTabUrl()).resolves.toBe('https://example.com'); + + tabsQuery.mockResolvedValue([]); + await expect(getCurrentTabUrl()).resolves.toBeUndefined(); + }); +}); diff --git a/src/helpers/get-current-tab.helper.ts b/src/helpers/get-current-tab.helper.ts new file mode 100644 index 0000000..2e9649c --- /dev/null +++ b/src/helpers/get-current-tab.helper.ts @@ -0,0 +1,6 @@ +import { tabs } from '@constants/index'; + +export const getCurrentTabUrl = async (): Promise => { + const [activeTab] = await tabs.query({ active: true, currentWindow: true }); + return activeTab?.url; +}; diff --git a/src/helpers/header.helper.spec.ts b/src/helpers/header.helper.spec.ts deleted file mode 100644 index e8d724f..0000000 --- a/src/helpers/header.helper.spec.ts +++ /dev/null @@ -1,141 +0,0 @@ -import type { Header } from '@interfaces/index'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const { storageLocal, tabsQuery, uuid } = vi.hoisted(() => ({ - storageLocal: { - get: vi.fn(), - set: vi.fn(), - }, - tabsQuery: vi.fn(), - uuid: vi.fn(), -})); - -vi.mock('@constants/index', () => ({ - storage: { local: storageLocal }, - tabs: { query: tabsQuery }, -})); - -vi.mock('uuid', () => ({ v4: uuid })); - -import { - activateHeader, - addHeader, - exportHeaders, - getCurrentTabUrl, - getHeaders, - importHeaders, - matchesUrl, - removeHeader, - reorderHeaders, - updateHeader, -} from './header.helper'; - -const header = (overrides: Partial
= {}): Header => ({ - id: 'header-id', - name: 'X-Example', - value: 'value', - enabled: false, - ...overrides, -}); - -describe('header helpers', () => { - beforeEach(() => { - vi.clearAllMocks(); - uuid.mockReturnValue('generated-id'); - }); - - it('returns stored headers unchanged when they all have IDs', async () => { - const headers = [header()]; - storageLocal.get.mockResolvedValue({ headers }); - - await expect(getHeaders()).resolves.toEqual(headers); - expect(storageLocal.set).not.toHaveBeenCalled(); - }); - - it('assigns IDs to legacy headers and persists the migration', async () => { - const legacyHeader = { ...header(), id: '' }; - storageLocal.get.mockResolvedValue({ headers: [legacyHeader] }); - - await expect(getHeaders()).resolves.toEqual([header({ id: 'generated-id' })]); - expect(storageLocal.set).toHaveBeenCalledWith({ headers: [header({ id: 'generated-id' })] }); - }); - - it('adds an enabled header with a generated ID', async () => { - storageLocal.get.mockResolvedValue({ headers: [header()] }); - - await expect(addHeader(header({ id: 'ignored', enabled: false }))).resolves.toEqual( - header({ id: 'generated-id', enabled: true }) - ); - expect(storageLocal.set).toHaveBeenCalledWith({ - headers: [header(), header({ id: 'generated-id', enabled: true })], - }); - }); - - it('updates and removes the selected header', async () => { - const original = header(); - const replacement = header({ value: 'updated' }); - storageLocal.get.mockResolvedValue({ headers: [original] }); - - await expect(updateHeader(replacement)).resolves.toEqual(replacement); - expect(storageLocal.set).toHaveBeenLastCalledWith({ headers: [replacement] }); - - storageLocal.get.mockResolvedValue({ headers: [replacement] }); - await removeHeader(replacement); - expect(storageLocal.set).toHaveBeenLastCalledWith({ headers: [] }); - }); - - it('updates activation only when the header exists', async () => { - const storedHeader = header({ enabled: false }); - storageLocal.get.mockResolvedValue({ headers: [storedHeader] }); - - await expect(activateHeader(storedHeader, true)).resolves.toEqual(header({ enabled: true })); - expect(storageLocal.set).toHaveBeenCalledWith({ headers: [header({ enabled: true })] }); - - storageLocal.get.mockResolvedValue({ headers: [] }); - await expect(activateHeader(storedHeader, true)).resolves.toBeUndefined(); - expect(storageLocal.set).toHaveBeenCalledTimes(1); - }); - - it('exports headers as a downloaded JSON file', async () => { - storageLocal.get.mockResolvedValue({ headers: [header()] }); - const createObjectURL = vi.fn(() => 'blob:test'); - const revokeObjectURL = vi.fn(); - const click = vi - .spyOn(HTMLAnchorElement.prototype, 'click') - .mockImplementation(() => undefined); - vi.stubGlobal('URL', { createObjectURL, revokeObjectURL }); - - await exportHeaders(); - - expect(createObjectURL).toHaveBeenCalledOnce(); - expect(click).toHaveBeenCalledOnce(); - expect(revokeObjectURL).toHaveBeenCalledWith('blob:test'); - click.mockRestore(); - }); - - it('imports arrays, reorders headers, and ignores malformed imports', async () => { - const headers = [header()]; - - await importHeaders(headers); - await reorderHeaders(headers); - await importHeaders({} as Header[]); - - expect(storageLocal.set).toHaveBeenCalledTimes(2); - expect(storageLocal.set).toHaveBeenNthCalledWith(1, { headers }); - expect(storageLocal.set).toHaveBeenNthCalledWith(2, { headers }); - }); - - it('gets the active tab URL when it is available', async () => { - tabsQuery.mockResolvedValue([{ url: 'https://example.com' }]); - await expect(getCurrentTabUrl()).resolves.toBe('https://example.com'); - - tabsQuery.mockResolvedValue([]); - await expect(getCurrentTabUrl()).resolves.toBeUndefined(); - }); - - it('matches wildcard URL patterns while treating regex characters literally', () => { - expect(matchesUrl('https://example.com/api/v1', ['https://example.com/*'])).toBe(true); - expect(matchesUrl('https://exampleXcom', ['https://example.com'])).toBe(false); - expect(matchesUrl('https://example.com', ['https://other.example/*'])).toBe(false); - }); -}); diff --git a/src/helpers/header.helper.ts b/src/helpers/header.helper.ts deleted file mode 100644 index 0db9386..0000000 --- a/src/helpers/header.helper.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { storage, tabs } from '@constants/index'; -import type { Header } from '@interfaces/index'; -import { v4 as uuidv4 } from 'uuid'; - -const setHeaders = async (headers: Header[]) => { - await storage.local.set({ headers }); -}; - -export const getHeaders = async (): Promise => { - const result = await storage.local.get('headers'); - const headers: Header[] = result.headers || []; - const headersWithoutId = headers.some(({ id }) => !id); - - if (headersWithoutId) { - const headersWithId = headers.map((h) => (h.id ? h : { ...h, id: uuidv4() })); - await setHeaders(headersWithId); - return headersWithId; - } - - return headers; -}; - -const getSelectedHeader = async (header: Header) => { - const headers = await getHeaders(); - return { - headers, - pos: headers.findIndex(({ id }) => id === header.id), - selectedHeader: headers.find(({ id }) => id === header.id), - }; -}; - -export const addHeader = async (header: Header) => { - const id = uuidv4(); - const newHeader = { ...header, id, enabled: true }; - const headers = await getHeaders(); - headers.push(newHeader); - await setHeaders(headers); - return newHeader; -}; - -export const updateHeader = async (header: Header) => { - const { headers, pos } = await getSelectedHeader(header); - headers[pos] = header; - await setHeaders(headers); - return header; -}; - -export const removeHeader = async (header: Header) => { - const { headers, pos } = await getSelectedHeader(header); - headers.splice(pos, 1); - await setHeaders(headers); -}; - -export const activateHeader = async (header: Header, isActive: boolean) => { - const { pos, headers, selectedHeader } = await getSelectedHeader(header); - if (selectedHeader) { - headers[pos] = { ...selectedHeader, enabled: isActive }; - await setHeaders(headers); - return headers[pos]; - } -}; - -export const exportHeaders = async () => { - const headers = await getHeaders(); - const exportData = { - name: 'HeaderTweaker export', - date: new Date().toISOString(), - headers: headers, - }; - const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'headertweaker-export.json'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -}; - -export const importHeaders = async (headers: Header[]) => { - if (!Array.isArray(headers)) return; - await setHeaders(headers); -}; - -export const reorderHeaders = async (headers: Header[]) => { - await setHeaders(headers); -}; - -export const getCurrentTabUrl = async (): Promise => { - const [activeTab] = await tabs.query({ active: true, currentWindow: true }); - return activeTab?.url; -}; - -export const matchesUrl = (url: string, patterns: string[]): boolean => { - return patterns.some((pattern) => { - const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); - return new RegExp(escaped).test(url); - }); -}; diff --git a/src/helpers/header/activate-header.helper.spec.ts b/src/helpers/header/activate-header.helper.spec.ts new file mode 100644 index 0000000..d74d137 --- /dev/null +++ b/src/helpers/header/activate-header.helper.spec.ts @@ -0,0 +1,34 @@ +import type { Header } from '@interfaces/index'; +import { describe, expect, it, vi } from 'vitest'; +import { activateHeader } from './activate-header.helper'; + +const { storageLocal } = vi.hoisted(() => ({ + storageLocal: { + get: vi.fn(), + set: vi.fn(), + }, +})); + +vi.mock('@constants/index', () => ({ storage: { local: storageLocal } })); + +const header = (overrides: Partial
= {}): Header => ({ + id: 'header-id', + name: 'X-Example', + value: 'value', + enabled: false, + ...overrides, +}); + +describe('activateHeader', () => { + it('updates activation only when the header exists', async () => { + const storedHeader = header({ enabled: false }); + storageLocal.get.mockResolvedValue({ headers: [storedHeader] }); + + await expect(activateHeader(storedHeader, true)).resolves.toEqual(header({ enabled: true })); + expect(storageLocal.set).toHaveBeenCalledWith({ headers: [header({ enabled: true })] }); + + storageLocal.get.mockResolvedValue({ headers: [] }); + await expect(activateHeader(storedHeader, true)).resolves.toBeUndefined(); + expect(storageLocal.set).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/helpers/header/activate-header.helper.ts b/src/helpers/header/activate-header.helper.ts new file mode 100644 index 0000000..e0e4508 --- /dev/null +++ b/src/helpers/header/activate-header.helper.ts @@ -0,0 +1,12 @@ +import { getSelectedHeader } from '@helpers/header/get-selected-header.helper'; +import { saveHeaders } from '@helpers/header/save-headers.helper'; +import type { Header } from '@interfaces/index'; + +export const activateHeader = async (header: Header, isActive: boolean) => { + const { pos, headers, selectedHeader } = await getSelectedHeader(header); + if (selectedHeader) { + headers[pos] = { ...selectedHeader, enabled: isActive }; + await saveHeaders(headers); + return headers[pos]; + } +}; diff --git a/src/helpers/header/add-header.helper.spec.ts b/src/helpers/header/add-header.helper.spec.ts new file mode 100644 index 0000000..0409b53 --- /dev/null +++ b/src/helpers/header/add-header.helper.spec.ts @@ -0,0 +1,41 @@ +import type { Header } from '@interfaces/index'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { addHeader } from './add-header.helper'; + +const { storageLocal, uuid } = vi.hoisted(() => ({ + storageLocal: { + get: vi.fn(), + set: vi.fn(), + }, + uuid: vi.fn(), +})); + +vi.mock('@constants/index', () => ({ storage: { local: storageLocal } })); + +vi.mock('uuid', () => ({ v4: uuid })); + +const header = (overrides: Partial
= {}): Header => ({ + id: 'header-id', + name: 'X-Example', + value: 'value', + enabled: false, + ...overrides, +}); + +describe('addHeader', () => { + beforeEach(() => { + vi.clearAllMocks(); + uuid.mockReturnValue('generated-id'); + }); + + it('adds an enabled header with a generated ID', async () => { + storageLocal.get.mockResolvedValue({ headers: [header()] }); + + await expect(addHeader(header({ id: 'ignored', enabled: false }))).resolves.toEqual( + header({ id: 'generated-id', enabled: true }) + ); + expect(storageLocal.set).toHaveBeenCalledWith({ + headers: [header(), header({ id: 'generated-id', enabled: true })], + }); + }); +}); diff --git a/src/helpers/header/add-header.helper.ts b/src/helpers/header/add-header.helper.ts new file mode 100644 index 0000000..be02b67 --- /dev/null +++ b/src/helpers/header/add-header.helper.ts @@ -0,0 +1,13 @@ +import type { Header } from '@interfaces/index'; +import { v4 as uuidv4 } from 'uuid'; +import { getHeaders } from './get-headers.helper'; +import { saveHeaders } from './save-headers.helper'; + +export const addHeader = async (header: Header) => { + const id = uuidv4(); + const newHeader = { ...header, id, enabled: true }; + const headers = await getHeaders(); + headers.push(newHeader); + await saveHeaders(headers); + return newHeader; +}; diff --git a/src/helpers/header/export-headers.helper.spec.ts b/src/helpers/header/export-headers.helper.spec.ts new file mode 100644 index 0000000..541728e --- /dev/null +++ b/src/helpers/header/export-headers.helper.spec.ts @@ -0,0 +1,39 @@ +import type { Header } from '@interfaces/index'; +import { describe, expect, it, vi } from 'vitest'; +import { exportHeaders } from './export-headers.helper'; + +const { storageLocal } = vi.hoisted(() => ({ + storageLocal: { + get: vi.fn(), + set: vi.fn(), + }, +})); + +vi.mock('@constants/index', () => ({ storage: { local: storageLocal } })); + +const header = (overrides: Partial
= {}): Header => ({ + id: 'header-id', + name: 'X-Example', + value: 'value', + enabled: false, + ...overrides, +}); + +describe('exportHeaders', () => { + it('exports headers as a downloaded JSON file', async () => { + storageLocal.get.mockResolvedValue({ headers: [header()] }); + const createObjectURL = vi.fn(() => 'blob:test'); + const revokeObjectURL = vi.fn(); + const click = vi + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => undefined); + vi.stubGlobal('URL', { createObjectURL, revokeObjectURL }); + + await exportHeaders(); + + expect(createObjectURL).toHaveBeenCalledOnce(); + expect(click).toHaveBeenCalledOnce(); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:test'); + click.mockRestore(); + }); +}); diff --git a/src/helpers/header/export-headers.helper.ts b/src/helpers/header/export-headers.helper.ts new file mode 100644 index 0000000..7a2365f --- /dev/null +++ b/src/helpers/header/export-headers.helper.ts @@ -0,0 +1,19 @@ +import { getHeaders } from '@helpers/header/get-headers.helper'; + +export const exportHeaders = async () => { + const headers = await getHeaders(); + const exportData = { + name: 'HeaderTweaker export', + date: new Date().toISOString(), + headers: headers, + }; + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'headertweaker-export.json'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +}; diff --git a/src/helpers/header/get-headers.helper.spec.ts b/src/helpers/header/get-headers.helper.spec.ts new file mode 100644 index 0000000..f40340d --- /dev/null +++ b/src/helpers/header/get-headers.helper.spec.ts @@ -0,0 +1,46 @@ +import type { Header } from '@interfaces/index'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getHeaders } from './get-headers.helper'; + +const { storageLocal, uuid } = vi.hoisted(() => ({ + storageLocal: { + get: vi.fn(), + set: vi.fn(), + }, + uuid: vi.fn(), +})); + +vi.mock('@constants/index', () => ({ storage: { local: storageLocal } })); + +vi.mock('uuid', () => ({ v4: uuid })); + +const header = (overrides: Partial
= {}): Header => ({ + id: 'header-id', + name: 'X-Example', + value: 'value', + enabled: false, + ...overrides, +}); + +describe('getHeaders', () => { + beforeEach(() => { + vi.clearAllMocks(); + uuid.mockReturnValue('generated-id'); + }); + + it('returns stored headers unchanged when they all have IDs', async () => { + const headers = [header()]; + storageLocal.get.mockResolvedValue({ headers }); + + await expect(getHeaders()).resolves.toEqual(headers); + expect(storageLocal.set).not.toHaveBeenCalled(); + }); + + it('assigns IDs to legacy headers and persists the migration', async () => { + const legacyHeader = { ...header(), id: '' }; + storageLocal.get.mockResolvedValue({ headers: [legacyHeader] }); + + await expect(getHeaders()).resolves.toEqual([header({ id: 'generated-id' })]); + expect(storageLocal.set).toHaveBeenCalledWith({ headers: [header({ id: 'generated-id' })] }); + }); +}); diff --git a/src/helpers/header/get-headers.helper.ts b/src/helpers/header/get-headers.helper.ts new file mode 100644 index 0000000..5c780c2 --- /dev/null +++ b/src/helpers/header/get-headers.helper.ts @@ -0,0 +1,18 @@ +import { storage } from '@constants/index'; +import { saveHeaders } from '@helpers/header/save-headers.helper'; +import type { Header } from '@interfaces/index'; +import { v4 as uuidv4 } from 'uuid'; + +export const getHeaders = async (): Promise => { + const result = await storage.local.get('headers'); + const headers: Header[] = result.headers || []; + const headersWithoutId = headers.some(({ id }) => !id); + + if (headersWithoutId) { + const headersWithId = headers.map((h) => (h.id ? h : { ...h, id: uuidv4() })); + await saveHeaders(headersWithId); + return headersWithId; + } + + return headers; +}; diff --git a/src/helpers/header/get-selected-header.helper.spec.ts b/src/helpers/header/get-selected-header.helper.spec.ts new file mode 100644 index 0000000..4f1d890 --- /dev/null +++ b/src/helpers/header/get-selected-header.helper.spec.ts @@ -0,0 +1,44 @@ +import type { Header } from '@interfaces/index'; +import { describe, expect, it, vi } from 'vitest'; +import { getSelectedHeader } from './get-selected-header.helper'; + +const { storageLocal } = vi.hoisted(() => ({ + storageLocal: { + get: vi.fn(), + set: vi.fn(), + }, +})); + +vi.mock('@constants/index', () => ({ storage: { local: storageLocal } })); + +const header = (overrides: Partial
= {}): Header => ({ + id: 'header-id', + name: 'X-Example', + value: 'value', + enabled: false, + ...overrides, +}); + +describe('getSelectedHeader', () => { + it('returns the selected header and its position', async () => { + const headers = [header({ id: 'first-id' }), header({ id: 'second-id' })]; + storageLocal.get.mockResolvedValue({ headers }); + + await expect(getSelectedHeader(header({ id: 'second-id' }))).resolves.toEqual({ + headers, + pos: 1, + selectedHeader: headers[1], + }); + }); + + it('returns no selected header when the ID is not stored', async () => { + const headers = [header()]; + storageLocal.get.mockResolvedValue({ headers }); + + await expect(getSelectedHeader(header({ id: 'missing-id' }))).resolves.toEqual({ + headers, + pos: -1, + selectedHeader: undefined, + }); + }); +}); diff --git a/src/helpers/header/get-selected-header.helper.ts b/src/helpers/header/get-selected-header.helper.ts new file mode 100644 index 0000000..de77b96 --- /dev/null +++ b/src/helpers/header/get-selected-header.helper.ts @@ -0,0 +1,11 @@ +import { getHeaders } from '@helpers/header/get-headers.helper'; +import type { Header } from '@interfaces/index'; + +export const getSelectedHeader = async (header: Header) => { + const headers = await getHeaders(); + return { + headers, + pos: headers.findIndex(({ id }) => id === header.id), + selectedHeader: headers.find(({ id }) => id === header.id), + }; +}; diff --git a/src/helpers/header/group-headers.helper.spec.ts b/src/helpers/header/group-headers.helper.spec.ts new file mode 100644 index 0000000..b71f6d7 --- /dev/null +++ b/src/helpers/header/group-headers.helper.spec.ts @@ -0,0 +1,88 @@ +import type { Header } from '@interfaces/index'; +import { describe, expect, it, vi } from 'vitest'; +import { groupHeaders } from './group-headers.helper'; + +vi.mock('@constants/index', () => ({ + storage: { local: { get: vi.fn(), set: vi.fn() } }, + tabs: { query: vi.fn() }, +})); + +const header = (urls?: string[]): Header => ({ + id: 'header-id', + name: 'X-Example', + value: 'value', + enabled: true, + urls, +}); + +describe('groupHeaders', () => { + it('groups headers by their exact combined set of urls', () => { + const a = { ...header(['example.com/a']), id: 'a' }; + const b = { ...header(['example.com/b']), id: 'b' }; + const c = { ...header(['example.com/a']), id: 'c' }; + + expect(groupHeaders([a, b, c])).toEqual([ + { urls: ['example.com/a'], headers: [a, c] }, + { urls: ['example.com/b'], headers: [b] }, + ]); + }); + + it('groups a header with multiple urls into a single combined group', () => { + const multi = { ...header(['example.com/a', 'example.com/b']), id: 'multi' }; + + expect(groupHeaders([multi])).toEqual([ + { urls: ['example.com/a', 'example.com/b'], headers: [multi] }, + ]); + }); + + it('groups headers that share the same urls regardless of their order', () => { + const one = { ...header(['example.com', 'example.net']), id: 'one' }; + const two = { ...header(['example.net', 'example.com']), id: 'two' }; + + expect(groupHeaders([one, two])).toEqual([ + { urls: ['example.com', 'example.net'], headers: [one, two] }, + ]); + }); + + it('sorts groups alphabetically by their combined urls', () => { + const b = { ...header(['example.com/b']), id: 'b' }; + const a = { ...header(['example.com/a']), id: 'a' }; + + expect(groupHeaders([b, a]).map((group) => group.urls.join(', '))).toEqual([ + 'example.com/a', + 'example.com/b', + ]); + }); + + it('lists groups scoped to multiple urls before single-url groups', () => { + const single = { ...header(['z.com']), id: 'single' }; + const joined = { ...header(['a.com', 'b.com']), id: 'joined' }; + + expect(groupHeaders([single, joined]).map((group) => group.urls)).toEqual([ + ['a.com', 'b.com'], + ['z.com'], + ]); + }); + + it('treats different paths on the same host as distinct groups', () => { + const a = { ...header(['example.com/pathA']), id: 'a' }; + const b = { ...header(['example.com/pathB']), id: 'b' }; + + expect(groupHeaders([a, b])).toHaveLength(2); + }); + + it('groups unscoped headers under a single "Global" group placed last', () => { + const global1 = { ...header(), id: 'global1' }; + const global2 = { ...header([]), id: 'global2' }; + const scoped = { ...header(['example.com/a']), id: 'scoped' }; + + expect(groupHeaders([global1, scoped, global2])).toEqual([ + { urls: ['example.com/a'], headers: [scoped] }, + { urls: [], headers: [global1, global2] }, + ]); + }); + + it('returns no groups for an empty list of headers', () => { + expect(groupHeaders([])).toEqual([]); + }); +}); diff --git a/src/helpers/header/group-headers.helper.ts b/src/helpers/header/group-headers.helper.ts new file mode 100644 index 0000000..eb568d2 --- /dev/null +++ b/src/helpers/header/group-headers.helper.ts @@ -0,0 +1,50 @@ +import type { Header } from '@interfaces/index'; + +export type HeaderGroup = { + urls: string[]; + headers: Header[]; +}; + +/** + * Group headers in the header list by scope url + * - headers that have the same url scope are grouped together + * - headers with combined scope-group are shown first + * - headers with a single scope shown after + * - unscoped headers shown last + */ +export const groupHeaders = (headers: ReadonlyArray
): HeaderGroup[] => { + const groups = new Map(); + const globalHeaders: Header[] = []; + + for (const header of headers) { + const urls = header.urls ?? []; + if (!urls.length) { + globalHeaders.push(header); + continue; + } + + // Group headers with the same scope (set) + const sortedUrls = [...urls].sort((a, b) => a.localeCompare(b)); + const key = sortedUrls.join('\u0000'); + + const group = groups.get(key); + if (group) { + group.headers.push(header); + } else { + groups.set(key, { urls: sortedUrls, headers: [header] }); + } + } + + // Place combined scope groups first, both sorted alphabetically. + const sortedGroups = Array.from(groups.values()).sort((a, b) => { + if (a.urls.length > 1 !== b.urls.length > 1) { + return a.urls.length > 1 ? -1 : 1; + } + return a.urls.join(', ').localeCompare(b.urls.join(', ')); + }); + + // Place unscoped headers last and group as "Global" + return globalHeaders.length + ? [...sortedGroups, { urls: [], headers: globalHeaders }] + : sortedGroups; +}; diff --git a/src/helpers/header/import-headers.helper.spec.ts b/src/helpers/header/import-headers.helper.spec.ts new file mode 100644 index 0000000..300ee2b --- /dev/null +++ b/src/helpers/header/import-headers.helper.spec.ts @@ -0,0 +1,35 @@ +import type { Header } from '@interfaces/index'; +import { describe, expect, it, vi } from 'vitest'; +import { importHeaders } from './import-headers.helper'; +import { saveHeaders } from './save-headers.helper'; + +const { storageLocal } = vi.hoisted(() => ({ + storageLocal: { + get: vi.fn(), + set: vi.fn(), + }, +})); + +vi.mock('@constants/index', () => ({ storage: { local: storageLocal } })); + +const header = (overrides: Partial
= {}): Header => ({ + id: 'header-id', + name: 'X-Example', + value: 'value', + enabled: false, + ...overrides, +}); + +describe('importHeaders', () => { + it('imports arrays, reorders headers, and ignores malformed imports', async () => { + const headers = [header()]; + + await importHeaders(headers); + await saveHeaders(headers); + await importHeaders({} as Header[]); + + expect(storageLocal.set).toHaveBeenCalledTimes(2); + expect(storageLocal.set).toHaveBeenNthCalledWith(1, { headers }); + expect(storageLocal.set).toHaveBeenNthCalledWith(2, { headers }); + }); +}); diff --git a/src/helpers/header/import-headers.helper.ts b/src/helpers/header/import-headers.helper.ts new file mode 100644 index 0000000..4d640d4 --- /dev/null +++ b/src/helpers/header/import-headers.helper.ts @@ -0,0 +1,7 @@ +import type { Header } from '@interfaces/index'; +import { saveHeaders } from './save-headers.helper'; + +export const importHeaders = async (headers: Header[]) => { + if (!Array.isArray(headers)) return; + await saveHeaders(headers); +}; diff --git a/src/helpers/header/remove-header.helper.spec.ts b/src/helpers/header/remove-header.helper.spec.ts new file mode 100644 index 0000000..05a2db0 --- /dev/null +++ b/src/helpers/header/remove-header.helper.spec.ts @@ -0,0 +1,47 @@ +import type { Header } from '@interfaces/index'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { storageLocal, uuid } = vi.hoisted(() => ({ + storageLocal: { + get: vi.fn(), + set: vi.fn(), + }, + uuid: vi.fn(), +})); + +vi.mock('@constants/index', () => ({ + storage: { local: storageLocal }, +})); + +vi.mock('uuid', () => ({ v4: uuid })); + +import { removeHeader } from './remove-header.helper'; +import { updateHeader } from './update-headers.helper'; + +const header = (overrides: Partial
= {}): Header => ({ + id: 'header-id', + name: 'X-Example', + value: 'value', + enabled: false, + ...overrides, +}); + +describe('removeHeader', () => { + beforeEach(() => { + vi.clearAllMocks(); + uuid.mockReturnValue('generated-id'); + }); + + it('updates and removes the selected header', async () => { + const original = header(); + const replacement = header({ value: 'updated' }); + storageLocal.get.mockResolvedValue({ headers: [original] }); + + await expect(updateHeader(replacement)).resolves.toEqual(replacement); + expect(storageLocal.set).toHaveBeenLastCalledWith({ headers: [replacement] }); + + storageLocal.get.mockResolvedValue({ headers: [replacement] }); + await removeHeader(replacement); + expect(storageLocal.set).toHaveBeenLastCalledWith({ headers: [] }); + }); +}); diff --git a/src/helpers/header/remove-header.helper.ts b/src/helpers/header/remove-header.helper.ts new file mode 100644 index 0000000..b8997f9 --- /dev/null +++ b/src/helpers/header/remove-header.helper.ts @@ -0,0 +1,9 @@ +import { getSelectedHeader } from '@helpers/header/get-selected-header.helper'; +import { saveHeaders } from '@helpers/header/save-headers.helper'; +import type { Header } from '@interfaces/index'; + +export const removeHeader = async (header: Header) => { + const { headers, pos } = await getSelectedHeader(header); + headers.splice(pos, 1); + await saveHeaders(headers); +}; diff --git a/src/helpers/header/save-headers.helper.spec.ts b/src/helpers/header/save-headers.helper.spec.ts new file mode 100644 index 0000000..50386b0 --- /dev/null +++ b/src/helpers/header/save-headers.helper.spec.ts @@ -0,0 +1,28 @@ +import type { Header } from '@interfaces/index'; +import { describe, expect, it, vi } from 'vitest'; +import { saveHeaders } from './save-headers.helper'; + +const { storageLocal } = vi.hoisted(() => ({ + storageLocal: { + get: vi.fn(), + set: vi.fn(), + }, +})); + +vi.mock('@constants/index', () => ({ storage: { local: storageLocal } })); + +const header: Header = { + id: 'header-id', + name: 'X-Example', + value: 'value', + enabled: true, +}; + +describe('saveHeaders', () => { + it('saves the provided headers to local storage', async () => { + storageLocal.set.mockResolvedValue(undefined); + + await expect(saveHeaders([header])).resolves.toBeUndefined(); + expect(storageLocal.set).toHaveBeenCalledWith({ headers: [header] }); + }); +}); diff --git a/src/helpers/header/save-headers.helper.ts b/src/helpers/header/save-headers.helper.ts new file mode 100644 index 0000000..6843606 --- /dev/null +++ b/src/helpers/header/save-headers.helper.ts @@ -0,0 +1,4 @@ +import { storage } from '@constants/index'; +import type { Header } from '@interfaces/index'; + +export const saveHeaders = async (headers: Header[]) => storage.local.set({ headers }); diff --git a/src/helpers/header/update-headers.helper.spec.ts b/src/helpers/header/update-headers.helper.spec.ts new file mode 100644 index 0000000..0c0dce3 --- /dev/null +++ b/src/helpers/header/update-headers.helper.spec.ts @@ -0,0 +1,31 @@ +import type { Header } from '@interfaces/index'; +import { describe, expect, it, vi } from 'vitest'; +import { updateHeader } from './update-headers.helper'; + +const { storageLocal } = vi.hoisted(() => ({ + storageLocal: { + get: vi.fn(), + set: vi.fn(), + }, +})); + +vi.mock('@constants/index', () => ({ storage: { local: storageLocal } })); + +const header = (overrides: Partial
= {}): Header => ({ + id: 'header-id', + name: 'X-Example', + value: 'value', + enabled: false, + ...overrides, +}); + +describe('updateHeader', () => { + it('replaces the matching header and persists the collection', async () => { + const original = header(); + const updated = header({ value: 'updated', enabled: true }); + storageLocal.get.mockResolvedValue({ headers: [original] }); + + await expect(updateHeader(updated)).resolves.toEqual(updated); + expect(storageLocal.set).toHaveBeenCalledWith({ headers: [updated] }); + }); +}); diff --git a/src/helpers/header/update-headers.helper.ts b/src/helpers/header/update-headers.helper.ts new file mode 100644 index 0000000..eb0ff44 --- /dev/null +++ b/src/helpers/header/update-headers.helper.ts @@ -0,0 +1,10 @@ +import { getSelectedHeader } from '@helpers/header/get-selected-header.helper'; +import { saveHeaders } from '@helpers/header/save-headers.helper'; +import type { Header } from '@interfaces/index'; + +export const updateHeader = async (header: Header) => { + const { headers, pos } = await getSelectedHeader(header); + headers[pos] = header; + await saveHeaders(headers); + return header; +}; diff --git a/src/helpers/headertweaker.helper.ts b/src/helpers/headertweaker.helper.ts index dc233ad..d8caf70 100644 --- a/src/helpers/headertweaker.helper.ts +++ b/src/helpers/headertweaker.helper.ts @@ -1,14 +1,13 @@ import { storage } from '@constants/index'; -import type { Status } from '@interfaces/index'; const STATUS_KEY = 'isDisabled'; -export const setStatus = async (status: Status) => { +export const setStatus = async (status: string) => { await storage.local.set({ [STATUS_KEY]: status === 'disabled' }); return status; }; -export const getStatus = async (): Promise => { +export const getStatus = async (): Promise => { const result = await storage.local.get(STATUS_KEY); return result[STATUS_KEY] ? 'disabled' : 'enabled'; }; diff --git a/src/helpers/scope/chrome-url-restriction.helper.spec.ts b/src/helpers/scope/chrome-url-restriction.helper.spec.ts new file mode 100644 index 0000000..dccbd5c --- /dev/null +++ b/src/helpers/scope/chrome-url-restriction.helper.spec.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { createChromeUrlRestriction } from './chrome-url-restriction.helper'; + +describe('createChromeUrlRestriction', () => { + it('creates a Chrome rule with the same matching behavior', () => { + const regexFilter = createChromeUrlRestriction('example.com/path'); + + expect(regexFilter).not.toBeNull(); + expect(new RegExp(regexFilter ?? '').test('https://test.example.com/path/123')).toBe(true); + expect(new RegExp(regexFilter ?? '').test('https://example.com/path-specified')).toBe(false); + }); +}); diff --git a/src/helpers/scope/chrome-url-restriction.helper.ts b/src/helpers/scope/chrome-url-restriction.helper.ts new file mode 100644 index 0000000..d19c923 --- /dev/null +++ b/src/helpers/scope/chrome-url-restriction.helper.ts @@ -0,0 +1,24 @@ +import { createHostnameExpression } from '@helpers/scope/create-host-expression.helper'; +import { createPathExpression } from '@helpers/scope/create-path-expression.helper'; +import { escapeRegularExpression } from '@helpers/scope/escape-regex.helper'; +import { parseUrlRestriction } from './parse-url-restriction.helper'; + +export const createChromeUrlRestriction = (restrictionValue: string) => { + const restriction = parseUrlRestriction(restrictionValue); + if (!restriction) return null; + + const hostnameExpression = restriction.hostname.includes('*') + ? createHostnameExpression(restriction.hostname) + : restriction.isExplicitSubdomain + ? escapeRegularExpression(restriction.hostname) + : `(?:[a-z0-9-]+\\.)*${escapeRegularExpression(restriction.hostname)}`; + + const pathnameExpression = + !restriction.pathname || restriction.pathname === '/' + ? '/.*' + : restriction.pathname.includes('*') + ? createPathExpression(restriction.pathname) + : `${escapeRegularExpression(restriction.pathname)}(?:/.*)?`; + + return `^https?://${hostnameExpression}(?::\\d+)?${pathnameExpression}(?:[?#].*)?$`; +}; diff --git a/src/helpers/scope/create-host-expression.helper.spec.ts b/src/helpers/scope/create-host-expression.helper.spec.ts new file mode 100644 index 0000000..860c6f8 --- /dev/null +++ b/src/helpers/scope/create-host-expression.helper.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { createHostnameExpression } from './create-host-expression.helper'; + +describe('createHostnameExpression', () => { + it('creates a hostname expression that keeps single wildcards within one label', () => { + const expression = new RegExp(`^${createHostnameExpression('*.example.com')}$`); + + expect(expression.test('www.example.com')).toBe(true); + expect(expression.test('www.api.example.com')).toBe(false); + }); + + it('escapes literal hostname characters', () => { + expect(createHostnameExpression('example.com')).toBe('example\\.com'); + }); +}); diff --git a/src/helpers/scope/create-host-expression.helper.ts b/src/helpers/scope/create-host-expression.helper.ts new file mode 100644 index 0000000..85518b8 --- /dev/null +++ b/src/helpers/scope/create-host-expression.helper.ts @@ -0,0 +1,4 @@ +import { createPathExpression } from './create-path-expression.helper'; + +export const createHostnameExpression = (hostname: string) => + createPathExpression(hostname).replace(/\[\^\/\]/g, '[^.]'); diff --git a/src/helpers/scope/create-path-expression.helper.spec.ts b/src/helpers/scope/create-path-expression.helper.spec.ts new file mode 100644 index 0000000..b492727 --- /dev/null +++ b/src/helpers/scope/create-path-expression.helper.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { createPathExpression } from './create-path-expression.helper'; + +describe('createPathExpression', () => { + it('matches a single-segment wildcard without crossing a slash', () => { + const expression = new RegExp(`^${createPathExpression('/api/*')}$`); + + expect(expression.test('/api/users')).toBe(true); + expect(expression.test('/api/users/details')).toBe(false); + }); + + it('matches multi-segment wildcards and their descendants', () => { + const expression = new RegExp(`^${createPathExpression('/api/**')}$`); + + expect(expression.test('/api/users/details')).toBe(true); + expect(expression.test('/api')).toBe(true); + }); + + it('escapes literal path characters', () => { + expect(createPathExpression('/api/v1.0')).toBe('/api/v1\\.0'); + }); +}); diff --git a/src/helpers/scope/create-path-expression.helper.ts b/src/helpers/scope/create-path-expression.helper.ts new file mode 100644 index 0000000..737b977 --- /dev/null +++ b/src/helpers/scope/create-path-expression.helper.ts @@ -0,0 +1,20 @@ +import { escapeRegularExpression } from './escape-regex.helper'; + +export const createPathExpression = (pathname: string) => { + if (pathname.endsWith('/**/*')) { + return `${escapeRegularExpression(pathname.slice(0, -5))}(?:/.*)?`; + } + + if (pathname.endsWith('/**')) { + return `${escapeRegularExpression(pathname.slice(0, -3))}(?:/.*)?`; + } + + return pathname + .split(/(\*\*|\*)/) + .map((part) => { + if (part === '**') return '.*'; + if (part === '*') return '[^/]*'; + return escapeRegularExpression(part); + }) + .join(''); +}; diff --git a/src/helpers/scope/escape-regex.helper.spec.ts b/src/helpers/scope/escape-regex.helper.spec.ts new file mode 100644 index 0000000..a076fc3 --- /dev/null +++ b/src/helpers/scope/escape-regex.helper.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { escapeRegularExpression } from './escape-regex.helper'; + +describe('escapeRegularExpression', () => { + it('escapes regular expression metacharacters', () => { + const escaped = escapeRegularExpression('a.b+c?d[e]'); + + expect(escaped).toBe('a\\.b\\+c\\?d\\[e\\]'); + expect(new RegExp(`^${escaped}$`).test('a.b+c?d[e]')).toBe(true); + expect(new RegExp(`^${escaped}$`).test('axb+c?d[e]')).toBe(false); + }); + + it('leaves ordinary text unchanged', () => { + expect(escapeRegularExpression('example/path')).toBe('example/path'); + }); +}); diff --git a/src/helpers/scope/escape-regex.helper.ts b/src/helpers/scope/escape-regex.helper.ts new file mode 100644 index 0000000..6033533 --- /dev/null +++ b/src/helpers/scope/escape-regex.helper.ts @@ -0,0 +1,2 @@ +export const escapeRegularExpression = (value: string) => + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); diff --git a/src/helpers/scope/filter-headers-by-scope.helper.spec.ts b/src/helpers/scope/filter-headers-by-scope.helper.spec.ts new file mode 100644 index 0000000..e51907a --- /dev/null +++ b/src/helpers/scope/filter-headers-by-scope.helper.spec.ts @@ -0,0 +1,37 @@ +import { SCOPES } from '@constants/scopes'; +import type { Header } from '@interfaces/index'; +import { describe, expect, it } from 'vitest'; +import { filterHeadersByScope } from './filter-headers-by-scope.helper'; + +const header = (urls?: string[]): Header => ({ + id: 'header-id', + name: 'X-Example', + value: 'value', + enabled: true, + urls, +}); + +describe('filterHeadersByScope', () => { + const unscoped = { ...header(), id: 'unscoped' }; + const scoped = { ...header(['https://example.com/*']), id: 'scoped' }; + const other = { ...header(['https://other.com/*']), id: 'other' }; + const headers = [unscoped, scoped, other]; + + it('returns every header for the "all" scope', () => { + expect(filterHeadersByScope(headers, SCOPES.ALL)).toEqual(headers); + }); + + it('returns only unscoped headers for the "no scope" filter', () => { + expect(filterHeadersByScope(headers, SCOPES.NO_SCOPE)).toEqual([unscoped]); + }); + + it('returns only headers matching the current URL', () => { + expect(filterHeadersByScope(headers, SCOPES.CURRENT_URL, 'https://example.com/page')).toEqual([ + scoped, + ]); + }); + + it('returns nothing for the current URL scope without a URL', () => { + expect(filterHeadersByScope(headers, SCOPES.CURRENT_URL)).toEqual([]); + }); +}); diff --git a/src/helpers/scope/filter-headers-by-scope.helper.ts b/src/helpers/scope/filter-headers-by-scope.helper.ts new file mode 100644 index 0000000..baeca85 --- /dev/null +++ b/src/helpers/scope/filter-headers-by-scope.helper.ts @@ -0,0 +1,25 @@ +import { SCOPES, type Scope } from '@constants/scopes'; +import type { Header } from '@interfaces/index'; +import { isScoped } from './is-scoped.helper'; +import { matchUrlRestriction } from './match-url-restriction.helper'; + +export const filterHeadersByScope = ( + headers: ReadonlyArray
, + scope: Scope, + currentUrl?: string +) => { + switch (scope) { + case SCOPES.NO_SCOPE: + return headers.filter((header) => !isScoped(header)); + case SCOPES.CURRENT_URL: + return currentUrl + ? headers.filter( + (header) => + isScoped(header) && + (header.urls ?? []).some((url) => matchUrlRestriction(currentUrl, url)) + ) + : []; + default: + return headers; + } +}; diff --git a/src/helpers/scope/get-duplicate-url.helper.spec.ts b/src/helpers/scope/get-duplicate-url.helper.spec.ts new file mode 100644 index 0000000..4a98691 --- /dev/null +++ b/src/helpers/scope/get-duplicate-url.helper.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { getDuplicateUrlIndexes } from './get-duplicate-url.helper'; + +describe('getDuplicateUrlIndexes', () => { + it('ignores empty entries and reports every repeated normalized URL', () => { + expect( + getDuplicateUrlIndexes(['example.com', ' ', 'https://www.example.com', 'other.com']) + ).toEqual([2]); + }); + + it('returns an empty list when all URLs are unique', () => { + expect(getDuplicateUrlIndexes(['example.com', 'api.example.com'])).toEqual([]); + }); +}); diff --git a/src/helpers/scope/get-duplicate-url.helper.ts b/src/helpers/scope/get-duplicate-url.helper.ts new file mode 100644 index 0000000..b033bae --- /dev/null +++ b/src/helpers/scope/get-duplicate-url.helper.ts @@ -0,0 +1,18 @@ +import { normalizeUrlRestriction } from './normalize-url-restriction.helper'; + +export const getDuplicateUrlIndexes = (urls: string[]) => { + const seen = new Set(); + + return urls.reduce((duplicates, url, index) => { + const normalized = normalizeUrlRestriction(url); + if (!normalized) return duplicates; + + if (seen.has(normalized)) { + duplicates.push(index); + return duplicates; + } + + seen.add(normalized); + return duplicates; + }, []); +}; diff --git a/src/helpers/scope/get-known-urls.helper.spec.ts b/src/helpers/scope/get-known-urls.helper.spec.ts new file mode 100644 index 0000000..7488167 --- /dev/null +++ b/src/helpers/scope/get-known-urls.helper.spec.ts @@ -0,0 +1,23 @@ +import type { Header } from '@interfaces/index'; +import { describe, expect, it } from 'vitest'; +import { getKnownUrls } from './get-known-urls.helper'; + +const header = (urls?: string[]): Header => ({ + id: 'header-id', + name: 'X-Example', + value: 'value', + enabled: true, + urls, +}); + +describe('getKnownUrls', () => { + it('collects unique URLs from all headers in alphabetical order', () => { + expect( + getKnownUrls([header(['https://www.example.com', 'b.com']), header(['example.com'])]) + ).toEqual(['b.com', 'https://www.example.com']); + }); + + it('returns an empty list when no header is scoped', () => { + expect(getKnownUrls([header(), header([])])).toEqual([]); + }); +}); diff --git a/src/helpers/scope/get-known-urls.helper.ts b/src/helpers/scope/get-known-urls.helper.ts new file mode 100644 index 0000000..fab342e --- /dev/null +++ b/src/helpers/scope/get-known-urls.helper.ts @@ -0,0 +1,18 @@ +import type { Header } from '@interfaces/index'; +import { normalizeUrlRestriction } from './normalize-url-restriction.helper'; + +export const getKnownUrls = (headers: ReadonlyArray
) => { + const seen = new Set(); + + return headers + .flatMap((header) => header.urls ?? []) + .filter((url) => { + const normalized = normalizeUrlRestriction(url); + if (!normalized || seen.has(normalized)) return false; + + seen.add(normalized); + return true; + }) + .map((url) => url.trim()) + .sort((a, b) => a.localeCompare(b)); +}; diff --git a/src/helpers/scope/get-scoped-error.helper.spec.ts b/src/helpers/scope/get-scoped-error.helper.spec.ts new file mode 100644 index 0000000..06df4fe --- /dev/null +++ b/src/helpers/scope/get-scoped-error.helper.spec.ts @@ -0,0 +1,18 @@ +import { SCOPES } from '@constants/scopes'; +import { describe, expect, it } from 'vitest'; +import { getScopeErrorMessageKey } from './get-scoped-error.helper'; + +describe('getScopeErrorMessageKey', () => { + it('returns a distinct key for every scope', () => { + const keys = Object.values(SCOPES).map(getScopeErrorMessageKey); + + expect(new Set(keys).size).toBe(keys.length); + expect(keys.every(Boolean)).toBe(true); + }); + + it('points at the message explaining why the selected scope is empty', () => { + expect(getScopeErrorMessageKey(SCOPES.ALL)).toBe('label.scope.emptyAll'); + expect(getScopeErrorMessageKey(SCOPES.NO_SCOPE)).toBe('label.scope.emptyNoScope'); + expect(getScopeErrorMessageKey(SCOPES.CURRENT_URL)).toBe('label.scope.emptyCurrentUrl'); + }); +}); diff --git a/src/helpers/scope/get-scoped-error.helper.ts b/src/helpers/scope/get-scoped-error.helper.ts new file mode 100644 index 0000000..5718e0a --- /dev/null +++ b/src/helpers/scope/get-scoped-error.helper.ts @@ -0,0 +1,13 @@ +import { SCOPES, type Scope } from '@constants/scopes'; +import type { TranslationKey } from '@i18n/config'; + +export const getScopeErrorMessageKey = (scope: Scope): TranslationKey => { + switch (scope) { + case SCOPES.NO_SCOPE: + return 'label.scope.emptyNoScope'; + case SCOPES.CURRENT_URL: + return 'label.scope.emptyCurrentUrl'; + default: + return 'label.scope.emptyAll'; + } +}; diff --git a/src/helpers/scope/is-scoped.helper.spec.ts b/src/helpers/scope/is-scoped.helper.spec.ts new file mode 100644 index 0000000..63ef421 --- /dev/null +++ b/src/helpers/scope/is-scoped.helper.spec.ts @@ -0,0 +1,19 @@ +import type { Header } from '@interfaces/index'; +import { describe, expect, it } from 'vitest'; +import { isScoped } from './is-scoped.helper'; + +const header = (urls?: string[]): Header => ({ + id: 'header-id', + name: 'X-Example', + value: 'value', + enabled: true, + urls, +}); + +describe('isScoped', () => { + it('is only true when the header has at least one URL', () => { + expect(isScoped(header())).toBe(false); + expect(isScoped(header([]))).toBe(false); + expect(isScoped(header(['example.com']))).toBe(true); + }); +}); diff --git a/src/helpers/scope/is-scoped.helper.ts b/src/helpers/scope/is-scoped.helper.ts new file mode 100644 index 0000000..f2c60c3 --- /dev/null +++ b/src/helpers/scope/is-scoped.helper.ts @@ -0,0 +1,3 @@ +import type { Header } from '@interfaces/index'; + +export const isScoped = (header: Header) => Boolean(header.urls?.length); diff --git a/src/helpers/scope/match-url-restriction.helper.spec.ts b/src/helpers/scope/match-url-restriction.helper.spec.ts new file mode 100644 index 0000000..ed32f50 --- /dev/null +++ b/src/helpers/scope/match-url-restriction.helper.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; +import { matchUrlRestriction } from './match-url-restriction.helper'; + +vi.mock('@constants/index', () => ({ + storage: { local: { get: vi.fn(), set: vi.fn() } }, + tabs: { query: vi.fn() }, +})); + +describe('matchUrlRestriction', () => { + it('matches a registrable domain and its subdomains', () => { + expect(matchUrlRestriction('https://example.com/path', 'example.com/path')).toBe(true); + expect(matchUrlRestriction('https://test.example.com/path/123', 'example.com/path')).toBe(true); + }); + + it('matches a literal path and its descendants, but not adjacent paths', () => { + expect(matchUrlRestriction('https://example.com/path/details', 'example.com/path')).toBe(true); + expect(matchUrlRestriction('https://example.com/path-specified', 'example.com/path')).toBe( + false + ); + }); + + it('matches an explicit subdomain only', () => { + expect(matchUrlRestriction('https://test.example.com/path', 'test.example.com/path')).toBe( + true + ); + expect(matchUrlRestriction('https://preview.example.com/path', 'test.example.com/path')).toBe( + false + ); + }); + + it('supports single-segment and multi-segment wildcards', () => { + expect(matchUrlRestriction('https://example.com/path-specified', 'example.com/path*')).toBe( + true + ); + expect(matchUrlRestriction('https://example.com/path/details', 'example.com/path*')).toBe( + false + ); + expect(matchUrlRestriction('https://example.com/path/details', 'example.com/path/**')).toBe( + true + ); + }); +}); diff --git a/src/helpers/scope/match-url-restriction.helper.ts b/src/helpers/scope/match-url-restriction.helper.ts new file mode 100644 index 0000000..f8714fa --- /dev/null +++ b/src/helpers/scope/match-url-restriction.helper.ts @@ -0,0 +1,45 @@ +import { createHostnameExpression } from '@helpers/scope/create-host-expression.helper'; +import { createPathExpression } from '@helpers/scope/create-path-expression.helper'; +import { parseUrlRestriction } from './parse-url-restriction.helper'; + +type ParsedUrlRestriction = { + hostname: string; + pathname?: string; + isExplicitSubdomain: boolean; +}; + +const matchHostname = (hostname: string, restriction: ParsedUrlRestriction) => { + if (restriction.hostname.includes('*')) { + return new RegExp(`^${createHostnameExpression(restriction.hostname)}$`, 'i').test(hostname); + } + + return restriction.isExplicitSubdomain + ? hostname === restriction.hostname + : hostname === restriction.hostname || hostname.endsWith(`.${restriction.hostname}`); +}; + +const matchPathname = (pathname: string, restrictionPathname: string | undefined) => { + if (!restrictionPathname || restrictionPathname === '/') return true; + + if (!restrictionPathname.includes('*')) { + return pathname === restrictionPathname || pathname.startsWith(`${restrictionPathname}/`); + } + + return new RegExp(`^${createPathExpression(restrictionPathname)}$`).test(pathname); +}; + +export const matchUrlRestriction = (url: string, restrictionValue: string) => { + const restriction = parseUrlRestriction(restrictionValue); + if (!restriction) return false; + + try { + const requestUrl = new URL(url); + return ( + /^https?:$/.test(requestUrl.protocol) && + matchHostname(requestUrl.hostname.toLowerCase(), restriction) && + matchPathname(requestUrl.pathname, restriction.pathname) + ); + } catch { + return false; + } +}; diff --git a/src/helpers/scope/normalize-url-restriction.helper.spec.ts b/src/helpers/scope/normalize-url-restriction.helper.spec.ts new file mode 100644 index 0000000..3784739 --- /dev/null +++ b/src/helpers/scope/normalize-url-restriction.helper.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeUrlRestriction } from './normalize-url-restriction.helper'; + +describe('normalizeUrlRestriction', () => { + it('returns an empty value for whitespace-only input', () => { + expect(normalizeUrlRestriction(' ')).toBe(''); + }); + + it('normalizes HTTP(S), the hostname, and the www prefix while preserving paths', () => { + expect(normalizeUrlRestriction(' HTTPS://WWW.Example.COM/path?query=value ')).toBe( + 'example.com/path' + ); + expect(normalizeUrlRestriction('http://example.com/Path')).toBe('example.com/Path'); + expect(normalizeUrlRestriction('example.com/nested/path')).toBe('example.com/nested/path'); + }); + + it('uses the URL-like hostname when the input cannot be parsed', () => { + expect(normalizeUrlRestriction('www. example.com/path?query=value')).toBe('example.com/path'); + }); +}); diff --git a/src/helpers/scope.helper.ts b/src/helpers/scope/normalize-url-restriction.helper.ts similarity index 56% rename from src/helpers/scope.helper.ts rename to src/helpers/scope/normalize-url-restriction.helper.ts index e722afe..1a1cc52 100644 --- a/src/helpers/scope.helper.ts +++ b/src/helpers/scope/normalize-url-restriction.helper.ts @@ -1,5 +1,3 @@ -import type { Header } from '@interfaces/index'; - export const normalizeUrlRestriction = (url: string) => { const value = url.trim(); if (!value) return ''; @@ -17,13 +15,3 @@ export const normalizeUrlRestriction = (url: string) => { .split(/[?#]/)[0]; } }; - -export const isDuplicateUrl = (header: Header | null, index: number) => { - const url = normalizeUrlRestriction(header?.urls?.[index] ?? ''); - return Boolean( - url && - header?.urls?.some((value, valueIndex) => { - return valueIndex !== index && normalizeUrlRestriction(value) === url; - }) - ); -}; diff --git a/src/helpers/scope/parse-url-restriction.helper.spec.ts b/src/helpers/scope/parse-url-restriction.helper.spec.ts new file mode 100644 index 0000000..ed6e41e --- /dev/null +++ b/src/helpers/scope/parse-url-restriction.helper.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { parseUrlRestriction } from './parse-url-restriction.helper'; + +describe('parseUrlRestriction', () => { + it('normalizes protocol, port, query, and fragment while preserving the path', () => { + expect(parseUrlRestriction(' HTTPS://WWW.Example.COM:8443/api/items?sort=asc#top ')).toEqual({ + hostname: 'www.example.com', + pathname: '/api/items', + isExplicitSubdomain: true, + }); + }); + + it('detects an explicit subdomain', () => { + expect(parseUrlRestriction('api.example.com')).toEqual({ + hostname: 'api.example.com', + pathname: undefined, + isExplicitSubdomain: true, + }); + }); + + it('returns null when no hostname is provided', () => { + expect(parseUrlRestriction('https://')).toBeNull(); + }); +}); diff --git a/src/helpers/scope/parse-url-restriction.helper.ts b/src/helpers/scope/parse-url-restriction.helper.ts new file mode 100644 index 0000000..7eb13e3 --- /dev/null +++ b/src/helpers/scope/parse-url-restriction.helper.ts @@ -0,0 +1,29 @@ +import { parse } from 'tldts'; + +type ParsedUrlRestriction = { + hostname: string; + pathname?: string; + isExplicitSubdomain: boolean; +}; + +export const parseUrlRestriction = (value: string): ParsedUrlRestriction | null => { + const withoutProtocol = value.trim().replace(/^[a-z][a-z\d+.-]*:\/\//i, ''); + const withoutQueryOrFragment = withoutProtocol.split(/[?#]/)[0] ?? ''; + const slashIndex = withoutQueryOrFragment.indexOf('/'); + const hostname = ( + slashIndex === -1 ? withoutQueryOrFragment : withoutQueryOrFragment.slice(0, slashIndex) + ) + .replace(/:\d+$/, '') + .toLowerCase(); + + if (!hostname) return null; + + const pathname = slashIndex === -1 ? undefined : withoutQueryOrFragment.slice(slashIndex) || '/'; + const domain = parse(hostname).domain; + + return { + hostname, + pathname, + isExplicitSubdomain: Boolean(domain && hostname !== domain), + }; +}; diff --git a/src/helpers/scope.helper.spec.ts b/src/helpers/url/duplicate-url.helper.spec.ts similarity index 56% rename from src/helpers/scope.helper.spec.ts rename to src/helpers/url/duplicate-url.helper.spec.ts index 9eda59d..b72b3b7 100644 --- a/src/helpers/scope.helper.spec.ts +++ b/src/helpers/url/duplicate-url.helper.spec.ts @@ -1,6 +1,6 @@ import type { Header } from '@interfaces/index'; import { describe, expect, it } from 'vitest'; -import { isDuplicateUrl, normalizeUrlRestriction } from './scope.helper'; +import { isDuplicateUrl } from './duplicate-url.helper'; const header = (urls?: string[]): Header => ({ id: 'header-id', @@ -10,24 +10,6 @@ const header = (urls?: string[]): Header => ({ urls, }); -describe('normalizeUrlRestriction', () => { - it('returns an empty value for whitespace-only input', () => { - expect(normalizeUrlRestriction(' ')).toBe(''); - }); - - it('normalizes HTTP(S), the hostname, and the www prefix while preserving paths', () => { - expect(normalizeUrlRestriction(' HTTPS://WWW.Example.COM/path?query=value ')).toBe( - 'example.com/path' - ); - expect(normalizeUrlRestriction('http://example.com/Path')).toBe('example.com/Path'); - expect(normalizeUrlRestriction('example.com/nested/path')).toBe('example.com/nested/path'); - }); - - it('uses the URL-like hostname when the input cannot be parsed', () => { - expect(normalizeUrlRestriction('www. example.com/path?query=value')).toBe('example.com/path'); - }); -}); - describe('isDuplicateUrl', () => { it('returns false for null headers, missing URLs, and the only matching entry', () => { expect(isDuplicateUrl(null, 0)).toBe(false); diff --git a/src/helpers/url/duplicate-url.helper.ts b/src/helpers/url/duplicate-url.helper.ts new file mode 100644 index 0000000..4cb5d94 --- /dev/null +++ b/src/helpers/url/duplicate-url.helper.ts @@ -0,0 +1,13 @@ +import { normalizeUrlRestriction } from '@helpers/scope/normalize-url-restriction.helper'; +import type { Header } from '@interfaces/index'; + +export const isDuplicateUrl = (header: Header | null, index: number) => { + const url = normalizeUrlRestriction(header?.urls?.[index] ?? ''); + + return Boolean( + url && + header?.urls?.some((value, valueIndex) => { + return valueIndex !== index && normalizeUrlRestriction(value) === url; + }) + ); +}; diff --git a/src/helpers/url/url-matches.helper.spec.ts b/src/helpers/url/url-matches.helper.spec.ts new file mode 100644 index 0000000..ba2bf88 --- /dev/null +++ b/src/helpers/url/url-matches.helper.spec.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { urlMatches } from './url-matches.helper'; + +describe('urlMatches', () => { + it('matches wildcard URL patterns while treating regex characters literally', () => { + expect(urlMatches('https://example.com/api/v1', ['https://example.com/*'])).toBe(true); + expect(urlMatches('https://exampleXcom', ['https://example.com'])).toBe(false); + expect(urlMatches('https://example.com', ['https://other.example/*'])).toBe(false); + }); +}); diff --git a/src/helpers/url/url-matches.helper.ts b/src/helpers/url/url-matches.helper.ts new file mode 100644 index 0000000..faf432d --- /dev/null +++ b/src/helpers/url/url-matches.helper.ts @@ -0,0 +1,6 @@ +export const urlMatches = (url: string, patterns: string[]): boolean => { + return patterns.some((pattern) => { + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); + return new RegExp(escaped).test(url); + }); +}; diff --git a/src/helpers/import.helper.spec.ts b/src/helpers/validate-header-import.helper.spec.ts similarity index 93% rename from src/helpers/import.helper.spec.ts rename to src/helpers/validate-header-import.helper.spec.ts index 6533cb7..c9644ad 100644 --- a/src/helpers/import.helper.spec.ts +++ b/src/helpers/validate-header-import.helper.spec.ts @@ -1,11 +1,14 @@ import type { Header } from '@interfaces/index'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { getHeaders } = vi.hoisted(() => ({ getHeaders: vi.fn() })); +const { getHeaders } = vi.hoisted(() => ({ + uuid: vi.fn(), + getHeaders: vi.fn(), +})); -vi.mock('@helpers/header.helper', () => ({ getHeaders })); +vi.mock('@helpers/header/get-headers.helper', () => ({ getHeaders })); -import { validateHeaderImport } from './import.helper'; +import { validateHeaderImport } from './validate-header-import.helper'; class MockFileReader { static instance: MockFileReader; diff --git a/src/helpers/import.helper.ts b/src/helpers/validate-header-import.helper.ts similarity index 96% rename from src/helpers/import.helper.ts rename to src/helpers/validate-header-import.helper.ts index 74f2be9..17effc3 100644 --- a/src/helpers/import.helper.ts +++ b/src/helpers/validate-header-import.helper.ts @@ -1,5 +1,5 @@ -import { getHeaders } from '@helpers/header.helper'; import type { Header } from '@interfaces/index'; +import { getHeaders } from './header/get-headers.helper'; type HeaderImportCallbacks = { onSuccess: (headers: Header[]) => void | Promise; diff --git a/src/i18n/config.ts b/src/i18n/config.ts new file mode 100644 index 0000000..91adb42 --- /dev/null +++ b/src/i18n/config.ts @@ -0,0 +1,16 @@ +import i18next, { type ParseKeys } from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import enUS from './locales/en-US.json'; + +export type TranslationKey = ParseKeys; + +export const DEFAULT_LOCALE = 'en-US'; + +i18next.use(initReactI18next).init({ + lng: DEFAULT_LOCALE, + fallbackLng: DEFAULT_LOCALE, + resources: { [DEFAULT_LOCALE]: { translation: enUS } }, + interpolation: { escapeValue: false }, +}); + +export default i18next; diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json new file mode 100644 index 0000000..0a4d6ca --- /dev/null +++ b/src/i18n/locales/en-US.json @@ -0,0 +1,173 @@ +{ + "app": { + "name": "HeaderTweaker", + "disabled": "HeaderTweaker is disabled", + "status": "HeaderTweaker is {{status}}" + }, + "title": { + "settings": "Settings", + "header": { "edit": "Edit header", "delete": "Delete header" }, + "scope": { + "wizard": "Bulk URL target change", + "steps": { + "headerSelect": "Select headers", + "scopeSelect": "Choose URLs" + } + }, + "feedback": { + "success": { + "default": "Success", + "import": "Headers imported" + } + } + }, + "description": { + "scope": { + "headerSelect": "Select the headers you want to apply to specific URLs." + } + }, + "label": { + "selectAll": "Select all", + "deselectAll": "Deselect all", + "next": "Next", + "previous": "Previous", + "header": { + "key": "Key", + "value": "Value", + "label": "Label" + }, + "status": { + "enabled": "enabled", + "disabled": "disabled", + "enabledHeader": "Header is active", + "disabledHeader": "Header is disabled" + }, + "scope": { + "all": "All", + "scoped": "Targeted", + "noScope": "Global", + "currentUrl": "Current", + "target_one": "Target", + "target_other": "Targets", + "selector": "Choose the URLs where the selected headers will be applied", + "addNewUrl": "Or add a new URL…", + "emptyAll": "No headers match the selected filter", + "emptyScoped": "None of the headers have a URL target", + "emptyNoScope": "Every header is applied to all requests", + "emptyCurrentUrl": "None of the headers are targeted to the current URL", + "noScopeWarning_one": "This header is global and will be applied to all requests. Add a target to apply it only to specific URLs.", + "noScopeWarning_other": "These headers are global and will be applied to all requests. Add a target to apply them only to specific URLs." + }, + "select": { + "chooseExisting": "Choose an existing option", + "createNew": "Or create new…" + }, + "settings": { + "useLabels": "Use labels", + "import": "Import new headers", + "export_one": "Export {{count}} header", + "export_other": "Export {{count}} headers" + }, + "wizard": { + "progress": "Step {{current}} of {{total}}" + } + }, + "button": { + "header": { + "add": "Add header", + "save": "Save header" + }, + "scope": { + "wizard": "Bulk URL targets", + "save": "Save targets", + "addUrl": "Add URL" + }, + "toast": { + "close": "Close" + }, + "feedback": { + "confirm": "OK", + "cancel": "Cancel", + "confirmDelete": "Yes", + "cancelDelete": "No" + } + }, + "placeholder": { + "header": { + "create": { + "key": "Header key", + "value": "Header value" + }, + "label": "Optional label for the header" + }, + "scope": { + "url": { + "select": "Select URL", + "add": "example.com", + "option": "Select an option" + } + } + }, + "feedback": { + "error": { + "scopeChange": "There was an error changing the URL targets of the selected headers. No changes were saved.", + "url": { + "exists": "This URL is already added." + } + }, + "confirm": { + "delete": "Are you sure you want to delete the \"{{name}}\" header? This action cannot be undone." + }, + "success": { + "header": { + "create": "{{header}} header created", + "update": "Header updated", + "bulkUpdate": "The selected headers will only be applied to {{urls}}.", + "delete": "Header removed" + }, + "scopeChange": "The selected headers are now applied to the chosen URLs.", + "import": { + "successMessage_one": "{{count}} header imported.", + "successMessage_other": "{{count}} headers imported." + } + }, + "empty": { + "headers": "No headers to display yet, add your first one below" + }, + "form": { + "dropActive": "Drop your file here…", + "dropIdle": "Drag & drop a HeaderTweaker export file here, or click to select" + } + }, + "tooltip": { + "scope": { + "scope": "The header will be applied to the following URLs: {{urls}}", + "currentUrl": "The header will be applied to the current URL", + "noScope": "The header is not targeted to a specific URL" + } + }, + "a11y": { + "ariaLabel": { + "header": { + "select": "Select {{name}}", + "selectAll": "Select all headers", + "deselectAll": "Deselect all headers", + "edit": "Edit header", + "delete": "Delete header" + }, + "scope": { + "remove": "Remove URL" + }, + "import": { + "form": "File upload form" + }, + "modal": { + "close": "Close modal" + }, + "steps": { + "goToStep": "Go to step {{number}}", + "goToStepTitled": "Go to step {{number}}: {{title}}" + } + } + } +} diff --git a/src/interfaces/i18next.d.ts b/src/interfaces/i18next.d.ts new file mode 100644 index 0000000..9b95c19 --- /dev/null +++ b/src/interfaces/i18next.d.ts @@ -0,0 +1,11 @@ +import 'i18next'; +import type en from '../i18n/locales/en-US.json'; + +declare module 'i18next' { + interface CustomTypeOptions { + defaultNS: 'translation'; + resources: { + translation: typeof en; + }; + } +} diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index 7167f60..a04ec9e 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -6,5 +6,3 @@ export type Header = { urls?: string[]; label?: string; }; - -export type Status = 'enabled' | 'disabled'; diff --git a/src/styles/global.scss b/src/styles/global.scss index 9527f78..8ff4de8 100644 --- a/src/styles/global.scss +++ b/src/styles/global.scss @@ -4,7 +4,7 @@ body { background-color: vars.$colors-primary-background; width: 800px; - height: 500px; + height: 560px; padding:0; } diff --git a/src/styles/reset.scss b/src/styles/reset.scss index 0feb64c..8dcc959 100644 --- a/src/styles/reset.scss +++ b/src/styles/reset.scss @@ -21,6 +21,8 @@ input, textarea, select, button { outline: none; } +label { cursor: pointer } + input, button { border-style: solid; border-width: 2px; diff --git a/src/styles/variables.scss b/src/styles/variables.scss index 969d176..7edd3d3 100644 --- a/src/styles/variables.scss +++ b/src/styles/variables.scss @@ -5,30 +5,35 @@ $colors-primary-action-hover: #5f14c2; $colors-primary-action-disabled: #333A5D; $colors-primary-success: #00D27C; $colors-primary-error: #e3332d; +$colors-primary-warning: #ed9f22; + +$colors-accent-primary: #006dff; $colors-secondary-foreground: #414362; $colors-tertiary-foreground: #9B9DB1; -$spacing-core-1: 0.25rem; -$spacing-core-2: 0.5rem; -$spacing-core-3: 0.75rem; -$spacing-core-4: 1rem; -$spacing-core-5: 1.25rem; -$spacing-core-6: 1.5rem; -$spacing-core-8: 2rem; -$spacing-core-10: 2.5rem; -$spacing-core-12: 3rem; -$spacing-core-14: 3.5rem; -$spacing-core-16: 4rem; -$spacing-core-18: 4.5rem; -$spacing-core-20: 5rem; -$spacing-core-22: 5.5rem; -$spacing-core-24: 6rem; -$spacing-core-26: 6.5rem; -$spacing-core-28: 7rem; -$spacing-core-30: 7.5rem; -$spacing-core-025: 0.0625rem; -$spacing-core-05: 0.125rem; +$spacing-core-1: 4px; +$spacing-core-2: 8px; +$spacing-core-3: 12px; +$spacing-core-4: 16px; +$spacing-core-5: 20px; +$spacing-core-6: 24px; +$spacing-core-8: 32px; +$spacing-core-10: 40px; +$spacing-core-12: 48px; +$spacing-core-14: 56px; +$spacing-core-16: 64px; +$spacing-core-18: 72px; +$spacing-core-20: 80px; +$spacing-core-22: 88px; +$spacing-core-24: 96px; +$spacing-core-26: 104px; +$spacing-core-28: 112px; +$spacing-core-30: 120px; +$spacing-core-025: 1px; +$spacing-core-05: 2px; -$border-radius-primary: 0.5rem; +$border-radius-primary: $spacing-core-2; +$border-radius-small: 8px; +$border-radius-full: 50vh; diff --git a/tsconfig.json b/tsconfig.json index c1b78c4..27a90f4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,7 @@ "@contexts/*": ["./src/contexts/*"], "@components/*": ["./src/components/*"], "@helpers/*": ["./src/helpers/*"], + "@i18n/*": ["./src/i18n/*"], "@interfaces/*": ["./src/interfaces/*"], "@styles/*": ["./src/styles/*"] } diff --git a/vite.config.ts b/vite.config.ts index 73b844d..14d1fc2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -49,6 +49,7 @@ export default defineConfig({ '@contexts': path.resolve(__dirname, 'src/contexts'), '@components': path.resolve(__dirname, 'src/components'), '@helpers': path.resolve(__dirname, 'src/helpers'), + '@i18n': path.resolve(__dirname, 'src/i18n'), '@interfaces': path.resolve(__dirname, 'src/interfaces'), '@styles': path.resolve(__dirname, 'src/styles'), },