From e4253e6de24191a54898ffe67163925d048561d7 Mon Sep 17 00:00:00 2001 From: worble Date: Sun, 2 Mar 2025 17:15:58 +0000 Subject: [PATCH] init --- .env.development | 5 + .env.production | 2 + .envrc | 1 + .gitignore | 3 + biome.json | 26 + flake.lock | 64 ++ flake.nix | 26 + index.html | 18 + package-lock.json | 1023 +++++++++++++++++ package.json | 24 + src/App.tsx | 14 + src/components/Chatters.tsx | 214 ++++ src/components/Loading.tsx | 5 + src/components/ResourceComponent.tsx | 68 ++ src/contexts/Contexts.tsx | 11 + src/contexts/TwitchServiceContext.tsx | 56 + src/contexts/UserContext.tsx | 23 + src/index.tsx | 8 + src/services/twitch/TwitchApiService.ts | 89 ++ .../twitch/responses/GetChattersResponse.ts | 13 + .../responses/GetSubscriptionsResponse.ts | 23 + .../twitch/responses/GetUsersResponse.ts | 17 + src/utils/parseError.ts | 3 + src/utils/useRequiredContext.ts | 13 + tsconfig.json | 41 + vite.config.ts | 16 + 26 files changed, 1806 insertions(+) create mode 100644 .env.development create mode 100644 .env.production create mode 100644 .envrc create mode 100644 .gitignore create mode 100644 biome.json create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/App.tsx create mode 100644 src/components/Chatters.tsx create mode 100644 src/components/Loading.tsx create mode 100644 src/components/ResourceComponent.tsx create mode 100644 src/contexts/Contexts.tsx create mode 100644 src/contexts/TwitchServiceContext.tsx create mode 100644 src/contexts/UserContext.tsx create mode 100644 src/index.tsx create mode 100644 src/services/twitch/TwitchApiService.ts create mode 100644 src/services/twitch/responses/GetChattersResponse.ts create mode 100644 src/services/twitch/responses/GetSubscriptionsResponse.ts create mode 100644 src/services/twitch/responses/GetUsersResponse.ts create mode 100644 src/utils/parseError.ts create mode 100644 src/utils/useRequiredContext.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..80131b2 --- /dev/null +++ b/.env.development @@ -0,0 +1,5 @@ +# Generate a client id through the twitch CLI +VITE_CLIENT_ID=412c7e3c3df177bbd466a213ce4d05 +VITE_TWITCH_API_BASE=http://localhost:8080/mock +# Then get yourself a token via curl -X POST 'http://localhost:8080/auth/authorize?client_id={client_id}&client_secret={client_secret}&grant_type=user_token&user_id={user_id}&scope={scopes} +VITE_TOKEN_OVERRIDE=ed70cddba8db975 diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..386dfbd --- /dev/null +++ b/.env.production @@ -0,0 +1,2 @@ +VITE_CLIENT_ID=vzn24kqo5sq0g9duja3vpuenknend6 +VITE_TWITCH_API_BASE=https://api.twitch.tv/helix diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..8392d15 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..30ba776 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +.direnv diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..5b68051 --- /dev/null +++ b/biome.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "all": true, + "correctness": { + "noUndeclaredDependencies": "off" + }, + "complexity": { + "useLiteralKeys": "off" + }, + "style": { + "useExplicitLengthCheck": "off", + "useNamingConvention": "off" + } + }, + "ignore": [ + "src/Services/Miniflux/Requests/*", + "src/Services/Miniflux/Responses/*" + ] + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..12ff1eb --- /dev/null +++ b/flake.lock @@ -0,0 +1,64 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": [ + "systems" + ] + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1736200483, + "narHash": "sha256-JO+lFN2HsCwSLMUWXHeOad6QUxOuwe9UOAF/iSl1J4I=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "3f0a8ac25fb674611b98089ca3a5dd6480175751", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-24.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "systems": "systems" + } + }, + "systems": { + "locked": { + "lastModified": 1680978846, + "narHash": "sha256-Gtqg8b/v49BFDpDetjclCYXm8mAnTrUzR0JnE2nv5aw=", + "owner": "nix-systems", + "repo": "x86_64-linux", + "rev": "2ecfcac5e15790ba6ce360ceccddb15ad16d08a8", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "x86_64-linux", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..4d38f0d --- /dev/null +++ b/flake.nix @@ -0,0 +1,26 @@ +{ + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; + inputs.systems.url = "github:nix-systems/x86_64-linux"; + inputs.flake-utils = { + url = "github:numtide/flake-utils"; + inputs.systems.follows = "systems"; + }; + + outputs = + { nixpkgs, flake-utils, ... }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + devShells.default = pkgs.mkShell { + packages = with pkgs;[ + biome + nodejs_22 + twitch-cli + ]; + }; + } + ); +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..6b00cb0 --- /dev/null +++ b/index.html @@ -0,0 +1,18 @@ + + + + + + + Twitch Chatter Helper + + + + + + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cf158a0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1023 @@ +{ + "name": "vite-template-solid", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vite-template-solid", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@picocss/pico": "^2.0.6", + "es-toolkit": "^1.32.0", + "solid-js": "^1.9.5" + }, + "devDependencies": { + "typescript": "^5.8.2", + "vite": "^6.2.0", + "vite-plugin-solid": "^2.11.6", + "vite-tsconfig-paths": "^5.1.4" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.26.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.26.8", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.26.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.9", + "@babel/helper-compilation-targets": "^7.26.5", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/template": "^7.26.9", + "@babel/traverse": "^7.26.9", + "@babel/types": "^7.26.9", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.26.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.26.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.26.5", + "@babel/helper-validator-option": "^7.25.9", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.26.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.26.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.26.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.26.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.9" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.26.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.26.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.9", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.26.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@picocss/pico": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@picocss/pico/-/pico-2.0.6.tgz", + "integrity": "sha512-/d8qsykowelD6g8k8JYgmCagOIulCPHMEc2NC4u7OjmpQLmtSetLhEbt0j1n3fPNJVcrT84dRp0RfJBn3wJROA==", + "license": "MIT", + "engines": { + "node": ">=18.19.0" + } + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.34.9", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.34.9", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-plugin-jsx-dom-expressions": { + "version": "0.39.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "7.18.6", + "@babel/plugin-syntax-jsx": "^7.18.6", + "@babel/types": "^7.20.7", + "html-entities": "2.3.3", + "parse5": "^7.1.2", + "validate-html-nesting": "^1.2.1" + }, + "peerDependencies": { + "@babel/core": "^7.20.12" + } + }, + "node_modules/babel-plugin-jsx-dom-expressions/node_modules/@babel/helper-module-imports": { + "version": "7.18.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/babel-preset-solid": { + "version": "1.9.5", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jsx-dom-expressions": "^0.39.7" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.24.4", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001688", + "electron-to-chromium": "^1.5.73", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001701", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.1.3", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.109", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "4.5.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-toolkit": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.32.0.tgz", + "integrity": "sha512-ZfSfHP1l6ubgW/B/FRtqb9bYdMvI6jizbOSfbwwJNcOQ1QE6TFsC3jpQkZ900uUPSR3t3SU5Ds7UWKnYz+uP8Q==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.25.0", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.0", + "@esbuild/android-arm": "0.25.0", + "@esbuild/android-arm64": "0.25.0", + "@esbuild/android-x64": "0.25.0", + "@esbuild/darwin-arm64": "0.25.0", + "@esbuild/darwin-x64": "0.25.0", + "@esbuild/freebsd-arm64": "0.25.0", + "@esbuild/freebsd-x64": "0.25.0", + "@esbuild/linux-arm": "0.25.0", + "@esbuild/linux-arm64": "0.25.0", + "@esbuild/linux-ia32": "0.25.0", + "@esbuild/linux-loong64": "0.25.0", + "@esbuild/linux-mips64el": "0.25.0", + "@esbuild/linux-ppc64": "0.25.0", + "@esbuild/linux-riscv64": "0.25.0", + "@esbuild/linux-s390x": "0.25.0", + "@esbuild/linux-x64": "0.25.0", + "@esbuild/netbsd-arm64": "0.25.0", + "@esbuild/netbsd-x64": "0.25.0", + "@esbuild/openbsd-arm64": "0.25.0", + "@esbuild/openbsd-x64": "0.25.0", + "@esbuild/sunos-x64": "0.25.0", + "@esbuild/win32-arm64": "0.25.0", + "@esbuild/win32-ia32": "0.25.0", + "@esbuild/win32-x64": "0.25.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/globrex": { + "version": "0.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/html-entities": { + "version": "2.3.3", + "dev": true, + "license": "MIT" + }, + "node_modules/is-what": { + "version": "4.1.16", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/merge-anything": { + "version": "5.1.7", + "dev": true, + "license": "MIT", + "dependencies": { + "is-what": "^4.1.8" + }, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.8", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^4.5.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.3", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.34.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.6" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.34.9", + "@rollup/rollup-android-arm64": "4.34.9", + "@rollup/rollup-darwin-arm64": "4.34.9", + "@rollup/rollup-darwin-x64": "4.34.9", + "@rollup/rollup-freebsd-arm64": "4.34.9", + "@rollup/rollup-freebsd-x64": "4.34.9", + "@rollup/rollup-linux-arm-gnueabihf": "4.34.9", + "@rollup/rollup-linux-arm-musleabihf": "4.34.9", + "@rollup/rollup-linux-arm64-gnu": "4.34.9", + "@rollup/rollup-linux-arm64-musl": "4.34.9", + "@rollup/rollup-linux-loongarch64-gnu": "4.34.9", + "@rollup/rollup-linux-powerpc64le-gnu": "4.34.9", + "@rollup/rollup-linux-riscv64-gnu": "4.34.9", + "@rollup/rollup-linux-s390x-gnu": "4.34.9", + "@rollup/rollup-linux-x64-gnu": "4.34.9", + "@rollup/rollup-linux-x64-musl": "4.34.9", + "@rollup/rollup-win32-arm64-msvc": "4.34.9", + "@rollup/rollup-win32-ia32-msvc": "4.34.9", + "@rollup/rollup-win32-x64-msvc": "4.34.9", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/seroval": { + "version": "1.2.1", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.2.1", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/solid-js": { + "version": "1.9.5", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.0", + "seroval": "^1.1.0", + "seroval-plugins": "^1.1.0" + } + }, + "node_modules/solid-refresh": { + "version": "0.6.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.23.6", + "@babel/helper-module-imports": "^7.22.15", + "@babel/types": "^7.23.6" + }, + "peerDependencies": { + "solid-js": "^1.3" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tsconfck": { + "version": "3.1.5", + "dev": true, + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "5.8.2", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/validate-html-nesting": { + "version": "1.2.2", + "dev": true, + "license": "ISC" + }, + "node_modules/vite": { + "version": "6.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "postcss": "^8.5.3", + "rollup": "^4.30.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-solid": { + "version": "2.11.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.3", + "@types/babel__core": "^7.20.4", + "babel-preset-solid": "^1.8.4", + "merge-anything": "^5.1.7", + "solid-refresh": "^0.6.3", + "vitefu": "^1.0.4" + }, + "peerDependencies": { + "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", + "solid-js": "^1.7.2", + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "@testing-library/jest-dom": { + "optional": true + } + } + }, + "node_modules/vite-tsconfig-paths": { + "version": "5.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "globrex": "^0.1.2", + "tsconfck": "^3.0.3" + }, + "peerDependencies": { + "vite": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.0.6", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0586175 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "vite-template-solid", + "version": "0.0.0", + "description": "", + "type": "module", + "scripts": { + "start": "vite", + "dev": "vite", + "build": "vite build", + "serve": "vite preview" + }, + "license": "MIT", + "devDependencies": { + "typescript": "^5.8.2", + "vite": "^6.2.0", + "vite-plugin-solid": "^2.11.6", + "vite-tsconfig-paths": "^5.1.4" + }, + "dependencies": { + "@picocss/pico": "^2.0.6", + "es-toolkit": "^1.32.0", + "solid-js": "^1.9.5" + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..0bc3e35 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,14 @@ +import { Chatters } from "@components/Chatters.tsx"; +import { Contexts } from "@contexts/Contexts.tsx"; +import type { Component } from "solid-js"; + +export const App: Component = () => { + return ( +
+

Current Chatters

+ + + +
+ ); +}; diff --git a/src/components/Chatters.tsx b/src/components/Chatters.tsx new file mode 100644 index 0000000..1871a51 --- /dev/null +++ b/src/components/Chatters.tsx @@ -0,0 +1,214 @@ +import { TwitchServiceContext } from "@contexts/TwitchServiceContext"; +import { UserContext } from "@contexts/UserContext"; +import type { ChatterResponse } from "@services/twitch/responses/GetChattersResponse.ts"; +import type { SubscriptionResponse } from "@services/twitch/responses/GetSubscriptionsResponse.ts"; +import { useRequiredContext } from "@utils/useRequiredContext"; +import { sortBy } from "es-toolkit"; +import { + type Accessor, + type Component, + For, + type Setter, + Show, + createResource, + createSignal, +} from "solid-js"; +import { ResourceComponent } from "./ResourceComponent.tsx"; + +const ExportDialog: Component<{ + setRef: Setter; + ref: Accessor; + getJson: () => string; +}> = (props) => { + const [clipboardStatus, setClipboardStatus] = createSignal< + "Writing" | "Written" | "Initial" + >("Initial"); + + const copyClipboard = async () => { + setClipboardStatus("Writing"); + await navigator.clipboard.writeText(props.getJson()); + setClipboardStatus("Written"); + setTimeout(() => setClipboardStatus("Initial"), 1000); + }; + + return ( + +
+
+ + +
+
+					{props.getJson()}
+				
+
+
+ ); +}; + +const ChatttersView: Component<{ + chatters: ChatterResponse[]; + subscriptions: SubscriptionResponse[]; +}> = (props) => { + const [selected, setSelected] = createSignal([]); + const [search, setSearch] = createSignal(""); + const subs = sortBy(props.subscriptions, ["user_name"]); + const scrubs = sortBy( + props.chatters.filter((chat) => + props.subscriptions.find((sub) => sub.user_id !== chat.user_id), + ), + ["user_name"], + ); + + const onChange = (id: string) => { + setSelected((prev: string[]) => + prev.includes(id) + ? prev.filter((prevId) => prevId !== id) + : [...prev, id], + ); + }; + + const oneHundredClick = () => { + setSelected([ + ...subs.slice(0, 100).map((sub) => sub.user_id), + ...scrubs.slice(0, 100 - subs.length).map((scrub) => scrub.user_id), + ]); + }; + + const [dialogRef, setDialogRef] = createSignal(); + + const getJson = () => + JSON.stringify( + props.chatters + .filter((chat) => selected().includes(chat.user_id)) + .map((chat) => chat.user_name), + null, + 4, + ); + + return ( + <> + +
+
+ + + + +
+ setSearch(e.currentTarget.value.toLocaleLowerCase())} + /> +
+

Subs

+ + search() + ? sub.user_name.toLocaleLowerCase().includes(search()) + : true, + )} + > + {(sub) => ( + + )} + +
+
+

Scrubs

+
+ + search() + ? scrub.user_name.toLocaleLowerCase().includes(search()) + : true, + )} + > + {(scrub) => ( + + )} + +
+
+
+ + ); +}; + +export const Chatters: Component = () => { + const twitchService = useRequiredContext(TwitchServiceContext); + const user = useRequiredContext(UserContext); + + const [resource] = createResource(async () => { + const chatters = await twitchService.getChatters(user.id); + const subscriptions = await twitchService.getSubscribers( + user.id, + chatters.map((e) => e.user_id), + ); + return { + chatters, + subscriptions, + }; + }); + + return ( + + {(response) => ( + + )} + + ); +}; diff --git a/src/components/Loading.tsx b/src/components/Loading.tsx new file mode 100644 index 0000000..95f1920 --- /dev/null +++ b/src/components/Loading.tsx @@ -0,0 +1,5 @@ +import type { Component } from "solid-js"; + +export const Loading: Component = () => { + return "Loading..."; +}; diff --git a/src/components/ResourceComponent.tsx b/src/components/ResourceComponent.tsx new file mode 100644 index 0000000..a20ec21 --- /dev/null +++ b/src/components/ResourceComponent.tsx @@ -0,0 +1,68 @@ +import { Loading } from "@components/Loading.tsx"; +import { parseError } from "@utils/parseError.ts"; +import { + type Accessor, + type Component, + type JSX, + Match, + type Resource, + Show, + Switch, +} from "solid-js"; + +export const ResourceComponent = (props: { + children: (value: Accessor>) => JSX.Element; + resource: Resource; + errorComponent?: (e: Accessor) => JSX.Element; + loadingComponent?: () => JSX.Element; + fallback?: JSX.Element; +}) => { + const isError = () => { + const error = props.resource.error; + if (error) { + return parseError(error); + } + return undefined; + }; + + return ( + + + }> + {(loadingComponent) => loadingComponent()()} + + + + {(error) => ( + } + > + {(errorComponent) => errorComponent()(error)} + + )} + + {(value) => props.children(value)} + + ); +}; + +const FallbackError: Component<{ error: Accessor }> = (props) => { + return ( + + ); +}; diff --git a/src/contexts/Contexts.tsx b/src/contexts/Contexts.tsx new file mode 100644 index 0000000..1410271 --- /dev/null +++ b/src/contexts/Contexts.tsx @@ -0,0 +1,11 @@ +import type { ParentComponent } from "solid-js"; +import { TwitchServiceContextProvider } from "./TwitchServiceContext.tsx"; +import { UserContextProvider } from "./UserContext.tsx"; + +export const Contexts: ParentComponent = (props) => { + return ( + + {props.children} + + ); +}; diff --git a/src/contexts/TwitchServiceContext.tsx b/src/contexts/TwitchServiceContext.tsx new file mode 100644 index 0000000..f08b5fe --- /dev/null +++ b/src/contexts/TwitchServiceContext.tsx @@ -0,0 +1,56 @@ +import { TwitchApiService } from "@services/twitch/TwitchApiService.ts"; +import { + type ParentComponent, + Show, + createContext, + createRenderEffect, + createSignal, +} from "solid-js"; + +const searchParams = new URLSearchParams({ + client_id: import.meta.env["VITE_CLIENT_ID"], + redirect_uri: new URL(location.origin).toString(), + response_type: "token", + scope: "moderator:read:chatters channel:read:subscriptions", +}); +const url = new URL( + `https://id.twitch.tv/oauth2/authorize?${searchParams.toString()}`, +); + +export const TwitchServiceContext = createContext(); + +export const TwitchServiceContextProvider: ParentComponent = (props) => { + const [twitchApiService, setTwitchApiService] = + createSignal(); + + createRenderEffect(() => { + const devOverride = import.meta.env["VITE_TOKEN_OVERRIDE"]; + if (devOverride) { + setTwitchApiService(new TwitchApiService(devOverride)); + return; + } + + const urlSearchParams = new URLSearchParams(location.hash.slice(1)); + const accessToken = urlSearchParams.get("access_token"); + if (accessToken) { + setTwitchApiService(new TwitchApiService(accessToken)); + window.location.hash = ""; + } + }, []); + + return ( + + To use this application, please{" "} + Connect with Twitch +

+ } + > + + {props.children} + +
+ ); +}; diff --git a/src/contexts/UserContext.tsx b/src/contexts/UserContext.tsx new file mode 100644 index 0000000..b88d0bf --- /dev/null +++ b/src/contexts/UserContext.tsx @@ -0,0 +1,23 @@ +import { ResourceComponent } from "@components/ResourceComponent.tsx"; +import type { UserResponse } from "@services/twitch/responses/GetUsersResponse.ts"; +import { type ParentComponent, createContext, createResource } from "solid-js"; +import { useRequiredContext } from "../utils/useRequiredContext.ts"; +import { TwitchServiceContext } from "./TwitchServiceContext.tsx"; + +export const UserContext = createContext(); + +export const UserContextProvider: ParentComponent = (props) => { + const twitchService = useRequiredContext(TwitchServiceContext); + + const [resource] = createResource(async () => await twitchService.getUser()); + + return ( + + {(user) => ( + + {props.children} + + )} + + ); +}; diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..bb945f2 --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,8 @@ +/* @refresh reload */ +import "@picocss/pico"; +import { render } from "solid-js/web"; +import { App } from "./App.tsx"; + +const root = document.createElement("div"); +document.body.appendChild(root); +render(() => , root); diff --git a/src/services/twitch/TwitchApiService.ts b/src/services/twitch/TwitchApiService.ts new file mode 100644 index 0000000..b716539 --- /dev/null +++ b/src/services/twitch/TwitchApiService.ts @@ -0,0 +1,89 @@ +import type { + ChatterResponse, + GetChattersResponse, +} from "./responses/GetChattersResponse.ts"; +import type { + GetSubscriptionsResponse, + SubscriptionResponse, +} from "./responses/GetSubscriptionsResponse.ts"; +import type { + GetUsersResponse, + UserResponse, +} from "./responses/GetUsersResponse.ts"; + +export class TwitchApiService { + baseUrl: string; + headers: HeadersInit; + + constructor(accessToken: string) { + this.baseUrl = import.meta.env["VITE_TWITCH_API_BASE"]; + this.headers = { + Authorization: `Bearer ${accessToken}`, + "Client-Id": import.meta.env["VITE_CLIENT_ID"], + }; + } + + private async parseResponse(response: Response): Promise { + if (!response.ok) { + throw new Error( + `${response.status.toString()}: ${await response.text()}`, + ); + } + return await response.json(); + } + + getUser = async (): Promise => { + const url = new URL([this.baseUrl, "users"].join("/")); + const response = await fetch(url.toString(), { headers: this.headers }); + const users = await this.parseResponse(response); + return users.data[0] as UserResponse; + }; + + getChatters = async (id: string): Promise => { + const params = new URLSearchParams({ + broadcaster_id: id, + moderator_id: id, + first: (1000).toString(), + }); + const url = new URL([this.baseUrl, "chat", "chatters"].join("/")); + + const response = await fetch(`${url.toString()}?${params.toString()}`, { + headers: this.headers, + }); + let parsed = await this.parseResponse(response); + let chatters = parsed.data; + + while (parsed.pagination?.cursor) { + params.set("after", parsed.pagination.cursor); + const response = await fetch(`${url.toString()}?${params.toString()}`, { + headers: this.headers, + }); + parsed = await this.parseResponse(response); + chatters = chatters.concat(parsed.data); + } + + return chatters; + }; + + getSubscribers = async ( + id: string, + userIds: string[], + ): Promise => { + const url = new URL([this.baseUrl, "subscriptions"].join("/")); + let subscribers: SubscriptionResponse[] = []; + + for (let index = 0; index < userIds.length; index += 100) { + const ids = userIds + .slice(index, index + 100) + .map((id) => ["user_id", id]); + const params = new URLSearchParams([["broadcaster_id", id]].concat(ids)); + const response = await fetch(`${url.toString()}?${params.toString()}`, { + headers: this.headers, + }); + const parsed = + await this.parseResponse(response); + subscribers = subscribers.concat(parsed.data); + } + return subscribers; + }; +} diff --git a/src/services/twitch/responses/GetChattersResponse.ts b/src/services/twitch/responses/GetChattersResponse.ts new file mode 100644 index 0000000..27dcc3d --- /dev/null +++ b/src/services/twitch/responses/GetChattersResponse.ts @@ -0,0 +1,13 @@ +export interface ChatterResponse { + user_id: string; + user_login: string; + user_name: string; +} + +export interface GetChattersResponse { + data: ChatterResponse[]; + pagination?: { + cursor: string; + }; + total: number; +} diff --git a/src/services/twitch/responses/GetSubscriptionsResponse.ts b/src/services/twitch/responses/GetSubscriptionsResponse.ts new file mode 100644 index 0000000..7f442d1 --- /dev/null +++ b/src/services/twitch/responses/GetSubscriptionsResponse.ts @@ -0,0 +1,23 @@ +export interface SubscriptionResponse { + broadcaster_id: string; + broadcaster_login: string; + broadcaster_name: string; + gifter_id: string; + gifter_login: string; + gifter_name: string; + is_gift: boolean; + tier: string; + plan_name: string; + user_id: string; + user_name: string; + user_login: string; +} + +export interface GetSubscriptionsResponse { + data: SubscriptionResponse[]; + pagination: { + cursor: string; + }; + total: number; + points: number; +} diff --git a/src/services/twitch/responses/GetUsersResponse.ts b/src/services/twitch/responses/GetUsersResponse.ts new file mode 100644 index 0000000..396d32d --- /dev/null +++ b/src/services/twitch/responses/GetUsersResponse.ts @@ -0,0 +1,17 @@ +export interface UserResponse { + id: string; + login: string; + display_name: string; + type: string; + broadcaster_type: string; + description: string; + profile_image_url: string; + offline_image_url: string; + view_count: number; + email: string; + created_at: string; +} + +export interface GetUsersResponse { + data: UserResponse[]; +} diff --git a/src/utils/parseError.ts b/src/utils/parseError.ts new file mode 100644 index 0000000..7c58460 --- /dev/null +++ b/src/utils/parseError.ts @@ -0,0 +1,3 @@ +export function parseError(error: unknown): Error { + return error instanceof Error ? error : new Error(JSON.stringify(error)); +} diff --git a/src/utils/useRequiredContext.ts b/src/utils/useRequiredContext.ts new file mode 100644 index 0000000..d5d681f --- /dev/null +++ b/src/utils/useRequiredContext.ts @@ -0,0 +1,13 @@ +import { type Context, useContext } from "solid-js"; + +export const useRequiredContext = (context: Context): T => { + const getContext = useContext(context); + + if (getContext === undefined) { + throw new Error( + `${context.constructor.name} must be used within a provider instance`, + ); + } + + return getContext; +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5742db4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,41 @@ +{ + "compilerOptions": { + "strict": true, + "allowUnusedLabels": false, + "allowUnreachableCode": false, + "exactOptionalPropertyTypes": false, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + + "isolatedModules": true, + + "checkJs": true, + + "esModuleInterop": true, + "skipLibCheck": true, + + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "types": ["vite/client"], + "noEmit": true, + "allowImportingTsExtensions": true, + + "paths": { + "@components/*": ["./src/components/*"], + "@services/*": ["./src/services/*"], + "@contexts/*": ["./src/contexts/*"], + "@utils/*": ["./src/utils/*"], + "@src/*": ["./src/*"] + } + }, + "include": ["src/**/*"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..5322a13 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vite"; +import solidPlugin from "vite-plugin-solid"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [tsconfigPaths(), solidPlugin()], + server: { + port: 3000, + }, + build: { + target: "esnext", + watch: { + exclude: ["**/.devenv/**", "**/.direnv/**"], + }, + }, +});