diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..f3f8345 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,71 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Clean dist + run: rm -rf dist + + - name: Build + env: + NODE_ENV: production + BASE_URL: ${{ github.event.repository.name }} + run: npm run build + + - name: Verify bundle location + run: | + echo "Checking bundle.js location..." + if [ ! -f "dist/js/bundle.js" ]; then + echo "Error: bundle.js not found in dist/js/" + echo "Contents of dist directory:" + ls -R dist/ + exit 1 + fi + echo "Bundle.js found at correct location" + + - name: Setup Pages + uses: actions/configure-pages@v3 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v2 + with: + path: dist + + deploy: + needs: build + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..039c111 --- /dev/null +++ b/.gitignore @@ -0,0 +1,139 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Build artifacts +bundle.js + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* +*.vsix +.vscode/settings.json +*.pem +*.crx +*.zip +/keywords/categories diff --git a/.vscodeignore b/.vscodeignore new file mode 100644 index 0000000..f369b5e --- /dev/null +++ b/.vscodeignore @@ -0,0 +1,4 @@ +.vscode/** +.vscode-test/** +.gitignore +vsc-extension-quickstart.md diff --git a/CNAME b/CNAME new file mode 100644 index 0000000..f191f78 --- /dev/null +++ b/CNAME @@ -0,0 +1 @@ +mutesky.app diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..036218e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Chrissy LeMaire + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ab0709a --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# Mutesky - Bulk manage Bluesky mutes with pre-populated keyword lists + +Mutesky gives you control over what appears in your Bluesky feed through curated keyword collections. It works directly with Bluesky's native mute system to filter out content you'd rather not see. + +## Key Features + +- **Instant Setup**: Pre-populated with 1,400+ keywords +- **Smart Categories**: 20+ organized topic groups from politics to climate +- **Two Ways to Filter**: + - Simple Mode: Quick topic-based filtering + - Advanced Mode: Fine-tune individual keywords +- **Smart Search**: Filter keywords to find related terms, then enable/disable them all at once or individually +- **Real-Time**: Changes hit your feed instantly +- **Zero Storage**: Works directly with Bluesky's mute system - we never store your data + +## Get Started + +1. Visit [mutesky.app](https://mutesky.app) +2. Sign in with your Bluesky account (ex. username.bsky.social) +3. Pick your topics or dive into keyword management +4. Click "Mute" to apply changes + +## Made With + +- Frontend: Vanilla JS, HTML, CSS +- Integration: Bluesky/ATP API +- Deployment: GitHub Pages +- Build: Webpack + +## Local Development + +```bash +# Install dependencies +npm install + +# Start dev server +npm run dev + +# Create production build +npm run build +``` + +## Related Projects + +Check out [US Politician Labeler](https://bsky.app/profile/did:plc:bxnuth7kms5l57v2milp5gb3) + +## Coming Soon + +AI-powered dynamic keyword updates: An optional service that automatically identifies and updates mute keywords hourly based on emerging trends and topics. \ No newline at end of file diff --git a/callback.html b/callback.html new file mode 100644 index 0000000..807e1ab --- /dev/null +++ b/callback.html @@ -0,0 +1,30 @@ + + + + Bluesky Auth Callback + + + + + + + + +
+

Authentication Successful

+

✨ Rendering keywords

+
+
+
+

+ Return to app +
+ + + + + diff --git a/client-metadata.json b/client-metadata.json new file mode 100644 index 0000000..a6b1bc7 --- /dev/null +++ b/client-metadata.json @@ -0,0 +1,19 @@ +{ + "client_id": "https://mutesky.app/client-metadata.json", + "client_name": "Mutesky", + "client_uri": "https://mutesky.app", + "redirect_uris": [ + "https://mutesky.app/callback.html" + ], + "scope": "atproto transition:generic", + "grant_types": [ + "authorization_code", + "refresh_token" + ], + "response_types": [ + "code" + ], + "token_endpoint_auth_method": "none", + "application_type": "web", + "dpop_bound_access_tokens": true +} diff --git a/css/base.css b/css/base.css new file mode 100644 index 0000000..33454e2 --- /dev/null +++ b/css/base.css @@ -0,0 +1,172 @@ +/* Import Inter font */ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap'); + +/* Theme Variables */ +:root { + /* Light Theme Colors */ + --primary: #0085ff; + --primary-rgb: 0, 133, 255; + --primary-hover: #0066cc; + --primary-light: #e6f3ff; + --surface: #ffffff; + --background: #f8fafc; + --background-light: #ffffff; + --text: #000000; + --text-secondary: #536471; + --border: #e4e6eb; + --shadow: rgba(0, 0, 0, 0.08); + --disabled: #e4e6eb; + --danger: #f4212e; + --error: #dc3545; + --like: #f91880; + --repost: #00ba7c; + --link: #0085ff; + + /* Font Size Variables */ + --base-font-size: 15px; + --font-scale: 1; + --font-size-small: calc(0.867rem * var(--font-scale)); /* 13px equivalent */ + --font-size-default: calc(1rem * var(--font-scale)); /* 15px equivalent */ + --font-size-large: calc(1.133rem * var(--font-scale)); /* 17px equivalent */ + + /* Gradients */ + --surface-gradient: linear-gradient(180deg, + rgba(255, 255, 255, 0.05) 0%, + rgba(255, 255, 255, 0.02) 100%); + + /* Shadows */ + --card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + --hover-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + + /* Spacing */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 16px; + --spacing-lg: 24px; + --spacing-xl: 32px; + + /* Layout */ + --branding-width: 42%; + --content-width: 58%; + --min-width: 320px; + + /* Other */ + --border-radius: 8px; + --button-transition: 200ms ease-in-out; +} + +/* Dark Theme (Dim) */ +[data-theme="dim"] { + --surface: #15202b; + --background: #1e2732; + --background-light: #1a2634; + --text: #f7f9f9; + --text-secondary: #8b98a5; + --border: #38444d; + --shadow: rgba(255, 255, 255, 0.08); + --primary-light: rgba(0, 133, 255, 0.1); + + /* Dark theme specific */ + --surface-gradient: linear-gradient(180deg, + rgba(255, 255, 255, 0.03) 0%, + rgba(255, 255, 255, 0.01) 100%); + --card-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + --hover-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); +} + +/* Reset & Base Styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + font-size: var(--base-font-size); +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + line-height: 1.5; + color: var(--text); + background: var(--background); + font-size: var(--font-size-default); + min-height: 100vh; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Links */ +a { + color: var(--link); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +/* Loading Overlay */ +.loading-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--background); + display: flex; + justify-content: center; + align-items: center; + z-index: 9999; + opacity: 1; + transition: opacity 0.3s ease-out; +} + +.loading-overlay.hidden { + opacity: 0; + pointer-events: none; +} + +.loading-spinner { + width: 40px; + height: 40px; + border: 3px solid var(--border); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Utility Classes */ +.hidden { + display: none !important; +} + +.visible { + display: block !important; +} + +/* System Theme Detection */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + /* Default to dim theme when system prefers dark */ + --surface: #15202b; + --background: #1e2732; + --background-light: #1a2634; + --text: #f7f9f9; + --text-secondary: #8b98a5; + --border: #38444d; + --shadow: rgba(255, 255, 255, 0.08); + --primary-light: rgba(0, 133, 255, 0.1); + --surface-gradient: linear-gradient(180deg, + rgba(255, 255, 255, 0.03) 0%, + rgba(255, 255, 255, 0.01) 100%); + --card-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + --hover-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + } +} diff --git a/css/callback.css b/css/callback.css new file mode 100644 index 0000000..9501d26 --- /dev/null +++ b/css/callback.css @@ -0,0 +1,105 @@ +/* Remove duplicate theme variables since they're in base.css */ + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif; + line-height: 1.5; + color: var(--text); + background: var(--background); + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + margin: 0; +} + +.callback-container { + background: var(--surface); + padding: 32px; + border-radius: var(--border-radius); + box-shadow: 0 2px 8px var(--shadow); + text-align: center; + width: 90%; + max-width: 400px; +} + +h2 { + color: var(--text); + margin-bottom: 16px; +} + +.status-text { + color: var(--text-secondary); + margin: 16px 0; +} + +.progress-container { + background: var(--background); + border-radius: 8px; + height: 8px; + overflow: hidden; + margin: 24px 0; + position: relative; +} + +.progress-bar { + background: var(--primary); + height: 100%; + width: 0%; + border-radius: 8px; + animation: progress 2s ease-out forwards; +} + +.error-message { + color: var(--error); + margin: 16px 0; + display: none; +} + +.home-link { + color: var(--primary); + text-decoration: none; + display: inline-block; + margin-top: 16px; +} + +.home-link:hover { + color: var(--primary-hover); + text-decoration: underline; +} + +/* Animations */ +@keyframes progress { + 0% { width: 0%; } + 100% { width: 100%; } +} + +.loading-dots::after { + content: ''; + animation: dots 2s infinite; +} + +@keyframes dots { + 0%, 20% { content: '.'; } + 40% { content: '..'; } + 60%, 100% { content: '...'; } +} + +/* Error states */ +.error .progress-container, +.error .loading-dots { + display: none; +} + +.error .status-text { + display: none; +} + +.error .error-message { + display: block; +} + +/* Theme transition class */ +.js-loaded { + visibility: visible; + transition: color 0.2s ease, background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; +} diff --git a/css/components.css b/css/components.css new file mode 100644 index 0000000..583881e --- /dev/null +++ b/css/components.css @@ -0,0 +1,2 @@ +/* Import all component styles */ +@import 'components/index.css'; diff --git a/css/components/advanced-mode.css b/css/components/advanced-mode.css new file mode 100644 index 0000000..8ae7ca7 --- /dev/null +++ b/css/components/advanced-mode.css @@ -0,0 +1,154 @@ +/* Advanced Mode Layout */ +.advanced-layout { + display: flex; + height: calc(100vh - 72px - 40px); /* Subtract footer height */ + overflow: hidden; + position: fixed; + top: 72px; + left: 0; + right: 0; + background: var(--background); +} + +.advanced-filter-manager { + flex: 1; + display: flex; + flex-direction: column; + height: 100%; +} + +.categories-grid { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--spacing-lg); + padding: var(--spacing-lg); + overflow-y: auto; + overflow-x: hidden; + border-top-right-radius: var(--border-radius); + border-bottom-right-radius: var(--border-radius); + background: var(--surface); + margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xs) 0; + box-shadow: -1px 0 2px rgba(0, 0, 0, 0.05); + border-left: 1px solid var(--border); +} + +.category-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-md); +} + +/* Hide checkboxes in category titles only in the categories-grid (right side) */ +.categories-grid .category-title input[type="checkbox"] { + display: none; +} + +/* Category Items */ +.category-item { + display: flex; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-sm) var(--spacing-md); + margin-bottom: var(--spacing-xs); + border-radius: var(--border-radius); + cursor: pointer; + transition: var(--transition); + border: 1px solid transparent; +} + +/* Updated hover effect - very light gray background only */ +.category-item:hover { + background: rgba(128, 128, 128, 0.05); +} + +/* Remove underline from category items and their links */ +.category-item, +.category-item a, +.category-item:hover, +.category-item:hover a, +.category-item a:hover { + text-decoration: none !important; +} + +.category-name { + flex-grow: 1; + font-size: 14px; + font-weight: 500; +} + +.category-count { + color: var(--text-secondary); + font-size: 13px; + padding: var(--spacing-xs) var(--spacing-sm); + background: var(--background-light); + border: 1px solid var(--border); + border-radius: var(--border-radius); + min-width: 60px; + text-align: center; +} + +/* Keywords Section */ +.keywords-section { + flex: 2; + display: flex; + flex-direction: column; + height: 100%; + padding: var(--spacing-lg); + overflow-y: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; +} + +.keywords-container { + column-count: 3; + column-gap: 24px; + margin-top: 16px; +} + +.last-updated { + position: sticky; + bottom: 0; + background: var(--surface); + padding: var(--spacing-sm) 0; + margin-top: var(--spacing-lg); + text-align: right; + border-top: 1px solid var(--border); +} + +/* Media Queries */ +@media (max-width: 768px) { + .advanced-layout { + position: absolute; + height: auto; + min-height: calc(100vh - 72px - 40px); /* Subtract footer height */ + overflow: visible; + overflow-x: hidden; + } + + .advanced-filter-manager { + height: auto; + min-height: calc(100vh - 72px - 40px); /* Subtract footer height */ + overflow-x: hidden; + } + + .categories-grid, + .keywords-section { + height: auto; + min-height: 100%; + overflow-y: visible; + overflow-x: hidden; + } + + .keywords-container { + column-count: 1; + } + + /* Enable native scrolling on mobile */ + body { + overflow-y: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + } +} diff --git a/css/components/app-intro.css b/css/components/app-intro.css new file mode 100644 index 0000000..4262339 --- /dev/null +++ b/css/components/app-intro.css @@ -0,0 +1,15 @@ +.app-intro { + max-width: 900px; + margin: 3.5rem auto 4rem; + padding: 0 var(--spacing-md); +} + +.app-intro p { + font-size: 1.125rem; + line-height: 1.7; + color: var(--text-primary); + text-align: left; + margin-bottom: var(--spacing-xl); + letter-spacing: 0.01em; + opacity: 0.9; +} diff --git a/css/components/auth.css b/css/components/auth.css new file mode 100644 index 0000000..5150f67 --- /dev/null +++ b/css/components/auth.css @@ -0,0 +1,209 @@ +/* Auth Container */ +.bsky-connect { + background: var(--background); + border-radius: 16px; + padding: 24px; + margin-top: 24px; + border: 1px solid var(--border); + position: relative; + overflow: hidden; +} + +.bsky-connect::before { + content: ''; + position: absolute; + inset: 0; + background: var(--surface-gradient); + opacity: 0; + transition: opacity 0.3s ease; +} + +.bsky-connect:hover::before { + opacity: 1; +} + +.sign-in-title { + font-size: 22px; + font-weight: 800; + color: var(--text); + margin: 0 0 4px 0; + letter-spacing: -0.02em; + position: relative; +} + +.bsky-auth-container { + display: flex; + flex-direction: column; + gap: 16px; + position: relative; + margin-top: 12px; +} + +.auth-section { + width: 100%; +} + +/* Input Styling */ +.input-wrapper { + position: relative; + width: 100%; + margin-top: 16px; +} + +.input-wrapper::before { + content: '@'; + position: absolute; + left: 16px; + top: 50%; + transform: translateY(-50%); + color: var(--text-secondary); + font-size: 15px; + z-index: 1; + opacity: 0.7; + pointer-events: none; +} + +.bsky-handle-input { + width: 100%; + padding: 12px 16px 12px 36px; + font-size: 15px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + color: var(--text); + transition: var(--transition); +} + +.bsky-handle-input:hover:not(:disabled) { + border-color: var(--text-secondary); + background: var(--background); +} + +.bsky-handle-input:focus:not(:disabled) { + outline: none; + border-color: var(--primary); + background: var(--background); + box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.15); +} + +.bsky-handle-input.error { + border-color: var(--error); + background: rgba(var(--error-rgb), 0.05); +} + +.bsky-handle-input.error:focus { + box-shadow: 0 0 0 3px rgba(var(--error-rgb), 0.15); +} + +.bsky-handle-input:disabled { + opacity: 0.6; + cursor: not-allowed; + background: var(--surface); + border-color: var(--border); +} + +.bsky-handle-input::placeholder { + color: var(--text-secondary); + opacity: 0.7; +} + +/* Auth Button */ +.btn-auth { + width: 100%; + padding: 12px 16px; + font-size: 15px; + font-weight: 700; + color: #ffffff; + background: var(--primary); + border: none; + border-radius: 12px; + cursor: pointer; + transition: var(--transition); + position: relative; + overflow: hidden; +} + +.btn-auth::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 0.1) 50%, + rgba(255, 255, 255, 0) 100%); + opacity: 0; + transition: opacity 0.3s ease; +} + +.btn-auth:hover:not(:disabled) { + background: var(--primary-hover); + transform: translateY(-1px); +} + +.btn-auth:hover:not(:disabled)::before { + opacity: 1; +} + +.btn-auth:active:not(:disabled) { + transform: translateY(0); +} + +.btn-auth:disabled { + opacity: 0.6; + cursor: not-allowed; + background: var(--primary); +} + +/* Auth Message */ +.bsky-auth-message { + margin-top: 4px; + font-size: 14px; + color: var(--text-secondary); + padding: 0 4px; + transition: all 0.3s ease; +} + +.bsky-auth-message.error { + color: var(--error); + background: rgba(var(--error-rgb), 0.1); + border-radius: 8px; + padding: 12px; + margin: 8px 0; +} + +.auth-error { + font-weight: 500; + line-height: 1.5; + padding: 4px 0; +} + +@media (max-width: 768px) { + .bsky-connect { + margin-top: 20px; + padding: 20px; + } + + .sign-in-title { + font-size: 20px; + } +} + +@media (max-width: 480px) { + .bsky-connect { + padding: 16px; + margin-top: 16px; + } + + .sign-in-title { + font-size: 18px; + } + + .bsky-handle-input, + .btn-auth { + padding: 10px 14px 10px 32px; + } + + .input-wrapper::before { + left: 14px; + } +} diff --git a/css/components/buttons.css b/css/components/buttons.css new file mode 100644 index 0000000..e3c45bb --- /dev/null +++ b/css/components/buttons.css @@ -0,0 +1,117 @@ +/* Button Base Styles */ +.btn-auth, +.btn-refresh, +.btn-mute-keywords, +.nav-mute-button { + padding: 8px 16px; + border-radius: 9999px; /* Bluesky uses fully rounded buttons */ + cursor: pointer; + transition: var(--transition); + border: none; + font-size: 15px; + font-weight: 600; + line-height: 20px; + text-align: center; +} + +/* Auth Button */ +.btn-auth { + background: var(--primary); + color: #ffffff; + margin: 0 auto; + padding: 12px 24px; + min-width: 120px; +} + +.btn-auth-small { + padding: 8px 16px; + margin: 0; +} + +.btn-auth:hover { + background: var(--primary-hover); +} + +/* Refresh Button */ +.btn-refresh { + background: transparent; + color: var(--text); + border: 1px solid var(--border); + font-weight: 500; + display: flex; + align-items: center; + gap: var(--spacing-xs); +} + +.btn-refresh:hover { + background: var(--background); + border-color: var(--border); +} + +.btn-refresh:disabled { + opacity: 0.5; + cursor: not-allowed; + background: transparent; + border-color: var(--border); + color: var(--text-secondary); +} + +/* Spinning animation for refresh button */ +.btn-refresh.spinning { + position: relative; +} + +.btn-refresh.spinning::before { + content: '↻'; + display: inline-block; + margin-right: 4px; + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Mute Buttons */ +.btn-mute-keywords, +.nav-mute-button { + background: var(--primary); + color: #ffffff; + display: none; +} + +.btn-mute-keywords.visible, +.nav-mute-button.visible { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.btn-mute-keywords:hover, +.nav-mute-button:hover { + background: var(--primary-hover); +} + +/* Secondary Button Style */ +.btn-secondary { + background: transparent; + color: var(--text); + border: 1px solid var(--border); +} + +.btn-secondary:hover { + background: var(--background); +} + +/* Outline Button Style */ +.btn-outline { + background: transparent; + color: var(--primary); + border: 1px solid var(--primary); +} + +.btn-outline:hover { + background: var(--primary-light); +} diff --git a/css/components/cards.css b/css/components/cards.css new file mode 100644 index 0000000..70dfe29 --- /dev/null +++ b/css/components/cards.css @@ -0,0 +1,220 @@ +/* Card Base Styles */ +.feature-card, +.context-card, +.category-section { + background: var(--surface); + padding: 16px; + border: 1px solid var(--border); + border-radius: 16px; + transition: var(--transition); +} + +/* Landing Page Feature Cards - Specific class */ +.landing-feature-card { + background: var(--surface); + padding: 24px; + border: 1px solid var(--border); + border-radius: 20px; + transition: all 0.2s ease-in-out; + display: flex; + align-items: flex-start; + gap: 16px; + position: relative; + overflow: hidden; +} + +.landing-feature-card .feature-icon { + font-size: 28px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + background: var(--background); + border-radius: 14px; + transition: transform 0.2s ease; +} + +.landing-feature-card .feature-text { + flex: 1; + min-width: 0; +} + +.landing-feature-card .feature-text h3 { + font-size: 20px; + font-weight: 700; + color: var(--primary); + margin: 0 0 8px 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + letter-spacing: -0.01em; +} + +.landing-feature-card .feature-text p { + font-size: 15px; + line-height: 1.5; + color: var(--text-secondary); + margin: 0; +} + +/* Landing Feature Card Hover Effects */ +.landing-feature-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + border-color: var(--primary); + background: linear-gradient(to bottom right, var(--surface), var(--background)); +} + +.landing-feature-card:hover .feature-icon { + transform: scale(1.1); +} + +/* Hover state for other cards */ +.feature-card:hover, +.context-card:hover { + background: var(--background); +} + +/* Category Section Specific */ +.category-section { + margin-bottom: 16px; +} + +.category-section .category-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.category-section .category-title { + display: flex; + align-items: center; + gap: 8px; +} + +.category-section .category-title h3 { + margin: 0; + font-size: 20px; + font-weight: 700; + color: var(--text); +} + +.category-section .count { + color: var(--text-secondary); + font-size: 15px; +} + +.category-section .keywords-container { + columns: 3; + column-gap: 24px; + padding: 8px 0; +} + +/* Content Section */ +.content-section { + padding: 16px; + border-bottom: 1px solid var(--border); +} + +.content-section:last-child { + border-bottom: none; +} + +/* List Items */ +.list-item { + padding: 12px 16px; + border-bottom: 1px solid var(--border); + transition: var(--transition); +} + +.list-item:last-child { + border-bottom: none; +} + +.list-item:hover { + background: var(--background); +} + +/* Card Header */ +.card-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.card-title { + font-size: 15px; + font-weight: 700; + color: var(--text); + margin: 0; +} + +.card-subtitle { + font-size: 13px; + color: var(--text-secondary); + margin: 0; +} + +/* Card Content */ +.card-content { + font-size: 15px; + line-height: 1.5; + color: var(--text); +} + +/* Card Footer */ +.card-footer { + display: flex; + align-items: center; + gap: 16px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border); +} + +/* Stats Display */ +.stats { + display: flex; + align-items: center; + gap: 4px; + color: var(--text-secondary); + font-size: 13px; +} + +/* Avatar */ +.avatar { + width: 48px; + height: 48px; + border-radius: 50%; + object-fit: cover; +} + +.avatar-small { + width: 32px; + height: 32px; +} + +/* Media Queries */ +@media (max-width: 768px) { + .landing-feature-card { + padding: 20px; + } + + .landing-feature-card .feature-icon { + width: 40px; + height: 40px; + font-size: 24px; + } + + .landing-feature-card .feature-text h3 { + font-size: 18px; + } + + .landing-feature-card .feature-text p { + font-size: 14px; + } +} diff --git a/css/components/context-builder.css b/css/components/context-builder.css new file mode 100644 index 0000000..ef999a7 --- /dev/null +++ b/css/components/context-builder.css @@ -0,0 +1,47 @@ +/* Simple Mode - Context Builder */ +.context-builder { + height: calc(100vh - 72px - 40px); /* Match advanced mode height (accounting for header and footer) */ + overflow-y: auto; /* Enable native scrollbar behavior - only appears when needed */ + overflow-x: hidden; + position: fixed; + top: 72px; /* Match header height */ + left: 0; + right: 0; + background: var(--background); + padding: var(--spacing-xl) 0; /* Add vertical padding to the scrollable area */ +} + +.context-builder-inner { + max-width: 1200px; + margin: 0 auto; + padding: 0 var(--spacing-lg); +} + +.context-selector { + margin-bottom: var(--spacing-xl); +} + +.context-card { + border: 1px solid var(--border); + cursor: pointer; + transition: var(--transition); + background: var(--surface); +} + +.context-card:hover { + border-color: var(--primary); +} + +.context-card.selected { + background: var(--primary-light); + border-color: var(--primary); +} + +.context-card h3 { + margin-bottom: var(--spacing-sm); +} + +/* Hide bottom spacing div since we're using padding */ +.bottom-spacing { + display: none; +} diff --git a/css/components/exceptions.css b/css/components/exceptions.css new file mode 100644 index 0000000..e341ca9 --- /dev/null +++ b/css/components/exceptions.css @@ -0,0 +1,51 @@ +/* Exceptions Panel */ +.exceptions-panel { + margin-top: var(--spacing-lg); + display: none; +} + +.exceptions-panel.visible { + display: block; +} + +.exception-tags { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-sm); + margin-top: var(--spacing-md); +} + +.exception-tag { + padding: var(--spacing-sm) var(--spacing-md); + background: var(--background); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--border-radius); + cursor: pointer; + transition: var(--transition); + font-size: 14px; +} + +.exception-tag:hover { + background: var(--primary-light); + color: var(--primary); + border-color: var(--primary); +} + +.exception-tag.selected { + background: var(--primary); + color: white; + border-color: var(--primary); +} + +/* Media Queries */ +@media (max-width: 768px) { + .exception-tags { + flex-direction: column; + } + + .exception-tag { + width: 100%; + text-align: center; + } +} diff --git a/css/components/footer.css b/css/components/footer.css new file mode 100644 index 0000000..42b606b --- /dev/null +++ b/css/components/footer.css @@ -0,0 +1,141 @@ +.app-footer { + position: fixed; + bottom: 0; + left: 0; + width: 100%; + padding: 8px 16px; + background: var(--surface); + border-top: 1px solid var(--border); + font-size: 14px; + color: var(--text); + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + z-index: 100; +} + +.app-footer p { + margin: 0; +} + +.footer-left { + text-align: left; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + visibility: visible; +} + +/* Hide footer-left content when it would overflow */ +.footer-left:not(:hover):has(> *) { + max-width: min-content; +} + +.footer-center { + text-align: center; +} + +.footer-right { + text-align: right; + display: flex; + justify-content: flex-end; + align-items: center; +} + +.app-footer a { + color: var(--primary); + text-decoration: none; +} + +.app-footer a:hover { + text-decoration: underline; +} + +/* Theme Toggle Switch */ +.theme-toggle { + position: relative; + width: 64px; + height: 32px; + border-radius: 50px; + border: none; + background: none; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: space-between; + padding: 4px 8px; + outline: none; + background-color: var(--background); + border: 2px solid var(--border); + transition: all 0.3s ease; + margin-left: 8px; +} + +.theme-toggle:hover { + border-color: var(--primary); +} + +.theme-toggle::before { + content: ""; + position: absolute; + left: 4px; + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--primary); + transition: transform 0.3s ease, background-color 0.3s ease; +} + +.theme-toggle.dark::before { + transform: translateX(28px); +} + +.theme-toggle .toggle-icon { + display: flex; + align-items: center; + justify-content: center; + z-index: 1; + color: var(--text); + font-size: 14px; + line-height: 1; + width: 20px; + height: 20px; + position: relative; +} + +.theme-toggle .sun-icon { + margin-right: auto; + transform: translateX(-2px); +} + +.theme-toggle .moon-icon { + margin-left: auto; + transform: translateX(2px); +} + +/* Hide emoji in dark mode */ +.theme-toggle.dark .sun-icon { + opacity: 0.5; +} + +/* Hide emoji in light mode */ +.theme-toggle:not(.dark) .moon-icon { + opacity: 0.5; +} + +/* Mobile styles */ +@media (max-width: 768px) { + .theme-toggle, + .footer-left { + display: none !important; + } + + .app-footer { + grid-template-columns: 1fr; + } + + .footer-center { + grid-column: 1; + } +} diff --git a/css/components/forms.css b/css/components/forms.css new file mode 100644 index 0000000..930bf37 --- /dev/null +++ b/css/components/forms.css @@ -0,0 +1,198 @@ +/* Search Input */ +.sidebar-search { + width: 100%; + padding: 12px; + border: 1px solid var(--border); + border-radius: 9999px; + font-size: 15px; + background: transparent; + color: var(--text); + transition: var(--transition); +} + +.sidebar-search::placeholder { + color: var(--text-secondary); +} + +.sidebar-search:focus { + outline: none; + border-color: var(--primary); + background: transparent; +} + +/* Bluesky Handle Input */ +.input-wrapper { + position: relative; + width: 100%; +} + +.input-wrapper::before { + content: '@'; + position: absolute; + left: 16px; + top: 50%; + transform: translateY(-50%); + color: var(--text-secondary); + font-size: 15px; + z-index: 1; + pointer-events: none; +} + +.bsky-handle-input { + width: 100%; + height: 48px; + padding: 0 16px 0 36px; + border: none; + border-radius: 8px; + font-size: 15px; + background: var(--background); + color: var(--text); + transition: var(--transition); +} + +.bsky-handle-input::placeholder { + color: var(--text-secondary); + opacity: 0.7; +} + +.bsky-handle-input:focus { + outline: none; + background: var(--background); + box-shadow: 0 0 0 2px var(--primary); +} + +.bsky-handle-input.error { + background: rgba(220, 53, 69, 0.1); + box-shadow: 0 0 0 2px var(--danger); + animation: shake 0.5s; +} + +/* Checkbox */ +.keyword-checkbox { + display: flex; + align-items: center; + break-inside: avoid; + padding: 8px 0; + cursor: pointer; + color: var(--text); + font-size: 15px; + gap: 8px; +} + +.keyword-checkbox input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 18px; + height: 18px; + border: 1px solid var(--text-secondary); + border-radius: 4px; + background: transparent; + cursor: pointer; + position: relative; + transition: var(--transition); +} + +.keyword-checkbox input[type="checkbox"]:checked { + background: var(--primary); + border-color: var(--primary); +} + +.keyword-checkbox input[type="checkbox"]:checked::after { + content: ''; + position: absolute; + left: 5px; + top: 2px; + width: 4px; + height: 8px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +.keyword-checkbox input[type="checkbox"]:indeterminate { + background: transparent; + border-color: var(--primary); +} + +.keyword-checkbox input[type="checkbox"]:indeterminate::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 10px; + height: 2px; + background: var(--primary); +} + +.keyword-checkbox input[type="checkbox"]:focus { + outline: none; + border-color: var(--primary); +} + +.keyword-checkbox:hover input[type="checkbox"]:not(:checked) { + border-color: var(--text); +} + +/* Category Links */ +.category-name { + color: var(--text); + text-decoration: none; + cursor: pointer; +} + +.category-name:hover { + color: var(--primary); +} + +/* Select Inputs */ +select { + padding: 12px; + border: 1px solid var(--border); + border-radius: 4px; + font-size: 15px; + background: transparent; + color: var(--text); + cursor: pointer; + transition: var(--transition); +} + +select:focus { + outline: none; + border-color: var(--primary); +} + +/* Radio Buttons */ +input[type="radio"] { + appearance: none; + -webkit-appearance: none; + width: 18px; + height: 18px; + border: 2px solid var(--border); + border-radius: 50%; + background: transparent; + cursor: pointer; + position: relative; + transition: var(--transition); +} + +input[type="radio"]:checked { + border-color: var(--primary); +} + +input[type="radio"]:checked::after { + content: ''; + position: absolute; + left: 3px; + top: 3px; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--primary); +} + +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-5px); } + 75% { transform: translateX(5px); } +} diff --git a/css/components/index.css b/css/components/index.css new file mode 100644 index 0000000..8d290a7 --- /dev/null +++ b/css/components/index.css @@ -0,0 +1,21 @@ +/* Component Styles */ +@import 'buttons.css'; +@import 'forms.css'; +@import 'cards.css'; +@import 'toggles.css'; +@import 'auth.css'; +@import 'media.css'; +@import 'modals.css'; +@import 'profile.css'; +@import 'notifications.css'; +@import 'nav.css'; +@import 'landing.css'; +@import 'advanced-mode.css'; +@import 'scrollbars.css'; +@import 'context-builder.css'; +@import 'exceptions.css'; +@import 'footer.css'; +@import 'slider.css'; +@import 'app-intro.css'; +@import 'settings.css'; +@import 'simple-mode.css'; diff --git a/css/components/landing.css b/css/components/landing.css new file mode 100644 index 0000000..d7cb1d1 --- /dev/null +++ b/css/components/landing.css @@ -0,0 +1,166 @@ +/* Split Layout for Landing Page */ +.split-layout { + display: flex; + height: 100vh; + background: var(--surface); + overflow: hidden; +} + +/* Branding Section (Left) */ +.branding-section { + flex: 0 0 var(--branding-width); + background: var(--background); + display: flex; + align-items: center; + justify-content: center; + padding: 32px; +} + +.branding-content { + text-align: center; + max-width: 420px; +} + +.logo { + margin-bottom: 24px; + filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.1)); +} + +.logo img { + width: 245px; /* Half of original 490px width */ + height: auto; + display: block; + margin: 0 auto; +} + +.branding-content h1 { + font-size: 48px; + font-weight: 800; + color: var(--text); + margin-bottom: 16px; + letter-spacing: -0.02em; +} + +.tagline { + font-size: 20px; + line-height: 1.4; + color: var(--text-secondary); + margin: 0 auto; + max-width: 360px; +} + +/* Content Section (Right) */ +.content-section { + flex: 0 0 var(--content-width); + background: var(--surface); + display: flex; + flex-direction: column; + height: 100vh; +} + +.content-wrapper { + max-width: 720px; + margin: 0 auto; + padding: 32px; + width: 100%; + display: flex; + flex-direction: column; + justify-content: center; + height: 100%; + gap: 32px; +} + +/* Landing Content */ +.landing-content { + max-width: 800px; + margin: 0 auto; + padding: var(--spacing-xl); + text-align: center; + min-height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; + gap: var(--spacing-xl); +} + +.landing-header h1 { + font-size: 2.5rem; + margin-bottom: var(--spacing-md); +} + +.feature-card h3 { + color: var(--primary); + margin-bottom: var(--spacing-sm); +} + +/* Responsive Layout */ +@media (max-width: 1200px) { + .content-wrapper { + padding: 24px; + } +} + +@media (max-width: 1024px) { + .split-layout { + flex-direction: column; + height: auto; + min-height: 100vh; + } + + .branding-section, + .content-section { + flex: 0 0 auto; + width: 100%; + height: auto; + } + + .branding-section { + padding: 48px 24px; + } + + .content-wrapper { + padding: 32px 24px; + gap: 32px; + } + + .branding-content { + max-width: 100%; + } + + .tagline { + max-width: 480px; + } +} + +@media (max-width: 768px) { + .landing-content { + padding: var(--spacing-md); + } + + .logo img { + width: 200px; + } +} + +@media (max-width: 480px) { + .branding-section { + padding: 32px 20px; + } + + .content-wrapper { + padding: 24px 20px; + } + + .logo img { + width: 160px; + } + + .branding-content h1 { + font-size: 36px; + margin-bottom: 12px; + } + + .tagline { + font-size: 18px; + } +} diff --git a/css/components/media.css b/css/components/media.css new file mode 100644 index 0000000..f494dce --- /dev/null +++ b/css/components/media.css @@ -0,0 +1,34 @@ +/* Media Queries */ +@media (max-width: 768px) { + /* Button Responsiveness */ + .btn-auth { + width: 100%; + } + + .btn-auth-small { + width: 100%; + } + + /* Toggle Controls Responsiveness */ + .mode-toggle, + .toggle-all-controls { + width: 100%; + } + + /* Auth Container Responsiveness */ + .bsky-connect { + flex-direction: column; + width: 100%; + padding: 0 var(--spacing-md); + } + + .bsky-auth-container { + max-width: none; + width: 100%; + } + + /* Category Section Responsiveness */ + .category-section .keywords-container { + columns: 1; + } +} diff --git a/css/components/modals.css b/css/components/modals.css new file mode 100644 index 0000000..ed92e3e --- /dev/null +++ b/css/components/modals.css @@ -0,0 +1,179 @@ +/* Modal Overlay */ +.modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.4); + z-index: 1000; + backdrop-filter: blur(4px); +} + +/* Modal Container */ +.modal-content { + position: relative; + background: var(--surface); + margin: 5vh auto; + padding: 20px; + border-radius: 16px; + width: 90%; + max-width: 600px; + border: 1px solid var(--border); +} + +/* Modal Header */ +.modal-header { + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); +} + +.modal-header h2 { + margin: 0; + font-size: calc(1.333rem * var(--font-scale)); /* 20px equivalent */ + font-weight: 700; + color: var(--text); +} + +/* Modal Body */ +.modal-body { + margin-bottom: 16px; + color: var(--text); + font-size: var(--font-size-default); + line-height: 1.4; + max-height: 70vh; + overflow-y: auto; +} + +/* Settings Groups */ +.settings-group { + margin-bottom: 20px; + padding-bottom: 16px; +} + +.settings-group:last-child { + margin-bottom: 0; + padding-bottom: 0; +} + +.settings-group h3 { + margin: 0 0 12px 0; + font-size: var(--font-size-default); + font-weight: 600; + color: var(--text); + display: block; +} + +/* Settings Options */ +.settings-option { + margin: 12px 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.settings-option:first-child { + margin-top: 0; +} + +.settings-option:last-child { + margin-bottom: 0; +} + +.settings-option label { + cursor: pointer; + color: var(--text); + font-size: var(--font-size-default); + margin: 0; + padding: 0; +} + +/* Modal Footer */ +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 12px; + padding-top: 12px; + border-top: 1px solid var(--border); +} + +/* Close Button */ +.modal-close { + position: absolute; + right: 12px; + top: 12px; + width: 32px; + height: 32px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--text); + border: none; + background: var(--background); + transition: all 0.2s ease; + font-size: calc(1.333rem * var(--font-scale)); /* 20px equivalent */ + border: 1px solid var(--border); +} + +.modal-close:hover { + background: var(--border); + transform: scale(1.05); +} + +/* Modal Buttons */ +.modal .btn-primary { + padding: 8px 16px; + border-radius: 9999px; + cursor: pointer; + transition: var(--transition); + border: none; + background: var(--primary); + color: white; + font-size: var(--font-size-default); + font-weight: 600; +} + +.modal .btn-primary:hover { + background: var(--primary-hover); +} + +.modal .btn-secondary { + padding: 8px 16px; + border-radius: 9999px; + cursor: pointer; + transition: var(--transition); + border: 1px solid var(--border); + background: transparent; + color: var(--text); + font-size: var(--font-size-default); + font-weight: 600; +} + +.modal .btn-secondary:hover { + background: var(--background); +} + +/* Animation */ +@keyframes modalFadeIn { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.modal.visible { + display: block; +} + +.modal.visible .modal-content { + animation: modalFadeIn 0.2s ease-out; +} diff --git a/css/components/nav.css b/css/components/nav.css new file mode 100644 index 0000000..b3ee338 --- /dev/null +++ b/css/components/nav.css @@ -0,0 +1,5 @@ +/* Navigation Component Styles */ +@import './nav/top-nav.css'; +@import './nav/mode-toggle.css'; +@import './nav/user-menu.css'; +@import './nav/mobile.css'; diff --git a/css/components/nav/mobile.css b/css/components/nav/mobile.css new file mode 100644 index 0000000..9aa127f --- /dev/null +++ b/css/components/nav/mobile.css @@ -0,0 +1,165 @@ +/* Mobile Navigation Styles */ +.hamburger-menu { + display: none; + flex-direction: column; + justify-content: space-between; + width: 24px; + height: 20px; + background: none; + border: none; + cursor: pointer; + padding: 0; +} + +.hamburger-menu span { + display: block; + width: 100%; + height: 2px; + background: var(--text); + transition: var(--transition); +} + +/* Hide mobile mode switches by default (desktop) */ +.mobile-mode-switches { + display: none; +} + +/* Mobile Styles */ +@media (max-width: 768px) { + .top-nav { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--spacing-sm); + height: 55px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + } + + .brand { + flex: 0 0 auto; + } + + .brand h1 { + font-size: 1.1rem; + margin: 0; + white-space: nowrap; + } + + .hamburger-menu { + display: flex; + position: absolute; + top: 17px; + right: var(--spacing-sm); + } + + /* Hide non-essential elements on mobile */ + .mode-toggle, + .keywords-updated, + .user-name, + .profile-tooltip, + .profile-pic, + .profile-button { + display: none !important; + } + + /* Style nav-mute-button for mobile */ + .nav-mute-button { + position: absolute; + right: 56px; /* Position to left of hamburger menu */ + top: 50%; + transform: translateY(-50%); + padding: 8px 12px; + font-size: 0.9rem; + min-height: 36px; /* Ensure good touch target size */ + display: none; /* Hidden by default */ + } + + .nav-mute-button.visible { + display: block; /* Show only when there are changes */ + } + + /* Remove the space the mode-toggle was taking up */ + .mode-toggle { + height: 0; + margin: 0; + padding: 0; + overflow: hidden; + } + + /* Show and style mobile mode switches */ + .mobile-mode-switches { + display: block; + border-bottom: 1px solid var(--border); + margin-bottom: 8px; + padding: 4px 0; + } + + .mobile-mode-switches .user-menu-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + cursor: pointer; + transition: var(--transition); + } + + .mobile-mode-switches .user-menu-item:hover { + background: var(--surface-hover); + } + + .mobile-mode-switches .user-menu-item svg { + flex-shrink: 0; + } + + .user-menu-dropdown { + position: fixed; + top: 55px; + right: var(--spacing-sm); + left: var(--spacing-sm); + background: var(--surface); + border-radius: var(--border-radius); + box-shadow: var(--card-shadow); + z-index: 1000; + } + + /* Adjust search and controls area */ + .sidebar-header { + padding: 8px var(--spacing-sm); + margin: 0; + background: var(--surface); + } + + .toggle-all-controls { + display: flex; + gap: 8px; + padding: 8px 0; + margin: 0; + } + + .sidebar-search { + margin: 0; + padding: 8px 12px; + } + + /* Adjust main content area */ + .interface-mode { + margin-top: 0; + } + + .advanced-layout { + padding-top: 0; + } + + /* Hamburger menu active state */ + .hamburger-menu.active span:nth-child(1) { + transform: translateY(9px) rotate(45deg); + } + + .hamburger-menu.active span:nth-child(2) { + opacity: 0; + } + + .hamburger-menu.active span:nth-child(3) { + transform: translateY(-9px) rotate(-45deg); + } +} diff --git a/css/components/nav/mode-toggle.css b/css/components/nav/mode-toggle.css new file mode 100644 index 0000000..94d3551 --- /dev/null +++ b/css/components/nav/mode-toggle.css @@ -0,0 +1,25 @@ +/* Mode Toggle Styles */ +.mode-toggle { + display: flex; + gap: var(--spacing-sm); + padding: 4px; + background: var(--background); + border-radius: 20px; + border: 1px solid var(--border); +} + +.mode-switch { + padding: 6px 12px; + border-radius: 16px; + border: none; + background: none; + color: var(--text); + cursor: pointer; + transition: background-color var(--button-transition), box-shadow var(--button-transition); +} + +.mode-switch.active { + background: var(--surface); + color: var(--primary); + box-shadow: var(--card-shadow); +} diff --git a/css/components/nav/top-nav.css b/css/components/nav/top-nav.css new file mode 100644 index 0000000..c0d1fee --- /dev/null +++ b/css/components/nav/top-nav.css @@ -0,0 +1,91 @@ +/* Top Navigation Base Styles */ +.top-nav { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-md); + background: var(--surface); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 100; +} + +.brand { + display: flex; + align-items: center; +} + +.brand h1 { + font-size: 1.25rem; + font-weight: 600; + margin: 0; + color: var(--text); + display: flex; + align-items: center; + gap: 8px; +} + +.nav-logo { + width: 32px; + height: auto; + display: block; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1)); +} + +.nav-group { + display: flex; + align-items: center; + gap: var(--spacing-md); +} + +/* Right nav group with profile handling */ +.nav-group:last-child { + min-width: 0; +} + +/* Ensure mute button is always visible and takes priority */ +.btn-mute-keywords { + padding: 8px 16px; + flex-shrink: 0; + order: -1; /* Ensure mute button comes first */ + white-space: nowrap; +} + +/* Make profile section and hamburger menu shrinkable */ +.nav-group:last-child .user-menu, +.nav-group:last-child .hamburger-menu { + flex-shrink: 1; + min-width: 0; + transition: all 0.5s ease-in-out; /* Slower, smoother transition */ +} + +/* When space gets tight, collapse profile section */ +.nav-group:last-child:has(.btn-mute-keywords:not(.hidden)) .user-menu, +.nav-group:last-child:has(.btn-mute-keywords:not(.hidden)) .hamburger-menu { + width: 0; + padding: 0; + margin: 0; + visibility: hidden; + opacity: 0; + pointer-events: none; + transition: all 0.5s ease-in-out; /* Match transition timing */ +} + +/* Ensure dropdown is visible when menu is active */ +.nav-group:last-child .user-menu.active { + visibility: visible; + opacity: 1; + width: auto; + padding: initial; + margin: initial; + pointer-events: auto; + transition: all 0.5s ease-in-out; /* Consistent transition */ +} + +/* Ensure dropdown is always visible when parent is active */ +.user-menu.active .user-menu-dropdown { + display: block; + visibility: visible; + opacity: 1; +} diff --git a/css/components/nav/user-menu.css b/css/components/nav/user-menu.css new file mode 100644 index 0000000..c579797 --- /dev/null +++ b/css/components/nav/user-menu.css @@ -0,0 +1,193 @@ +/* User Menu Styles */ +.user-menu { + position: relative; +} + +.profile-button { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: 8px; + border: none; + background: none; + color: var(--text); + cursor: pointer; + border-radius: var(--border-radius); + transition: var(--transition); +} + +.profile-button:hover { + background: var(--background); +} + +.profile-pic { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--background); + overflow: hidden; +} + +.profile-pic img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* Overlay background when menu is open */ +.user-menu::before { + content: ''; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.3); + display: none; + z-index: 100; +} + +.user-menu.active::before { + display: block; +} + +.user-menu-dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--border-radius-lg); + box-shadow: var(--card-shadow); + width: 320px !important; + min-width: 320px !important; + max-width: calc(100vw - 32px) !important; + display: none; + z-index: 101; + flex-shrink: 0; +} + +.user-menu.active .user-menu-dropdown { + display: block; +} + +.user-menu-header { + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-md); +} + +.user-handle { + font-size: 1.1rem; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; +} + +.total-mutes { + font-size: 0.9rem; + color: var(--text-secondary); + padding: 2px 8px; + background: var(--background); + border-radius: var(--border-radius); + white-space: nowrap; + flex-shrink: 0; +} + +.user-menu-item { + padding: var(--spacing-md) var(--spacing-lg); + color: var(--text); + cursor: pointer; + transition: var(--transition); + display: flex; + align-items: center; + gap: var(--spacing-md); + font-size: 0.95rem; + min-height: 44px; +} + +.user-menu-item svg { + color: var(--text-secondary); + flex-shrink: 0; + position: relative; + top: 1px; + width: 20px; + height: 20px; +} + +#refresh-data { + min-width: 160px; + justify-content: flex-start; +} + +#refresh-data svg { + transform-origin: center; + flex-shrink: 0; + width: 20px; +} + +#refresh-data span { + flex: 1; + text-align: left; + white-space: nowrap; +} + +.user-menu-item:hover { + background: var(--background); + text-decoration: none; +} + +.user-menu-item:hover svg { + color: var(--text); +} + +.user-menu-item.logout { + color: var(--text); + margin-top: var(--spacing-sm); + border-top: 1px solid var(--border); + padding-top: calc(var(--spacing-md) + 4px); + gap: calc(var(--spacing-md) + 16px); +} + +.user-menu-item.logout svg { + color: var(--text-secondary); + position: relative; + top: 4px; + margin-left: 3px; +} + +.mobile-mode-switches { + display: none; + border-bottom: 1px solid var(--border); + padding-bottom: var(--spacing-sm); + margin-bottom: var(--spacing-sm); +} + +/* Override any parent constraints */ +.nav-group:last-child .user-menu { + min-width: auto !important; + width: auto !important; +} + +@media (max-width: 768px) { + .mobile-mode-switches { + display: block; + } +} + +@media (max-width: 400px) { + .total-mutes { + display: none; + } + + .user-menu-dropdown { + width: calc(100vw - 32px) !important; + min-width: auto !important; + right: -8px; + } +} diff --git a/css/components/notifications.css b/css/components/notifications.css new file mode 100644 index 0000000..98f6260 --- /dev/null +++ b/css/components/notifications.css @@ -0,0 +1,41 @@ +.notification { + position: fixed; + top: 80px; /* Increased from 20px to move it below the header */ + left: 50%; + transform: translateX(-50%) translateY(-20px); + padding: 12px 24px; + border-radius: 8px; + background: #2ecc71; + color: white; + font-size: 14px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + z-index: 1000; + opacity: 0; + transition: opacity 0.3s ease, transform 0.3s ease; + text-align: center; +} + +.notification.show { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +.notification.error { + background: #e74c3c; +} + +.notification.hide { + opacity: 0; + transform: translateX(-50%) translateY(-20px); +} + +/* Mobile-friendly adjustments */ +@media screen and (max-width: 768px) { + .notification { + width: 90%; + max-width: 300px; + top: 60px; + padding: 10px 16px; + font-size: 13px; + } +} diff --git a/css/components/profile.css b/css/components/profile.css new file mode 100644 index 0000000..cb4fa57 --- /dev/null +++ b/css/components/profile.css @@ -0,0 +1,90 @@ +.profile-button { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + border: none; + background: none; + cursor: pointer; + position: relative; +} + +.profile-button .user-name, +#user-display-name { + color: var(--text) !important; + font-weight: 600 !important; +} + +.profile-pic { + width: 32px; + height: 32px; + border-radius: 50%; + background-color: var(--background); + position: relative; +} + +.profile-tooltip { + display: none; + position: absolute; + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + white-space: nowrap; + top: 100%; + left: 50%; + transform: translateX(-50%); + margin-top: 4px; + z-index: 1000; +} + +.profile-pic:hover + .profile-tooltip { + display: block; +} + +.user-menu-dropdown { + position: absolute; + top: 100%; + right: 0; + background: var(--surface); + border-radius: 8px; + box-shadow: 0 2px 8px var(--shadow); + min-width: 200px; + z-index: 1000; + display: none; +} + +.user-menu-dropdown.visible { + display: block; +} + +/* Base styles for all menu items */ +.user-menu-item { + padding: 8px 16px; + cursor: pointer; + color: var(--text) !important; + text-decoration: none !important; + display: block; + width: 100%; + text-align: left; + border: none; + background: none; + font-size: 15px; /* Match the default font size */ + font-weight: 400; + line-height: 1.5; +} + +/* Ensure all menu items have the same styles */ +.user-menu-item.refresh, +.user-menu-item.logout, +.user-menu-item.moderation-link { + font-size: 15px; /* Match the default font size */ + font-weight: 400; +} + +.user-menu-item:hover { + background-color: var(--background); + color: var(--text) !important; + text-decoration: none !important; +} diff --git a/css/components/scrollbars.css b/css/components/scrollbars.css new file mode 100644 index 0000000..be332e4 --- /dev/null +++ b/css/components/scrollbars.css @@ -0,0 +1,57 @@ +/* Dark mode scrollbar styles */ +[data-theme="dark"] .categories-sidebar, +[data-theme="dark"] .categories-grid, +[data-theme="dark"] .keywords-section, +[data-theme="dark"] .context-builder, +[data-theme="dim"] .categories-sidebar, +[data-theme="dim"] .categories-grid, +[data-theme="dim"] .keywords-section, +[data-theme="dim"] .context-builder { + scrollbar-width: auto; + scrollbar-color: #3f3f4f #1e1e2e; +} + +[data-theme="dark"] .categories-sidebar::-webkit-scrollbar, +[data-theme="dark"] .categories-grid::-webkit-scrollbar, +[data-theme="dark"] .keywords-section::-webkit-scrollbar, +[data-theme="dark"] .context-builder::-webkit-scrollbar, +[data-theme="dim"] .categories-sidebar::-webkit-scrollbar, +[data-theme="dim"] .categories-grid::-webkit-scrollbar, +[data-theme="dim"] .keywords-section::-webkit-scrollbar, +[data-theme="dim"] .context-builder::-webkit-scrollbar { + width: 14px; +} + +[data-theme="dark"] .categories-sidebar::-webkit-scrollbar-track, +[data-theme="dark"] .categories-grid::-webkit-scrollbar-track, +[data-theme="dark"] .keywords-section::-webkit-scrollbar-track, +[data-theme="dark"] .context-builder::-webkit-scrollbar-track, +[data-theme="dim"] .categories-sidebar::-webkit-scrollbar-track, +[data-theme="dim"] .categories-grid::-webkit-scrollbar-track, +[data-theme="dim"] .keywords-section::-webkit-scrollbar-track, +[data-theme="dim"] .context-builder::-webkit-scrollbar-track { + background: #1e1e2e; +} + +[data-theme="dark"] .categories-sidebar::-webkit-scrollbar-thumb, +[data-theme="dark"] .categories-grid::-webkit-scrollbar-thumb, +[data-theme="dark"] .keywords-section::-webkit-scrollbar-thumb, +[data-theme="dark"] .context-builder::-webkit-scrollbar-thumb, +[data-theme="dim"] .categories-sidebar::-webkit-scrollbar-thumb, +[data-theme="dim"] .categories-grid::-webkit-scrollbar-thumb, +[data-theme="dim"] .keywords-section::-webkit-scrollbar-thumb, +[data-theme="dim"] .context-builder::-webkit-scrollbar-thumb { + background: #3f3f4f; + border-radius: 4px; +} + +[data-theme="dark"] .categories-sidebar::-webkit-scrollbar-thumb:hover, +[data-theme="dark"] .categories-grid::-webkit-scrollbar-thumb:hover, +[data-theme="dark"] .keywords-section::-webkit-scrollbar-thumb:hover, +[data-theme="dark"] .context-builder::-webkit-scrollbar-thumb:hover, +[data-theme="dim"] .categories-sidebar::-webkit-scrollbar-thumb:hover, +[data-theme="dim"] .categories-grid::-webkit-scrollbar-thumb:hover, +[data-theme="dim"] .keywords-section::-webkit-scrollbar-thumb:hover, +[data-theme="dim"] .context-builder::-webkit-scrollbar-thumb:hover { + background: #57576a; +} diff --git a/css/components/settings.css b/css/components/settings.css new file mode 100644 index 0000000..2afb6f1 --- /dev/null +++ b/css/components/settings.css @@ -0,0 +1,16 @@ +@import 'settings/settings-nav.css'; +@import 'settings/settings-user-menu.css'; +@import 'settings/settings-sidebar.css'; +@import 'settings/settings-modal.css'; + +/* Settings Page Layout */ +.page { + min-height: 100vh; +} + +/* Media Queries */ +@media (max-width: 768px) { + .advanced-layout { + flex-direction: column; + } +} diff --git a/css/components/settings/settings-modal-about.css b/css/components/settings/settings-modal-about.css new file mode 100644 index 0000000..a35f447 --- /dev/null +++ b/css/components/settings/settings-modal-about.css @@ -0,0 +1,153 @@ +/* About Tab Styles */ +.about-content { + padding: 24px; + max-width: 600px; + margin: 0 auto; +} + +.about-header { + display: flex; + align-items: center; + gap: 20px; + margin-bottom: 24px; + padding-bottom: 24px; + border-bottom: 1px solid var(--border); +} + +.creator-image-container { + width: 80px; + height: 80px; + flex-shrink: 0; + border-radius: 50%; + overflow: hidden; + background: var(--background); + border: 2px solid var(--border); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.creator-image { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.3s ease; +} + +.creator-image:hover { + transform: scale(1.05); +} + +.about-title { + flex: 1; +} + +.about-title h2 { + margin: 0; + font-size: calc(var(--font-size-default) * 1.5); + color: var(--text); + font-weight: 600; +} + +.version { + display: block; + color: var(--text-secondary); + font-size: var(--font-size-small); + margin-top: 4px; +} + +.about-description { + margin-bottom: 24px; + line-height: 1.6; + color: var(--text); +} + +.about-description p { + margin: 0; + font-size: calc(var(--font-size-default) * 1.1); +} + +.about-links { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + background: var(--background); + border-radius: 12px; + margin-bottom: 32px; +} + +.about-link { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + transition: background-color 0.2s ease; +} + +.about-link:hover { + background: var(--surface); + border-radius: 8px; +} + +.link-label { + color: var(--text-secondary); + font-size: var(--font-size-small); + min-width: 80px; +} + +.link-value { + color: var(--primary); + text-decoration: none; + font-weight: 500; + transition: color 0.2s ease; +} + +.link-value:hover { + color: var(--primary-hover); + text-decoration: underline; +} + +/* Sponsor Section */ +.sponsor-section { + text-align: center; + padding: 24px; + margin-top: 24px; + background: linear-gradient(135deg, #ff79c6, #bd93f9); + border-radius: 16px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.sponsor-section:hover { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15); +} + +.sponsor-button { + display: inline-flex; + align-items: center; + gap: 12px; + padding: 16px 32px; + background: rgba(255, 255, 255, 0.95); + border-radius: 12px; + text-decoration: none; + color: #2a2a2a; + font-size: calc(var(--font-size-default) * 1.2); + font-weight: 600; + transition: transform 0.2s ease; +} + +.sponsor-button:hover { + transform: scale(1.02); +} + +.sponsor-button img { + height: 24px; + width: auto; +} + +.sponsor-text { + color: white; + font-size: calc(var(--font-size-default) * 1.1); + margin-bottom: 16px; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} diff --git a/css/components/settings/settings-modal-controls.css b/css/components/settings/settings-modal-controls.css new file mode 100644 index 0000000..b26b0e5 --- /dev/null +++ b/css/components/settings/settings-modal-controls.css @@ -0,0 +1,56 @@ +/* Button Groups */ +.button-group { + display: flex; + background: var(--background); + border-radius: 24px; + padding: 4px; + width: 100%; +} + +/* Theme Mode Switches */ +.button-group .theme-mode-switch { + flex: 1; + padding: 8px 16px; + border: none; + background: none; + color: var(--text-secondary); + cursor: pointer; + font-size: var(--font-size-base); + font-weight: 500; + border-radius: 20px; + transition: var(--transition); +} + +.button-group .theme-mode-switch:hover { + color: var(--text); +} + +.button-group .theme-mode-switch.active { + background: var(--surface); + color: var(--text); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +/* Font Switches */ +.button-group .font-switch { + flex: 1; + padding: 8px 16px; + border: none; + background: none; + color: var(--text-secondary); + cursor: pointer; + font-size: var(--font-size-base); + font-weight: 500; + border-radius: 20px; + transition: var(--transition); +} + +.button-group .font-switch:hover { + color: var(--text); +} + +.button-group .font-switch.active { + background: var(--surface); + color: var(--text); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} diff --git a/css/components/settings/settings-modal-core.css b/css/components/settings/settings-modal-core.css new file mode 100644 index 0000000..999894d --- /dev/null +++ b/css/components/settings/settings-modal-core.css @@ -0,0 +1,11 @@ +/* Core Animations */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/css/components/settings/settings-modal-footer.css b/css/components/settings/settings-modal-footer.css new file mode 100644 index 0000000..1b87ce5 --- /dev/null +++ b/css/components/settings/settings-modal-footer.css @@ -0,0 +1,30 @@ +/* Modal Footer */ +.modal-footer { + padding: 16px; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 16px; +} + +.modal-footer .btn-primary { + min-width: 200px; + height: 40px; + font-size: var(--font-size-default); + font-weight: 500; +} + +/* Footer */ +.last-update { + padding: 16px; + border-top: 1px solid var(--border); + color: var(--text-secondary); + font-size: var(--font-size-small); + text-align: center; +} + +/* Appearance Settings Specific Styles */ +.settings-content[data-content="appearance"] .settings-group h3 { + font-size: calc(var(--font-size-default) * 1.3); + margin-bottom: 16px; +} diff --git a/css/components/settings/settings-modal-header.css b/css/components/settings/settings-modal-header.css new file mode 100644 index 0000000..3ffbb2d --- /dev/null +++ b/css/components/settings/settings-modal-header.css @@ -0,0 +1,33 @@ +/* Modal Header */ +.modal-header { + padding: 16px; + border-bottom: 1px solid var(--border); +} + +.modal-header h2 { + margin: 0; +} + +.modal-close { + background: none; + border: none; + font-size: calc(1.6rem * var(--font-scale)); /* 24px equivalent */ + cursor: pointer; + padding: 0; + color: var(--text-secondary); + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: var(--transition); + position: absolute; + right: 16px; + top: 16px; +} + +.modal-close:hover { + background: var(--background); + color: var(--text); +} diff --git a/css/components/settings/settings-modal-warning.css b/css/components/settings/settings-modal-warning.css new file mode 100644 index 0000000..b38c79e --- /dev/null +++ b/css/components/settings/settings-modal-warning.css @@ -0,0 +1,27 @@ +/* Settings Warning Message */ +.settings-warning { + background: var(--warning-background, #fff3cd); + border: 1px solid var(--warning-border, #ffeeba); + color: var(--warning-text, #856404); + padding: 8px 12px; + border-radius: 8px; + margin-right: 16px; + font-size: var(--font-size-small); + line-height: 1.3; + min-height: 40px; + align-items: center; + flex: 1; + display: none !important; /* Hidden by default, let JS control visibility */ +} + +/* Only show when JavaScript sets display:flex explicitly */ +.settings-warning.visible { + display: flex !important; + animation: fadeIn 0.3s ease-in-out; +} + +/* Hide warning when not on muting tab */ +.settings-content[data-content="appearance"].active ~ .modal-footer .settings-warning, +.settings-content[data-content="about"].active ~ .modal-footer .settings-warning { + display: none !important; +} diff --git a/css/components/settings/settings-modal.css b/css/components/settings/settings-modal.css new file mode 100644 index 0000000..d76e1e2 --- /dev/null +++ b/css/components/settings/settings-modal.css @@ -0,0 +1,9 @@ +/* Settings Modal Styles - Split for maintainability */ + +/* Order is important - core styles and animations first, then specific components */ +@import 'settings-modal-core.css'; +@import 'settings-modal-warning.css'; +@import 'settings-modal-header.css'; +@import 'settings-modal-controls.css'; +@import 'settings-modal-footer.css'; +@import 'settings-modal-about.css'; diff --git a/css/components/settings/settings-nav.css b/css/components/settings/settings-nav.css new file mode 100644 index 0000000..90bcb6a --- /dev/null +++ b/css/components/settings/settings-nav.css @@ -0,0 +1,48 @@ +/* Navigation */ +.top-nav { + background: var(--surface); + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: var(--spacing-md); +} + +.brand h1 { + font-size: 1.5rem; + margin: 0; + flex: 1; +} + +.mode-toggle { + position: absolute; + left: 50%; + transform: translateX(-50%); +} + +.nav-group { + display: flex; + align-items: center; + gap: var(--spacing-md); + flex: 1; + justify-content: flex-end; +} + +@media (max-width: 768px) { + .top-nav { + flex-direction: column; + align-items: stretch; + padding: 8px; + } + + .mode-toggle { + position: static; + transform: none; + margin: 8px 0; + } + + .nav-group { + justify-content: center; + gap: 8px; + } +} diff --git a/css/components/settings/settings-sidebar.css b/css/components/settings/settings-sidebar.css new file mode 100644 index 0000000..2f21bdf --- /dev/null +++ b/css/components/settings/settings-sidebar.css @@ -0,0 +1,222 @@ +/* Sidebar Layout */ +.categories-sidebar { + width: 320px; + background: var(--surface); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + height: 100%; + border-top-left-radius: var(--border-radius); + border-bottom-left-radius: var(--border-radius); + margin: var(--spacing-xs) 0 var(--spacing-xs) var(--spacing-xs); + box-shadow: 1px 0 2px rgba(0, 0, 0, 0.05); +} + +/* Search and Toggle Controls */ +.search-controls { + padding: var(--spacing-md); + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.search-input { + width: 100%; + height: 36px; + padding: 0 var(--spacing-md); + border: 2px solid var(--border); + border-radius: var(--border-radius); + background: var(--background-light); + color: var(--text); + font-size: 14px; + transition: var(--transition); +} + +.search-input:focus { + outline: none; + border-color: var(--primary); + background: var(--surface); + box-shadow: 0 0 0 1px var(--primary-light); +} + +.search-input:focus::placeholder { + color: transparent; +} + +.search-input::placeholder { + color: var(--text-secondary); +} + +.toggle-controls { + display: flex; + gap: var(--spacing-sm); + height: 36px; +} + +.toggle-button { + flex: 1; + padding: 0 var(--spacing-md); + border: 2px solid var(--border); + border-radius: var(--border-radius); + background: var(--background-light); + color: var(--text); + font-size: 14px; + cursor: pointer; + transition: var(--transition); + text-align: center; + font-weight: 500; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.toggle-button:hover { + background: var(--background); + border-color: var(--primary); +} + +.toggle-button:active { + background: var(--primary-light); +} + +.sidebar-header { + padding: 24px; + border-bottom: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 16px; +} + +.category-list { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +/* Settings Groups */ +.settings-group { + margin-bottom: 24px; +} + +.settings-group:last-child { + margin-bottom: 0; +} + +.settings-group h3 { + margin-bottom: 16px; + font-size: 15px; + font-weight: 500; + color: var(--text); +} + +/* Settings Tabs */ +.settings-tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--border); + background: var(--surface); + padding: 0 16px; +} + +.settings-tab { + padding: 16px 24px; + border: none; + background: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 18px; + font-weight: 600; + position: relative; + transition: var(--transition); +} + +.settings-tab:hover { + color: var(--text); +} + +.settings-tab.active { + color: var(--primary); +} + +.settings-tab.active::after { + content: ''; + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 2px; + background: var(--primary); +} + +.settings-content { + display: none; + padding: 24px; +} + +.settings-content.active { + display: block; +} + +/* Appearance Settings */ +.settings-option { + display: flex; + gap: 8px; + margin-bottom: 8px; +} + +.settings-option:last-child { + margin-bottom: 0; +} + +/* Color Mode Selection */ +.mode-switch, +.theme-switch, +.font-switch { + flex: 1; + padding: 12px; + border: 2px solid var(--border); + border-radius: var(--border-radius); + background: var(--background-light); + color: var(--text); + font-size: 14px; + cursor: pointer; + transition: var(--transition); + text-align: center; + font-weight: 500; + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.mode-switch:hover, +.theme-switch:hover, +.font-switch:hover { + background: var(--background); + border-color: var(--border-hover); +} + +.mode-switch.active, +.theme-switch.active, +.font-switch.active { + background: var(--primary-light); + border-color: var(--primary); + color: var(--primary); +} + +.mode-switch.active:hover, +.theme-switch.active:hover, +.font-switch.active:hover { + background: var(--primary-light); + border-color: var(--primary); +} + +@media (max-width: 768px) { + .categories-sidebar { + width: 100%; + height: auto; + margin: var(--spacing-xs); + border-radius: var(--border-radius); + } +} diff --git a/css/components/settings/settings-user-menu.css b/css/components/settings/settings-user-menu.css new file mode 100644 index 0000000..de0fce7 --- /dev/null +++ b/css/components/settings/settings-user-menu.css @@ -0,0 +1,95 @@ +/* User Menu */ +.user-menu { + position: relative; +} + +.profile-button { + display: flex; + align-items: center; + gap: 12px; + background: transparent; + border: none; + cursor: pointer; + padding: 8px; + border-radius: 9999px; + transition: var(--transition); +} + +.profile-button:hover { + background: var(--background); +} + +.user-name { + color: var(--text); + font-size: 15px; + font-weight: 500; +} + +.profile-pic { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--primary-light); +} + +.user-menu-dropdown { + position: absolute; + top: 100%; + right: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + box-shadow: 0 4px 12px var(--shadow); + min-width: 240px; + padding: 8px; + margin-top: 4px; + display: none; + z-index: 1000; +} + +.user-menu-dropdown.visible { + display: block; +} + +.user-menu-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + color: var(--text); + text-decoration: none; + border-radius: 8px; + transition: var(--transition); + cursor: pointer; + font-size: 15px; +} + +.user-menu-item:hover { + background: var(--background); +} + +.user-menu-item.profile-info { + border-bottom: 1px solid var(--border); + margin-bottom: 8px; + padding-bottom: 12px; + cursor: default; +} + +.user-menu-item.profile-info:hover { + background: none; +} + +.user-menu-item.refresh { + color: var(--primary); +} + +.user-menu-item.moderation-link { + color: var(--text); +} + +.user-menu-item.logout { + color: var(--danger); + border-top: 1px solid var(--border); + margin-top: 8px; + padding-top: 12px; +} diff --git a/css/components/simple-mode.css b/css/components/simple-mode.css new file mode 100644 index 0000000..194808e --- /dev/null +++ b/css/components/simple-mode.css @@ -0,0 +1,31 @@ +/* Container styles */ +#simple-mode { + max-width: 900px; + margin: 0 auto; +} + +/* Component styles */ +simple-mode { + display: block; +} + +simple-mode .intro-text { + font-size: 1.125rem; + line-height: 1.7; + color: var(--text-primary); + text-align: left; + margin: 0 auto 2rem; + letter-spacing: 0.01em; + opacity: 0.95; + max-width: 800px; + padding: 0 2rem; +} + +simple-mode .interface-mode { + width: 100%; +} + +/* Ensure intro text is visible at the top with reduced spacing */ +simple-mode .context-builder-inner { + padding-top: var(--spacing-md); +} diff --git a/css/components/slider.css b/css/components/slider.css new file mode 100644 index 0000000..410a8ac --- /dev/null +++ b/css/components/slider.css @@ -0,0 +1,117 @@ +.filter-slider { + width: 100%; + margin: 2rem 0; +} + +.filter-slider h2, +.context-selector h2, +.exceptions-panel h2 { + margin-bottom: var(--spacing-sm); + color: var(--text); + font-size: 1.5rem; + font-weight: 600; +} + +.filter-note { + position: relative; + color: var(--text); + font-size: 1rem; + line-height: 1.5; + margin-bottom: var(--spacing-md); + padding: 0.75rem 1rem 0.75rem 2.5rem; + background: var(--warning-bg, rgba(255, 193, 7, 0.1)); + border-left: 3px solid var(--warning, #ffc107); + border-radius: 4px; +} + +.filter-note::before { + content: "⚠️"; + position: absolute; + left: 0.75rem; + top: 0.75rem; +} + +.filter-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--spacing-md); + margin: 1rem 0; + width: 100%; +} + +.filter-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--border-radius); + padding: var(--spacing-md); + cursor: pointer; + transition: var(--button-transition); + height: 100%; + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.filter-card h3 { + font-size: 1rem; + font-weight: 600; + margin: 0; + color: var(--text); +} + +.filter-card p { + font-size: 0.85rem; + line-height: 1.4; + margin: 0; + color: var(--text-secondary); +} + +.filter-card:hover { + background: var(--background-light); +} + +.filter-card.active { + background: var(--primary); + border-color: var(--primary); +} + +.filter-card.active h3, +.filter-card.active p { + color: #ffffff; +} + +/* Dark Theme Adjustments */ +[data-theme="dim"] .filter-card:hover { + background: var(--background); +} + +[data-theme="dim"] .filter-card.active { + background: var(--primary); + border-color: var(--primary); +} + +/* Context Builder Styles */ +.context-selector { + margin-bottom: var(--spacing-xl); +} + +/* Exception Tags Styles */ +.exceptions-panel { + display: none; +} + +.exceptions-panel.visible { + display: block; +} + +.exception-tags { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-sm); +} + +/* Bottom Spacing */ +.bottom-spacing { + height: 60px; + width: 100%; +} diff --git a/css/components/toggles.css b/css/components/toggles.css new file mode 100644 index 0000000..92cac50 --- /dev/null +++ b/css/components/toggles.css @@ -0,0 +1,246 @@ +/* Mode Toggle in Top Nav */ +.top-nav .mode-toggle { + display: flex; + gap: 4px; + background: var(--background); + padding: 4px; + border-radius: 9999px; + border: 1px solid var(--border); + width: auto; +} + +.top-nav .mode-switch { + padding: 8px 24px; + border: none; + border-radius: 9999px; + background: transparent; + cursor: pointer; + transition: var(--transition); + color: var(--text-secondary); + font-size: 15px; + font-weight: 400; + text-align: center; + white-space: nowrap; +} + +/* Common hover and active states */ +.mode-switch:hover { + color: var(--text); +} + +.mode-switch.active { + background: var(--surface); + color: var(--text); + font-weight: 600; +} + +/* Toggle All Controls */ +.toggle-all-controls { + display: flex; + gap: 8px; + padding: 4px; +} + +.toggle-all-btn { + flex: 1; + padding: 8px 16px; + border: 1px solid var(--border); + border-radius: 9999px; + background: var(--surface); + font-size: 13px; + font-weight: 500; + color: var(--text); + cursor: pointer; + transition: var(--transition); +} + +.toggle-all-btn:hover { + background: var(--background); +} + +.toggle-all-btn.active { + background: var(--primary); + color: white; + border-color: var(--primary); +} + +/* Muting Settings Radio Buttons */ +.settings-option { + position: relative; + display: flex; + align-items: center; + padding: 10px 14px; + margin: 6px 0; + border-radius: 12px; + transition: var(--transition); + cursor: pointer; + background: var(--background); + border: 1px solid transparent; +} + +.settings-option:hover { + border-color: var(--border); +} + +.settings-option input[type="radio"] { + position: absolute; + opacity: 0; + width: 20px; + height: 20px; + margin: 0; + cursor: pointer; + z-index: 1; +} + +.settings-option .radio-circle { + position: relative; + width: 20px; + height: 20px; + border: 2px solid var(--text-secondary); + border-radius: 50%; + margin-right: 10px; + transition: all 0.2s ease; + pointer-events: none; + flex-shrink: 0; + background: var(--surface); +} + +.settings-option input[type="radio"]:checked + .radio-circle { + border-color: var(--primary); + border-width: 2px; +} + +.settings-option .radio-circle:after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(0); + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--primary); + transition: transform 0.2s ease; + pointer-events: none; +} + +.settings-option input[type="radio"]:checked + .radio-circle:after { + transform: translate(-50%, -50%) scale(1); +} + +.settings-option label { + flex: 1; + font-size: 15px; + font-weight: 500; + color: var(--text); + margin-left: 8px; + cursor: pointer; + user-select: none; +} + +/* Checkbox Styling */ +.settings-option input[type="checkbox"] { + position: absolute; + opacity: 0; + width: 20px; + height: 20px; + margin: 0; + cursor: pointer; + z-index: 1; +} + +.settings-option .checkbox-box { + position: relative; + width: 20px; + height: 20px; + border: 2px solid var(--text-secondary); + border-radius: 6px; + margin-right: 10px; + transition: all 0.2s ease; + pointer-events: none; + flex-shrink: 0; + background: var(--surface); +} + +.settings-option input[type="checkbox"]:checked + .checkbox-box { + background: var(--primary); + border-color: var(--primary); +} + +.settings-option .checkbox-box:after { + content: ''; + position: absolute; + top: 45%; + left: 50%; + width: 10px; + height: 6px; + border-left: 2px solid white; + border-bottom: 2px solid white; + transform-origin: center; + transform: translate(-50%, -50%) scale(0) rotate(-45deg); + transition: transform 0.2s ease; + pointer-events: none; +} + +.settings-option input[type="checkbox"]:checked + .checkbox-box:after { + transform: translate(-50%, -50%) scale(1) rotate(-45deg); +} + +/* Settings Groups */ +.settings-group { + margin-bottom: 16px; +} + +.settings-group h3 { + font-size: 15px; + font-weight: 600; + color: var(--text); + margin-bottom: 12px; +} + +/* Appearance Settings */ +#appearance-modal .settings-option { + display: flex; + background: var(--background); + padding: 3px; + border-radius: 9999px; + border: 1px solid var(--border); + margin-top: 8px; +} + +#appearance-modal .mode-switch, +#appearance-modal .theme-switch, +#appearance-modal .font-switch { + flex: 1; + padding: 6px 16px; + border: none; + border-radius: 9999px; + background: transparent; + color: var(--text-secondary); + font-size: 15px; + font-weight: 400; + cursor: pointer; + transition: var(--transition); + text-align: center; +} + +#appearance-modal .mode-switch:hover, +#appearance-modal .theme-switch:hover, +#appearance-modal .font-switch:hover { + color: var(--text); +} + +#appearance-modal .mode-switch.active, +#appearance-modal .theme-switch.active, +#appearance-modal .font-switch.active { + background: var(--surface); + color: var(--text); + font-weight: 600; +} + +#appearance-modal p { + color: var(--text-secondary); + font-size: 14px; + margin: 6px 0; + line-height: 1.4; +} diff --git a/css/index.css b/css/index.css new file mode 100644 index 0000000..b270823 --- /dev/null +++ b/css/index.css @@ -0,0 +1,8 @@ +/* Base styles and variables */ +@import './base.css'; + +/* Reusable components */ +@import './components.css'; + +/* Layout and structure */ +@import './layout.css'; diff --git a/css/layout.css b/css/layout.css new file mode 100644 index 0000000..a50b5f3 --- /dev/null +++ b/css/layout.css @@ -0,0 +1,51 @@ +/* Import specific layout components */ +@import 'components/landing.css'; +@import 'components/settings.css'; + +/* Landing Page Feature Grid */ +.feature-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 32px; + margin: 40px 0; + max-width: 100%; +} + +/* Other Grid Layouts */ +.context-options { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 24px; + margin-top: 24px; +} + +.keywords-container { + column-count: 3; + column-gap: 24px; + margin-top: 16px; +} + +/* Media Queries for Grid Layouts */ +@media (max-width: 1024px) { + .feature-grid { + gap: 24px; + margin: 32px 0; + } +} + +@media (max-width: 768px) { + .keywords-container { + column-count: 1; + } + + .feature-grid { + grid-template-columns: 1fr; + gap: 20px; + margin: 24px 0; + } + + .context-options { + grid-template-columns: 1fr; + gap: 20px; + } +} diff --git a/docs/1-architecture/1-core-concepts.md b/docs/1-architecture/1-core-concepts.md new file mode 100644 index 0000000..70a95a1 --- /dev/null +++ b/docs/1-architecture/1-core-concepts.md @@ -0,0 +1,189 @@ +# Core Architecture Concepts + +## Overview + +MuteSky is built around several key architectural concepts that form its foundation: + +## State Management + +### Core State Components +```javascript +export const state = { + // Authentication + authenticated: false, + did: null, // Track current user's DID + + // Mode + mode: 'simple', // 'simple' or 'advanced' + + // Keywords and Groups + keywordGroups: {}, // All available keyword groups + contextGroups: {}, // All available context groups + displayConfig: {}, // UI display configuration + + // Keyword Sets + activeKeywords: new Set(), // Currently checked keywords (only from our list) + originalMutedKeywords: new Set(), // All user's muted keywords (for safety check) + sessionMutedKeywords: new Set(), // New keywords muted this session + manuallyUnchecked: new Set(), // Keywords user has manually unchecked (persists across sessions) + + // Selection State + selectedContexts: new Set(), // Currently selected contexts + selectedExceptions: new Set(), // Categories marked as exceptions + selectedCategories: new Set(), // Currently selected categories + + // UI State + searchTerm: '', // Current search filter + filterMode: 'all', // Current filter mode + menuOpen: false, // Menu visibility state + lastModified: null, // Last-Modified header from keywords file + + // Filter Settings + targetKeywordCount: 100, // Target number of keywords (default: minimal) + filterLevel: 0, // Current filter level (0-3) + lastBulkAction: null // Track when enable/disable all is used +} +``` + +### State Persistence + +1. Storage Key Generation +```javascript +function getStorageKey() { + if (!state.did) { + throw new Error('No DID set in state'); + } + return `muteskyState-${state.did}`; +} +``` + +2. Saved State Structure +```javascript +const saveData = { + activeKeywords: Array.from(state.activeKeywords), + selectedCategories: Array.from(state.selectedCategories), + selectedContexts: Array.from(state.selectedContexts), + selectedExceptions: Array.from(state.selectedExceptions), + manuallyUnchecked: Array.from(state.manuallyUnchecked), + mode: state.mode, + lastModified: state.lastModified, + targetKeywordCount: state.targetKeywordCount, + filterLevel: state.filterLevel, + lastBulkAction: state.lastBulkAction +} +``` + +3. State Loading Process +- Clear existing selections (except manuallyUnchecked) +- Load saved state from localStorage +- Restore case-sensitive keywords using keyword map +- Initialize default values if no saved state +- Preserve manuallyUnchecked across errors + +4. Error Recovery +```javascript +try { + // Load state operations +} catch (error) { + // Preserve manuallyUnchecked set + const unchecked = new Set(state.manuallyUnchecked); + resetState(); + state.manuallyUnchecked = unchecked; +} +``` + +### State Reset Behavior + +1. Preserved State: + - Authentication (did, authenticated) + - Mute state (originalMutedKeywords, sessionMutedKeywords) + - Manual unchecks (manuallyUnchecked) + +2. Reset State: + - Mode (returns to 'simple') + - Selections (contexts, exceptions, categories) + - UI state (search, filter, menu) + - Filter settings (level, target count) + +### Cache Management + +1. Cache Structure +```javascript +const cache = { + keywords: new Map(), // Cached keywords by category + categoryStates: new Map(), // Cached category states + contextKeywords: new Map(), // Cached context-specific keywords + activeKeywordsByCategory: new Map(), // Active keywords per category + lastUpdate: 0 // For throttling updates +} +``` + +2. Cache Invalidation +```javascript +invalidateCategory(category) { + const now = Date.now(); + if (now - this.lastUpdate < 50) return; + this.lastUpdate = now; + // Clear relevant caches +} +``` + +3. Performance Optimization +```javascript +const debouncedUpdate = (() => { + let timeout; + return (fn) => { + if (timeout) clearTimeout(timeout); + timeout = setTimeout(() => { + fn(); + notifyKeywordChanges(); + }, 16); + }; +})(); +``` + +## Mode System + +### Simple Mode +- Context-based filtering with filter levels +- Keywords derived from selected contexts +- Exceptions for granular control +- Filter levels determine target keyword count + +### Advanced Mode +- Direct keyword management +- Source of truth for keyword state +- Maintains original case of keywords +- No automatic keyword rebuilding + +### Mode Synchronization +- Advanced mode is source of truth +- Simple mode derives from advanced mode state +- Changes in advanced mode reflect in simple mode +- Exceptions preserved across mode switches + +## Best Practices + +1. State Operations + - Always verify DID before state operations + - Use consistent auth state checking + - Maintain proper mode hierarchy + - Defer state saves to explicit actions + +2. Error Handling + - Preserve manuallyUnchecked across errors + - Handle case sensitivity properly + - Validate state before operations + - Provide clear error messages + +3. Performance + - Use cache for expensive calculations + - Throttle rapid updates (50ms threshold) + - Clear cache when target count changes + - Batch process large operations + +4. Mode Management + - Respect mode hierarchy + - Preserve exceptions when valid + - Update UI immediately + - Defer persistence to mute/unmute diff --git a/docs/1-architecture/10-custom-muting.md b/docs/1-architecture/10-custom-muting.md new file mode 100644 index 0000000..9bef57f --- /dev/null +++ b/docs/1-architecture/10-custom-muting.md @@ -0,0 +1,210 @@ +# Custom Muting System Implementation + +## Overview + +MuteSky's custom muting system is designed to be non-destructive - it respects and preserves user's existing muted keywords while providing a curated list of additional keywords to mute. This document details the implementation specifics and example scenarios. + +## Implementation Details + +### 1. Keyword Type Handling +```javascript +// Store all keywords in lowercase for comparison +userKeywords.forEach(keyword => { + const lowerKeyword = keyword.toLowerCase(); + state.originalMutedKeywords.add(lowerKeyword); + + // If it's one of our managed keywords, add to activeKeywords with proper case + const originalCase = ourKeywordsMap.get(lowerKeyword); + if (originalCase) { + state.activeKeywords.add(originalCase); + cache.invalidateCategory(getKeywordCategory(originalCase)); + } +}); +``` + +### 2. Safe Keyword Comparison +```javascript +// Check if keyword exists in our list (case-insensitive) +const cachedKeywords = cache.getKeywords(category, true); +const wasOriginallyMuted = state.originalMutedKeywords.has(keyword.toLowerCase()); +const isInOurList = cachedKeywords.has(keyword.toLowerCase()); +``` + +### 3. Context Completion Check +```javascript +// Check if all categories in context are complete +let allCategoriesComplete = true; +context.categories.forEach(category => { + const categoryKeywords = cache.getKeywords(category, true); + const activeInCategory = cache.getActiveKeywordsForCategory(category); + + if (activeInCategory.size < categoryKeywords.size) { + allCategoriesComplete = false; + state.selectedExceptions.add(category); + } +}); + +// If context is complete, clear all exceptions +if (allCategoriesComplete) { + context.categories.forEach(category => { + state.selectedExceptions.delete(category); + cache.invalidateCategory(category); + }); +} +``` + +## Example Scenarios + +### 1. Mixed Keywords Scenario +```javascript +// Initial State +const userMutedKeywords = ['biden', 'kitty', 'ELON']; +const ourKeywordsList = ['Biden', 'DeSantis', 'Pence']; + +// Resulting State +state.originalMutedKeywords = new Set(['biden', 'kitty', 'elon']); // All lowercase +state.activeKeywords = new Set(['Biden']); // Original case from our list +cache.keywords = new Map([ + ['politics', new Set(['Biden', 'DeSantis', 'Pence'])] +]); + +// UI State +// ✓ Biden (checkmark, can unmute - matches 'biden') +// □ DeSantis (no checkmark, can mute) +// □ Pence (no checkmark, can mute) +// Note: 'kitty' and 'ELON' preserved but not shown +``` + +### 2. Complete Context Selection +```javascript +// Context Structure +const politicalContext = { + id: 'political-discord', + categories: [ + { + id: 'us-political-figures', + keywords: ['Biden', 'Trump', 'Harris'] + }, + { + id: 'political-organizations', + keywords: ['Democrat', 'Republican'] + } + ] +}; + +// When all keywords selected: +// - Both categories show no exceptions +// - Context appears fully selected in Simple mode +// - No partial selection indicators +// - Cache maintains efficient lookup +``` + +### 3. Partial Selection Handling +```javascript +// Category with some keywords selected +const category = 'us-political-figures'; +const keywords = ['Biden', 'Trump', 'Harris']; +const selectedKeywords = ['Biden', 'Trump']; + +// Results in: +state.selectedExceptions.add(category); // Marked as partial +cache.invalidateCategory(category); // Cache updated +``` + +## State Transitions + +### 1. Initial Load +```javascript +async function initializeState() { + // Get user's currently muted keywords + const mutedKeywords = await muteService.getMutedKeywords(); + + // Store in lowercase for comparison + state.originalMutedKeywords = new Set( + mutedKeywords.map(k => k.toLowerCase()) + ); + + // Show checkmarks for our keywords that user has muted + const ourKeywordsMap = getOurKeywordsMap(); + mutedKeywords.forEach(keyword => { + const originalCase = ourKeywordsMap.get(keyword.toLowerCase()); + if (originalCase) { + state.activeKeywords.add(originalCase); + } + }); +} +``` + +### 2. Adding New Keywords +```javascript +function addKeyword(keyword) { + // Preserve case from our list + const originalCase = ourKeywordsMap.get(keyword.toLowerCase()); + if (originalCase) { + state.activeKeywords.add(originalCase); + state.originalMutedKeywords.add(keyword.toLowerCase()); + cache.invalidateCategory(getKeywordCategory(originalCase)); + } +} +``` + +### 3. Removing Keywords +```javascript +function removeKeyword(keyword) { + // Only remove if it's in our list + if (ourKeywordsMap.has(keyword.toLowerCase())) { + state.activeKeywords.delete(keyword); + // Note: originalMutedKeywords updated only after API call succeeds + cache.invalidateCategory(getKeywordCategory(keyword)); + } +} +``` + +## Edge Cases + +### 1. Case Mismatches +```javascript +// User has "BITCOIN" muted +// Our list has "Bitcoin" +// Result: "Bitcoin" shows as checked, preserves our case when toggled +``` + +### 2. Partial Context Selection +```javascript +// Some keywords in a context are muted externally +// Result: Context shows as partially selected +// Exceptions are properly tracked +``` + +### 3. Bulk Operations +```javascript +// Enable All with external mutes +// Result: Preserves external mutes +// Updates only our managed keywords +``` + +## Best Practices + +1. Case Sensitivity + - Always compare in lowercase + - Preserve original case for display + - Use consistent case in storage + - Handle case variants properly + +2. State Management + - Track both managed and custom keywords + - Preserve user preferences + - Handle state transitions cleanly + - Maintain cache consistency + +3. Performance + - Use efficient data structures + - Implement proper caching + - Batch operations when possible + - Handle large datasets gracefully + +4. Error Prevention + - Validate all operations + - Handle edge cases + - Preserve user data + - Maintain consistency diff --git a/docs/1-architecture/11-simple-mode.md b/docs/1-architecture/11-simple-mode.md new file mode 100644 index 0000000..d9224e3 --- /dev/null +++ b/docs/1-architecture/11-simple-mode.md @@ -0,0 +1,310 @@ +# Simple Mode Technical Implementation + +## Overview + +Simple mode provides an intuitive interface for content filtering through contexts, filter levels, and exceptions. This document details the technical implementation of these components. + +## Core Components + +### 1. Filter Level System +```javascript +class SimpleMode extends HTMLElement { + constructor() { + this.currentLevel = 0; // Default level + this.levelTargets = { + 0: 100, // Minimal + 1: 300, // Moderate + 2: 500, // Extensive + 3: 2000 // Complete + }; + } + + updateLevel(level) { + if (level === this.currentLevel) return; + this.currentLevel = level; + this.updateFilterUI(); + setTargetKeywordCount(this.levelTargets[level]); + } +} +``` + +### 2. Context Management System + +#### Context Selection Handler +```javascript +export function handleContextToggle(contextId) { + if (!state.authenticated) return; + + const isSelected = state.selectedContexts.has(contextId); + const context = state.contextGroups[contextId]; + + // Store unchecked state + const uncheckedKeywords = new Set(state.manuallyUnchecked); + + if (isSelected) { + // Remove context + state.selectedContexts.delete(contextId); + + // Clear exceptions + context.categories.forEach(category => { + state.selectedExceptions.delete(category); + cache.invalidateCategory(category); + }); + + // Mark keywords for removal + const keywordsToRemove = new Set(); + for (const category of context.categories) { + if (!state.selectedExceptions.has(category)) { + const keywords = cache.getKeywords(category, true); + for (const keyword of keywords) { + if (!uncheckedKeywords.has(keyword)) { + keywordsToRemove.add(keyword); + } + } + } + } + + // Remove after counts calculated + for (const keyword of keywordsToRemove) { + state.activeKeywords.delete(keyword); + } + } else { + // Add context + state.selectedContexts.add(contextId); + + // Add keywords + for (const category of context.categories) { + if (!state.selectedExceptions.has(category)) { + const keywords = cache.getKeywords(category, true); + for (const keyword of keywords) { + if (!uncheckedKeywords.has(keyword)) { + state.activeKeywords.add(keyword); + } + } + } + } + } + + // Update UI with debouncing + const debouncedUpdate = createDebouncedUpdate(); + await debouncedUpdate(async () => { + renderInterface(); + await saveState(); + }); +} +``` + +### 3. Exception System + +#### Exception Toggle Handler +```javascript +export function handleExceptionToggle(category) { + if (!state.authenticated) return; + + // Store unchecked state + const uncheckedKeywords = new Set(state.manuallyUnchecked); + + const wasException = state.selectedExceptions.has(category); + if (wasException) { + state.selectedExceptions.delete(category); + } else { + state.selectedExceptions.add(category); + } + + cache.invalidateCategory(category); + + // Rebuild keywords in simple mode + if (state.mode === 'simple') { + state.activeKeywords.clear(); + + // Rebuild from contexts + for (const contextId of state.selectedContexts) { + activateContextKeywords(contextId, cache); + } + + // Re-apply original muted + for (const keyword of state.originalMutedKeywords) { + if (!state.activeKeywords.has(keyword)) { + state.activeKeywords.add(keyword); + } + } + + // Re-apply unchecked + for (const keyword of uncheckedKeywords) { + state.activeKeywords.delete(keyword); + state.manuallyUnchecked.add(keyword); + } + } + + // Update UI + const debouncedUpdate = createDebouncedUpdate(); + await debouncedUpdate(async () => { + renderInterface(); + await saveState(); + }); +} +``` + +## Performance Optimizations + +### 1. Caching System +```javascript +const cache = { + keywords: new Map(), + categoryStates: new Map(), + contextKeywords: new Map(), + activeKeywordsByCategory: new Map(), + lastUpdate: 0, + + invalidateCategory(category) { + const now = Date.now(); + if (now - this.lastUpdate < 50) return; + this.lastUpdate = now; + + this.keywords.delete(category); + this.categoryStates.delete(category); + this.contextKeywords.delete(category); + this.activeKeywordsByCategory.delete(category); + } +}; +``` + +### 2. Debounced Updates +```javascript +const createDebouncedUpdate = () => { + let timeout; + return async (fn) => { + if (timeout) clearTimeout(timeout); + timeout = setTimeout(async () => { + await fn(); + }, 16); + }; +}; +``` + +## State Management + +### 1. Component State +```javascript +class SimpleMode extends HTMLElement { + constructor() { + this.currentLevel = 0; + this.currentExceptions = new Set(); + } + + connectedCallback() { + // Restore from last saved state + this.currentLevel = state.filterLevel; + this.currentExceptions = new Set(state.selectedExceptions); + this.setupEventListeners(); + } +} +``` + +### 2. State Persistence Rules +```javascript +async function handleMuteSubmit() { + // Process changes + await processChanges(); + + // Save state including: + // - Filter level + // - Selected contexts + // - Active keywords + // - Exceptions + await saveState(); +} +``` + +## Event Handling + +### 1. Filter Level Changes +```javascript +handleFilterLevelChange(event) { + const level = parseInt(event.target.value); + this.updateLevel(level); + this.updateFilterUI(); + notifyKeywordChanges(); +} +``` + +### 2. Context Changes +```javascript +handleContextChange(event) { + const contextId = event.target.dataset.context; + handleContextToggle(contextId); + this.updateExceptions(this.getActiveExceptions()); +} +``` + +## Best Practices + +### 1. State Updates +```javascript +// Always update UI immediately +this.updateFilterUI(); + +// But defer persistence +debouncedUpdate(async () => { + renderInterface(); + await saveState(); +}); +``` + +### 2. Performance +```javascript +// Use cache for expensive operations +const keywords = cache.getKeywords(category, true); + +// Throttle updates +if (now - this.lastUpdate < 50) return; + +// Batch operations +processBatchKeywords(keywords, operation); +``` + +### 3. Error Prevention +```javascript +// Validate all inputs +if (level < 0 || level > 3) return; + +// Handle edge cases +if (!context?.categories) return 'none'; + +// Maintain consistency +if (this.isProcessing) return; +``` + +## Testing Considerations + +### 1. State Transitions +```javascript +// Test level changes +async function testLevelChange() { + const before = getCurrentState(); + await updateLevel(newLevel); + const after = getCurrentState(); + assert(validateStateTransition(before, after)); +} +``` + +### 2. Performance Monitoring +```javascript +// Monitor update timing +const start = performance.now(); +await operation(); +const duration = performance.now() - start; +console.debug(`Operation took ${duration}ms`); +``` + +### 3. Edge Cases +```javascript +// Test rapid updates +async function testRapidUpdates() { + for (let i = 0; i < 100; i++) { + await handleContextToggle(contextId); + } +} +``` + +This implementation ensures efficient performance while maintaining a clear and intuitive user interface. diff --git a/docs/1-architecture/12-state-management.md b/docs/1-architecture/12-state-management.md new file mode 100644 index 0000000..9e74cc0 --- /dev/null +++ b/docs/1-architecture/12-state-management.md @@ -0,0 +1,289 @@ +# Centralized State Management System + +## Overview + +MuteSky uses a centralized state management system to handle user preferences, filter levels, and keyword selections. The system is explicitly tied to user actions to ensure predictable behavior. + +## Core Components + +### 1. State Structure +```javascript +// state.js +export const state = { + // Authentication + authenticated: false, + did: null, + + // Mode + mode: 'simple', + + // Keywords + activeKeywords: new Set(), // Currently checked keywords + originalMutedKeywords: new Set(), // All muted keywords (lowercase) + sessionMutedKeywords: new Set(), // New mutes this session + manuallyUnchecked: new Set(), // User's manual unchecks + + // Selections + selectedContexts: new Set(), + selectedExceptions: new Set(), + selectedCategories: new Set(), + + // Settings + filterLevel: 0, + targetKeywordCount: 100, + lastBulkAction: null +}; +``` + +### 2. State Persistence Rules + +#### When State Saves +```javascript +async function handleMuteSubmit() { + // Save complete state after mute/unmute + const saveData = { + activeKeywords: Array.from(state.activeKeywords), + selectedCategories: Array.from(state.selectedCategories), + selectedContexts: Array.from(state.selectedContexts), + selectedExceptions: Array.from(state.selectedExceptions), + mode: state.mode, + filterLevel: state.filterLevel, + targetKeywordCount: state.targetKeywordCount, + lastBulkAction: state.lastBulkAction + }; + + localStorage.setItem(getStorageKey(), JSON.stringify(saveData)); +} +``` + +#### What Doesn't Auto-Save +- Filter level changes +- Context toggles +- Exception toggles +- Mode switches +- Individual keyword toggles +- Enable/disable all actions +- Logout operations + +### 3. State Restoration Flow + +#### Login Restoration +```javascript +async function restoreState() { + const savedState = localStorage.getItem(getStorageKey()); + if (savedState) { + const data = JSON.parse(savedState); + + // Restore with proper case handling + const keywordMap = getKeywordsWithCase(); + state.activeKeywords = new Set( + data.activeKeywords.map(keyword => + keywordMap.get(keyword.toLowerCase()) || keyword + ) + ); + + // Restore selections + state.selectedCategories = new Set(data.selectedCategories); + state.selectedContexts = new Set(data.selectedContexts); + state.selectedExceptions = new Set(data.selectedExceptions); + + // Restore settings + state.mode = data.mode || 'simple'; + state.filterLevel = data.filterLevel || 0; + state.targetKeywordCount = data.targetKeywordCount || 100; + state.lastBulkAction = data.lastBulkAction || null; + } +} +``` + +#### Session Management +```javascript +// During session +function handleStateChange() { + // Update UI immediately + renderInterface(); + + // But don't save until mute/unmute + notifyKeywordChanges(); +} + +// On logout +function handleLogout() { + // Preserve last saved state + // Don't auto-save current state + resetUIState(); +} +``` + +## Implementation Details + +### 1. Filter Level Management +```javascript +function updateFilterLevel(level) { + // Update immediately + state.filterLevel = level; + state.targetKeywordCount = getTargetCount(level); + + // Update UI + updateFilterUI(); + + // Don't save until mute/unmute + notifyKeywordChanges(); +} + +function getTargetCount(level) { + const targets = { + 0: 100, // Minimal + 1: 300, // Moderate + 2: 500, // Extensive + 3: 2000 // Complete + }; + return targets[level] || 100; +} +``` + +### 2. State Change Handlers +```javascript +// Context changes +function handleContextToggle(contextId) { + const isSelected = state.selectedContexts.has(contextId); + if (isSelected) { + state.selectedContexts.delete(contextId); + } else { + state.selectedContexts.add(contextId); + } + + // Update UI only + renderInterface(); +} + +// Exception changes +function handleExceptionToggle(category) { + const wasException = state.selectedExceptions.has(category); + if (wasException) { + state.selectedExceptions.delete(category); + } else { + state.selectedExceptions.add(category); + } + + // Update UI only + renderInterface(); +} +``` + +### 3. Mode Transitions +```javascript +function switchMode(newMode) { + // Update mode + state.mode = newMode; + + // Update UI + if (newMode === 'simple') { + initializeSimpleMode(); + } else { + initializeAdvancedMode(); + } + + // Don't save until mute/unmute + renderInterface(); +} +``` + +## Best Practices + +### 1. State Updates +```javascript +// DO: Update UI immediately +function handleChange() { + updateState(); + renderInterface(); +} + +// DON'T: Save automatically +function handleChange() { + updateState(); + saveState(); // Wrong! Wait for mute/unmute +} +``` + +### 2. User Actions +```javascript +// DO: Allow experimentation +function handleFilterChange(level) { + updateFilterLevel(level); + // Don't save - let user experiment +} + +// DO: Save on explicit action +async function handleMuteClick() { + await processChanges(); + await saveState(); // Correct! User explicitly acted +} +``` + +### 3. State Restoration +```javascript +// DO: Restore from last save +async function initializeState() { + await restoreState(); + renderInterface(); +} + +// DON'T: Mix current and saved state +function restoreState() { + const saved = loadSavedState(); + // Wrong! Don't mix current and saved state + state.activeKeywords = new Set([ + ...saved.activeKeywords, + ...state.activeKeywords + ]); +} +``` + +## Error Prevention + +### 1. State Validation +```javascript +function validateState(state) { + if (!state.did) { + throw new Error('No DID set in state'); + } + + if (state.filterLevel < 0 || state.filterLevel > 3) { + state.filterLevel = 0; // Reset to safe default + } +} +``` + +### 2. Safe State Updates +```javascript +function updateState(changes) { + // Create backup + const backup = { ...state }; + + try { + Object.assign(state, changes); + validateState(state); + } catch (error) { + // Restore from backup on error + Object.assign(state, backup); + throw error; + } +} +``` + +### 3. Consistent State Loading +```javascript +async function loadState() { + try { + await restoreState(); + } catch (error) { + // On error, reset to defaults but preserve DID + const did = state.did; + resetState(); + state.did = did; + } +} +``` + +This centralized approach ensures consistent state management while maintaining a clear and predictable persistence model. diff --git a/docs/1-architecture/2-authentication.md b/docs/1-architecture/2-authentication.md new file mode 100644 index 0000000..653dd6f --- /dev/null +++ b/docs/1-architecture/2-authentication.md @@ -0,0 +1,188 @@ +# Authentication Architecture + +## Overview + +MuteSky uses Bluesky's OAuth implementation through the `@atproto/oauth-client-browser` library. The system ensures proper session management, token refresh, and state persistence across user sessions. + +## OAuth Flow + +1. Initial Setup +```javascript +this.client = await BrowserOAuthClient.load({ + clientId: 'https://mutesky.app/client-metadata.json', + handleResolver: 'https://bsky.social/' +}); +``` + +2. Sign In Process + - User enters Bluesky handle + - App initiates OAuth flow with `client.signIn(handle)` + - User redirects to Bluesky for authorization + - Bluesky redirects to callback page + - Callback processes response and establishes session + +## Session Management + +### Components + +1. AuthService (js/auth.js) + - Handles OAuth client initialization + - Manages sign in/out operations + - Provides session refresh capabilities + +2. BlueskyService (js/bluesky.js) + - Coordinates between services + - Handles session state changes + - Manages session refresh events + +3. Individual Services + - Maintain own session references + - Handle API calls with current session + - Dispatch refresh events when needed + +### Session States + +1. No Session + - Initial app load + - After sign out + - After failed authentication + +2. Active Session + - After successful sign in + - After successful token refresh + - Contains valid access token + +3. Expired Session + - Token has expired + - Triggers refresh flow + - Temporary state during refresh + +## Token Refresh Mechanism + +1. Detection +```javascript +if (error.status === 401) { + // Dispatch event for session refresh + const refreshEvent = new CustomEvent('mutesky:session:refresh:needed'); + window.dispatchEvent(refreshEvent); +} +``` + +2. Handling +```javascript +setupSessionRefreshHandler() { + window.addEventListener('mutesky:session:refresh:needed', async () => { + if (this.isRefreshing) return; // Prevent multiple refreshes + + try { + this.isRefreshing = true; + const result = await this.auth.refreshSession(); + if (result.success) { + // Update services with new session + this.profile.setSession(result.session); + this.mute.setSession(result.session); + } else { + await this.signOut(); + } + } finally { + this.isRefreshing = false; + } + }); +} +``` + +## Callback System + +### 1. Callback Page Structure +```html +
+

Authentication Successful

+

✨ Rendering keywords

+
+
+
+

+ Return to app +
+``` + +### 2. Event System +- `mutesky:auth:complete`: Fired when OAuth callback processing finishes +- `mutesky:setup:complete`: Fired when initial data loading finishes + +### 3. Error Handling +```javascript +showError(message) { + this.container.classList.add('error'); + this.titleElement.textContent = 'Authentication Failed'; + this.errorElement.textContent = message; + this.homeLink.style.display = 'block'; +} +``` + +## Error Handling + +1. Token Expiration + - Detected through 401 responses + - Triggers automatic refresh attempt + - Retries failed operation if refresh succeeds + - Signs out user if refresh fails + +2. Network Issues + - Services clear cached data on errors + - Errors are logged with context + - User-friendly error messages displayed + +3. Race Conditions + - Prevents multiple simultaneous refresh attempts + - Maintains consistent session state across services + - Cleans up properly on sign out + +## Implementation Details + +1. Session Storage + - OAuth client handles token storage + - Services maintain runtime session references + - No sensitive data stored in localStorage + +2. Service Coordination +```javascript +// Example: Updating services with new session +this.profile.setSession(result.session); +this.mute.setSession(result.session); +this.ui.updateLoginState(true); +``` + +3. Error Recovery +```javascript +try { + await operation(); +} catch (error) { + if (error.status === 401) { + // Trigger refresh flow + window.dispatchEvent(new CustomEvent('mutesky:session:refresh:needed')); + } + // Clear any cached data + this.cachedData = null; +} +``` + +## Best Practices + +1. Session Management + - Clear cached data when session changes + - Handle 401 errors at service level + - Use event system for refresh coordination + - Prevent multiple simultaneous refreshes + +2. Error Handling + - Provide clear user feedback + - Log errors with context + - Clean up state on failures + - Handle race conditions + +3. Security + - No sensitive data in localStorage + - Clear all state on logout + - Validate session before operations + - Handle token refresh securely diff --git a/docs/1-architecture/3-muting-system.md b/docs/1-architecture/3-muting-system.md new file mode 100644 index 0000000..d5a960d --- /dev/null +++ b/docs/1-architecture/3-muting-system.md @@ -0,0 +1,223 @@ +# Muting System Architecture + +## Overview + +MuteSky provides a specialized keyword muting system that respects user's existing muted keywords while providing a curated list of additional keywords to mute. The system is non-destructive - it never removes keywords that users have muted outside of our curated list. + +## Core Components + +### 1. MuteService +```javascript +class MuteService { + constructor(session) { + this.agent = session ? new Agent(session) : null; + this.session = session; + this.cachedKeywords = null; + this.cachedPreferences = null; + } +} +``` + +### 2. Mute Settings +```javascript +const settings = loadMuteSettings(); +{ + scope: 'all' | 'tags-only', // Where to apply muting + duration: number, // Mute duration in days + excludeFollows: boolean // Whether to exclude followed users +} +``` + +### 3. Keyword Types + +1. **Curated Keywords** + - From our predefined list + - Can be muted/unmuted through UI + - Case-insensitive matching + - Original case preserved when muting + - Support mute settings (scope, duration, excludes) + +2. **User's Custom Keywords** + - Muted outside our list + - Never shown in UI but tracked + - Preserved during all operations + - Stored lowercase for comparison + - Original settings preserved + +## Implementation Details + +### 1. Keyword Preservation +```javascript +// Separate user's custom keywords +const userCustomKeywords = currentMutedPref.items + .filter(item => !ourKeywordsSet.has(item.value.toLowerCase())) + .map(item => ({ + value: item.value, + targets: item.targets || ['content', 'tag'] + })); + +// Create new items for selected keywords +const newManagedItems = selectedKeywords + .filter(keyword => ourKeywordsSet.has(keyword.toLowerCase())) + .map(keyword => ({ + value: keyword, + targets: settings.scope === 'tags-only' ? ['tag'] : ['content', 'tag'], + ...(settings.excludeFollows && { actorTarget: 'notFollowed' }), + ...(expiresAt && { expires: expiresAt.toISOString() }) + })); + +// Combine preserving user's keywords +const updatedItems = [ + ...userCustomKeywords, // Preserve all user's custom keywords + ...newManagedItems // Only include selected keywords from our list +]; +``` + +### 2. Case Sensitivity Handling +```javascript +// Store all keywords in lowercase for comparison +userKeywords.forEach(keyword => { + const lowerKeyword = keyword.toLowerCase(); + state.originalMutedKeywords.add(lowerKeyword); + + // If it's one of our managed keywords, add to activeKeywords with proper case + const originalCase = ourKeywordsMap.get(lowerKeyword); + if (originalCase) { + state.activeKeywords.add(originalCase); + cache.invalidateCategory(getKeywordCategory(originalCase)); + } +}); +``` + +### 3. Caching System +```javascript +class MuteService { + async getMutedKeywords(forceRefresh = false) { + // Return cached keywords if available + if (!forceRefresh && this.cachedKeywords !== null) { + return this.cachedKeywords; + } + + // Fetch and cache new keywords + const response = await agent.api.app.bsky.actor.getPreferences(); + this.cachedPreferences = response.data.preferences; + const mutedWordsPref = this.cachedPreferences.find( + pref => pref.$type === 'app.bsky.actor.defs#mutedWordsPref' + ); + this.cachedKeywords = mutedWordsPref?.items?.map(item => item.value) || []; + return this.cachedKeywords; + } +} +``` + +## Muting Operations + +### 1. Initial Load +```javascript +async function initializeKeywordState() { + // Get user's currently muted keywords + const mutedKeywords = await muteService.getMutedKeywords(); + + // Store in lowercase for comparison + state.originalMutedKeywords = new Set( + mutedKeywords.map(k => k.toLowerCase()) + ); + + // Show checkmarks for our keywords that user has muted + const ourKeywordsMap = getOurKeywordsMap(); + mutedKeywords.forEach(keyword => { + const originalCase = ourKeywordsMap.get(keyword.toLowerCase()); + if (originalCase) { + state.activeKeywords.add(originalCase); + } + }); +} +``` + +### 2. Muting Process +```javascript +async function handleMuteSubmit() { + // Get current mute settings + const settings = loadMuteSettings(); + + // Apply settings to keywords + const keywordsToMute = Array.from(state.activeKeywords) + .filter(k => !state.originalMutedKeywords.has(k.toLowerCase())); + + // Update on Bluesky + await muteService.updateMutedKeywords(keywordsToMute, ourKeywordsList); + + // Update local state + keywordsToMute.forEach(k => { + state.originalMutedKeywords.add(k.toLowerCase()); + state.sessionMutedKeywords.add(k); + }); +} +``` + +### 3. Unmuting Process +```javascript +// Can only unmute our keywords +function canUnmuteKeyword(keyword) { + return ourKeywordsMap.has(keyword.toLowerCase()); +} + +// Preserve user's custom keywords during unmute +const userCustomKeywords = currentMutedPref.items + .filter(item => !ourKeywordsSet.has(item.value.toLowerCase())); +``` + +## Error Handling + +### 1. Session Errors +```javascript +if (error.status === 401) { + // Dispatch event for session refresh + const refreshEvent = new CustomEvent('mutesky:session:refresh:needed'); + window.dispatchEvent(refreshEvent); + // Clear caches + this.cachedKeywords = null; + this.cachedPreferences = null; +} +``` + +### 2. Cache Management +```javascript +setSession(session) { + this.agent = session ? new Agent(session) : null; + this.session = session; + // Clear caches when session changes + this.cachedKeywords = null; + this.cachedPreferences = null; +} +``` + +## Best Practices + +1. Keyword Management + - Store keywords lowercase for comparison + - Preserve original case for display/muting + - Never modify user's custom keywords + - Track both custom and managed keywords + - Use caching for efficient lookups + +2. Error Prevention + - Validate session before operations + - Handle case sensitivity properly + - Verify keyword existence + - Maintain consistent state + - Clear caches on errors + +3. Performance + - Cache expensive API calls + - Batch keyword updates + - Throttle rapid operations + - Clear caches appropriately + - Use efficient data structures + +4. User Experience + - Preserve user preferences + - Provide clear feedback + - Handle errors gracefully + - Maintain responsive UI + - Show accurate mute counts diff --git a/docs/1-architecture/4-mode-system.md b/docs/1-architecture/4-mode-system.md new file mode 100644 index 0000000..860fd82 --- /dev/null +++ b/docs/1-architecture/4-mode-system.md @@ -0,0 +1,249 @@ +# Mode System Architecture + +## Overview + +MuteSky operates in two distinct modes: +- Simple Mode: Context-based filtering with filter levels +- Advanced Mode: Direct keyword management + +The system maintains consistency between these modes while preserving user preferences. + +## Context System Implementation + +### 1. Context Toggle Handler +```javascript +export async function handleContextToggle(contextId) { + // Store currently unchecked keywords + const uncheckedKeywords = new Set(state.manuallyUnchecked); + + if (state.selectedContexts.has(contextId)) { + // Unchecking context + state.selectedContexts.delete(contextId); + + // Remove exceptions for this context + context.categories.forEach(category => { + state.selectedExceptions.delete(category); + cache.invalidateCategory(category); + }); + + // Mark keywords for removal but keep temporarily for getMuteUnmuteCounts + const keywordsToRemove = new Set(); + for (const category of context.categories) { + if (!state.selectedExceptions.has(category)) { + const keywords = cache.getKeywords(category, true); + for (const keyword of keywords) { + if (!uncheckedKeywords.has(keyword)) { + keywordsToRemove.add(keyword); + } + } + } + } + + // Remove after counts are calculated + for (const keyword of keywordsToRemove) { + state.activeKeywords.delete(keyword); + } + } else { + // Checking context + state.selectedContexts.add(contextId); + + // Add keywords while respecting unchecked state + for (const category of context.categories) { + if (!state.selectedExceptions.has(category)) { + const keywords = cache.getKeywords(category, true); + for (const keyword of keywords) { + if (!uncheckedKeywords.has(keyword)) { + state.activeKeywords.add(keyword); + } + } + } + } + } + + // Update UI with debouncing + const debouncedUpdate = createDebouncedUpdate(); + await debouncedUpdate(async () => { + renderInterface(); + await saveState(); + }); +} +``` + +### 2. Exception Handler +```javascript +export async function handleExceptionToggle(category) { + // Store unchecked state + const uncheckedKeywords = new Set(state.manuallyUnchecked); + + const wasException = state.selectedExceptions.has(category); + if (wasException) { + state.selectedExceptions.delete(category); + } else { + state.selectedExceptions.add(category); + } + + cache.invalidateCategory(category); + + // Only rebuild keywords in simple mode + if (state.mode === 'simple') { + // Clear and rebuild active keywords + state.activeKeywords.clear(); + for (const contextId of state.selectedContexts) { + activateContextKeywords(contextId, cache); + } + + // Re-apply original muted keywords + for (const keyword of state.originalMutedKeywords) { + if (!state.activeKeywords.has(keyword)) { + state.activeKeywords.add(keyword); + } + } + + // Re-apply unchecked status + for (const keyword of uncheckedKeywords) { + state.activeKeywords.delete(keyword); + state.manuallyUnchecked.add(keyword); + } + } +} +``` + +## Mode-Specific State Management + +### 1. Simple Mode State Updates +```javascript +export async function updateSimpleModeState() { + if (!state.authenticated) return; + + // Only rebuild keywords in simple mode + if (state.mode === 'simple') { + // Check contexts + for (const contextId of Array.from(state.selectedContexts)) { + const contextState = cache.getContextState(contextId); + if (contextState === 'none') { + state.selectedContexts.delete(contextId); + } + } + + cache.clear(); + rebuildActiveKeywords(); + } + + await debouncedUpdate(async () => { + renderInterface(); + await saveState(); + }); +} +``` + +### 2. Advanced Mode State +```javascript +export function handleKeywordToggle(keyword, enabled) { + if (enabled) { + state.activeKeywords.add(keyword); + state.manuallyUnchecked.delete(keyword); + } else { + state.activeKeywords.delete(keyword); + state.manuallyUnchecked.add(keyword); + } + + cache.invalidateCategory(getKeywordCategory(keyword)); + notifyKeywordChanges(); +} +``` + +## State Preservation + +### 1. Keyword State Preservation +```javascript +// Store unchecked state before changes +const uncheckedKeywords = new Set(state.manuallyUnchecked); + +// Re-apply after changes +for (const keyword of uncheckedKeywords) { + state.activeKeywords.delete(keyword); + state.manuallyUnchecked.add(keyword); +} +``` + +### 2. Context State Tracking +```javascript +getContextState(contextId) { + const context = state.contextGroups[contextId]; + if (!context?.categories) return 'none'; + + let allNone = true; + for (const category of context.categories) { + // Skip excepted categories + if (state.selectedExceptions.has(category)) continue; + + const categoryState = this.getCategoryState(category); + if (categoryState !== 'none') { + allNone = false; + break; + } + } + return allNone ? 'none' : 'partial'; +} +``` + +## Performance Optimizations + +### 1. Cache System +```javascript +const cache = { + keywords: new Map(), + categoryStates: new Map(), + contextKeywords: new Map(), + activeKeywordsByCategory: new Map(), + + invalidateCategory(category) { + const now = Date.now(); + if (now - this.lastUpdate < 50) return; + this.lastUpdate = now; + this.keywords.delete(category); + this.categoryStates.delete(category); + this.contextKeywords.delete(category); + this.activeKeywordsByCategory.delete(category); + } +}; +``` + +### 2. Debounced Updates +```javascript +const createDebouncedUpdate = () => { + let timeout; + return async (fn) => { + if (timeout) clearTimeout(timeout); + timeout = setTimeout(async () => { + await fn(); + }, 16); + }; +}; +``` + +## Best Practices + +### 1. State Updates +- Store unchecked state before changes +- Re-apply unchecked state after changes +- Use proper cache invalidation +- Maintain mode-specific behavior + +### 2. Performance +- Use caching for expensive operations +- Debounce UI updates +- Throttle cache invalidation +- Batch related operations + +### 3. Mode Synchronization +- Respect mode hierarchy +- Preserve exceptions when valid +- Update UI immediately +- Defer persistence to mute/unmute + +### 4. Error Prevention +- Validate state before changes +- Handle edge cases properly +- Maintain consistent state +- Provide clear feedback diff --git a/docs/1-architecture/5-performance.md b/docs/1-architecture/5-performance.md new file mode 100644 index 0000000..d042ffa --- /dev/null +++ b/docs/1-architecture/5-performance.md @@ -0,0 +1,254 @@ +# Performance Optimizations + +## Overview + +MuteSky implements sophisticated performance optimizations to ensure responsive UI and efficient data handling, particularly for operations involving large sets of keywords and frequent state updates. + +## Core Optimizations + +### 1. Set Operations +```javascript +// State implementation using Sets for O(1) operations +export const state = { + activeKeywords: new Set(), // O(1) lookup + originalMutedKeywords: new Set(), // O(1) lookup + sessionMutedKeywords: new Set(), // O(1) lookup + manuallyUnchecked: new Set(), // O(1) lookup + selectedContexts: new Set(), // O(1) lookup + selectedExceptions: new Set(), // O(1) lookup + selectedCategories: new Set() // O(1) lookup +}; + +// Example usage +if (state.activeKeywords.has(keyword)) { // O(1) instead of O(n) + state.activeKeywords.delete(keyword); // O(1) operation +} +``` + +### 2. Multi-Level Caching System +```javascript +const cache = { + keywords: new Map(), + categoryStates: new Map(), + contextKeywords: new Map(), + activeKeywordsByCategory: new Map(), + lastUpdate: 0, + + getKeywords(category, sortByWeight = false) { + const key = `${category}-${sortByWeight}`; + if (!this.keywords.has(key)) { + this.keywords.set(key, new Set(getAllKeywordsForCategory(category, sortByWeight))); + } + return this.keywords.get(key); + }, + + invalidateCategory(category) { + const now = Date.now(); + if (now - this.lastUpdate < 50) return; // Throttle updates + this.lastUpdate = now; + + const patterns = [`${category}-true`, `${category}-false`]; + patterns.forEach(p => this.keywords.delete(p)); + this.activeKeywordsByCategory.delete(category); + } +}; +``` + +### 3. Progressive Processing +```javascript +function processNextCategory() { + if (processedCount >= allCategories.length) return; + + const category = allCategories[processedCount++]; + const keywords = keywordCache.getKeywordsForCategory(category); + + // Process in chunks + processBatchKeywords(keywords, keyword => { + state.activeKeywords.add(keyword); + }); + + // Schedule next chunk + requestAnimationFrame(processNextCategory); +} + +function processBatchKeywords(keywords, operation) { + const chunkSize = 100; + const chunks = Array.from(keywords); + + let index = 0; + function processNextChunk() { + if (index >= chunks.length) return; + + const end = Math.min(index + chunkSize, chunks.length); + for (let i = index; i < end; i++) { + operation(chunks[i]); + } + + index += chunkSize; + requestAnimationFrame(processNextChunk); + } + + processNextChunk(); +} +``` + +### 4. Debounced Updates +```javascript +const debouncedUpdate = (() => { + let timeout; + let frameRequest; + return (fn) => { + if (timeout) clearTimeout(timeout); + if (frameRequest) cancelAnimationFrame(frameRequest); + + timeout = setTimeout(() => { + frameRequest = requestAnimationFrame(() => { + fn(); + notifyKeywordChanges(); + }); + }, 16); // One frame duration + }; +})(); + +// Usage +debouncedUpdate(() => { + renderInterface(); + saveState(); +}); +``` + +## State Update Optimizations + +### 1. Batched State Changes +```javascript +// Before: Multiple individual updates +state.activeKeywords.add(keyword1); +state.activeKeywords.add(keyword2); +saveState(); +renderInterface(); + +// After: Batched updates with debouncing +keywords.forEach(k => state.activeKeywords.add(k)); +debouncedUpdate(() => { + saveState(); + renderInterface(); +}); +``` + +### 2. Efficient State Persistence +```javascript +const debouncedSave = (() => { + let timeout; + return () => { + if (timeout) clearTimeout(timeout); + timeout = setTimeout(() => { + const saveData = { + activeKeywords: Array.from(state.activeKeywords), + selectedCategories: Array.from(state.selectedCategories), + selectedContexts: Array.from(state.selectedContexts), + selectedExceptions: Array.from(state.selectedExceptions), + manuallyUnchecked: Array.from(state.manuallyUnchecked), + mode: state.mode, + lastModified: state.lastModified, + targetKeywordCount: state.targetKeywordCount, + filterLevel: state.filterLevel, + lastBulkAction: state.lastBulkAction + }; + try { + localStorage.setItem(getStorageKey(), JSON.stringify(saveData)); + } catch (error) { + console.error('Error saving state:', error); + } + }, 16); + }; +})(); +``` + +## Memory Management + +### 1. Cache Size Control +```javascript +shouldInvalidate() { + const now = Date.now(); + if (now - this.lastUpdate < 50) return false; + this.lastUpdate = now; + return true; +} + +invalidateCategory(category) { + if (!this.shouldInvalidate()) return; + // Clear relevant caches + this.keywords.delete(category); + this.categoryStates.delete(category); +} +``` + +### 2. Efficient Data Structures +```javascript +// Use Maps for key-value lookups +const keywordCache = { + categoryKeywords: new Map(), + lastUpdate: 0, + updateThreshold: 16 +}; + +// Use Sets for unique collections +const uniqueKeywords = new Set(keywords); +``` + +## UI Optimizations + +### 1. Frame-Aligned Updates +```javascript +function updateUI() { + requestAnimationFrame(() => { + renderInterface(); + updateMuteButton(); + }); +} +``` + +### 2. Throttled Operations +```javascript +const throttledUIUpdate = (() => { + let lastUpdate = 0; + return (fn) => { + const now = Date.now(); + if (now - lastUpdate < 16) return; + lastUpdate = now; + fn(); + }; +})(); +``` + +## Best Practices + +### 1. Data Structures +- Use Sets for unique collections +- Maps for key-value lookups +- Batch array operations +- Minimize object creation + +### 2. UI Updates +- Debounce rapid changes +- Use requestAnimationFrame +- Batch DOM operations +- Throttle expensive updates + +### 3. Memory Management +- Clear unused caches +- Limit cache sizes +- Batch similar operations +- Use efficient structures + +### 4. State Operations +- Batch state changes +- Debounce saves +- Use Set operations +- Implement proper throttling + +### 5. Error Prevention +- Validate data before processing +- Handle edge cases +- Provide fallbacks +- Monitor performance metrics diff --git a/docs/1-architecture/6-click-performance.md b/docs/1-architecture/6-click-performance.md new file mode 100644 index 0000000..7e0af49 --- /dev/null +++ b/docs/1-architecture/6-click-performance.md @@ -0,0 +1,174 @@ +# Click Performance Optimization + +## Overview + +This document details the performance optimizations implemented to reduce checkbox click response time from 700ms to near-instant operation. The optimizations focus on efficient data structures, caching, and UI updates while maintaining functionality between Simple and Advanced modes. + +## Key Optimizations + +### 1. Set Operations +```javascript +// Before: Array operations +keywords.filter(k => activeKeywords.includes(k)) // O(n) lookup time + +// After: Set operations +const activeKeywords = new Set(); +keywords.filter(k => activeKeywords.has(k)) // O(1) lookup time +``` +- Replaced array operations with Set operations +- O(1) lookups instead of O(n) array searches +- Faster add/remove operations with Set.add() and Set.delete() +- Eliminated duplicate handling automatically + +### 2. Enhanced Caching System +```javascript +const cache = { + keywords: new Map(), + categoryStates: new Map(), + contextKeywords: new Map(), + activeKeywordsByCategory: new Map(), + lastUpdate: 0, + + getKeywords(category, sortByWeight = false) { + const key = `${category}-${sortByWeight}`; + if (!this.keywords.has(key)) { + this.keywords.set(key, new Set(getAllKeywordsForCategory(category, sortByWeight))); + } + return this.keywords.get(key); + } +} +``` +- Implemented memoization for expensive operations +- Cached keyword sets by category +- Cached active keywords by category +- Cached context-specific keyword sets +- Added intelligent cache invalidation + +### 3. Deferred UI Updates +```javascript +const debouncedUpdate = (() => { + let timeout; + let frameRequest; + return (fn) => { + if (timeout) clearTimeout(timeout); + if (frameRequest) cancelAnimationFrame(frameRequest); + + timeout = setTimeout(() => { + frameRequest = requestAnimationFrame(() => { + fn(); + notifyKeywordChanges(); + }); + }, 16); + }; +})(); +``` +- Batched UI updates using requestAnimationFrame +- Debounced state saves +- Prevented UI thrashing +- Reduced unnecessary re-renders + +### 4. Optimized State Updates +```javascript +// Before: Multiple individual updates +state.activeKeywords.add(keyword1); +state.activeKeywords.add(keyword2); +saveState(); +renderInterface(); + +// After: Batched updates +keywords.forEach(k => state.activeKeywords.add(k)); +debouncedUpdate(() => { + saveState(); + renderInterface(); +}); +``` +- Batched state changes +- Reduced number of save operations +- Minimized localStorage writes +- Optimized state synchronization + +### 5. Memory Management +```javascript +cache.invalidateCategory(category) { + if (!this.shouldInvalidate()) return; + + const patterns = [`${category}-true`, `${category}-false`]; + patterns.forEach(p => this.keywords.delete(p)); + this.activeKeywordsByCategory.delete(category); +} +``` +- Improved cache size management +- Selective cache clearing +- Reduced memory footprint +- Prevented memory leaks + +### 6. Throttled Operations +```javascript +shouldInvalidate() { + const now = Date.now(); + if (now - this.lastUpdate < 50) return false; + this.lastUpdate = now; + return true; +} +``` +- Added throttling for cache invalidation +- Prevented redundant operations +- Reduced CPU usage +- Improved responsiveness + +## Implementation Locations + +### 1. State Management (state.js) +- Core Set operations for state tracking +- Keyword caching system +- Debounced state saves +- Memory-efficient state updates + +### 2. Context Handling (contextHandlers.js) +- Enhanced caching with memory management +- Batch keyword operations +- Optimized UI updates with requestAnimationFrame +- Throttling for rapid operations + +### 3. Mute Operations (muteHandlers.js) +- Mute-specific caching +- Batch processing for keywords +- Debounced UI updates +- Optimized Set operations + +### 4. Keyword Operations (keywordHandlers.js) +- Category keyword caching +- Batch processing +- Optimized DOM operations +- Throttling for rapid toggles + +## Results + +- Reduced click response time from 700ms to near-instant +- Maintained functionality between modes +- Preserved state consistency +- Improved overall responsiveness + +## Best Practices + +1. Use Set operations for collections that need fast lookups +2. Implement caching for expensive operations +3. Batch UI updates and state changes +4. Use throttling for frequent operations +5. Manage memory efficiently +6. Maintain functionality while optimizing + +## Trade-offs + +1. Slightly increased memory usage for caching +2. Added complexity in cache invalidation +3. Delayed state persistence for performance +4. Required careful management of cached data + +## Future Improvements + +1. Consider implementing Web Workers for heavy computations +2. Add cache size limits +3. Implement progressive loading for large datasets +4. Add performance monitoring +5. Optimize cache invalidation strategies diff --git a/docs/1-architecture/7-bluesky-api.md b/docs/1-architecture/7-bluesky-api.md new file mode 100644 index 0000000..89dbe47 --- /dev/null +++ b/docs/1-architecture/7-bluesky-api.md @@ -0,0 +1,255 @@ +# Bluesky API Integration + +## Overview + +This document details how MuteSky integrates with Bluesky's API, specifically focusing on the muting system implementation through the `app.bsky.actor.putPreferences` endpoint. + +## API Specifications + +### Endpoint +``` +PUT https://[host]/xrpc/app.bsky.actor.putPreferences +``` + +### Authentication +- Bearer token authentication required +- Token obtained via OAuth flow using @atproto/oauth-client-browser + +### Data Structures + +```typescript +interface MutedWord { + id?: string // Optional unique identifier + value: string // Word/phrase to mute + targets: ('content' | 'tag')[] // Where to apply muting + actorTarget: 'all' | 'exclude-following' // Who to apply muting to + expiresAt?: string // Optional expiration date +} + +interface MutedWordsPref { + $type: 'app.bsky.actor.defs#mutedWordsPref' + items: MutedWord[] +} +``` + +## Implementation + +### Required Dependencies +```json +{ + "@atproto/api": "^0.13.18", + "@atproto/oauth-client-browser": "^0.3.2" +} +``` + +### Core Service Implementation +```javascript +import { Agent } from '@atproto/api' + +class MuteService { + constructor(session) { + this.agent = session ? new Agent(session) : null; + this.session = session; + this.cachedKeywords = null; + this.cachedPreferences = null; + } + + setSession(session) { + this.agent = session ? new Agent(session) : null; + this.session = session; + // Clear caches when session changes + this.cachedKeywords = null; + this.cachedPreferences = null; + } + + async getMutedKeywords(forceRefresh = false) { + if (!this.session) { + throw new Error('Not logged in'); + } + + if (!forceRefresh && this.cachedKeywords !== null) { + return this.cachedKeywords; + } + + const agent = new Agent(this.session); + const response = await agent.api.app.bsky.actor.getPreferences(); + this.cachedPreferences = response.data.preferences; + + const mutedWordsPref = this.cachedPreferences.find( + pref => pref.$type === 'app.bsky.actor.defs#mutedWordsPref' + ); + + this.cachedKeywords = mutedWordsPref?.items?.map(item => item.value) || []; + return this.cachedKeywords; + } + + async updateMutedKeywords(selectedKeywords, ourKeywordsList) { + if (!this.session) { + throw new Error('Not logged in'); + } + + const agent = new Agent(this.session); + const response = await agent.api.app.bsky.actor.getPreferences(); + this.cachedPreferences = response.data.preferences; + + // Create efficient lookup + const ourKeywordsSet = new Set(ourKeywordsList.map(k => k.toLowerCase())); + + // Find current muted words pref + const mutedWordsIndex = this.cachedPreferences.findIndex( + pref => pref.$type === 'app.bsky.actor.defs#mutedWordsPref' + ); + + // Get current or create new + const currentMutedPref = mutedWordsIndex >= 0 ? + this.cachedPreferences[mutedWordsIndex] : { + $type: 'app.bsky.actor.defs#mutedWordsPref', + items: [] + }; + + // Preserve user's custom keywords + const userCustomKeywords = currentMutedPref.items + .filter(item => !ourKeywordsSet.has(item.value.toLowerCase())); + + // Create new items with settings + const newManagedItems = selectedKeywords + .filter(keyword => ourKeywordsSet.has(keyword.toLowerCase())) + .map(keyword => ({ + value: keyword, + targets: ['content', 'tag'], + actorTarget: 'all' + })); + + // Combine preserving user's keywords + const updatedItems = [ + ...userCustomKeywords, + ...newManagedItems + ]; + + // Update preferences + const updatedMutedPref = { + $type: 'app.bsky.actor.defs#mutedWordsPref', + items: updatedItems + }; + + if (mutedWordsIndex >= 0) { + this.cachedPreferences[mutedWordsIndex] = updatedMutedPref; + } else { + this.cachedPreferences.push(updatedMutedPref); + } + + // Update on Bluesky + await agent.api.app.bsky.actor.putPreferences({ + preferences: this.cachedPreferences + }); + + // Clear caches after successful update + this.cachedKeywords = null; + this.cachedPreferences = null; + } +} +``` + +## Advanced Features + +### 1. Muting Options +```javascript +// Content type targeting +const contentTypeOptions = { + contentOnly: ['content'], + tagsOnly: ['tag'], + both: ['content', 'tag'] +}; + +// Actor targeting +const actorTargetOptions = { + all: 'all', + excludeFollowing: 'exclude-following' +}; +``` + +### 2. Expiration Support +```javascript +const muteWithExpiration = { + value: keyword, + targets: ['content', 'tag'], + actorTarget: 'all', + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() // 7 days +}; +``` + +### 3. Batch Operations +```javascript +const batchMuteKeywords = async (keywords) => { + const items = keywords.map(keyword => ({ + value: keyword, + targets: ['content', 'tag'], + actorTarget: 'all' + })); + + const prefs = { + $type: 'app.bsky.actor.defs#mutedWordsPref', + items + }; + + await agent.api.app.bsky.actor.putPreferences({ + preferences: [prefs] + }); +}; +``` + +## Error Handling + +### 1. Session Errors +```javascript +try { + await operation(); +} catch (error) { + if (error.status === 401) { + // Trigger session refresh + window.dispatchEvent(new CustomEvent('mutesky:session:refresh:needed')); + } + // Clear caches + this.cachedKeywords = null; + this.cachedPreferences = null; + throw error; +} +``` + +### 2. Network Issues +```javascript +try { + await agent.api.app.bsky.actor.putPreferences(/* ... */); +} catch (error) { + console.error('API Error:', error); + // Extract API error message if available + const apiError = error.message || 'Failed to update muted keywords'; + throw new Error(apiError); +} +``` + +## Best Practices + +1. Error Handling + - Verify session before API calls + - Handle network errors gracefully + - Provide clear error messages + - Implement proper retry logic + +2. Performance + - Cache muted keywords list + - Batch multiple mute operations + - Clear caches on session change + - Update UI optimistically + +3. User Experience + - Preserve user's custom keywords + - Handle case sensitivity properly + - Provide clear feedback + - Support easy unmuting + +4. Security + - Always use fresh agent instances + - Clear sensitive data on logout + - Validate input before API calls + - Handle session expiration properly diff --git a/docs/1-architecture/8-context-persistence.md b/docs/1-architecture/8-context-persistence.md new file mode 100644 index 0000000..69c89e9 --- /dev/null +++ b/docs/1-architecture/8-context-persistence.md @@ -0,0 +1,220 @@ +# Context Persistence Architecture + +## Core Principle + +The fundamental principle is that **advanced mode is the source of truth**, not simple mode. This architectural decision ensures consistent state management and reliable user experience. + +## Implementation Architecture + +### 1. State Hierarchy +```javascript +// Advanced mode drives the system +state.activeKeywords = new Set(); // Source of truth +state.originalMutedKeywords = new Set(); // All muted keywords +state.manuallyUnchecked = new Set(); // User preferences +state.selectedContexts = new Set(); // UI state +state.selectedExceptions = new Set(); // Exception tracking +``` + +### 2. Multi-User Support +```javascript +// Storage key format +const storageKey = `muteskyState-${state.did}`; + +// User state isolation +class StateManager { + getStorageKey() { + if (!state.did) { + throw new Error('No DID set in state'); + } + return `muteskyState-${state.did}`; + } +} +``` + +### 3. State Flow + +#### Initialization +1. Set DID in state when user authenticates +2. Load saved state from DID-specific localStorage key +3. Restore advanced mode selections first +4. If in simple mode: + - Derive context selections from advanced mode state + - Generate appropriate keywords from those contexts +5. Maintain manually unchecked keywords across modes +6. Sync with external services (e.g., Bluesky) + +#### Updates +```javascript +// Advanced Mode Updates +function handleAdvancedModeUpdate() { + // Direct modifications preserved + state.activeKeywords.add(keyword); + // Changes saved to DID-specific storage + saveState(); + // Simple mode respects these changes + updateSimpleModeState(); +} + +// Simple Mode Updates +function handleSimpleModeUpdate() { + // Context selections generate keywords + const contextKeywords = getContextKeywords(contextId); + // But don't override advanced mode + contextKeywords.forEach(keyword => { + if (!state.manuallyUnchecked.has(keyword)) { + state.activeKeywords.add(keyword); + } + }); +} +``` + +## Case Sensitivity Handling + +### 1. Comparison Rules +```javascript +// Store lowercase for comparison +const lowerKeyword = keyword.toLowerCase(); +state.originalMutedKeywords.add(lowerKeyword); + +// Preserve original case for display +const originalCase = ourKeywordsMap.get(lowerKeyword); +if (originalCase) { + state.activeKeywords.add(originalCase); +} +``` + +### 2. Implementation Locations +- **state.js**: Case-insensitive comparisons +- **contextUtils.js**: Case-insensitive activation +- **contextCache.js**: Case-insensitive lookups + +## Exception Handling + +### 1. Bulk Actions +```javascript +// Track bulk actions +state.lastBulkAction = action; // 'enable' or 'disable' + +// Clear exceptions on commit +if (state.lastBulkAction) { + clearExceptions(); + state.lastBulkAction = null; +} +``` + +### 2. Exception Rules +- Only clear when mute/unmute follows bulk action +- Regular operations preserve exceptions +- Exceptions persist across sessions +- Valid exceptions restored on login + +## Cache System + +### 1. Memory Management +```javascript +const cache = { + keywords: new Map(), + categoryStates: new Map(), + contextKeywords: new Map(), + activeKeywordsByCategory: new Map(), + + shouldInvalidate() { + const now = Date.now(); + if (now - this.lastUpdate < 50) return false; + this.lastUpdate = now; + return true; + } +}; +``` + +### 2. Performance Features +- Frame-timed updates (16ms) +- Batch processing +- Smart invalidation +- Category-specific caching + +## Error Recovery + +### 1. State Recovery +```javascript +try { + loadState(); +} catch (error) { + // Preserve unchecked keywords + const unchecked = new Set(state.manuallyUnchecked); + resetState(); + state.manuallyUnchecked = unchecked; +} +``` + +### 2. Cache Invalidation +```javascript +function invalidateCache() { + // Clear on mode transitions + cache.clear(); + // Invalidate affected categories + affectedCategories.forEach(category => { + cache.invalidateCategory(category); + }); +} +``` + +## File Structure + +### 1. Core Files +- **contextState.js**: State initialization and updates +- **contextHandlers.js**: Context and exception management +- **contextUtils.js**: Core utilities and processing +- **contextCache.js**: Caching implementation +- **keywordHandlers.js**: Bulk actions and toggles +- **muteHandlers.js**: Mute operations and integration +- **state.js**: Core state structure and persistence + +### 2. Key Functions +```javascript +// State Initialization +export async function initializeState() { + const did = await auth.getDID(); + state.did = did; + await loadSavedState(); + await initializeUI(); +} + +// Context Management +export async function handleContextToggle(contextId) { + const uncheckedKeywords = new Set(state.manuallyUnchecked); + // Update state while preserving preferences + updateContextState(contextId, uncheckedKeywords); + await debouncedUpdate(() => { + renderInterface(); + saveState(); + }); +} +``` + +## Best Practices + +1. State Management + - Use DID-specific storage keys + - Derive simple mode from advanced + - Sync external services when needed + - Preserve user preferences + +2. Error Handling + - Maintain unchecked keywords + - Handle mode transitions + - Implement error boundaries + - Clear caches appropriately + +3. Performance + - Use efficient data structures + - Implement proper caching + - Batch operations + - Debounce updates + +4. Multi-User Support + - Isolate user states + - Clean state switching + - Preserve preferences + - Handle edge cases diff --git a/docs/1-architecture/9-mute-button.md b/docs/1-architecture/9-mute-button.md new file mode 100644 index 0000000..4f8c671 --- /dev/null +++ b/docs/1-architecture/9-mute-button.md @@ -0,0 +1,220 @@ +# Mute Button Architecture + +## Overview + +The mute button is a critical UI component that appears in multiple locations and handles complex state synchronization and bulk operations. This document details its architecture and performance optimizations. + +## Core Architecture + +### 1. UI Components +```javascript +// Button locations +const elements = { + muteButton: document.getElementById('muteButton'), // Main interface + navMuteButton: document.getElementById('navMuteButton') // Navigation bar +}; +``` + +### 2. State Synchronization +```javascript +export function updateMuteButton() { + const buttonText = getButtonText(); + const hasChanges = buttonText !== 'No changes'; + + // Update main button + if (elements.muteButton) { + elements.muteButton.textContent = buttonText; + elements.muteButton.classList.toggle('visible', hasChanges); + } + + // Sync nav button + if (elements.navMuteButton) { + elements.navMuteButton.textContent = buttonText; + elements.navMuteButton.classList.toggle('visible', hasChanges); + } +} +``` + +## Bulk Operations + +### 1. Disable All Operation +```javascript +// Atomic operation for instant feedback +function handleDisableAll() { + state.activeKeywords.clear(); + state.lastBulkAction = 'disable'; + updateMuteButton(); +} +``` + +### 2. Enable All Operation +```javascript +function handleEnableAll() { + // Gather categories + const allCategories = [ + ...Object.keys(state.keywordGroups), + ...Object.keys(state.displayConfig.combinedCategories || {}) + ]; + + let processedCount = 0; + + // Progressive processing + function processNextCategory() { + if (processedCount >= allCategories.length) return; + + const category = allCategories[processedCount++]; + const keywords = keywordCache.getKeywordsForCategory(category); + + // Process in batches + processBatchKeywords(keywords, keyword => { + state.activeKeywords.add(keyword); + }); + + // Schedule next batch + requestAnimationFrame(processNextCategory); + } + + state.lastBulkAction = 'enable'; + processNextCategory(); +} +``` + +## Performance Optimizations + +### 1. Keyword Caching +```javascript +const keywordCache = { + categoryKeywords: new Map(), + lastUpdate: 0, + updateThreshold: 16, // One frame duration + + getKeywordsForCategory(category) { + const cached = this.categoryKeywords.get(category); + if (cached && Date.now() - this.lastUpdate < this.updateThreshold) { + return cached; + } + + const keywords = getAllKeywordsForCategory(category); + this.categoryKeywords.set(category, keywords); + this.lastUpdate = Date.now(); + return keywords; + } +}; +``` + +### 2. Batch Processing +```javascript +function processBatchKeywords(keywords, operation) { + const chunkSize = 100; + const chunks = Array.from(keywords); + let index = 0; + + function processNextChunk() { + if (index >= chunks.length) return; + + const end = Math.min(index + chunkSize, chunks.length); + for (let i = index; i < end; i++) { + operation(chunks[i]); + } + + index += chunkSize; + requestAnimationFrame(processNextChunk); + } + + processNextChunk(); +} +``` + +### 3. Debounced Updates +```javascript +const debouncedUpdate = (() => { + let timeout; + let frameRequest; + + return (fn) => { + if (timeout) clearTimeout(timeout); + if (frameRequest) cancelAnimationFrame(frameRequest); + + timeout = setTimeout(() => { + frameRequest = requestAnimationFrame(() => { + fn(); + notifyKeywordChanges(); + }); + }, 16); + }; +})(); +``` + +## Button States + +### 1. Text Calculation +```javascript +function getButtonText() { + const { toMute, toUnmute } = getMuteUnmuteCounts(); + + if (toMute === 0 && toUnmute === 0) { + return 'No changes'; + } + + const parts = []; + if (toMute > 0) parts.push(`Mute ${toMute} new`); + if (toUnmute > 0) parts.push(`Unmute ${toUnmute} existing`); + + return parts.join(', '); +} +``` + +### 2. Visibility Control +```javascript +function updateButtonVisibility() { + const hasChanges = getButtonText() !== 'No changes'; + elements.muteButton?.classList.toggle('visible', hasChanges); + elements.navMuteButton?.classList.toggle('visible', hasChanges); +} +``` + +## Implementation Benefits + +1. UI Consistency + - Synchronized state across button instances + - Clear visual feedback + - Consistent button text + - Proper visibility states + +2. Performance + - Responsive during bulk operations + - No UI blocking + - Smooth animations + - Efficient memory usage + +3. User Experience + - Progressive feedback + - Clear operation status + - Responsive interface + - Predictable behavior + +## Best Practices + +1. State Updates + - Use debounced updates + - Batch operations + - Cache calculations + - Maintain consistency + +2. Performance + - Process in chunks + - Use requestAnimationFrame + - Implement caching + - Optimize memory usage + +3. UI Feedback + - Show clear status + - Update progressively + - Maintain responsiveness + - Handle edge cases + +4. Error Prevention + - Validate state + - Handle missing elements + - Clear timeouts + - Cancel animations diff --git a/docs/2-development/1-known-issues.md b/docs/2-development/1-known-issues.md new file mode 100644 index 0000000..f117d72 --- /dev/null +++ b/docs/2-development/1-known-issues.md @@ -0,0 +1,186 @@ +# Known Issues and Solutions + +## Mode System Issues + +### 1. Checkbox Persistence in Advanced Mode + +**Problem**: Checkboxes would visually check for half a second then uncheck themselves. + +**Root Cause**: State management conflict between modes where updateSimpleModeState() was rebuilding keywords and losing direct changes. + +**Original Code**: +```javascript +// Before the fix: +export async function updateSimpleModeState() { + // Check contexts + for (const contextId of Array.from(state.selectedContexts)) { + const contextState = cache.getContextState(contextId); + if (contextState === 'none') { + state.selectedContexts.delete(contextId); + } + } + + cache.clear(); + rebuildActiveKeywords(); // <-- This was the problem +} +``` + +**Flow of the Issue**: +1. Click checkbox in advanced mode -> handleKeywordToggle adds/removes keyword +2. updateSimpleModeState runs +3. rebuildActiveKeywords clears all keywords +4. Rebuilds only from contexts +5. Loses direct checkbox changes + +**Solution**: +```javascript +export async function updateSimpleModeState() { + if (!state.authenticated) return; + + // Only rebuild keywords in simple mode + if (state.mode === 'simple') { // <-- Added mode check + // Check contexts and rebuild keywords + for (const contextId of Array.from(state.selectedContexts)) { + const contextState = cache.getContextState(contextId); + if (contextState === 'none') { + state.selectedContexts.delete(contextId); + } + } + + cache.clear(); + rebuildActiveKeywords(); // <-- Only runs in simple mode now + } + + // Maintain async performance optimizations + await debouncedUpdate(async () => { + renderInterface(); + await saveState(); + }); +} +``` + +**Result**: +- Simple mode derives keywords from contexts +- Advanced mode preserves direct modifications +- Both modes maintain expected behavior +- Performance optimizations preserved + +## Case Sensitivity Issues + +### 1. Duplicate Keywords + +**Problem**: Keywords like "Paris Agreement" appearing multiple times with different cases. + +**Root Cause**: Case-sensitive keyword storage causing duplicates. + +**Solution**: Implemented case-insensitive storage with original case preservation: +```javascript +// Store lowercase for comparison +const lowerKeyword = keyword.toLowerCase(); +state.originalMutedKeywords.add(lowerKeyword); + +// Preserve original case for display +const originalCase = ourKeywordsMap.get(lowerKeyword); +if (originalCase) { + state.activeKeywords.add(originalCase); +} +``` + +### 2. Payload Size Issues + +**Problem**: "413 Payload Too Large" error when sending to Bluesky. + +**Root Cause**: Duplicate keywords with different cases inflating payload size. + +**Solution**: Case-insensitive deduplication before API calls. + +## Authentication Issues + +### 1. Missing DID in State + +**Problem**: "No DID set in state" error during state saves. + +**Root Cause**: Inconsistent DID handling across components. + +**Solution**: Centralized DID management: +```javascript +class AuthService { + verifyDID() { + if (!state.did) { + throw new Error('No DID set in state'); + } + return state.did; + } +} +``` + +### 2. Session State Inconsistency + +**Problem**: Different components using different auth state checks. + +**Solution**: Standardized auth checking: +```javascript +export function isAuthenticated() { + return state.authenticated && state.did; +} +``` + +## State Persistence Issues + +### 1. Lost State After Login + +**Problem**: User preferences not persisting across sessions. + +**Solution**: Implemented proper state restoration flow: +```javascript +async function handleLogin() { + const did = await auth.getDID(); + state.did = did; + await loadSavedState(); + await initializeUI(); +} +``` + +### 2. Premature State Saves + +**Problem**: State saving before user confirms changes. + +**Solution**: Tied state persistence to explicit user actions: +```javascript +async function handleMuteUnmute() { + await processChanges(); + await saveState(); +} +``` + +## Best Practices for Prevention + +### 1. Mode Management +- Verify mode before state rebuilds +- Respect mode hierarchy +- Test mode transitions +- Document mode-specific flows + +### 2. State Management +- Centralize DID handling +- Standardize auth checks +- Defer state saves to user actions +- Implement proper error recovery + +### 3. Case Sensitivity +- Use case-insensitive storage +- Preserve original case +- Implement deduplication +- Verify payload sizes + +### 4. Testing +- Test mode transitions +- Verify state persistence +- Check case handling +- Monitor performance + +### 5. Error Handling +- Implement proper error boundaries +- Provide clear error messages +- Handle edge cases +- Log relevant context diff --git a/docs/2-development/2-troubleshooting-guide.md b/docs/2-development/2-troubleshooting-guide.md new file mode 100644 index 0000000..c577ebf --- /dev/null +++ b/docs/2-development/2-troubleshooting-guide.md @@ -0,0 +1,291 @@ +# Troubleshooting Guide + +This guide provides a systematic approach to diagnosing and fixing common issues in MuteSky. + +## Problem Categories + +### 1. Mode Switching Issues + +**Symptoms**: +- Keywords muted in advanced mode show as "to unmute" in simple mode +- Checkboxes in advanced mode check then uncheck after half second +- State inconsistency between modes + +**Diagnostic Steps**: +1. Check mode state: +```javascript +console.debug('[Mode] Current mode:', state.mode); +console.debug('[Mode] Active keywords:', state.activeKeywords.size); +console.debug('[Mode] Original muted:', state.originalMutedKeywords.size); +``` + +2. Verify state hierarchy: +```javascript +// Advanced mode should be source of truth +console.debug('[State] Advanced selections:', { + activeKeywords: Array.from(state.activeKeywords), + manuallyUnchecked: Array.from(state.manuallyUnchecked) +}); + +// Simple mode should derive from this +console.debug('[State] Simple mode state:', { + selectedContexts: Array.from(state.selectedContexts), + selectedExceptions: Array.from(state.selectedExceptions) +}); +``` + +### 2. State Management Issues + +**Symptoms**: +- Inconsistent state across mode switches +- Lost preferences after operations +- Unexpected state changes + +**Diagnostic Steps**: +1. Check state relationships: +```javascript +console.debug('[State] State relationships:', { + activeKeywords: state.activeKeywords.size, + originalMuted: state.originalMutedKeywords.size, + sessionMuted: state.sessionMutedKeywords.size, + manuallyUnchecked: state.manuallyUnchecked.size +}); +``` + +2. Verify state persistence: +```javascript +// Check storage key +const storageKey = getStorageKey(); +console.debug('[Storage] Current key:', storageKey); + +// Check saved state +const savedState = localStorage.getItem(storageKey); +console.debug('[Storage] Saved state exists:', !!savedState); +``` + +### 3. Case Sensitivity Issues + +**Symptoms**: +- Keywords not matching case variants +- Duplicate keywords with different cases +- Large payload sizes to Bluesky + +**Diagnostic Steps**: +1. Check case handling: +```javascript +// Log case variants +const keyword = 'Example'; +console.debug('[Case] Variants:', { + original: keyword, + lower: keyword.toLowerCase(), + stored: state.originalMutedKeywords.has(keyword.toLowerCase()), + active: state.activeKeywords.has(keyword) +}); +``` + +2. Check payload size: +```javascript +// Log unique keywords +const uniqueKeywords = new Set( + Array.from(state.activeKeywords).map(k => k.toLowerCase()) +); +console.debug('[Payload] Unique keywords:', uniqueKeywords.size); +``` + +### 4. Authentication Issues + +**Symptoms**: +- "No DID set in state" errors +- Inconsistent auth state +- Session refresh problems + +**Diagnostic Steps**: +1. Check auth state: +```javascript +console.debug('[Auth] State:', { + authenticated: state.authenticated, + did: state.did, + hasSession: !!session +}); +``` + +2. Verify DID consistency: +```javascript +// Check DID across components +console.debug('[DID] Storage key:', getStorageKey()); +console.debug('[DID] State DID:', state.did); +console.debug('[DID] Session DID:', session?.did); +``` + +## Common Fixes + +### 1. Mode Switching Fix +```javascript +export async function updateSimpleModeState() { + if (!state.authenticated) return; + + // Only rebuild in simple mode + if (state.mode === 'simple') { + // Your mode-specific logic here + } + + await debouncedUpdate(async () => { + renderInterface(); + await saveState(); + }); +} +``` + +### 2. State Persistence Fix +```javascript +function saveState() { + if (!state.did) { + console.error('[State] Cannot save - no DID'); + return; + } + + const key = getStorageKey(); + try { + localStorage.setItem(key, JSON.stringify({ + // Your state here + })); + } catch (error) { + console.error('[State] Save failed:', error); + } +} +``` + +### 3. Case Sensitivity Fix +```javascript +// Store lowercase for comparison +const lowerKeyword = keyword.toLowerCase(); +state.originalMutedKeywords.add(lowerKeyword); + +// Preserve original case for display +const originalCase = ourKeywordsMap.get(lowerKeyword); +if (originalCase) { + state.activeKeywords.add(originalCase); +} +``` + +### 4. Auth State Fix +```javascript +function isAuthenticated() { + return state.authenticated && state.did; +} + +// Use consistently across all files +if (!isAuthenticated()) { + console.error('[Auth] Not authenticated'); + return; +} +``` + +## Prevention Checklist + +### 1. Mode Switching +- [ ] Verify mode before state rebuilds +- [ ] Respect mode hierarchy +- [ ] Test mode transitions +- [ ] Log state changes + +### 2. State Management +- [ ] Check DID before operations +- [ ] Verify state relationships +- [ ] Test persistence +- [ ] Handle errors gracefully + +### 3. Case Sensitivity +- [ ] Use lowercase for comparisons +- [ ] Preserve original case +- [ ] Check payload sizes +- [ ] Implement deduplication + +### 4. Authentication +- [ ] Verify DID consistency +- [ ] Handle session refresh +- [ ] Test auth flows +- [ ] Log auth state changes + +## Debugging Tools + +### 1. State Inspector +```javascript +function inspectState() { + return { + mode: state.mode, + auth: { + authenticated: state.authenticated, + did: state.did + }, + keywords: { + active: state.activeKeywords.size, + original: state.originalMutedKeywords.size, + session: state.sessionMutedKeywords.size, + unchecked: state.manuallyUnchecked.size + }, + contexts: { + selected: state.selectedContexts.size, + exceptions: state.selectedExceptions.size + } + }; +} +``` + +### 2. Storage Validator +```javascript +function validateStorage() { + const key = getStorageKey(); + try { + const saved = localStorage.getItem(key); + const parsed = JSON.parse(saved); + return { + valid: true, + data: parsed + }; + } catch (error) { + return { + valid: false, + error: error.message + }; + } +} +``` + +## Best Practices + +1. Always log state changes: +```javascript +console.debug('[State] Before:', inspectState()); +// Make changes +console.debug('[State] After:', inspectState()); +``` + +2. Verify auth state consistently: +```javascript +if (!isAuthenticated()) { + console.error('[Auth] Operation failed - not authenticated'); + return; +} +``` + +3. Handle errors gracefully: +```javascript +try { + await operation(); +} catch (error) { + console.error('[Error]', { + operation: 'name', + error: error.message, + state: inspectState() + }); +} +``` + +4. Test mode transitions thoroughly: +```javascript +async function testModeTransition() { + console.debug('[Test] Before switch:', inspectState()); + await switchMode(newMode); + console.debug('[Test] After switch:', inspectState()); +} diff --git a/docs/3-guides/1-understanding-modes.md b/docs/3-guides/1-understanding-modes.md new file mode 100644 index 0000000..410d360 --- /dev/null +++ b/docs/3-guides/1-understanding-modes.md @@ -0,0 +1,101 @@ +# Understanding MuteSky's Modes + +## The Big Picture + +MuteSky has two ways of working - Simple Mode and Advanced Mode. Think of it like having both an automatic and manual transmission in your car. Simple Mode is like automatic - it handles a lot of the complexity for you. Advanced Mode is like manual - you get complete control over everything. + +## Simple Mode: The Easy Way + +Imagine you're organizing your closet. Instead of dealing with individual pieces of clothing, you might say "I want to organize my work clothes" or "I want to sort my gym wear". This is how Simple Mode works: + +1. **Contexts Instead of Keywords** + - Rather than managing individual keywords, you select broader contexts like "Political Content" or "Sports Drama" + - Each context automatically handles related keywords for you + - It's like having a professional organizer who knows what goes where + +2. **Filter Levels** + - Level 0 (Minimal): Just the essentials, like having a basic wardrobe + - Level 1 (Moderate): A balanced approach + - Level 2 (Extensive): More comprehensive filtering + - Level 3 (Complete): Maximum filtering, like organizing everything down to the last sock + +3. **Smart Exceptions** + - Want to keep track of your favorite team but filter other sports? Use exceptions + - It's like saying "I want to organize all my clothes, but leave my favorite jacket where it is" + +## Advanced Mode: Complete Control + +Advanced Mode is for when you want to manage everything yourself. It's like deciding exactly where each piece of clothing goes in your closet: + +1. **Direct Keyword Management** + - See every keyword available + - Choose exactly what to mute or unmute + - Perfect for fine-tuning your filters + +2. **Category Organization** + - Keywords are grouped into categories + - Easy to find related terms + - Toggle entire categories at once + +## How They Work Together + +Here's the clever part - these modes work together seamlessly: + +1. **Advanced Mode is the Boss** + - Any changes you make in Advanced Mode stick + - Simple Mode respects these changes + - It's like having your manual organization take priority over the automatic system + +2. **Simple Mode is Smart** + - Derives its actions from Advanced Mode's state + - Updates automatically when Advanced Mode changes + - Maintains a user-friendly interface while respecting your detailed preferences + +## Real-World Example + +Let's say you're managing political content: + +1. **In Simple Mode:** + - Select the "Political Content" context + - Choose a filter level + - Maybe add an exception for your favorite topic + - The system handles all the keywords automatically + +2. **In Advanced Mode:** + - See all political keywords + - Choose specific terms to mute + - Fine-tune exactly what you want to see or hide + - Your choices are remembered and respected by Simple Mode + +## When to Use Each Mode + +### Use Simple Mode When: +- You want quick, easy content management +- You prefer broader categories over specific terms +- You're just getting started with content filtering +- You want a "set it and forget it" approach + +### Use Advanced Mode When: +- You need precise control +- You want to see exactly what's being filtered +- You have specific terms you want to manage +- You're comfortable with more detailed configuration + +## Pro Tips + +1. **Start Simple** + - Begin with Simple Mode to get a feel for the system + - Use filter levels to find your comfort zone + - Add exceptions as needed + +2. **Graduate to Advanced** + - Switch to Advanced Mode when you want more control + - Your Simple Mode settings are preserved + - Make fine-tuned adjustments without losing your base configuration + +3. **Mix and Match** + - Use Simple Mode for broad categories + - Switch to Advanced for specific adjustments + - The system maintains consistency between both + +Remember: There's no "right" way to use MuteSky. Choose the mode that works best for you, and feel free to switch between them as your needs change. diff --git a/docs/3-guides/2-muting-explained.md b/docs/3-guides/2-muting-explained.md new file mode 100644 index 0000000..f349217 --- /dev/null +++ b/docs/3-guides/2-muting-explained.md @@ -0,0 +1,119 @@ +# Understanding MuteSky's Muting System + +## The Basics: How Muting Works + +Think of MuteSky's muting system like a smart content filter for your Bluesky feed. It's designed to work alongside any muting you've already set up, not replace it. + +## Your Keywords Are Safe + +One of the most important things to understand is that MuteSky is very careful with your existing muted keywords: + +1. **We Never Delete Your Keywords** + - If you've muted "cats" outside of MuteSky, we'll never remove it + - Think of it like having a personal list that we promise never to erase + - We only manage the keywords from our curated list + +2. **Case Doesn't Matter** + - Whether you muted "Bitcoin", "bitcoin", or "BITCOIN", we handle it + - It's like having a smart assistant who understands these are the same thing + - We preserve the original case when displaying keywords + +## How It All Works Together + +### When You First Log In + +1. **We Check Your Existing Mutes** + - MuteSky looks at what you've already muted on Bluesky + - Like taking inventory of your current setup + - We note these down to make sure we don't mess with them + +2. **We Show What's Already Muted** + - Keywords from our list that you've already muted show up as checked + - Your custom keywords stay in the background, safely preserved + - It's like having two lists: our suggestions and your personal choices + +### When You Make Changes + +1. **Adding New Mutes** + - Select keywords from our list + - Click the mute button + - We add these to your Bluesky mutes without touching your existing ones + +2. **Removing Mutes** + - You can only unmute keywords from our list + - Your personal muted keywords stay untouched + - It's like having a guest who can only move their own things + +## Muting Settings + +You have control over how muting works: + +1. **Scope Options** + - "All Content": Mutes keywords in posts and tags + - "Tags Only": Only mutes hashtags + - Like choosing whether to filter just headlines or entire articles + +2. **Duration** + - Choose how long mutes should last + - Set them to expire after a certain time + - Or keep them permanent + +3. **Following Exception** + - Option to not mute content from people you follow + - Like saying "I trust these sources, show me their content anyway" + +## Real-World Examples + +### Scenario 1: Mixed Keywords +``` +Your existing mutes: "bitcoin", "kitty", "ELON" +Our list includes: "Bitcoin", "DeSantis", "Pence" + +What you'll see: +✓ Bitcoin (checkmark, can unmute - matches your "bitcoin") +□ DeSantis (no checkmark, can mute) +□ Pence (no checkmark, can mute) +("kitty" and "ELON" are preserved but not shown) +``` + +### Scenario 2: Making Changes +``` +You mute "DeSantis" through MuteSky: +- "DeSantis" gets added to your Bluesky mutes +- Your original mutes ("bitcoin", "kitty", "ELON") stay exactly as they are +- The checkbox for "DeSantis" shows as checked +``` + +## Pro Tips + +1. **Start Small** + - Begin with a few keywords + - See how they affect your feed + - Add more as needed + +2. **Use the Preview** + - The mute button shows exactly what will change + - "Mute 5 new" means adding 5 new keywords + - "Unmute 3 existing" means removing 3 from our list + +3. **Check Your Settings** + - Review your muting settings periodically + - Adjust scope and duration as needed + - Consider the following exception for trusted sources + +## Common Questions + +### "What happens to my existing mutes?" +They stay exactly as they are. MuteSky never removes mutes you've set up outside our system. + +### "Can I unmute everything?" +You can unmute any keywords from our list, but your personal muted keywords will stay muted. + +### "Why do some keywords show as already muted?" +These are keywords from our list that match ones you've already muted on Bluesky (regardless of case). + +### "What's the difference between muting all content vs tags only?" +- All content: Filters the keyword everywhere (posts and tags) +- Tags only: Only filters when the keyword is used as a hashtag + +Remember: MuteSky is designed to enhance your Bluesky experience, not take control of it. Your preferences always come first, and we're just here to help make content filtering easier. diff --git a/docs/3-guides/3-state-persistence.md b/docs/3-guides/3-state-persistence.md new file mode 100644 index 0000000..11d1147 --- /dev/null +++ b/docs/3-guides/3-state-persistence.md @@ -0,0 +1,139 @@ +# Understanding How MuteSky Saves Your Changes + +## The Big Picture + +MuteSky is designed to let you experiment freely with different settings and keywords before committing your changes. Think of it like online shopping - you can add items to your cart (make changes), but nothing is final until you click "checkout" (click the mute/unmute button). + +## When Changes Are Saved + +### The Golden Rule: Mute Button is King +The most important thing to understand is that changes are only saved when you click the mute/unmute button. This is intentional and here's why: + +1. **Freedom to Experiment** + - Try different filter levels + - Toggle contexts on and off + - Check and uncheck keywords + - Nothing is permanent until you're ready + +2. **Preview Your Changes** + - The mute button shows exactly what will happen + - "Mute 5 new" means adding 5 new keywords + - "Unmute 3 existing" means removing 3 keywords + - You can see the impact before committing + +## What Gets Saved + +When you do click the mute/unmute button, here's what's saved: + +1. **Active Keywords** + - Which keywords are checked/unchecked + - Your manually unchecked preferences + - Context selections in Simple mode + +2. **Mode Settings** + - Whether you're in Simple or Advanced mode + - Your selected filter level + - Which contexts are selected + - Any exceptions you've set + +3. **User Preferences** + - Target keyword count + - Last bulk action (enable/disable all) + - Interface settings + +## Real-World Examples + +### Scenario 1: Experimenting with Filters +``` +1. You're in Simple mode +2. Change filter level from 0 to 2 +3. UI updates immediately +4. BUT changes aren't saved yet +5. Click mute button to make it permanent +``` + +### Scenario 2: Context Changes +``` +1. Select "Political Content" context +2. UI shows new keywords to be muted +3. Add an exception for a specific category +4. Nothing is muted yet +5. Click mute button to apply changes +``` + +### Scenario 3: Logging Back In +``` +1. Last session: clicked mute button after setting filter level 2 +2. Log out, then log back in +3. Filter level 2 is restored +4. All your selections are exactly as you left them +``` + +## What's Not Automatically Saved + +1. **Filter Level Changes** + - Changing levels updates the UI + - But doesn't save until mute/unmute + +2. **Context Toggles** + - Selecting/deselecting contexts + - Adding/removing exceptions + - All temporary until mute/unmute + +3. **Keyword Toggles** + - Checking/unchecking keywords + - Enable/disable all actions + - Need mute/unmute to persist + +## Why This Design? + +1. **Safety First** + - No accidental mass changes + - Clear preview of what will happen + - Easy to experiment without fear + +2. **Clear Intent** + - Clicking mute/unmute shows clear intention + - No surprise changes to your Bluesky mutes + - You're always in control + +3. **Better Experience** + - Free to try different combinations + - See the effects before committing + - Easy to back out of changes + +## Pro Tips + +1. **Experiment Freely** + - Try different filter levels + - Toggle contexts on and off + - The mute button will show the outcome + +2. **Watch the Mute Button** + - It's your guide to what will change + - Shows exactly what will be muted/unmuted + - Clear indication of pending changes + +3. **Understanding Persistence** + - Changes aren't lost on mode switch + - But they're not saved until mute/unmute + - Your last saved state is restored on login + +## Common Questions + +### "Why didn't my filter level save?" +You need to click the mute/unmute button after changing the filter level. This lets you preview the effect before committing. + +### "Will I lose my changes if I switch modes?" +No! Changes stay in memory until you either: +- Click mute/unmute to save them +- Log out without saving +- Refresh the page without saving + +### "What happens if I log out without saving?" +You'll return to your last saved state - the last time you clicked mute/unmute. + +### "Do I need to save after every small change?" +No! Make all your changes, then save once with mute/unmute when you're happy with everything. + +Remember: The mute/unmute button is your "save" button. Feel free to experiment - nothing is permanent until you click it! diff --git a/docs/3-guides/4-authentication-guide.md b/docs/3-guides/4-authentication-guide.md new file mode 100644 index 0000000..5e89f57 --- /dev/null +++ b/docs/3-guides/4-authentication-guide.md @@ -0,0 +1,100 @@ +# Understanding MuteSky's Authentication + +## How Authentication Works + +When you use MuteSky, you're actually connecting it to your Bluesky account. Think of it like giving a trusted assistant permission to help manage your muted keywords. Here's how it works: + +## The Login Process + +1. **Enter Your Handle** + - Type in your Bluesky handle (like @you.bsky.social) + - MuteSky uses this to start the connection process + - It's like telling the system "This is who I am" + +2. **Authorize with Bluesky** + - You'll be taken to Bluesky's website + - Bluesky asks "Do you want to let MuteSky help manage your mutes?" + - You approve the connection + - It's like giving MuteSky a special key to help manage your mutes + +3. **Back to MuteSky** + - After approval, you return to MuteSky + - You'll see a loading screen while we: + * Set up your connection + * Load your current muted keywords + * Prepare your personalized interface + +## What's Happening Behind the Scenes + +1. **Secure Connection** + - MuteSky gets a secure token from Bluesky + - Like a special pass that proves you gave permission + - This token is handled securely and never stored permanently + +2. **Session Management** + - Your session stays active while you use MuteSky + - If it expires, we automatically refresh it + - You don't need to keep logging in + +3. **Error Handling** + - If something goes wrong, you'll see clear error messages + - We automatically try to fix connection issues + - You can always try logging in again if needed + +## Common Questions + +### "Do I need to log in every time?" +No! Your session persists until you: +- Click "Sign Out" +- Clear your browser data +- The session naturally expires (we handle renewal automatically) + +### "Is it safe to authorize MuteSky?" +Yes! MuteSky: +- Only asks for permissions it needs +- Never stores your Bluesky password +- Uses secure OAuth (the same system many apps use) +- Can only help manage mutes, nothing else + +### "What if I see an error?" +Most errors can be fixed by: +1. Refreshing the page +2. Signing out and back in +3. Clearing browser cache if needed + +### "What happens if I lose connection?" +- MuteSky tries to reconnect automatically +- Your changes are preserved +- You might need to log in again if reconnection fails + +## Pro Tips + +1. **Stay Signed In** + - Don't clear browser data if you want to stay logged in + - MuteSky handles session renewal automatically + - Your preferences are preserved between sessions + +2. **Watch for Status Messages** + - The app shows clear status updates + - You'll know exactly what's happening + - Error messages explain any issues + +3. **Handle Errors Gracefully** + - If you see an error, try refreshing first + - Sign out and back in if refresh doesn't work + - Contact support if problems persist + +## Security Notes + +1. **What MuteSky Can Do** + - View your current muted keywords + - Add or remove muted keywords + - That's it! No posting, following, or other actions + +2. **What MuteSky Can't Do** + - Post on your behalf + - Follow/unfollow accounts + - Access your private messages + - Change your account settings + +Remember: MuteSky is designed to be a helpful tool for managing your muted keywords while respecting your privacy and security. You're always in control and can revoke access at any time through your Bluesky settings. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..157e806 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,154 @@ +# MuteSky Documentation + +## Overview + +This documentation is organized into three main sections: +1. User Guides - Human-friendly explanations of how MuteSky works +2. Architecture - Technical documentation of system design and implementation +3. Development - Implementation details and troubleshooting guides + +## Documentation Structure + +### 1. User Guides +Easy-to-understand explanations for users and developers. + +1. [Understanding Modes](3-guides/1-understanding-modes.md) + - Simple vs Advanced Mode explained + - When to use each mode + - Real-world examples + - Pro tips and best practices + +2. [Muting Explained](3-guides/2-muting-explained.md) + - How muting works with Bluesky + - Keyword preservation + - Settings and options + - Common questions answered + +3. [State Persistence](3-guides/3-state-persistence.md) + - When changes are saved + - What gets saved + - Real-world examples + - Troubleshooting guide + +### 2. Architecture +Technical documentation for developers. + +1. [Core Concepts](1-architecture/1-core-concepts.md) + - Two-mode system overview + - State management hierarchy + - Persistence model + - Multi-user support + +2. [Authentication](1-architecture/2-authentication.md) + - OAuth implementation + - Session management + - Token refresh mechanism + - Callback system + +3. [Muting System](1-architecture/3-muting-system.md) + - Keyword types and management + - Muting behavior + - Case sensitivity handling + - API integration + +4. [Mode System](1-architecture/4-mode-system.md) + - Simple mode components + - Advanced mode components + - Mode synchronization + - State management + +5. [Performance](1-architecture/5-performance.md) + - Core optimizations + - Bulk operations + - State updates + - Memory management + +6. [Click Performance](1-architecture/6-click-performance.md) + - Set operations optimization + - Enhanced caching system + - Deferred UI updates + - Response time improvements + +### 3. Development +Implementation details and troubleshooting. + +1. [Known Issues](2-development/1-known-issues.md) + - Mode-related issues + - Case sensitivity problems + - Authentication issues + - Performance concerns + +## Key Concepts + +### State Management +- Advanced mode is source of truth +- State only saves on mute/unmute +- DID-specific storage keys +- Case-insensitive comparisons + +### Performance +- Set operations for O(1) lookups +- Debounced UI updates +- Progressive bulk operations +- Efficient caching system + +### Mode System +- Simple mode: Context-based filtering +- Advanced mode: Direct keyword management +- Synchronized state between modes +- Exception handling for granular control + +### Authentication +- Bluesky OAuth integration +- Token refresh mechanism +- Session state management +- Multi-user support + +## Contributing + +When working with this codebase: + +1. State Management + - Follow the established hierarchy + - Respect the persistence model + - Maintain case sensitivity rules + - Handle exceptions properly + +2. Performance + - Use provided optimization patterns + - Implement proper caching + - Follow bulk operation patterns + - Monitor memory usage + +3. Error Handling + - Follow established patterns + - Provide clear error messages + - Implement proper recovery + - Log relevant context + +4. Testing + - Verify mode transitions + - Test case sensitivity + - Check state persistence + - Monitor performance + +## Documentation Updates + +When updating documentation: + +1. Consider Both Audiences + - Add/update technical docs in Architecture + - Add/update user guides in Guides + - Keep explanations appropriate for each audience + +2. Maintain Structure + - Keep sections focused and concise + - Include relevant code examples + - Update the README.md index + - Cross-reference related sections + +3. Include Real-World Context + - Add practical examples + - Explain the "why" not just the "how" + - Address common questions + - Provide troubleshooting tips diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..be94300 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,172 @@ +# Keyword Weighting System Migration + +## Current System + +The current system uses weights 1-10 for both categories and keywords, with higher numbers being more significant. + +### Category Weights +- 9-10: Most significant categories (e.g., Economic Policy, Education) +- 7-8: Important categories (e.g., Climate and Environment, Healthcare) +- 5-6: Extended coverage categories +- 1-4: Basic coverage categories + +### Keyword Weights +- 7-8: Highly frequent/significant terms +- 5-6: Common/regular terms +- 3-4: Occasional/moderate terms +- 1-2: Rare/basic terms + +### Distribution Levels +The system currently has four distribution levels with actual keyword counts: +- Minimal: 190 highest weighted keywords +- Moderate: 413 keywords +- Extensive: 815 keywords +- Complete: All remaining keywords (~2000+ total keywords) + +## Weight Threshold Algorithm + +The current algorithm in weightManager.js determines which keywords to include based on both category and keyword weights: + +```javascript +case 190: // Minimal + return categoryWeight >= 9 ? 8 : // For highest categories (9), include keywords weighted 8+ + categoryWeight >= 8 ? 9 : // For high categories (8), only include keywords weighted 9 + 11; // For others, exclude all + +case 413: // Moderate + return categoryWeight >= 9 ? 7 : // For highest categories, include keywords weighted 7+ + categoryWeight >= 8 ? 8 : // For high categories, include keywords weighted 8+ + 9; // For others, only highest weighted keywords + +case 815: // Extensive + return categoryWeight >= 9 ? 6 : // For highest categories, include keywords weighted 6+ + categoryWeight >= 8 ? 7 : // For high categories, include keywords weighted 7+ + 8; // For others, include keywords weighted 8+ +``` + +## New Scale (0-3) + +The new scale inverts the power relationship, with 0 being least significant and 3 being most significant. This aligns with common programming practices where array indices and enums typically start at 0. + +### Category Weights +- 3: Most significant categories (previously 9-10) +- 2: Important categories (previously 7-8) +- 1: Extended coverage categories (previously 5-6) +- 0: Basic coverage categories (previously 1-4) + +### Keyword Weights +- 3: Highly frequent/significant terms (previously 7-8) +- 2: Common/regular terms (previously 5-6) +- 1: Occasional/moderate terms (previously 3-4) +- 0: Rare/basic terms (previously 1-2) + +### Distribution Levels +The distribution levels remain the same but with inverted significance: +- Level 0 (Complete): All keywords (~2000+) +- Level 1 (Extensive): 815 keywords +- Level 2 (Moderate): 413 keywords +- Level 3 (Minimal): 190 keywords + +### Keyword Distribution + +The actual distribution of keywords across levels: +- Level 3 (Minimal): Top 190 keywords from highest weighted categories + * Category weight 3: Keywords weighted 3 + * Category weight 2: Keywords weighted 3 + * Others: None included +- Level 2 (Moderate): 413 keywords + * Category weight 3: Keywords weighted 2-3 + * Category weight 2: Keywords weighted 3 + * Others: Keywords weighted 3 only +- Level 1 (Extensive): 815 keywords + * Category weight 3: Keywords weighted 1-3 + * Category weight 2: Keywords weighted 2-3 + * Others: Keywords weighted 3 +- Level 0 (Complete): All 2000+ keywords included + +### Examples + +#### Economic Policy (Category Weight 3, previously 9) +- "recession" (Weight 3, previously 9): Highly frequent economic term +- "debt ceiling" (Weight 3, previously 8): Highly frequent policy crisis +- "banking crisis" (Weight 2, previously 7): Frequent financial term +- "tax cut" (Weight 1, previously 6): Common policy term +- "capital gains" (Weight 0, previously 4): Technical tax term + +#### Climate and Environment (Category Weight 2, previously 8) +- "climate change" (Weight 3, previously 9): Highly frequent environmental term +- "extreme heat" (Weight 3, previously 8): Frequent weather crisis term +- "drought" (Weight 2, previously 7): Frequent weather crisis term +- "carbon footprint" (Weight 1, previously 5): Regular environmental impact term +- "desertification" (Weight 0, previously 4): Occasional environmental term + +## Migration Benefits + +1. **Intuitive Scaling**: 0-3 provides a clearer, more concise range compared to 1-10 +2. **Programming Alignment**: Starts at 0, matching common programming patterns +3. **Simplified Logic**: Four distinct levels make the weighting system more straightforward +4. **Maintained Relationships**: Preserves the existing keyword distribution and category importance while using a cleaner scale + +## Implementation Steps + +1. **Update Category Files** + - Convert category weights: + * 9-10 → 3 + * 7-8 → 2 + * 5-6 → 1 + * 1-4 → 0 + - Convert keyword weights: + * 8-10 → 3 + * 7-6 → 2 + * 3-4 → 1 + * 1-2 → 0 + +2. **Update weightManager.js** + ```javascript + case 190: // Level 3 (Minimal) + return categoryWeight === 3 ? 3 : // For highest categories, include keywords weighted 3 + categoryWeight === 2 ? 3 : // For high categories, include keywords weighted 3 + 4; // For others, exclude all + + case 413: // Level 2 (Moderate) + return categoryWeight === 3 ? 2 : // For highest categories, include keywords weighted 2+ + categoryWeight === 2 ? 3 : // For high categories, include keywords weighted 3 + 3; // For others, only highest weighted keywords + + case 815: // Level 1 (Extensive) + return categoryWeight === 3 ? 1 : // For highest categories, include keywords weighted 1+ + categoryWeight === 2 ? 2 : // For high categories, include keywords weighted 2+ + 3; // For others, include keywords weighted 3 + ``` + +3. **Update UI Components** + - Modify any UI elements that display weight values + - Update any sorting logic that depends on weights + - Ensure filtering mechanisms reflect the new scale + +4. **Update Tests** + - Modify test cases to use new weight values + - Update expected results in keyword filtering tests + - Add migration-specific tests to verify correct weight conversion + +5. **Documentation Updates** + - Update API documentation + - Update user guides + - Add migration notes for developers + +## Migration Safety + +### Validation Steps +1. **Pre-migration Validation** + - Count total keywords at each level + - Generate distribution report for each category + - Verify current keyword inclusion patterns + +2. **Post-migration Validation** + - Verify total keyword counts match pre-migration + - Confirm keyword inclusion patterns are preserved + - Check category distribution matches expected patterns + - Validate that Level 3 (Minimal) still contains the same 190 most significant keywords + +### Rollback Procedure +1. Don't worry about it, we use source control. \ No newline at end of file diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..26fec60 Binary files /dev/null and b/favicon.ico differ diff --git a/images/logo.png b/images/logo.png new file mode 100644 index 0000000..d5f9150 Binary files /dev/null and b/images/logo.png differ diff --git a/images/sponsor.svg b/images/sponsor.svg new file mode 100644 index 0000000..4f1e3f3 --- /dev/null +++ b/images/sponsor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..33203aa --- /dev/null +++ b/index.html @@ -0,0 +1,83 @@ + + + + + + Mutesky - Bluesky Edition + + + + + + + + + + + + +
+
+
+ + +
+ +
+ + + + + + + + + + + + + + + + diff --git a/js/api.js b/js/api.js new file mode 100644 index 0000000..e2aff02 --- /dev/null +++ b/js/api.js @@ -0,0 +1,246 @@ +import { KEYWORDS_BASE_URL, CONTEXT_GROUPS_URL, DISPLAY_CONFIG_URL, getWeightThreshold } from './config.js'; +import { state, forceRefresh } from './state.js'; + +// Backup category files list +const BACKUP_CATEGORY_FILES = [ + 'climate-and-environment.json', + 'economic-policy.json', + 'education.json', + 'gun-policy.json', + 'healthcare-and-public-health.json', + 'immigration.json', + 'international-coverage.json', + 'lgbtq.json', + 'media-personalities.json', + 'military-and-defense.json', + 'new-developments.json', + 'political-organizations.json', + 'political-rhetoric.json', + 'political-violence-and-security-threats.json', + 'race-relations.json', + 'relational-violence.json', + 'religion.json', + 'reproductive-health.json', + 'social-policy.json', + 'us-government-institutions.json', + 'us-political-figures-full-name.json', + 'us-political-figures-single-name.json', + 'vaccine-policy.json', + 'world-leaders.json' +]; + +const BACKUP_LAST_MODIFIED = 'Dec 1, 2023 9:00 PM'; + +// Cache implementation +const cache = { + data: new Map(), + getItem: function(key) { + const item = this.data.get(key); + if (!item) return null; + if (Date.now() > item.expiry) { + this.data.delete(key); + return null; + } + return item.value; + }, + setItem: function(key, value, ttl = 3600000) { // 1 hour default TTL + const expiry = Date.now() + ttl; + this.data.set(key, { value, expiry }); + } +}; + +async function getLastModifiedDate() { + const repoOwner = 'potatoqualitee'; + const repoName = 'calm-the-chaos'; + const filePath = 'keywords/categories'; + const cacheKey = `lastModified_${repoOwner}_${repoName}_${filePath}`; + + try { + // Check cache first + const cachedDate = cache.getItem(cacheKey); + if (cachedDate) return cachedDate; + + const apiUrl = `https://api.github.com/repos/${repoOwner}/${repoName}/commits?path=${filePath}&per_page=1`; + const response = await fetch(apiUrl, { + headers: { + 'User-Agent': 'MuteSky-App' + } + }); + const data = await response.json(); + + if (data && data[0] && data[0].commit && data[0].commit.committer.date) { + const date = new Date(data[0].commit.committer.date); + const formattedDate = date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + cache.setItem(cacheKey, formattedDate); + return formattedDate; + } + } catch (error) { + console.error('Failed to fetch last modified date:', error); + } + return BACKUP_LAST_MODIFIED; +} + +async function listCategoryFiles() { + const repoOwner = 'potatoqualitee'; + const repoName = 'calm-the-chaos'; + const path = 'keywords/categories'; + const cacheKey = `categoryFiles_${repoOwner}_${repoName}_${path}`; + + try { + // Check cache first + const cachedFiles = cache.getItem(cacheKey); + if (cachedFiles) return cachedFiles; + + const apiUrl = `https://api.github.com/repos/${repoOwner}/${repoName}/contents/${path}`; + const response = await fetch(apiUrl, { + headers: { + 'User-Agent': 'MuteSky-App' + } + }); + + if (response.status === 403) { + console.debug('GitHub API rate limit reached, using backup files'); + return BACKUP_CATEGORY_FILES; + } + + const data = await response.json(); + const files = data.filter(file => file.name.endsWith('.json')).map(file => file.name); + cache.setItem(cacheKey, files); + return files; + } catch (error) { + console.error('Failed to list category files:', error); + return BACKUP_CATEGORY_FILES; + } +} + +export async function fetchKeywordGroups(forceFresh = false) { + try { + // Get list of category files + const categoryFiles = await listCategoryFiles(); + console.debug('Found category files:', categoryFiles); + + // Get the target count from state or default to 2000 + const targetCount = state.targetKeywordCount || 2000; + + // Fetch and process each category file + const keywordGroups = {}; + const results = await Promise.allSettled(categoryFiles.map(async (fileName) => { + try { + const url = `${KEYWORDS_BASE_URL}/${fileName}`; + const response = await fetch(url, { cache: 'no-store' }); + if (!response.ok) return; + + const categoryData = await response.json(); + const categoryName = Object.keys(categoryData)[0]; + + // Store the entire category data structure + keywordGroups[categoryName] = categoryData; + + console.debug(`Loaded ${categoryName} with weight ${categoryData[categoryName].weight} and ${Object.keys(categoryData[categoryName].keywords).length} keywords`); + } catch (error) { + console.error(`Failed to load category ${fileName}:`, error); + } + })); + + // Sort categories alphabetically and create a new ordered object + const orderedKeywordGroups = {}; + Object.keys(keywordGroups) + .sort((a, b) => a.localeCompare(b)) + .forEach(key => { + orderedKeywordGroups[key] = keywordGroups[key]; + }); + + // Update state with ordered groups + state.lastModified = await getLastModifiedDate(); + state.keywordGroups = orderedKeywordGroups; + + // Initialize selected categories if empty + if (state.selectedCategories.size === 0) { + Object.keys(orderedKeywordGroups).forEach(category => { + state.selectedCategories.add(category); + }); + } + + console.debug('Keyword groups loaded:', Object.keys(orderedKeywordGroups).length, 'categories'); + } catch (error) { + console.error('Error fetching keyword groups:', error); + throw error; + } +} + +export async function fetchContextGroups(forceFresh = false) { + try { + const url = forceFresh ? forceRefresh().contextGroupsUrl : CONTEXT_GROUPS_URL; + const response = await fetch(url, { cache: 'no-store' }); + if (!response.ok) throw new Error('Failed to fetch context groups'); + state.contextGroups = await response.json(); + } catch (error) { + console.error('Error fetching context groups:', error); + throw error; + } +} + +export async function fetchDisplayConfig(forceFresh = false) { + try { + const url = forceFresh ? forceRefresh().displayConfigUrl : DISPLAY_CONFIG_URL; + const response = await fetch(url, { cache: 'no-store' }); + if (!response.ok) throw new Error('Failed to fetch display config'); + state.displayConfig = await response.json(); + } catch (error) { + console.error('Error fetching display config:', error); + throw error; + } +} + +export async function refreshAllData() { + try { + // Store current state before refresh + const activeKeywords = new Set(state.activeKeywords); + const selectedContexts = new Set(state.selectedContexts); + const selectedExceptions = new Set(state.selectedExceptions); + const selectedCategories = new Set(state.selectedCategories); + const currentMode = state.mode; + const menuOpen = state.menuOpen; + const targetCount = state.targetKeywordCount; + // Preserve auth state + const did = state.did; + const authenticated = state.authenticated; + // Preserve mute state + const originalMutedKeywords = new Set(state.originalMutedKeywords); + const sessionMutedKeywords = new Set(state.sessionMutedKeywords); + + // Fetch fresh data + await Promise.all([ + fetchKeywordGroups(true), + fetchContextGroups(true), + fetchDisplayConfig(true) + ]); + + // Restore previous state + state.activeKeywords = activeKeywords; + state.selectedContexts = selectedContexts; + state.selectedExceptions = selectedExceptions; + state.selectedCategories = selectedCategories; + state.mode = currentMode; + state.menuOpen = menuOpen; + state.targetKeywordCount = targetCount; + // Restore auth state + state.did = did; + state.authenticated = authenticated; + // Restore mute state + state.originalMutedKeywords = originalMutedKeywords; + state.sessionMutedKeywords = sessionMutedKeywords; + + console.debug('Data refreshed successfully'); + } catch (error) { + console.error('Failed to refresh data:', error); + throw error; + } +} diff --git a/js/auth.js b/js/auth.js new file mode 100644 index 0000000..201f1d5 --- /dev/null +++ b/js/auth.js @@ -0,0 +1,121 @@ +import { BrowserOAuthClient } from '@atproto/oauth-client-browser' + +export class AuthService { + constructor() { + this.client = null; + this.session = null; + } + + async setup() { + try { + // Initialize the OAuth client with production configuration + this.client = await BrowserOAuthClient.load({ + clientId: 'https://mutesky.app/client-metadata.json', + handleResolver: 'https://bsky.social/' + }); + + // Let the client handle initialization and callback processing + const result = await this.client.init(); + + if (result?.session) { + this.session = result.session; + if (result.state) { + console.debug('[Auth] Session established from callback'); + // Dispatch event for callback page + window.dispatchEvent(new CustomEvent('mutesky:auth:complete', { + detail: { success: true } + })); + } else { + console.debug('[Auth] Session restored from last active session'); + } + return { success: true, session: this.session }; + } + + // Dispatch event for callback page if we're on the callback page + if (window.location.pathname.endsWith('callback.html')) { + window.dispatchEvent(new CustomEvent('mutesky:auth:complete', { + detail: { success: false } + })); + } + return { success: false, reason: 'no_session' }; + } catch (error) { + console.error('[Auth] Failed to initialize Bluesky client', error); + this.session = null; + // Dispatch event for callback page if we're on the callback page + if (window.location.pathname.endsWith('callback.html')) { + window.dispatchEvent(new CustomEvent('mutesky:auth:complete', { + detail: { success: false, error } + })); + } + return { success: false, error, reason: 'error' }; + } + } + + async signIn(handle) { + try { + console.debug('[Auth] Starting sign in for handle:', handle); + if (!this.client) { + throw new Error('Client not initialized. Call setup() first.'); + } + + if (!handle?.trim()) { + throw new Error('Please enter your Bluesky handle'); + } + + // Initiate the OAuth flow + await this.client.signIn(handle, { + scope: 'atproto transition:generic' + }); + // Note: The above line will redirect the user, so we won't reach here + // unless there's an error or the user navigates back + } catch (error) { + console.error('[Auth] Sign in failed:', error); + throw error; + } + } + + async signOut() { + if (this.session) { + try { + console.debug('[Auth] Starting sign out...'); + await this.session.signOut(); + this.session = null; + console.debug('[Auth] Sign out complete'); + return true; + } catch (error) { + console.error('[Auth] Sign out failed:', error); + throw error; + } + } + } + + async refreshSession() { + try { + if (!this.client) { + throw new Error('Client not initialized'); + } + + // Attempt to refresh the session + const result = await this.client.init(); + if (result?.session) { + this.session = result.session; + console.debug('[Auth] Session refreshed successfully'); + return { success: true, session: this.session }; + } + + return { success: false }; + } catch (error) { + console.error('[Auth] Session refresh failed:', error); + return { success: false, error }; + } + } + + // Add event listener for session invalidation + onSessionInvalidated(callback) { + this.client?.addEventListener('deleted', (event) => { + const { sub, cause } = event.detail; + console.error(`[Auth] Session for ${sub} is no longer available (cause: ${cause})`); + callback(sub, cause); + }); + } +} diff --git a/js/bluesky.js b/js/bluesky.js new file mode 100644 index 0000000..4eba254 --- /dev/null +++ b/js/bluesky.js @@ -0,0 +1,176 @@ +import { AuthService } from './auth.js' +import { ProfileService } from './profile.js' +import { MuteService } from './mute.js' +import { UIService } from './ui.js' + +class BlueskyService { + constructor() { + this.auth = new AuthService(); + this.profile = new ProfileService(null); + this.mute = new MuteService(null); + this.ui = new UIService(); + this.setupPromise = null; + this.isRefreshing = false; + } + + async setup() { + // Return existing setup promise if it exists + if (this.setupPromise) { + return this.setupPromise; + } + + // Create new setup promise + this.setupPromise = (async () => { + try { + const result = await this.auth.setup(); + + if (result.success && result.session) { + // Update services with active session + this.profile.setSession(result.session); + this.mute.setSession(result.session); + this.ui.updateLoginState(true); + + // Set up session refresh handler + this.setupSessionRefreshHandler(); + + // Only fetch profile initially + await this.updateProfile(); + + // Start mute count update + await this.updateMuteCount(); + + // Dispatch setup complete event + window.dispatchEvent(new CustomEvent('mutesky:setup:complete')); + + return result; + } else { + // Handle different reasons for no session + if (result.reason === 'no_session') { + this.ui.updateLoginState(false); + } else if (result.error?.name === 'OAuthCallbackError') { + this.ui.updateLoginState(false, `Failed to connect to Bluesky: ${result.error.message}`); + } else if (result.error) { + this.ui.updateLoginState(false, `Failed to connect to Bluesky: ${result.error.message || 'Unknown error'}`); + } + return result; + } + } catch (error) { + console.error('[Bluesky] Setup failed:', error); + this.ui.updateLoginState(false, `Setup failed: ${error.message || 'Unknown error'}`); + throw error; + } + })(); + + return this.setupPromise; + } + + setupSessionRefreshHandler() { + window.addEventListener('mutesky:session:refresh:needed', async () => { + if (this.isRefreshing) return; // Prevent multiple simultaneous refreshes + + try { + this.isRefreshing = true; + console.debug('[Bluesky] Attempting to refresh session...'); + + const result = await this.auth.refreshSession(); + + if (result.success && result.session) { + console.debug('[Bluesky] Session refreshed successfully'); + // Update services with new session + this.profile.setSession(result.session); + this.mute.setSession(result.session); + + // Retry the failed operations + await this.updateProfile(); + await this.updateMuteCount(); + } else { + console.error('[Bluesky] Session refresh failed'); + // If refresh fails, sign out user + await this.signOut(); + } + } catch (error) { + console.error('[Bluesky] Session refresh error:', error); + await this.signOut(); + } finally { + this.isRefreshing = false; + } + }); + } + + async updateProfile() { + try { + const profile = await this.profile.getProfile(); + if (profile) { + this.profile.updateUI(profile); + } + } catch (error) { + console.error('[Bluesky] Profile update failed:', error); + } + } + + async updateMuteCount() { + try { + const keywords = await this.mute.getMutedKeywords(); + this.profile.updateMuteCount(keywords.length); + } catch (error) { + console.error('[Bluesky] Mute count update failed:', error); + } + } + + async signIn() { + try { + const handle = this.ui.getHandleInput(); + if (!handle) { + this.ui.showError('Please enter your Bluesky handle'); + return; + } + await this.auth.signIn(handle); + } catch (error) { + console.error('[Bluesky] Sign in failed:', error); + this.ui.updateLoginState(false, `Sign in failed: ${error.message || 'Please try again'}`); + } + } + + async signOut() { + try { + await this.auth.signOut(); + + // Update services for sign out + this.profile.setSession(null); + this.mute.setSession(null); + this.profile.resetUI(); + this.ui.updateLoginState(false); + + // Clear setup promise on sign out + this.setupPromise = null; + } catch (error) { + console.error('[Bluesky] Sign out failed:', error); + this.ui.updateLoginState(false, `Sign out failed: ${error.message || 'Please try again'}`); + } + } + + // Mute operations + async muteKeyword(keyword) { + return this.mute.muteKeyword(keyword); + } + + async unmuteKeyword(keyword) { + return this.mute.unmuteKeyword(keyword); + } + + async muteActor(actor) { + return this.mute.muteActor(actor); + } + + async unmuteActor(actor) { + return this.mute.unmuteActor(actor); + } +} + +// Export singleton instance +const blueskyService = new BlueskyService(); + +// Initialize the service when the module loads +blueskyService.setup().catch(console.error); + +export { blueskyService }; diff --git a/js/callback.js b/js/callback.js new file mode 100644 index 0000000..2e410b7 --- /dev/null +++ b/js/callback.js @@ -0,0 +1,66 @@ +class CallbackHandler { + constructor() { + console.debug('[Callback] Initializing callback handler...'); + this.container = document.querySelector('.callback-container'); + this.errorElement = document.getElementById('error'); + this.titleElement = document.querySelector('h2'); + this.statusElement = document.querySelector('.status-text'); + this.homeLink = document.querySelector('.home-link'); + + // Hide the home link initially + if (this.homeLink) { + this.homeLink.style.display = 'none'; + } + } + + init() { + console.debug('[Callback] Starting callback processing...'); + this.showLoading(); + + // Listen for auth completion + window.addEventListener('mutesky:auth:complete', (event) => { + const { success } = event.detail || {}; + if (success) { + this.showKeywordLoading(); + } else { + // Show error and manual return link on failure + this.showError('Authentication failed. Please try again.'); + if (this.homeLink) { + this.homeLink.style.display = 'block'; + } + } + }); + + // Listen for setup completion + window.addEventListener('mutesky:setup:complete', () => { + // Redirect back to app + window.location.href = '/'; + }); + } + + showLoading() { + console.debug('[Callback] Processing auth callback...'); + this.titleElement.textContent = 'Authentication Successful'; + this.statusElement.textContent = 'Verifying credentials'; + } + + showKeywordLoading() { + console.debug('[Callback] Showing keyword loading state'); + this.titleElement.textContent = 'Loading Keywords'; + this.statusElement.textContent = 'This may take a moment'; + } + + showError(message) { + console.debug('[Callback] Showing error:', message); + this.container.classList.add('error'); + this.titleElement.textContent = 'Authentication Failed'; + this.errorElement.textContent = message; + } +} + +// Initialize when page loads +window.addEventListener('load', () => { + console.debug('[Callback] Page loaded, initializing handler...'); + const handler = new CallbackHandler(); + handler.init(); +}); diff --git a/js/categoryManager.js b/js/categoryManager.js new file mode 100644 index 0000000..e6ff0dd --- /dev/null +++ b/js/categoryManager.js @@ -0,0 +1,41 @@ +import { state } from './state.js'; +import { getDisplayName, getCategoryState, getCheckboxClass, getAllKeywordsForCategory } from './utils/categoryUtils.js'; +import { filterKeywordGroups } from './utils/keywordFilters.js'; + +function calculateKeywordsToMute() { + const keywordsToMute = new Set(); + + if (state.mode === 'simple') { + state.selectedContexts.forEach(contextId => { + const context = state.contextGroups[contextId]; + if (context && context.categories) { + context.categories.forEach(category => { + if (!state.selectedExceptions.has(category)) { + // Get keywords sorted by weight and limited by target count + const keywords = getAllKeywordsForCategory(category, true); + console.debug(`Adding ${keywords.length} keywords from ${category} to mute list`); + keywords.forEach(keyword => keywordsToMute.add(keyword)); + } + }); + } + }); + } else { + state.activeKeywords.forEach(keyword => keywordsToMute.add(keyword)); + } + + return keywordsToMute; +} + +function calculateKeywordCount() { + return calculateKeywordsToMute().size; +} + +export { + getDisplayName, + getCategoryState, + getCheckboxClass, + filterKeywordGroups, + getAllKeywordsForCategory, + calculateKeywordsToMute, + calculateKeywordCount +}; diff --git a/js/components/advanced-mode.js b/js/components/advanced-mode.js new file mode 100644 index 0000000..f99fa79 --- /dev/null +++ b/js/components/advanced-mode.js @@ -0,0 +1,38 @@ +class AdvancedMode extends HTMLElement { + constructor() { + super(); + } + + connectedCallback() { + this.innerHTML = ` +
+
+ + +
+
+ +
+
+
+
+ `; + } +} + +customElements.define('advanced-mode', AdvancedMode); + +export default AdvancedMode; diff --git a/js/components/app-intro.js b/js/components/app-intro.js new file mode 100644 index 0000000..deaa110 --- /dev/null +++ b/js/components/app-intro.js @@ -0,0 +1,17 @@ +class AppIntro extends HTMLElement { + constructor() { + super(); + } + + connectedCallback() { + this.innerHTML = ` +
+

Mutesky helps you manage and filter unwanted content on your Bluesky feed using curated keyword groups and smart filtering. Choose Simple Mode for easy context-based filtering with exceptions, or Advanced Mode for detailed control over individual keywords and categories.

+
+ `; + } +} + +customElements.define('app-intro', AppIntro); + +export default AppIntro; diff --git a/js/components/footer.js b/js/components/footer.js new file mode 100644 index 0000000..f494b94 --- /dev/null +++ b/js/components/footer.js @@ -0,0 +1,28 @@ +class AppFooter extends HTMLElement { + constructor() { + super(); + } + + connectedCallback() { + this.innerHTML = ` + + `; + } +} + +customElements.define('app-footer', AppFooter); + +export default AppFooter; diff --git a/js/components/index.js b/js/components/index.js new file mode 100644 index 0000000..b70dab4 --- /dev/null +++ b/js/components/index.js @@ -0,0 +1,18 @@ +import LandingPage from './landing-page.js'; +import TopNav from './top-nav.js'; +import SimpleMode from './simple-mode.js'; +import AdvancedMode from './advanced-mode.js'; +import { SettingsModal } from './modals.js'; +import AppFooter from './footer.js'; +import AppIntro from './app-intro.js'; + +// Export all components +export { + LandingPage, + TopNav, + SimpleMode, + AdvancedMode, + SettingsModal, + AppFooter, + AppIntro +}; diff --git a/js/components/landing-page.js b/js/components/landing-page.js new file mode 100644 index 0000000..53d8c57 --- /dev/null +++ b/js/components/landing-page.js @@ -0,0 +1,110 @@ +class LandingPage extends HTMLElement { + constructor() { + super(); + } + + connectedCallback() { + this.innerHTML = ` +
+ +
+
+ +

Mutesky

+

Bulk manage Bluesky mutes with
pre-populated keyword lists

+
+
+ + +
+
+ +
+
+ +
+

1,400+ Keywords

+

Continuously updated by AI to reflect current events

+
+
+
+ 🎯 +
+

20+ Categories

+

From politics to climate, choose what you want to see

+
+
+
+ 🎚️ +
+

Easy Management

+

Simple toggles or advanced keyword controls

+
+
+
+ +
+

Instant Updates

+

Changes take effect immediately on your feed

+
+
+
+ + +
+ + +
+
+
The next page will prompt for your username and Bluesky account password, not your app password. Your credentials are securely handled by Bluesky's official authentication service.
+
+ +
+
+ +
+ +
+
+
+
+
+
+ `; + + // Check for auth errors after component is mounted + this.checkAuthErrors(); + } + + checkAuthErrors() { + const error = sessionStorage.getItem('auth_error'); + const errorDescription = sessionStorage.getItem('auth_error_description'); + + if (error) { + const messageEl = document.getElementById('bsky-auth-message'); + const errorText = errorDescription || error; + + messageEl.innerHTML = ` +
+ Authentication failed: ${errorText} +
+ Please try again. +
+ `; + messageEl.classList.add('error'); + + // Clear error state + sessionStorage.removeItem('auth_error'); + sessionStorage.removeItem('auth_error_description'); + } + } +} + +customElements.define('landing-page', LandingPage); + +export default LandingPage; diff --git a/js/components/modals.js b/js/components/modals.js new file mode 100644 index 0000000..40c8947 --- /dev/null +++ b/js/components/modals.js @@ -0,0 +1,230 @@ +import { updateWarningVisibility } from '../handlers/modalHandlers.js'; +import { loadAppearanceSettings, saveAppearanceSettings } from '../settings/appearanceSettings.js'; + +class SettingsModal extends HTMLElement { + constructor() { + super(); + } + + connectedCallback() { + this.innerHTML = ` + + `; + + // Add tab switching functionality + this.setupTabs(); + // Add appearance settings handlers + this.setupAppearanceHandlers(); + } + + setupTabs() { + const tabs = this.querySelectorAll('.settings-tab'); + tabs.forEach(tab => { + tab.addEventListener('click', () => { + // Remove active class from all tabs and contents + this.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active')); + this.querySelectorAll('.settings-content').forEach(c => c.classList.remove('active')); + + // Add active class to clicked tab and corresponding content + tab.classList.add('active'); + const content = this.querySelector(`[data-content="${tab.dataset.tab}"]`); + content.classList.add('active'); + + // Show/hide warning based on active tab and duration + const warningElement = this.querySelector('.settings-warning'); + if (tab.dataset.tab === 'muting') { + const duration = document.querySelector('input[name="duration"]:checked')?.value; + warningElement.style.display = duration && duration !== 'forever' ? 'flex' : 'none'; + } else { + warningElement.style.display = 'none'; + } + + // Lazy load the creator image when about tab is clicked + if (tab.dataset.tab === 'about') { + const img = this.querySelector('.creator-image'); + if (img) { + img.loading = 'eager'; // Switch to eager loading when tab is active + } + } + }); + }); + } + + setupAppearanceHandlers() { + // Load current settings from localStorage + const settings = loadAppearanceSettings(); + + // Set initial active states + this.querySelector(`.theme-mode-switch[data-theme="${settings.colorMode}"]`)?.classList.add('active'); + this.querySelector(`.font-switch[data-font="${settings.font}"]`)?.classList.add('active'); + this.querySelector(`.font-switch[data-size="${settings.fontSize}"]`)?.classList.add('active'); + + // Theme buttons + this.querySelectorAll('.theme-mode-switch[data-theme]').forEach(button => { + button.addEventListener('click', () => { + this.querySelectorAll('.theme-mode-switch[data-theme]').forEach(btn => btn.classList.remove('active')); + button.classList.add('active'); + settings.colorMode = button.dataset.theme; + saveAppearanceSettings(settings); + }); + }); + + // Font buttons + this.querySelectorAll('.font-switch[data-font]').forEach(button => { + button.addEventListener('click', () => { + this.querySelectorAll('.font-switch[data-font]').forEach(btn => btn.classList.remove('active')); + button.classList.add('active'); + settings.font = button.dataset.font; + saveAppearanceSettings(settings); + }); + }); + + // Font size buttons + this.querySelectorAll('.font-switch[data-size]').forEach(button => { + button.addEventListener('click', () => { + this.querySelectorAll('.font-switch[data-size]').forEach(btn => btn.classList.remove('active')); + button.classList.add('active'); + settings.fontSize = button.dataset.size; + saveAppearanceSettings(settings); + }); + }); + } +} + +customElements.define('settings-modal', SettingsModal); + +export { SettingsModal }; diff --git a/js/components/simple-mode.js b/js/components/simple-mode.js new file mode 100644 index 0000000..1d1ad69 --- /dev/null +++ b/js/components/simple-mode.js @@ -0,0 +1,177 @@ +import { state } from '../state.js'; + +class SimpleMode extends HTMLElement { + constructor() { + super(); + this.currentLevel = 0; + this.currentExceptions = new Set(); + this.activeKeywordCount = 0; + this.handleKeywordsUpdated = this.handleKeywordsUpdated.bind(this); + } + + handleKeywordsUpdated(event) { + this.activeKeywordCount = event.detail.count; + this.updateFilterUI(); + } + + connectedCallback() { + this.innerHTML = ` +
+
+
+

Select the content types you want to filter, choose your filtering strength, and set any exceptions. Click the blue "Mute" button at the top right to apply your changes. For more detailed control, try Advanced Mode in the top menu.

+ +
+

I want to avoid content about...

+
+ +
+
+ +
+

Choose your filtering level

+ + +
+ + + + +
+
+ +
+

Keep Showing me content about...

+
+ +
+
+
+
+ +
+
+ `; + + // Initialize from saved state + this.currentLevel = state.filterLevel; + this.currentExceptions = new Set(state.selectedExceptions); + + // Start observing state changes + document.addEventListener('keywordsUpdated', this.handleKeywordsUpdated); + + // Initial UI update + this.activeKeywordCount = state.activeKeywords.size; + this.updateFilterUI(); + this.setupEventListeners(); + } + + setupEventListeners() { + const levels = this.querySelectorAll('.filter-card'); + + levels.forEach(level => { + // Click handler + level.addEventListener('click', (e) => { + this.setActiveLevel(parseInt(level.dataset.level)); + }); + + // Keyboard handler + level.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.setActiveLevel(parseInt(level.dataset.level)); + } + }); + }); + } + + updateFilterUI() { + const levels = this.querySelectorAll('.filter-card'); + const warningNote = this.querySelector('.filter-note'); + const keywordCount = this.querySelector('.keyword-count'); + + levels.forEach(el => { + const isActive = parseInt(el.dataset.level) === this.currentLevel; + el.classList.toggle('active', isActive); + el.setAttribute('aria-checked', isActive); + }); + + if (warningNote) { + // Show warning if active keywords exceed 200 + warningNote.style.display = this.activeKeywordCount > 200 ? 'block' : 'none'; + // Update the keyword count + if (keywordCount) { + keywordCount.textContent = this.activeKeywordCount; + } + } + } + + setActiveLevel(level) { + if (level === this.currentLevel) return; + this.currentLevel = level; + state.filterLevel = level; + this.updateFilterUI(); + + // Dispatch custom event for level change + this.dispatchEvent(new CustomEvent('filterLevelChange', { + detail: { level }, + bubbles: true + })); + } + + // Method to update level from outside + updateLevel(level) { + if (level === this.currentLevel) return; + this.currentLevel = level; + this.updateFilterUI(); + } + + // Method to update exceptions from outside + updateExceptions(exceptions) { + const newExceptions = new Set(exceptions); + if (this.areExceptionsEqual(this.currentExceptions, newExceptions)) return; + + this.currentExceptions = newExceptions; + + // Update exception tags UI if needed + const exceptionTags = this.querySelector('#exception-tags'); + if (exceptionTags) { + // Let the contextRenderer handle the actual UI update + this.dispatchEvent(new CustomEvent('exceptionsUpdated', { + detail: { exceptions: Array.from(newExceptions) }, + bubbles: true + })); + } + } + + // Helper to compare exception sets + areExceptionsEqual(set1, set2) { + if (set1.size !== set2.size) return false; + for (const item of set1) { + if (!set2.has(item)) return false; + } + return true; + } + + disconnectedCallback() { + // Clean up event listeners + document.removeEventListener('keywordsUpdated', this.handleKeywordsUpdated); + } +} + +customElements.define('simple-mode', SimpleMode); + +export default SimpleMode; diff --git a/js/components/top-nav.js b/js/components/top-nav.js new file mode 100644 index 0000000..4d70476 --- /dev/null +++ b/js/components/top-nav.js @@ -0,0 +1,115 @@ +class TopNav extends HTMLElement { + constructor() { + super(); + } + + connectedCallback() { + this.innerHTML = ` + + `; + + // Add click handler for hamburger menu + const hamburgerMenu = this.querySelector('.hamburger-menu'); + const userMenu = this.querySelector('.user-menu'); + + hamburgerMenu?.addEventListener('click', (e) => { + e.stopPropagation(); + hamburgerMenu.classList.toggle('active'); + userMenu.classList.toggle('active'); + }); + + // Close menu when clicking outside + document.addEventListener('click', (e) => { + if (!userMenu.contains(e.target) && !hamburgerMenu.contains(e.target)) { + hamburgerMenu?.classList.remove('active'); + userMenu.classList.remove('active'); + } + }); + + // Handle all interface mode switches (both desktop and mobile) with the same logic + this.querySelectorAll('.interface-mode-switch').forEach(button => { + button.addEventListener('click', () => { + const mode = button.dataset.mode; + // Use the centralized switchMode function + window.switchMode(mode); + // Close menu if it's in the mobile dropdown + if (button.closest('.mobile-mode-switches')) { + hamburgerMenu?.classList.remove('active'); + userMenu.classList.remove('active'); + } + }); + }); + } +} + +customElements.define('top-nav', TopNav); + +export default TopNav; diff --git a/js/config.js b/js/config.js new file mode 100644 index 0000000..d75a7e2 --- /dev/null +++ b/js/config.js @@ -0,0 +1,3 @@ +export const KEYWORDS_BASE_URL = 'https://raw.githubusercontent.com/potatoqualitee/calm-the-chaos/main/keywords/categories'; +export const CONTEXT_GROUPS_URL = 'https://raw.githubusercontent.com/potatoqualitee/calm-the-chaos/main/keywords/context-groups.json'; +export const DISPLAY_CONFIG_URL = 'https://raw.githubusercontent.com/potatoqualitee/calm-the-chaos/main/keywords/display-config.json'; diff --git a/js/dom.js b/js/dom.js new file mode 100644 index 0000000..bd8ee7d --- /dev/null +++ b/js/dom.js @@ -0,0 +1,42 @@ +export const elements = { + landingPage: document.getElementById('landing-page'), + appInterface: document.getElementById('app-interface'), + authButton: document.getElementById('bsky-login-btn'), + handleInput: document.getElementById('bsky-handle-input'), + logoutButton: document.getElementById('bsky-logout-btn'), + modeToggles: document.querySelectorAll('.mode-switch'), + simpleMode: document.getElementById('simple-mode'), + advancedMode: document.getElementById('advanced-mode'), + contextOptions: document.getElementById('context-options'), + exceptionsPanel: document.querySelector('.exceptions-panel'), + exceptionTags: document.getElementById('exception-tags'), + searchInput: document.getElementById('keyword-search'), + sidebarSearch: document.getElementById('sidebar-search'), + categoriesGrid: document.getElementById('categories-grid'), + categoryList: document.getElementById('category-list'), + sidebarLastUpdate: document.getElementById('sidebar-last-update'), + activeCount: document.getElementById('active-count'), + lastUpdate: document.getElementById('last-update'), + muteButton: document.querySelector('.btn-mute-keywords'), + navMuteButton: document.querySelector('.nav-mute-button'), + profileButton: document.querySelector('.profile-button'), + userMenuDropdown: document.querySelector('.user-menu-dropdown'), + enableAllBtn: document.getElementById('enable-all'), + disableAllBtn: document.getElementById('disable-all'), + refreshButton: document.getElementById('refresh-data'), + + // Settings modal elements + settingsButton: document.getElementById('muting-settings'), + settingsModal: document.getElementById('settings-modal'), + + // Appearance modal elements + appearanceButton: document.getElementById('appearance-settings'), + appearanceModal: document.getElementById('appearance-modal'), + colorModeToggles: document.querySelectorAll('.mode-switch[data-theme]'), + darkThemeToggles: document.querySelectorAll('.theme-switch[data-dark-theme]'), + fontToggles: document.querySelectorAll('.font-switch[data-font]'), + fontSizeToggles: document.querySelectorAll('.font-switch[data-size]'), + + // Bluesky specific elements + bskyHandle: document.getElementById('bsky-handle') +}; diff --git a/js/handlers/authHandlers.js b/js/handlers/authHandlers.js new file mode 100644 index 0000000..f51af48 --- /dev/null +++ b/js/handlers/authHandlers.js @@ -0,0 +1,140 @@ +import { state, loadState } from '../state.js'; +import { elements } from '../dom.js'; +import { blueskyService } from '../bluesky.js'; +import { initializeState } from './contextHandlers.js'; + +export async function handleAuth() { + try { + // Clear any previous error states first + if (elements.handleInput) { + elements.handleInput.classList.remove('error'); + } + const messageEl = document.getElementById('bsky-auth-message'); + if (messageEl) { + messageEl.classList.remove('error'); + messageEl.textContent = 'The next page will prompt for your username and Bluesky account password, not your app password. Your credentials are securely handled by Bluesky\'s official authentication service.'; + } + + // Validate handle before attempting auth + const handle = elements.handleInput?.value?.trim(); + if (!handle) { + if (elements.handleInput) { + elements.handleInput.classList.add('error'); + } + throw new Error('Please enter your Bluesky handle'); + } + + // Disable input and button during authentication + if (elements.handleInput) { + elements.handleInput.disabled = true; + } + if (elements.authButton) { + elements.authButton.disabled = true; + elements.authButton.textContent = 'Connecting...'; + } + + // Store current state before clearing + const savedContexts = new Set(state.selectedContexts); + const savedExceptions = new Set(state.selectedExceptions); + const filterLevel = state.filterLevel; + + // Clear active state + state.activeKeywords.clear(); + state.selectedContexts.clear(); + state.selectedExceptions.clear(); + state.selectedCategories.clear(); + + // Initiate Bluesky login + await blueskyService.signIn(); + + // Restore saved state after login + state.selectedContexts = savedContexts; + state.selectedExceptions = savedExceptions; + state.filterLevel = filterLevel; + + // Initialize state to restore context keywords + initializeState(); + + // The rest will be handled by the OAuth callback and blueskyService's setup + } catch (error) { + console.error('Authentication failed:', error); + + // Re-enable input and button on error + if (elements.handleInput) { + elements.handleInput.disabled = false; + elements.handleInput.classList.add('error'); + } + if (elements.authButton) { + elements.authButton.disabled = false; + elements.authButton.textContent = 'Connect to Bluesky'; + } + + // Update auth message with error + const messageEl = document.getElementById('bsky-auth-message'); + if (messageEl) { + messageEl.textContent = error.message || 'Authentication failed. Please try again.'; + messageEl.classList.add('error'); + } + + // Ensure UI service is updated + blueskyService.ui.updateLoginState(false, error.message || 'Authentication failed. Please try again.'); + } +} + +export async function handleLogout() { + try { + console.debug('[Auth] Starting logout, current exceptions:', Array.from(state.selectedExceptions)); + await blueskyService.signOut(); + + // Store exceptions and contexts before clearing state + const exceptions = new Set(state.selectedExceptions); + const contexts = new Set(state.selectedContexts); + const filterLevel = state.filterLevel; + console.debug('[Auth] Preserved exceptions for logout:', Array.from(exceptions)); + + // Clear state but preserve mode + state.authenticated = false; + state.activeKeywords.clear(); + state.selectedContexts.clear(); + state.selectedCategories.clear(); + state.mode = 'simple'; + state.menuOpen = false; + + // Restore preserved values + state.selectedExceptions = exceptions; + state.selectedContexts = contexts; + state.filterLevel = filterLevel; + console.debug('[Auth] Restored exceptions after state clear:', Array.from(state.selectedExceptions)); + + // Initialize state to restore context keywords + initializeState(); + + elements.landingPage.classList.remove('hidden'); + elements.appInterface.classList.add('hidden'); + elements.userMenuDropdown.classList.remove('visible'); + + // Reset UI elements to initial state + if (elements.handleInput) { + elements.handleInput.disabled = false; + elements.handleInput.classList.remove('error'); + elements.handleInput.value = ''; + } + if (elements.authButton) { + elements.authButton.disabled = false; + elements.authButton.textContent = 'Connect to Bluesky'; + } + + // Reset auth message + const messageEl = document.getElementById('bsky-auth-message'); + if (messageEl) { + messageEl.classList.remove('error'); + messageEl.textContent = 'The next page will prompt for your username and Bluesky account password, not your app password. Your credentials are securely handled by Bluesky\'s official authentication service.'; + } + + // Removed saveState() call since we only want to save during mute/unmute + console.debug('[Auth] Logout complete with exceptions:', Array.from(state.selectedExceptions)); + } catch (error) { + console.error('Logout failed:', error); + blueskyService.ui.updateLoginState(false, `Logout failed: ${error.message || 'Please try again'}`); + } +} diff --git a/js/handlers/context/contextCache.js b/js/handlers/context/contextCache.js new file mode 100644 index 0000000..2241f4a --- /dev/null +++ b/js/handlers/context/contextCache.js @@ -0,0 +1,124 @@ +import { state } from '../../state.js'; +import { getAllKeywordsForCategory } from '../../categoryManager.js'; +import { isKeywordActive } from '../keywordHandlers.js'; + +// Enhanced cache with memory management and performance optimizations +export const cache = { + keywords: new Map(), + categoryStates: new Map(), + contextKeywords: new Map(), + activeKeywordsByCategory: new Map(), + lastUpdate: 0, + maxCacheSize: 100, + updateThreshold: 16, + + getKeywords(category, sortByWeight = false) { + const key = `${category}-${sortByWeight}-${state.targetKeywordCount}`; + if (!this.keywords.has(key)) { + this.manageCache(this.keywords); + const keywords = getAllKeywordsForCategory(category, sortByWeight); + this.keywords.set(key, new Set(keywords)); + } + return this.keywords.get(key); + }, + + getActiveKeywordsForCategory(category) { + const key = `active-${category}-${state.targetKeywordCount}`; + if (!this.activeKeywordsByCategory.has(key)) { + this.manageCache(this.activeKeywordsByCategory); + const keywords = this.getKeywords(category, true); + const active = new Set(); + for (const k of keywords) { + if (isKeywordActive(k)) active.add(k); + } + this.activeKeywordsByCategory.set(key, active); + } + return this.activeKeywordsByCategory.get(key); + }, + + getCategoryState(category) { + const keywords = this.getKeywords(category, true); + const activeKeywords = this.getActiveKeywordsForCategory(category); + + if (activeKeywords.size === 0) return 'none'; + if (activeKeywords.size === keywords.size) return 'all'; + return 'partial'; + }, + + getContextKeywords(contextId) { + const key = `${contextId}-${state.targetKeywordCount}`; + if (!this.contextKeywords.has(key)) { + this.manageCache(this.contextKeywords); + const context = state.contextGroups[contextId]; + const keywordSet = new Set(); + + if (context?.categories) { + const nonExceptedCategories = context.categories.filter( + category => !state.selectedExceptions.has(category) + ); + + for (const category of nonExceptedCategories) { + const keywords = this.getKeywords(category, true); + for (const k of keywords) keywordSet.add(k); + } + } + this.contextKeywords.set(key, keywordSet); + } + return this.contextKeywords.get(key); + }, + + getContextState(contextId) { + const context = state.contextGroups[contextId]; + if (!context?.categories) return 'none'; + + let allNone = true; + for (const category of context.categories) { + if (state.selectedExceptions.has(category)) continue; + + const categoryState = this.getCategoryState(category); + if (categoryState !== 'none') { + allNone = false; + break; + } + } + return allNone ? 'none' : 'partial'; + }, + + manageCache(cacheMap) { + if (cacheMap.size >= this.maxCacheSize) { + const entriesToRemove = Math.ceil(this.maxCacheSize * 0.2); + const keys = Array.from(cacheMap.keys()); + for (let i = 0; i < entriesToRemove; i++) { + cacheMap.delete(keys[i]); + } + } + }, + + shouldUpdate() { + const now = Date.now(); + if (now - this.lastUpdate < this.updateThreshold) return false; + this.lastUpdate = now; + return true; + }, + + invalidateCategory(category) { + if (!this.shouldUpdate()) return; + + const keywordKeys = Array.from(this.keywords.keys()) + .filter(key => key.startsWith(`${category}-`)); + const activeKeys = Array.from(this.activeKeywordsByCategory.keys()) + .filter(key => key.startsWith(`active-${category}-`)); + + keywordKeys.forEach(key => this.keywords.delete(key)); + activeKeys.forEach(key => this.activeKeywordsByCategory.delete(key)); + this.contextKeywords.clear(); + }, + + clear() { + this.keywords.clear(); + this.categoryStates.clear(); + this.contextKeywords.clear(); + this.activeKeywordsByCategory.clear(); + this.lastUpdate = 0; + } +}; diff --git a/js/handlers/context/contextHandlers.js b/js/handlers/context/contextHandlers.js new file mode 100644 index 0000000..85c28e1 --- /dev/null +++ b/js/handlers/context/contextHandlers.js @@ -0,0 +1,220 @@ +import { renderInterface } from '../../renderer.js'; +import { state, saveState } from '../../state.js'; +import { cache } from './contextCache.js'; +import { + activateContextKeywords, + createDebouncedUpdate, + notifyKeywordChanges +} from './contextUtils.js'; +import { updateSimpleModeState, initializeState } from './contextState.js'; + +export async function handleContextToggle(contextId) { + console.debug('[handleContextToggle] Starting toggle for context:', contextId); + console.debug('[handleContextToggle] Initial state:', { + isAuthenticated: state.authenticated, + mode: state.mode, + selectedContextsCount: state.selectedContexts.size, + activeKeywordsCount: state.activeKeywords.size, + manuallyUncheckedCount: state.manuallyUnchecked.size + }); + + if (!state.authenticated) { + console.debug('[handleContextToggle] Not authenticated, returning'); + return; + } + + const isSelected = state.selectedContexts.has(contextId); + console.debug('[handleContextToggle] Context currently selected:', isSelected); + + const context = state.contextGroups[contextId]; + console.debug('[handleContextToggle] Context categories:', context?.categories); + + // Store currently unchecked keywords before context change + const uncheckedKeywords = new Set(state.manuallyUnchecked); + console.debug('[handleContextToggle] Stored unchecked keywords count:', uncheckedKeywords.size); + + if (isSelected) { + console.debug('[handleContextToggle] Unchecking context'); + + // 1. Update UI state first + state.selectedContexts.delete(contextId); + console.debug('[handleContextToggle] Removed context from selectedContexts'); + + if (context?.categories) { + context.categories.forEach(category => { + state.selectedExceptions.delete(category); + cache.invalidateCategory(category); + console.debug('[handleContextToggle] Removed exception and invalidated cache for category:', category); + }); + } + + // 2. Keep keywords in activeKeywords temporarily so getMuteUnmuteCounts works + const keywordsToRemove = new Set(); + if (context?.categories) { + for (const category of context.categories) { + if (!state.selectedExceptions.has(category)) { + const keywords = cache.getKeywords(category, true); + console.debug(`[handleContextToggle] Found ${keywords.size} keywords in category:`, category); + + for (const keyword of keywords) { + if (!uncheckedKeywords.has(keyword)) { + keywordsToRemove.add(keyword); + console.debug('[handleContextToggle] Marking keyword for removal:', keyword); + } + } + } + } + } + + console.debug('[handleContextToggle] Total keywords marked for removal:', keywordsToRemove.size); + console.debug('[handleContextToggle] Active keywords before removal:', state.activeKeywords.size); + + // 3. Now remove from activeKeywords after getMuteUnmuteCounts has run + for (const keyword of keywordsToRemove) { + state.activeKeywords.delete(keyword); + console.debug('[handleContextToggle] Removed keyword from activeKeywords:', keyword); + } + + console.debug('[handleContextToggle] Active keywords after removal:', state.activeKeywords.size); + + } else { + console.debug('[handleContextToggle] Checking context'); + + // 1. Update UI state + state.selectedContexts.add(contextId); + console.debug('[handleContextToggle] Added context to selectedContexts'); + + if (context?.categories) { + context.categories.forEach(category => { + cache.invalidateCategory(category); + console.debug('[handleContextToggle] Invalidated cache for category:', category); + }); + } + + // 2. Add keywords to activeKeywords + if (context?.categories) { + for (const category of context.categories) { + if (!state.selectedExceptions.has(category)) { + const keywords = cache.getKeywords(category, true); + console.debug(`[handleContextToggle] Found ${keywords.size} keywords in category:`, category); + + for (const keyword of keywords) { + if (!uncheckedKeywords.has(keyword)) { + state.activeKeywords.add(keyword); + console.debug('[handleContextToggle] Added keyword to activeKeywords:', keyword); + } + } + } + } + } + + console.debug('[handleContextToggle] Active keywords after additions:', state.activeKeywords.size); + } + + // Notify of keyword changes to update mute button + console.debug('[handleContextToggle] Notifying of keyword changes'); + notifyKeywordChanges(); + + // Create a new debounced update for this call + console.debug('[handleContextToggle] Creating debounced update'); + const debouncedUpdate = createDebouncedUpdate(); + await debouncedUpdate(async () => { + console.debug('[handleContextToggle] Executing debounced update'); + console.debug('[handleContextToggle] Final state:', { + selectedContextsCount: state.selectedContexts.size, + activeKeywordsCount: state.activeKeywords.size, + manuallyUncheckedCount: state.manuallyUnchecked.size + }); + renderInterface(); + await saveState(); + console.debug('[handleContextToggle] Completed interface render and state save'); + }); + + console.debug('[handleContextToggle] Toggle operation complete'); +} + +export async function handleExceptionToggle(category) { + console.debug('[handleExceptionToggle] Starting toggle for category:', category); + if (!state.authenticated) return; + + // Store currently unchecked keywords before exception change + const uncheckedKeywords = new Set(state.manuallyUnchecked); + + const wasException = state.selectedExceptions.has(category); + console.debug('[handleExceptionToggle] Was exception:', wasException); + + if (wasException) { + state.selectedExceptions.delete(category); + console.debug('[handleExceptionToggle] Removed exception'); + } else { + state.selectedExceptions.add(category); + console.debug('[handleExceptionToggle] Added exception'); + + // Check if any keywords in this category are currently muted + if (state.mode === 'simple') { + const categoryKeywords = cache.getKeywords(category, true); + for (const keyword of categoryKeywords) { + if (state.originalMutedKeywords.has(keyword)) { + state.activeKeywords.delete(keyword); + } + } + // Notify immediately of keyword changes to update mute button + notifyKeywordChanges(); + } + } + + cache.invalidateCategory(category); + console.debug('[handleExceptionToggle] Invalidated category cache'); + + // Only rebuild keywords in simple mode + if (state.mode === 'simple') { + console.debug('[handleExceptionToggle] Rebuilding keywords in simple mode'); + + // Clear and rebuild active keywords + state.activeKeywords.clear(); + for (const contextId of state.selectedContexts) { + activateContextKeywords(contextId, cache); + } + + // Add only original muted keywords that aren't in excepted categories + for (const keyword of state.originalMutedKeywords) { + if (!state.activeKeywords.has(keyword)) { + let isExcepted = false; + for (const exceptedCategory of state.selectedExceptions) { + const exceptedKeywords = cache.getKeywords(exceptedCategory, true); + if (exceptedKeywords.has(keyword)) { + isExcepted = true; + break; + } + } + if (!isExcepted) { + state.activeKeywords.add(keyword); + } + } + } + + // Re-apply unchecked status + for (const keyword of uncheckedKeywords) { + state.activeKeywords.delete(keyword); + state.manuallyUnchecked.add(keyword); + } + + console.debug('[handleExceptionToggle] Keyword counts after rebuild:', { + activeKeywords: state.activeKeywords.size, + manuallyUnchecked: state.manuallyUnchecked.size + }); + } + + // Create a new debounced update for this call + console.debug('[handleExceptionToggle] Creating debounced update'); + const debouncedUpdate = createDebouncedUpdate(); + await debouncedUpdate(async () => { + console.debug('[handleExceptionToggle] Executing debounced update'); + renderInterface(); + await saveState(); + console.debug('[handleExceptionToggle] Completed interface render and state save'); + }); +} + +// Re-export core functions +export { updateSimpleModeState, initializeState }; diff --git a/js/handlers/context/contextState.js b/js/handlers/context/contextState.js new file mode 100644 index 0000000..23d8f86 --- /dev/null +++ b/js/handlers/context/contextState.js @@ -0,0 +1,200 @@ +import { state, saveState, getStorageKey } from '../../state.js'; +import { renderInterface } from '../../renderer.js'; +import { cache } from './contextCache.js'; +import { isKeywordActive } from '../keywordHandlers.js'; +import { + rebuildActiveKeywords, + createDebouncedUpdate, + activateContextKeywords, + notifyKeywordChanges +} from './contextUtils.js'; + +export async function updateSimpleModeState() { + if (!state.authenticated) return; + + // Store currently unchecked keywords + const uncheckedKeywords = new Set(state.manuallyUnchecked); + + if (state.mode === 'simple') { + // First derive context selections from advanced mode state + for (const contextId in state.contextGroups) { + const context = state.contextGroups[contextId]; + if (!context?.categories) continue; + + // Check if all non-excepted categories in this context are fully selected + let allCategoriesActive = true; + for (const category of context.categories) { + if (state.selectedExceptions.has(category)) continue; + + // Get keywords considering filter level + const keywords = cache.getKeywords(category, true); + let allActive = true; + + // Check if all keywords at current filter level are active + for (const keyword of keywords) { + if (!isKeywordActive(keyword)) { + allActive = false; + break; + } + } + + if (!allActive) { + allCategoriesActive = false; + break; + } + } + + // Update context selection based on category states + if (allCategoriesActive) { + state.selectedContexts.add(contextId); + } else { + state.selectedContexts.delete(contextId); + } + } + + // Then check if any selected contexts should be deselected + for (const contextId of Array.from(state.selectedContexts)) { + const contextState = cache.getContextState(contextId); + if (contextState === 'none') { + state.selectedContexts.delete(contextId); + } + } + + cache.clear(); + + // Clear and rebuild active keywords from derived contexts + state.activeKeywords.clear(); + for (const contextId of state.selectedContexts) { + activateContextKeywords(contextId, cache); + } + + // Add only original muted keywords that aren't already active and weren't manually unchecked + for (const keyword of state.originalMutedKeywords) { + if (!state.activeKeywords.has(keyword) && !state.manuallyUnchecked.has(keyword)) { + state.activeKeywords.add(keyword); + } + } + + // Re-apply unchecked status + for (const keyword of uncheckedKeywords) { + state.activeKeywords.delete(keyword); + state.manuallyUnchecked.add(keyword); + } + } + + // Create a new debounced update for this call with state + const debouncedUpdate = createDebouncedUpdate(); + await debouncedUpdate(async () => { + renderInterface(); + await saveState(); + }); +} + +export async function initializeState() { + if (!state.authenticated) return; + + state.selectedContexts.clear(); + state.selectedExceptions.clear(); + state.activeKeywords.clear(); + cache.clear(); + + const saved = localStorage.getItem(getStorageKey()); + if (saved) { + try { + const data = JSON.parse(saved); + + if (data.selectedContexts) { + state.selectedContexts = new Set(data.selectedContexts); + } + + if (data.selectedExceptions) { + const validExceptions = new Set(); + for (const contextId of state.selectedContexts) { + const context = state.contextGroups[contextId]; + if (context?.categories) { + context.categories.forEach(category => { + if (data.selectedExceptions.includes(category)) { + validExceptions.add(category); + } + }); + } + } + state.selectedExceptions = validExceptions; + } + + if (data.manuallyUnchecked) { + state.manuallyUnchecked = new Set(data.manuallyUnchecked); + } + + if (state.mode === 'simple') { + // First derive context selections from advanced mode state + for (const contextId in state.contextGroups) { + const context = state.contextGroups[contextId]; + if (!context?.categories) continue; + + // Check if all non-excepted categories in this context are fully selected + let allCategoriesActive = true; + for (const category of context.categories) { + if (state.selectedExceptions.has(category)) continue; + + // Get keywords considering filter level + const keywords = cache.getKeywords(category, true); + let allActive = true; + + // Check if all keywords at current filter level are active + for (const keyword of keywords) { + if (!isKeywordActive(keyword)) { + allActive = false; + break; + } + } + + if (!allActive) { + allCategoriesActive = false; + break; + } + } + + // Update context selection based on category states + if (allCategoriesActive) { + state.selectedContexts.add(contextId); + } else { + state.selectedContexts.delete(contextId); + } + } + + // Clear and rebuild active keywords from derived contexts + state.activeKeywords.clear(); + for (const contextId of state.selectedContexts) { + activateContextKeywords(contextId, cache); + } + + // Add only original muted keywords that aren't already active and weren't manually unchecked + for (const keyword of state.originalMutedKeywords) { + if (!state.activeKeywords.has(keyword) && !state.manuallyUnchecked.has(keyword)) { + state.activeKeywords.add(keyword); + } + } + + // Re-apply unchecked status + for (const keyword of Array.from(state.manuallyUnchecked)) { + state.activeKeywords.delete(keyword); + } + } + + // Create a new debounced update for this call with state + const debouncedUpdate = createDebouncedUpdate(); + await debouncedUpdate(async () => { + renderInterface(); + await saveState(); + }); + } catch (error) { + console.error('Error initializing state:', error); + state.selectedContexts.clear(); + state.selectedExceptions.clear(); + state.activeKeywords.clear(); + // Don't clear manuallyUnchecked on error + await saveState(); + } + } +} diff --git a/js/handlers/context/contextUtils.js b/js/handlers/context/contextUtils.js new file mode 100644 index 0000000..0bf43a9 --- /dev/null +++ b/js/handlers/context/contextUtils.js @@ -0,0 +1,92 @@ +import { state } from '../../state.js'; + +// Helper function to notify keyword changes +export function notifyKeywordChanges() { + document.dispatchEvent(new CustomEvent('keywordsUpdated', { + detail: { count: state.activeKeywords.size } + })); +} + +// Enhanced debounced UI updates with frame timing +export const createDebouncedUpdate = () => { + let timeout; + let frameRequest; + return async (fn) => { + if (timeout) clearTimeout(timeout); + if (frameRequest) cancelAnimationFrame(frameRequest); + + timeout = setTimeout(() => { + frameRequest = requestAnimationFrame(async () => { + await fn(); + notifyKeywordChanges(); + }); + }, 16); + }; +}; + +// Batch process keywords +export function processBatchKeywords(keywords, operation) { + const chunkSize = 100; + const chunks = Array.from(keywords); + + let index = 0; + function processChunk() { + const chunk = chunks.slice(index, index + chunkSize); + if (chunk.length === 0) return; + + chunk.forEach(operation); + index += chunkSize; + + if (index < chunks.length) { + requestAnimationFrame(processChunk); + } + } + + processChunk(); +} + +// Helper function to activate context keywords +export function activateContextKeywords(contextId, cache) { + const context = state.contextGroups[contextId]; + if (!context?.categories) return; + + for (const category of context.categories) { + if (state.selectedExceptions.has(category)) continue; + // Get keywords considering filter level (sortByWeight = true) + const keywords = cache.getKeywords(category, true); + processBatchKeywords(keywords, keyword => { + // Only activate if not manually unchecked + if (!state.manuallyUnchecked.has(keyword)) { + state.activeKeywords.add(keyword); + } + }); + } +} + +// Helper function to rebuild active keywords +export function rebuildActiveKeywords(cache) { + // Only rebuild keywords in simple mode + if (state.mode === 'simple') { + // Store currently unchecked keywords + const uncheckedKeywords = new Set(state.manuallyUnchecked); + + // Clear and rebuild active keywords + state.activeKeywords.clear(); + for (const contextId of state.selectedContexts) { + activateContextKeywords(contextId, cache); + } + + // Add only original muted keywords that aren't already active and weren't manually unchecked + for (const keyword of state.originalMutedKeywords) { + if (!state.activeKeywords.has(keyword) && !state.manuallyUnchecked.has(keyword)) { + state.activeKeywords.add(keyword); + } + } + + // Re-apply unchecked status + for (const keyword of uncheckedKeywords) { + state.activeKeywords.delete(keyword); + state.manuallyUnchecked.add(keyword); + } + } +} diff --git a/js/handlers/contextHandlers.js b/js/handlers/contextHandlers.js new file mode 100644 index 0000000..31200b1 --- /dev/null +++ b/js/handlers/contextHandlers.js @@ -0,0 +1,25 @@ +// Import state and internal handlers +import { state } from '../state.js'; +import { + handleContextToggle as _handleContextToggle, + handleExceptionToggle as _handleExceptionToggle, + updateSimpleModeState as _updateSimpleModeState, + initializeState as _initializeState +} from './context/contextHandlers.js'; + +// Wrap the imported functions to automatically pass state +export async function handleContextToggle(contextId) { + return await _handleContextToggle(state, contextId); +} + +export async function handleExceptionToggle(category) { + return await _handleExceptionToggle(state, category); +} + +export async function updateSimpleModeState() { + return await _updateSimpleModeState(state); +} + +export async function initializeState() { + return await _initializeState(state); +} diff --git a/js/handlers/index.js b/js/handlers/index.js new file mode 100644 index 0000000..b15e220 --- /dev/null +++ b/js/handlers/index.js @@ -0,0 +1,15 @@ +export { handleAuth, handleLogout } from './authHandlers.js'; +export { handleContextToggle, handleExceptionToggle, updateSimpleModeState } from './context/contextHandlers.js'; +export { handleKeywordToggle, handleCategoryToggle, handleEnableAll, handleDisableAll } from './keywordHandlers.js'; +export { handleMuteSubmit, initializeKeywordState } from './muteHandlers.js'; +export { switchMode, handleRefreshData, showApp } from './uiHandlers.js'; +export { handleFooterThemeToggle } from './themeHandlers.js'; +export { + handleSettingsModalToggle, + applyAppearanceSettings, + loadAppearanceSettings, + saveAppearanceSettings, + loadMuteSettings, + saveMuteSettings, + getExpirationDate +} from './settingsHandlers.js'; diff --git a/js/handlers/keywordHandlers.js b/js/handlers/keywordHandlers.js new file mode 100644 index 0000000..ec40bca --- /dev/null +++ b/js/handlers/keywordHandlers.js @@ -0,0 +1,272 @@ +import { state, saveState } from '../state.js'; +import { getAllKeywordsForCategory, filterKeywordGroups } from '../categoryManager.js'; +import { renderInterface } from '../renderer.js'; +import { updateSimpleModeState } from './contextHandlers.js'; + +// Enhanced keyword cache with shorter timeout +const keywordCache = { + categoryKeywords: new Map(), + lastUpdate: 0, + updateThreshold: 16, // Reduced to one frame to match state.js + + shouldUpdate() { + const now = Date.now(); + if (now - this.lastUpdate < this.updateThreshold) return false; + this.lastUpdate = now; + return true; + }, + + getKeywordsForCategory(category) { + if (!this.categoryKeywords.has(category) || this.shouldUpdate()) { + this.categoryKeywords.set(category, new Set(getAllKeywordsForCategory(category))); + } + return this.categoryKeywords.get(category); + }, + + clear() { + this.categoryKeywords.clear(); + this.lastUpdate = 0; + } +}; + +// Debounced UI updates with frame timing +const debouncedUpdate = (() => { + let timeout; + let frameRequest; + return (fn) => { + if (timeout) clearTimeout(timeout); + if (frameRequest) cancelAnimationFrame(frameRequest); + + timeout = setTimeout(() => { + frameRequest = requestAnimationFrame(() => { + fn(); + notifyKeywordChanges(); + }); + }, 16); + }; +})(); + +// Batch process keywords +function processBatchKeywords(keywords, operation) { + const chunkSize = 100; + const chunks = Array.from(keywords); + + let index = 0; + function processChunk() { + const chunk = chunks.slice(index, index + chunkSize); + if (chunk.length === 0) { + // Save state after all chunks are processed + saveState(); + return; + } + + chunk.forEach(operation); + index += chunkSize; + + if (index < chunks.length) { + requestAnimationFrame(processChunk); + } else { + // Save state after final chunk + saveState(); + } + } + + processChunk(); +} + +// Helper function to notify keyword changes +function notifyKeywordChanges() { + document.dispatchEvent(new CustomEvent('keywordsUpdated', { + detail: { count: state.activeKeywords.size } + })); +} + +// Optimized checkbox update with proper CSS escaping +function updateCheckboxes(category, enabled) { + requestAnimationFrame(() => { + const escapedCategory = CSS.escape(category.replace(/\s+/g, '-').toLowerCase()); + // Use more specific selectors for better performance + const sidebarCheckbox = document.querySelector(`.category-item[data-category="${CSS.escape(category)}"] > input[type="checkbox"]`); + const mainCheckbox = document.querySelector(`#category-${escapedCategory} > input[type="checkbox"]`); + const keywordCheckboxes = document.querySelectorAll(`#category-${escapedCategory} .keywords-container input[type="checkbox"]`); + + if (sidebarCheckbox) { + sidebarCheckbox.checked = enabled; + sidebarCheckbox.indeterminate = false; + } + if (mainCheckbox) { + mainCheckbox.checked = enabled; + mainCheckbox.indeterminate = false; + } + keywordCheckboxes.forEach(checkbox => { + checkbox.checked = enabled; + }); + }); +} + +// Helper to check if keyword is active (case-insensitive) +export function isKeywordActive(keyword) { + const lowerKeyword = keyword.toLowerCase(); + for (const activeKeyword of state.activeKeywords) { + if (activeKeyword.toLowerCase() === lowerKeyword) { + return true; + } + } + return false; +} + +// Helper to remove keyword (case-insensitive) +function removeKeyword(keyword) { + const lowerKeyword = keyword.toLowerCase(); + for (const activeKeyword of state.activeKeywords) { + if (activeKeyword.toLowerCase() === lowerKeyword) { + state.activeKeywords.delete(activeKeyword); + break; + } + } +} + +export function handleKeywordToggle(keyword, enabled) { + if (enabled) { + // If manually checking, remove from unchecked list + state.manuallyUnchecked.delete(keyword); + // First remove any existing case variations + removeKeyword(keyword); + // Then add with original case + state.activeKeywords.add(keyword); + } else { + // If manually unchecking, add to unchecked list + state.manuallyUnchecked.add(keyword); + removeKeyword(keyword); + } + + debouncedUpdate(() => { + updateSimpleModeState(); + renderInterface(); + saveState(); + }); +} + +export function handleCategoryToggle(category, currentState) { + const keywords = keywordCache.getKeywordsForCategory(category); + const shouldEnable = currentState !== 'all'; + + processBatchKeywords(keywords, keyword => { + if (shouldEnable) { + // If enabling category, remove keywords from unchecked list + state.manuallyUnchecked.delete(keyword); + // First remove any existing case variations + removeKeyword(keyword); + // Then add with original case if not already active + if (!isKeywordActive(keyword)) { + state.activeKeywords.add(keyword); + } + } else { + // If disabling category, add keywords to unchecked list + state.manuallyUnchecked.add(keyword); + removeKeyword(keyword); + } + }); + + updateCheckboxes(category, shouldEnable); + + debouncedUpdate(() => { + updateSimpleModeState(); + renderInterface(); + saveState(); + }); +} + +export function handleEnableAll() { + // Clear manually unchecked since this is an explicit enable all + state.manuallyUnchecked.clear(); + // Set flag to indicate enable all was used + state.lastBulkAction = 'enable'; + + if (state.searchTerm) { + // When searching, only enable filtered keywords + const filteredGroups = filterKeywordGroups(); + processBatchKeywords(Object.values(filteredGroups).flat(), keyword => { + // First remove any existing case variations + removeKeyword(keyword); + // Then add with original case if not already active + if (!isKeywordActive(keyword)) { + state.activeKeywords.add(keyword); + } + }); + } else { + // When not searching, enable all keywords from all categories + const allCategories = [ + ...Object.keys(state.keywordGroups), + ...Object.keys(state.displayConfig.combinedCategories || {}) + ]; + + // Enable all contexts first + Object.keys(state.contextGroups).forEach(contextId => { + state.selectedContexts.add(contextId); + }); + + let processedCount = 0; + function processNextCategory() { + if (processedCount >= allCategories.length) { + debouncedUpdate(() => { + updateSimpleModeState(); + renderInterface(); + saveState(); + }); + return; + } + + const category = allCategories[processedCount++]; + const keywords = keywordCache.getKeywordsForCategory(category); + processBatchKeywords(keywords, keyword => { + // First remove any existing case variations + removeKeyword(keyword); + // Then add with original case if not already active + if (!isKeywordActive(keyword)) { + state.activeKeywords.add(keyword); + } + }); + + requestAnimationFrame(processNextCategory); + } + + processNextCategory(); + return; // Early return since updates are handled in processNextCategory + } + + debouncedUpdate(() => { + updateSimpleModeState(); + renderInterface(); + saveState(); + }); +} + +export function handleDisableAll() { + // Clear manually unchecked since this is an explicit disable all + state.manuallyUnchecked.clear(); + // Set flag to indicate disable all was used + state.lastBulkAction = 'disable'; + + if (state.searchTerm) { + // When searching, only disable filtered keywords + const filteredGroups = filterKeywordGroups(); + processBatchKeywords(Object.values(filteredGroups).flat(), keyword => { + removeKeyword(keyword); + }); + } else { + // Clear all contexts first + state.selectedContexts.clear(); + state.selectedExceptions.clear(); + + // When not searching, disable all keywords + state.activeKeywords.clear(); + keywordCache.clear(); + } + + debouncedUpdate(() => { + updateSimpleModeState(); + renderInterface(); + saveState(); + }); +} diff --git a/js/handlers/modalHandlers.js b/js/handlers/modalHandlers.js new file mode 100644 index 0000000..6317875 --- /dev/null +++ b/js/handlers/modalHandlers.js @@ -0,0 +1,107 @@ +import { loadMuteSettings, saveMuteSettings } from '../settings/muteSettings.js'; +import { loadAppearanceSettings, saveAppearanceSettings, updateAppearanceUI } from '../settings/appearanceSettings.js'; +import { getStorageKey } from '../state.js'; + +export function updateWarningVisibility() { + const duration = document.querySelector('input[name="duration"]:checked')?.value; + const warningElement = document.getElementById('settings-warning'); + if (duration) { + warningElement.classList.toggle('visible', duration !== 'forever'); + } +} + +function setupMuteSettingsListeners() { + // Duration radio buttons + document.querySelectorAll('input[name="duration"]').forEach(radio => { + radio.addEventListener('change', () => { + const settings = { + duration: document.querySelector('input[name="duration"]:checked').value, + scope: document.querySelector('input[name="scope"]:checked').value, + excludeFollows: document.getElementById('exclude-follows').checked + }; + saveMuteSettings(settings); + updateWarningVisibility(); + }); + }); + + // Scope radio buttons + document.querySelectorAll('input[name="scope"]').forEach(radio => { + radio.addEventListener('change', () => { + const settings = { + duration: document.querySelector('input[name="duration"]:checked').value, + scope: document.querySelector('input[name="scope"]:checked').value, + excludeFollows: document.getElementById('exclude-follows').checked + }; + saveMuteSettings(settings); + }); + }); + + // Exclude follows checkbox + const excludeFollows = document.getElementById('exclude-follows'); + excludeFollows.addEventListener('change', () => { + const settings = { + duration: document.querySelector('input[name="duration"]:checked').value, + scope: document.querySelector('input[name="scope"]:checked').value, + excludeFollows: excludeFollows.checked + }; + saveMuteSettings(settings); + }); +} + +function setupAppearanceSettingsListeners() { + // Theme mode switches + document.querySelectorAll('.theme-mode-switch').forEach(btn => { + btn.addEventListener('click', () => { + const settings = window.appearanceSettings || loadAppearanceSettings(); + settings.colorMode = btn.dataset.theme; + saveAppearanceSettings(settings); + }); + }); + + // Font switches + document.querySelectorAll('.font-switch[data-font]').forEach(btn => { + btn.addEventListener('click', () => { + const settings = window.appearanceSettings || loadAppearanceSettings(); + settings.font = btn.dataset.font; + saveAppearanceSettings(settings); + }); + }); + + // Font size switches + document.querySelectorAll('.font-switch[data-size]').forEach(btn => { + btn.addEventListener('click', () => { + const settings = window.appearanceSettings || loadAppearanceSettings(); + settings.fontSize = btn.dataset.size; + saveAppearanceSettings(settings); + }); + }); +} + +export function handleSettingsModalToggle() { + const modal = document.getElementById('settings-modal'); + modal.classList.toggle('visible'); + + if (modal.classList.contains('visible')) { + // Load mute settings + const muteSettings = loadMuteSettings(); + document.querySelector(`input[name="duration"][value="${muteSettings.duration}"]`).checked = true; + document.querySelector(`input[name="scope"][value="${muteSettings.scope}"]`).checked = true; + document.getElementById('exclude-follows').checked = muteSettings.excludeFollows; + + // Load appearance settings + const appearanceSettings = loadAppearanceSettings(); + updateAppearanceUI(appearanceSettings); + window.appearanceSettings = appearanceSettings; + + // Setup all change listeners + setupMuteSettingsListeners(); + setupAppearanceSettingsListeners(); + updateWarningVisibility(); + } else { + // When modal is closed, use the centralized switchMode function + // to ensure consistent state management + const savedState = localStorage.getItem(getStorageKey()); + const currentMode = savedState ? JSON.parse(savedState).mode || 'simple' : 'simple'; + window.switchMode(currentMode); + } +} diff --git a/js/handlers/muteHandlers.js b/js/handlers/muteHandlers.js new file mode 100644 index 0000000..d68d462 --- /dev/null +++ b/js/handlers/muteHandlers.js @@ -0,0 +1,197 @@ +import { state, canUnmuteKeyword, getMuteUnmuteCounts, saveState } from '../state.js'; +import { blueskyService } from '../bluesky.js'; +import { renderInterface } from '../renderer.js'; +import { showNotification } from '../utils/notifications.js'; + +// Enhanced keyword cache for mute operations +const muteCache = { + ourKeywordsMap: null, + lastUpdate: 0, + updateThreshold: 50, + + shouldUpdate() { + const now = Date.now(); + if (now - this.lastUpdate < this.updateThreshold) return false; + this.lastUpdate = now; + return true; + }, + + getOurKeywordsMap() { + if (this.ourKeywordsMap && !this.shouldUpdate()) { + console.debug('[muteCache] Returning cached keyword map'); + return this.ourKeywordsMap; + } + + console.debug('[muteCache] Building new keyword map'); + const map = new Map(); + Object.entries(state.keywordGroups).forEach(([category, categoryData]) => { + const categoryInfo = categoryData[category]; + if (categoryInfo?.keywords) { + Object.keys(categoryInfo.keywords).forEach(keyword => { + map.set(keyword.toLowerCase(), keyword); + }); + } + }); + this.ourKeywordsMap = map; + console.debug('[muteCache] New keyword map size:', map.size); + return map; + }, + + clear() { + console.debug('[muteCache] Clearing cache'); + this.ourKeywordsMap = null; + this.lastUpdate = 0; + } +}; + +// Debounced UI updates with frame timing +const debouncedUpdate = (() => { + let timeout; + let frameRequest; + return (fn) => { + if (timeout) clearTimeout(timeout); + if (frameRequest) cancelAnimationFrame(frameRequest); + + timeout = setTimeout(() => { + frameRequest = requestAnimationFrame(() => { + console.debug('[debouncedUpdate] Executing update'); + fn(); + }); + }, 16); + }; +})(); + +// Process all keywords immediately without batching +function processKeywords(keywords, operation) { + console.debug('[processKeywords] Processing', keywords.length, 'keywords'); + keywords.forEach(operation); + console.debug('[processKeywords] Finished processing all keywords'); +} + +export async function handleMuteSubmit() { + try { + console.debug('[handleMuteSubmit] Starting mute operation'); + + // Get selected keywords efficiently + const selectedKeywords = Array.from(state.activeKeywords); + console.debug('[handleMuteSubmit] Selected keywords:', selectedKeywords.length); + + // Use cached keyword map + const ourKeywordsMap = muteCache.getOurKeywordsMap(); + const ourKeywords = new Set(Array.from(ourKeywordsMap.keys())); + console.debug('[handleMuteSubmit] Our keywords total:', ourKeywords.size); + + // Get the counts before update + const { toMute, toUnmute } = getMuteUnmuteCounts(); + console.debug('[handleMuteSubmit] To mute:', toMute, 'To unmute:', toUnmute); + + // Update muted keywords + console.debug('[handleMuteSubmit] Updating keywords on Bluesky'); + await blueskyService.mute.updateMutedKeywords(selectedKeywords, Array.from(ourKeywords)); + console.debug('[handleMuteSubmit] Bluesky update complete'); + + // If this mute/unmute follows an enable/disable all action, clear exceptions + if (state.lastBulkAction) { + console.debug('[handleMuteSubmit] Clearing exceptions after bulk action'); + state.selectedExceptions.clear(); + state.lastBulkAction = null; // Reset the flag + } + + // Clear all caches and update counts + console.debug('[handleMuteSubmit] Clearing caches'); + muteCache.clear(); + console.debug('[handleMuteSubmit] Updating mute count in BlueskyService'); + await blueskyService.updateMuteCount(); + + // Get fresh muted keywords from Bluesky + console.debug('[handleMuteSubmit] Reinitializing keyword state'); + await initializeKeywordState(); + + // Save state after successful mute/unmute + console.debug('[handleMuteSubmit] Saving state'); + await saveState(); + + // Update UI with debouncing + console.debug('[handleMuteSubmit] Scheduling UI update'); + debouncedUpdate(async () => { + console.debug('[handleMuteSubmit] Rendering interface'); + renderInterface(); + + // Show appropriate notification + if (toMute > 0 && toUnmute > 0) { + showNotification(`Successfully muted ${toMute} and unmuted ${toUnmute} keywords`); + } else if (toMute > 0) { + showNotification(`Successfully muted ${toMute} ${toMute === 1 ? 'keyword' : 'keywords'}`); + } else if (toUnmute > 0) { + showNotification(`Successfully unmuted ${toUnmute} ${toUnmute === 1 ? 'keyword' : 'keywords'}`); + } + console.debug('[handleMuteSubmit] UI update complete'); + }); + } catch (error) { + console.error('[handleMuteSubmit] Failed to process mutes:', error); + + // Convert technical errors into user-friendly messages + let userMessage = 'Failed to update mutes. '; + if (error.message.includes('not logged in')) { + userMessage += 'Please log in and try again.'; + } else if (error.message.includes('401')) { + userMessage += 'Your session has expired. Please log in again.'; + } else if (error.message.includes('429')) { + userMessage += 'Too many requests. Please wait a moment and try again.'; + } else if (error.message.includes('503')) { + userMessage += 'Bluesky service is temporarily unavailable. Please try again later.'; + } else { + userMessage += error.message; + } + + showNotification(userMessage, 'error'); + } +} + +export async function initializeKeywordState() { + try { + console.debug('[initializeKeywordState] Starting initialization'); + + // Get user's muted keywords from Bluesky with force refresh + const userKeywords = await blueskyService.mute.getMutedKeywords(true); + console.debug('[initializeKeywordState] Fetched', userKeywords.length, 'keywords from Bluesky'); + + // Only clear mute tracking state, leave contexts alone + console.debug('[initializeKeywordState] Clearing state'); + const beforeOriginal = state.originalMutedKeywords.size; + const beforeSession = state.sessionMutedKeywords.size; + state.originalMutedKeywords.clear(); + state.sessionMutedKeywords.clear(); + console.debug('[initializeKeywordState] Cleared originalMutedKeywords (was:', beforeOriginal, ') and sessionMutedKeywords (was:', beforeSession, ')'); + + // Track which keywords are muted in Bluesky + console.debug('[initializeKeywordState] Processing user keywords'); + processKeywords(userKeywords, keyword => { + const lowerKeyword = keyword.toLowerCase(); + state.originalMutedKeywords.add(lowerKeyword); + }); + console.debug('[initializeKeywordState] Final originalMutedKeywords size:', state.originalMutedKeywords.size); + + } catch (error) { + console.error('[initializeKeywordState] Failed to initialize keyword state:', error); + showNotification('Failed to load your muted keywords. Please refresh the page.', 'error'); + } +} + +// Helper to update button text +export function getButtonText() { + const { toMute, toUnmute } = getMuteUnmuteCounts(); + console.debug('[getButtonText] To mute:', toMute, 'To unmute:', toUnmute); + const parts = []; + + if (toMute > 0) { + parts.push(`Mute ${toMute} new`); + } + if (toUnmute > 0) { + parts.push(`Unmute ${toUnmute} existing`); + } + + const text = parts.length > 0 ? parts.join(', ') : 'No changes'; + console.debug('[getButtonText] Button text:', text); + return text; +} diff --git a/js/handlers/settingsHandlers.js b/js/handlers/settingsHandlers.js new file mode 100644 index 0000000..8d4e75c --- /dev/null +++ b/js/handlers/settingsHandlers.js @@ -0,0 +1,29 @@ +// Re-export all settings-related functionality from their new modules +import { loadMuteSettings, saveMuteSettings, getExpirationDate } from '../settings/muteSettings.js'; +import { loadAppearanceSettings, saveAppearanceSettings, applyAppearanceSettings } from '../settings/appearanceSettings.js'; +import { handleSettingsModalToggle } from './modalHandlers.js'; +import { handleFooterThemeToggle } from './themeHandlers.js'; +import { initializeSettings } from '../settings/init.js'; + +// Expose handlers to window for HTML onclick handlers +if (typeof window !== 'undefined') { + window.settingsHandlers = { + handleSettingsModalToggle, + handleFooterThemeToggle, + saveAppearanceSettings, + saveMuteSettings + }; +} + +// Export for module usage +export { + loadMuteSettings, + saveMuteSettings, + getExpirationDate, + loadAppearanceSettings, + saveAppearanceSettings, + applyAppearanceSettings, + handleSettingsModalToggle, + handleFooterThemeToggle, + initializeSettings +}; diff --git a/js/handlers/themeHandlers.js b/js/handlers/themeHandlers.js new file mode 100644 index 0000000..2d76dd3 --- /dev/null +++ b/js/handlers/themeHandlers.js @@ -0,0 +1,22 @@ +import { loadAppearanceSettings, saveAppearanceSettings } from '../settings/appearanceSettings.js'; + +export function handleFooterThemeToggle() { + const settings = loadAppearanceSettings(); + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + const isDark = settings.colorMode === 'dark' || (settings.colorMode === 'system' && prefersDark); + const html = document.documentElement; + const currentTheme = html.getAttribute('data-theme'); + + // Toggle between light and dark + const newColorMode = currentTheme === 'dim' ? 'light' : 'dark'; + settings.colorMode = newColorMode; + + // Save and apply the new settings + saveAppearanceSettings(settings); + + // Update footer toggle state + const toggle = document.getElementById('footer-theme-toggle'); + if (toggle) { + toggle.classList.toggle('dark', newColorMode === 'dark'); + } +} diff --git a/js/handlers/uiHandlers.js b/js/handlers/uiHandlers.js new file mode 100644 index 0000000..e84b8c8 --- /dev/null +++ b/js/handlers/uiHandlers.js @@ -0,0 +1,117 @@ +import { elements } from '../dom.js'; +import { state, saveState } from '../state.js'; +import { renderInterface } from '../renderer.js'; +import { refreshAllData } from '../api.js'; +import { updateSimpleModeState } from './contextHandlers.js'; +import { updateStatusCounts, updateMuteButton, updateEnableDisableButtons, updateLastUpdate } from '../renderers/uiRenderer.js'; +import { isKeywordActive } from './keywordHandlers.js'; + +// Function to ensure mode toggles always reflect current state +export function updateModeToggles() { + document.querySelectorAll('.interface-mode-switch').forEach(toggle => { + toggle.classList.toggle('active', toggle.dataset.mode === state.mode); + }); +} + +// Single source of truth for mode management +export function switchMode(mode) { + if (mode !== 'simple' && mode !== 'advanced') { + mode = 'simple'; // Default to simple mode if invalid + } + + // Update state + const previousMode = state.mode; + state.mode = mode; + + // Ensure mode toggles reflect current state + updateModeToggles(); + + // Update mode visibility + const simpleMode = document.getElementById('simple-mode'); + const advancedMode = document.getElementById('advanced-mode'); + if (simpleMode) simpleMode.classList.toggle('hidden', mode !== 'simple'); + if (advancedMode) advancedMode.classList.toggle('hidden', mode !== 'advanced'); + + // Only update state when switching TO simple mode + // This ensures contexts drive keyword selection in simple mode + // But allows direct keyword selection in advanced mode + if (mode === 'simple' && previousMode === 'advanced') { + updateSimpleModeState(); + } + + // Always save state and update interface + saveState(); + renderInterface(); +} + +export async function handleRefreshData() { + const refreshButton = document.getElementById('refresh-data'); + if (!refreshButton) return; + + // Store the original SVG content + const svgIcon = ` + + `; + + const updateButtonContent = (svg, text) => { + refreshButton.innerHTML = `${svg}${text}`; + }; + + try { + // Add spinning animation class + refreshButton.classList.add('spinning'); + updateButtonContent(svgIcon, 'Refreshing...'); + refreshButton.disabled = true; + + await refreshAllData(); + + // Instead of full renderInterface, do targeted updates + // Update checkbox states without full redraw + document.querySelectorAll('input[type="checkbox"]').forEach(checkbox => { + if (checkbox.hasAttribute('onchange')) { + const keyword = checkbox.parentElement.textContent.trim(); + checkbox.checked = isKeywordActive(keyword); + } + }); + + // Update counts and status + updateStatusCounts(); + updateMuteButton(); + updateEnableDisableButtons(); + updateLastUpdate(); + // Ensure mode toggles stay in sync + updateModeToggles(); + + // Show success state briefly + refreshButton.classList.remove('spinning'); + updateButtonContent(svgIcon, 'Updated!'); + + // Reset button after a delay + setTimeout(() => { + updateButtonContent(svgIcon, 'Refresh Data'); + refreshButton.disabled = false; + }, 1000); + + } catch (error) { + console.error('Failed to refresh data:', error); + refreshButton.classList.remove('spinning'); + updateButtonContent(svgIcon, 'Refresh Failed'); + + // Reset button after a delay + setTimeout(() => { + updateButtonContent(svgIcon, 'Refresh Data'); + refreshButton.disabled = false; + }, 2000); + } +} + +export function showApp() { + elements.landingPage.classList.add('hidden'); + elements.appInterface.classList.remove('hidden'); + + // Ensure mode is set properly when showing app + switchMode(state.mode); +} + +// Expose refreshData function to window object for use in settings modal +window.refreshData = handleRefreshData; diff --git a/js/keywordState.js b/js/keywordState.js new file mode 100644 index 0000000..5593186 --- /dev/null +++ b/js/keywordState.js @@ -0,0 +1,96 @@ +import { state } from './state.js'; +import { keywordCache } from './stateCache.js'; + +// Helper to get all keywords from our list with their original case +export function getKeywordsWithCase() { + const keywordMap = new Map(); + Object.entries(state.keywordGroups).forEach(([category, categoryData]) => { + const categoryInfo = categoryData[category]; + if (categoryInfo?.keywords) { + Object.keys(categoryInfo.keywords).forEach(keyword => { + keywordMap.set(keyword.toLowerCase(), keyword); + }); + } + }); + return keywordMap; +} + +// Helper to get all keywords from our list +export function getOurKeywords() { + // Return cached version if valid + if (keywordCache.ourKeywords && !keywordCache.shouldUpdate()) { + return keywordCache.ourKeywords; + } + + const ourKeywords = new Set(); + Object.entries(state.keywordGroups).forEach(([category, categoryData]) => { + // Get the category info which contains the keywords + const categoryInfo = categoryData[category]; + if (categoryInfo?.keywords) { + // Add each keyword to our set + Object.keys(categoryInfo.keywords).forEach(keyword => { + ourKeywords.add(keyword.toLowerCase()); + }); + } + }); + + // Cache the result + keywordCache.ourKeywords = ourKeywords; + return ourKeywords; +} + +// Helper to determine if a keyword can be unmuted +export function canUnmuteKeyword(keyword) { + // Only allow unmuting if: + // 1. It's in our list of keywords (case-insensitive) + // 2. It was previously muted (either originally or this session) + const ourKeywords = getOurKeywords(); + const lowerKeyword = keyword.toLowerCase(); + return ourKeywords.has(lowerKeyword) && + (state.originalMutedKeywords.has(lowerKeyword) || state.sessionMutedKeywords.has(lowerKeyword)); +} + +// Optimized helper to get mute/unmute counts +export function getMuteUnmuteCounts() { + const ourKeywords = getOurKeywords(); + let toMute = 0; + let toUnmute = 0; + + // Create lowercase lookup Set for active keywords + const activeLowerKeywords = new Set(); + for (const keyword of state.activeKeywords) { + activeLowerKeywords.add(keyword.toLowerCase()); + } + + // Create lowercase lookup Set for originally muted keywords + const originalLowerKeywords = new Set(); + for (const keyword of state.originalMutedKeywords) { + originalLowerKeywords.add(keyword.toLowerCase()); + } + + // Only count keywords from our list + for (const keyword of ourKeywords) { + const isActive = activeLowerKeywords.has(keyword); + const wasOriginallyMuted = originalLowerKeywords.has(keyword); + + if (isActive && !wasOriginallyMuted) { + // New keyword to mute + toMute++; + } else if (!isActive && wasOriginallyMuted) { + // Existing keyword to unmute + toUnmute++; + } + } + + return { toMute, toUnmute }; +} + +// Helper to set target keyword count and trigger refresh +export function setTargetKeywordCount(count) { + const validCounts = new Set([100, 300, 500, 2000]); + if (!validCounts.has(count)) { + throw new Error('Invalid target keyword count. Must be one of: 100, 300, 500, 2000'); + } + state.targetKeywordCount = count; + keywordCache.clear(); // Clear cache when count changes +} diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..c4a0e7a --- /dev/null +++ b/js/main.js @@ -0,0 +1,288 @@ +import { elements } from './dom.js'; +import { state, loadState, setTargetKeywordCount } from './state.js'; +import { fetchKeywordGroups, fetchContextGroups, fetchDisplayConfig } from './api.js'; +import { renderInterface } from './renderer.js'; +import { debounce } from './utils.js'; +import { blueskyService } from './bluesky.js'; +import { getAllKeywordsForCategory } from './categoryManager.js'; +import { + handleAuth, + handleLogout, + handleMuteSubmit, + switchMode, + handleEnableAll, + handleDisableAll, + handleContextToggle, + handleExceptionToggle, + handleCategoryToggle, + handleKeywordToggle, + handleRefreshData, + showApp, + updateSimpleModeState, + initializeKeywordState, + handleSettingsModalToggle, + handleFooterThemeToggle, + applyAppearanceSettings +} from './handlers/index.js'; + +// Initialize Application +async function init() { + try { + // Show loading state + const loadingOverlay = document.getElementById('loading-state'); + + // Apply appearance settings first + applyAppearanceSettings(); + + // Check if we're on the callback page + const isCallbackPage = window.location.pathname.includes('callback.html'); + if (isCallbackPage) { + // Only do auth setup on callback page + await blueskyService.setup(); + return; + } + + // Initialize Bluesky service and handle auth first + const result = await blueskyService.setup(); + if (result?.session) { + // Set DID in state before loading saved state + state.did = result.session.did; + state.authenticated = true; + + // Now load saved state + loadState(); + + // Load all required data + await Promise.all([ + fetchDisplayConfig(), + fetchKeywordGroups(), + fetchContextGroups() + ]); + + await showApp(); + // Initialize keyword state after authentication + await initializeKeywordState(); + } + + // Now that all data is loaded, initialize the UI + if (state.authenticated) { + // First update simple mode state if needed + if (state.mode === 'simple') { + updateSimpleModeState(); + } + // Then switch to the correct mode + switchMode(state.mode); + // Finally render the interface + renderInterface(); + + // Update SimpleMode component with loaded state + const simpleMode = document.querySelector('simple-mode'); + if (simpleMode) { + simpleMode.updateLevel(state.filterLevel); + simpleMode.updateExceptions(state.selectedExceptions); + } + } else if (elements.landingPage && elements.appInterface) { + elements.landingPage.classList.remove('hidden'); + elements.appInterface.classList.add('hidden'); + } + + setupEventListeners(); + + // Listen for Bluesky login state changes + window.addEventListener('blueskyLoginStateChanged', async (event) => { + state.authenticated = event.detail.isLoggedIn; + if (state.authenticated) { + // Set DID in state when user logs in + state.did = blueskyService.auth.session?.did; + await showApp(); + // Initialize keyword state after authentication + await initializeKeywordState(); + // Re-render interface to show checked keywords + renderInterface(); + + // Update SimpleMode component with current state + const simpleMode = document.querySelector('simple-mode'); + if (simpleMode) { + simpleMode.updateLevel(state.filterLevel); + simpleMode.updateExceptions(state.selectedExceptions); + } + } else { + // Clear DID when user logs out + state.did = null; + if (elements.landingPage && elements.appInterface) { + elements.landingPage.classList.remove('hidden'); + elements.appInterface.classList.add('hidden'); + } + } + }); + + // Hide loading state + if (loadingOverlay) { + loadingOverlay.classList.add('hidden'); + // Remove from DOM after transition + setTimeout(() => loadingOverlay.remove(), 300); + } + + // Add js-loaded class to body to show content + document.body.classList.add('js-loaded'); + } catch (error) { + console.error('Initialization failed:', error); + // Hide loading state even on error + const loadingOverlay = document.getElementById('loading-state'); + if (loadingOverlay) { + loadingOverlay.classList.add('hidden'); + setTimeout(() => loadingOverlay.remove(), 300); + } + } +} + +// Event Listeners +function setupEventListeners() { + elements.authButton?.addEventListener('click', handleAuth); + elements.logoutButton?.addEventListener('click', handleLogout); + elements.muteButton?.addEventListener('click', handleMuteSubmit); + elements.navMuteButton?.addEventListener('click', handleMuteSubmit); + elements.enableAllBtn?.addEventListener('click', handleEnableAll); + elements.disableAllBtn?.addEventListener('click', handleDisableAll); + elements.refreshButton?.addEventListener('click', handleRefreshData); + + // Add Enter key handler for login input + const handleInput = document.getElementById('bsky-handle-input'); + if (handleInput) { + handleInput.addEventListener('keypress', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleAuth(); + } + }); + } + + // Set up intersection observer for auth button visibility + if (elements.authButton) { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach(entry => { + // Check if the button is being intersected (covered) by other elements + const isVisible = entry.intersectionRatio === 1.0; + elements.authButton.style.visibility = isVisible ? 'visible' : 'hidden'; + }); + }, + { + threshold: 1.0, // Only trigger when button is fully visible/invisible + root: null // Use viewport as root + } + ); + + observer.observe(elements.authButton); + } + + // Helper function to notify keyword changes + function notifyKeywordChanges() { + document.dispatchEvent(new CustomEvent('keywordsUpdated', { + detail: { count: state.activeKeywords.size } + })); + } + + // Handle filter level changes from simple mode + document.addEventListener('filterLevelChange', (event) => { + const level = event.detail.level; + + // Map intensity levels to keyword counts based on performance thresholds + const levelToCount = { + 0: 100, // Minimal: ~100 highest weighted keywords + 1: 300, // Moderate: ~300 keywords + 2: 500, // Extensive: ~500 keywords + 3: 2000 // Complete: All keywords + }; + + // Update filter level in state to match event + state.filterLevel = level; + + // Store current exceptions + const currentExceptions = new Set(state.selectedExceptions); + + // Update target keyword count based on intensity level + setTargetKeywordCount(levelToCount[level]); + + // Clear and rebuild active keywords while preserving exceptions + state.activeKeywords.clear(); + state.selectedContexts.forEach(contextId => { + const context = state.contextGroups[contextId]; + if (context && context.categories) { + context.categories.forEach(category => { + if (!currentExceptions.has(category)) { + // Get keywords sorted by weight and limited by new target count + const keywords = getAllKeywordsForCategory(category, true); + keywords.forEach(keyword => state.activeKeywords.add(keyword)); + } + }); + } + }); + + // Notify about keyword changes + notifyKeywordChanges(); + + // Restore exceptions + state.selectedExceptions = currentExceptions; + + // Update interface with new filtered keywords + renderInterface(); + }); + + elements.profileButton?.addEventListener('click', () => { + state.menuOpen = !state.menuOpen; + elements.userMenuDropdown?.classList.toggle('visible', state.menuOpen); + }); + + document.addEventListener('click', (event) => { + if (!event.target.closest('.user-menu') && state.menuOpen && elements.userMenuDropdown) { + state.menuOpen = false; + elements.userMenuDropdown.classList.remove('visible'); + } + }); + + elements.sidebarSearch?.addEventListener('input', debounce((e) => { + state.searchTerm = e.target.value.toLowerCase(); + renderInterface(); + }, 300)); + + // Listen for system theme changes + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + applyAppearanceSettings(); + }); + + // Handle visibility change to restore state when page becomes visible + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible' && state.did) { + loadState(); + + // Re-render interface with restored state + renderInterface(); + // Re-apply mode + switchMode(state.mode); + + // Update SimpleMode component with current state + const simpleMode = document.querySelector('simple-mode'); + if (simpleMode) { + simpleMode.updateLevel(state.filterLevel); + simpleMode.updateExceptions(state.selectedExceptions); + } + } + }); +} + +// Make handlers available globally +window.handleContextToggle = handleContextToggle; +window.handleExceptionToggle = handleExceptionToggle; +window.handleCategoryToggle = handleCategoryToggle; +window.handleKeywordToggle = handleKeywordToggle; +window.settingsHandlers = { + handleSettingsModalToggle, + handleFooterThemeToggle +}; +window.switchMode = switchMode; + +// Initialize app +document.addEventListener('DOMContentLoaded', () => { + init(); +}); diff --git a/js/mute.js b/js/mute.js new file mode 100644 index 0000000..6877ba5 --- /dev/null +++ b/js/mute.js @@ -0,0 +1,186 @@ +import { Agent } from '@atproto/api' +import { loadMuteSettings, getExpirationDate } from './handlers/settingsHandlers.js' + +export class MuteService { + constructor(session) { + this.agent = session ? new Agent(session) : null; + this.session = session; + this.cachedKeywords = null; + this.cachedPreferences = null; + console.debug('[MuteService] MuteService initialized, has session:', !!session); + } + + setSession(session) { + console.debug('[MuteService] Setting new session in MuteService:', !!session); + this.agent = session ? new Agent(session) : null; + this.session = session; + // Clear caches when session changes + this.cachedKeywords = null; + this.cachedPreferences = null; + } + + async getMutedKeywords(forceRefresh = false) { + if (!this.session) { + console.debug('[MuteService] Cannot get muted keywords - not logged in'); + return []; + } + + // Return cached keywords if available and not forcing refresh + if (!forceRefresh && this.cachedKeywords !== null) { + console.debug('[MuteService] Returning cached muted keywords'); + return this.cachedKeywords; + } + + try { + // Create fresh agent instance to ensure latest session + const agent = new Agent(this.session); + + // Get user's preferences from Bluesky + console.debug('[MuteService] Fetching user preferences...'); + const response = await agent.api.app.bsky.actor.getPreferences(); + this.cachedPreferences = response.data.preferences; + + // Find the muted words preference + const mutedWordsPref = this.cachedPreferences.find( + pref => pref.$type === 'app.bsky.actor.defs#mutedWordsPref' + ); + + // Extract just the values from the muted words + const mutedKeywords = mutedWordsPref?.items?.map(item => item.value) || []; + + // Cache the result + this.cachedKeywords = mutedKeywords; + + // Log the counts + console.debug('[MuteService] User muted keywords:', mutedKeywords); + + return mutedKeywords; + } catch (error) { + console.error('[MuteService] Failed to get muted keywords:', error); + // Try to refresh session if we got a 401 + if (error.status === 401) { + // Dispatch event for session refresh + const refreshEvent = new CustomEvent('mutesky:session:refresh:needed'); + window.dispatchEvent(refreshEvent); + } + // Clear caches on error + this.cachedKeywords = null; + this.cachedPreferences = null; + throw new Error('Failed to fetch muted keywords from Bluesky'); + } + } + + async updateMutedKeywords(selectedKeywords, ourKeywordsList) { + // Early validation + if (!this.session) { + throw new Error('Cannot update keywords - not logged in'); + } + + if (!Array.isArray(selectedKeywords) || !Array.isArray(ourKeywordsList)) { + throw new Error('Invalid input: selected keywords must be provided as arrays'); + } + + try { + // Create fresh agent instance to ensure latest session + const agent = new Agent(this.session); + + // Always get fresh preferences for updates + console.debug('[MuteService] Getting current preferences...'); + const response = await agent.api.app.bsky.actor.getPreferences(); + this.cachedPreferences = response.data.preferences; + + // Find current muted words pref + const mutedWordsIndex = this.cachedPreferences.findIndex( + pref => pref.$type === 'app.bsky.actor.defs#mutedWordsPref' + ); + + // Create efficient lookup Set for our keywords + const ourKeywordsSet = new Set(ourKeywordsList.map(k => k.toLowerCase())); + + // Get current muted words or initialize empty + const currentMutedPref = mutedWordsIndex >= 0 ? this.cachedPreferences[mutedWordsIndex] : { + $type: 'app.bsky.actor.defs#mutedWordsPref', + items: [] + }; + + // Separate user's custom keywords (those not in our list) + const userCustomKeywords = currentMutedPref.items + .filter(item => !ourKeywordsSet.has(item.value.toLowerCase())) + .map(item => ({ + value: item.value, + targets: item.targets || ['content', 'tag'] + })); + + // Load mute settings + const settings = loadMuteSettings(); + const expiresAt = getExpirationDate(settings.duration); + + // Create new items for selected keywords with settings applied + const newManagedItems = selectedKeywords + .filter(keyword => ourKeywordsSet.has(keyword.toLowerCase())) + .map(keyword => ({ + value: keyword, + targets: settings.scope === 'tags-only' ? ['tag'] : ['content', 'tag'], + ...(settings.excludeFollows && { actorTarget: 'notFollowed' }), + ...(expiresAt && { expires: expiresAt.toISOString() }) + })); + + // Log operations for verification + console.debug('[MuteService] Applied mute settings:', settings); + console.debug('[MuteService] User custom keywords (will be preserved):', userCustomKeywords.map(i => i.value)); + console.debug('[MuteService] New managed keywords to be set:', newManagedItems.map(i => i.value)); + + // Combine user's custom keywords with selected managed keywords + const updatedItems = [ + ...userCustomKeywords, // Preserve all user's custom keywords + ...newManagedItems // Only include selected keywords from our list + ]; + + // Create updated preference + const updatedMutedPref = { + $type: 'app.bsky.actor.defs#mutedWordsPref', + items: updatedItems + }; + + // Update preferences array + if (mutedWordsIndex >= 0) { + this.cachedPreferences[mutedWordsIndex] = updatedMutedPref; + } else { + this.cachedPreferences.push(updatedMutedPref); + } + + // Log final state + console.debug('[MuteService] Total keywords after update:', updatedItems.length); + + try { + // Update preferences using fresh agent + await agent.api.app.bsky.actor.putPreferences({ + preferences: this.cachedPreferences + }); + } catch (error) { + if (error.status === 401) { + // Dispatch event for session refresh + const refreshEvent = new CustomEvent('mutesky:session:refresh:needed'); + window.dispatchEvent(refreshEvent); + throw error; // Let BlueskyService handle the retry + } + throw error; + } + + // Clear caches after successful update + this.cachedKeywords = null; + this.cachedPreferences = null; + + console.debug('[MuteService] Successfully updated muted keywords'); + return true; + } catch (error) { + console.error('[MuteService] Failed to update muted keywords:', error); + // Clear caches on error + this.cachedKeywords = null; + this.cachedPreferences = null; + // Extract API error message if available + const apiError = error.message || 'Failed to update muted keywords'; + throw new Error(apiError); + } + } +} diff --git a/js/profile.js b/js/profile.js new file mode 100644 index 0000000..4a2e21d --- /dev/null +++ b/js/profile.js @@ -0,0 +1,112 @@ +import { Agent } from '@atproto/api' + +export class ProfileService { + constructor(session) { + this.agent = session ? new Agent(session) : null; + this.session = session; + this.handle = null; // Store handle for mute count updates + } + + // Made synchronous - just sets properties + setSession(session) { + this.agent = session ? new Agent(session) : null; + this.session = session; + } + + async getProfile() { + // Ensure we're using the latest session + if (!this.agent || !this.session) throw new Error('Not logged in'); + + // Create a fresh agent instance to ensure we're using the latest session + const agent = new Agent(this.session); + + try { + const response = await agent.getProfile({ + actor: this.session.did + }); + return response.data; + } catch (error) { + // Check if it's an unauthorized error (401) + if (error.status === 401) { + // Dispatch event for session refresh + const refreshEvent = new CustomEvent('mutesky:session:refresh:needed'); + window.dispatchEvent(refreshEvent); + } + console.error('Failed to get profile:', error); + return null; + } + } + + updateUI(profile) { + if (!profile) return; + + const handleEl = document.getElementById('bsky-handle'); + const dropdownHandleEl = document.getElementById('dropdown-handle'); + const displayNameEl = document.getElementById('user-display-name'); + const profilePic = document.querySelector('.profile-pic'); + + if (handleEl) { + // Store handle for mute count updates + this.handle = profile.handle; + handleEl.textContent = `@${profile.handle}`; + } + + if (dropdownHandleEl) { + dropdownHandleEl.textContent = `@${profile.handle}`; + } + + if (displayNameEl) { + displayNameEl.textContent = profile.displayName || profile.handle; + } + + if (profilePic && profile.avatar) { + profilePic.style.backgroundImage = `url(${profile.avatar})`; + profilePic.style.backgroundSize = 'cover'; + profilePic.style.backgroundPosition = 'center'; + } + } + + // Updated method to show mute count in both places + updateMuteCount(count) { + const muteCountEl = document.getElementById('total-mute-count'); + const handleEl = document.getElementById('bsky-handle'); + + if (muteCountEl) { + muteCountEl.textContent = `${count} muted`; + } + + if (handleEl && this.handle) { + handleEl.textContent = `@${this.handle} - ${count} mutes`; + } + } + + resetUI() { + const profilePic = document.querySelector('.profile-pic'); + const handleEl = document.getElementById('bsky-handle'); + const dropdownHandleEl = document.getElementById('dropdown-handle'); + const displayNameEl = document.getElementById('user-display-name'); + const muteCountEl = document.getElementById('total-mute-count'); + + if (profilePic) { + profilePic.style.backgroundImage = 'none'; + } + + if (handleEl) { + handleEl.textContent = ''; + } + + if (dropdownHandleEl) { + dropdownHandleEl.textContent = ''; + } + + if (displayNameEl) { + displayNameEl.textContent = ''; + } + + if (muteCountEl) { + muteCountEl.textContent = '0 muted'; + } + + this.handle = null; // Clear stored handle + } +} diff --git a/js/renderer.js b/js/renderer.js new file mode 100644 index 0000000..167e1b5 --- /dev/null +++ b/js/renderer.js @@ -0,0 +1 @@ +export { renderInterface } from './renderers/index.js'; diff --git a/js/renderers/categoryRenderer.js b/js/renderers/categoryRenderer.js new file mode 100644 index 0000000..d3a6a71 --- /dev/null +++ b/js/renderers/categoryRenderer.js @@ -0,0 +1,129 @@ +import { elements } from '../dom.js'; +import { state } from '../state.js'; +import { getDisplayName, getCategoryState, getCheckboxClass, filterKeywordGroups, getAllKeywordsForCategory } from '../categoryManager.js'; +import { isKeywordActive } from '../handlers/keywordHandlers.js'; + +export function renderAdvancedMode() { + if (!elements.categoriesGrid) return; + + const filteredGroups = filterKeywordGroups(true); // Pass true for right panel + elements.categoriesGrid.innerHTML = Object.entries(filteredGroups) + .map(([category, keywords]) => { + if (keywords.length === 0) return ''; + + const activeCount = keywords.filter(k => isKeywordActive(k)).length; + const displayName = category; + const categoryState = getCategoryState(category); + + // Special case: give US Political Figures the ID that matches the politicians link + const sectionId = category === 'US Political Figures - Full Name' ? 'politicians' : category.replace(/\s+/g, '-').toLowerCase(); + + return ` +
+
+
+
+ +
+

${displayName}

+ (${activeCount}/${keywords.length}) +
+
+
+ ${keywords.map(keyword => ` + + `).join('')} +
+
+ `; + }) + .join(''); + + // Set indeterminate state after rendering + document.querySelectorAll('.category-checkbox').forEach(checkbox => { + const category = checkbox.dataset.category; + const state = checkbox.dataset.state; + if (state === 'partial') { + checkbox.indeterminate = true; + checkbox.checked = false; + } + }); +} + +export function renderCategoryList() { + if (!elements.categoryList) return; + + // Get all categories including combined ones but excluding source categories + const allCategories = new Set([ + ...Object.keys(state.keywordGroups).filter(category => { + // Filter out categories that are part of combined categories + return !Object.values(state.displayConfig.combinedCategories || {}) + .some(sources => sources.includes(category)); + }), + ...Object.keys(state.displayConfig.combinedCategories || {}) + ]); + + const categories = Array.from(allCategories) + .map(category => { + const keywords = getAllKeywordsForCategory(category); + const totalKeywords = keywords.length; + const activeKeywords = keywords.filter(k => isKeywordActive(k)).length; + const displayName = getDisplayName(category); + const categoryState = getCategoryState(category); + + return { + category, + displayName, + activeKeywords, + totalKeywords, + state: categoryState + }; + }) + .sort((a, b) => a.displayName.localeCompare(b.displayName)); + + const html = categories.map(({ category, displayName, activeKeywords, totalKeywords, state }) => ` +
+
+ +
+ + ${displayName} + + ${activeKeywords}/${totalKeywords} +
+ `).join(''); + + elements.categoryList.innerHTML = html; + + // Set indeterminate state after rendering + document.querySelectorAll('.category-checkbox').forEach(checkbox => { + const state = checkbox.dataset.state; + if (state === 'partial') { + checkbox.indeterminate = true; + checkbox.checked = false; + } + }); +} diff --git a/js/renderers/contextRenderer.js b/js/renderers/contextRenderer.js new file mode 100644 index 0000000..9362e6c --- /dev/null +++ b/js/renderers/contextRenderer.js @@ -0,0 +1,58 @@ +import { elements } from '../dom.js'; +import { state } from '../state.js'; + +export function renderContextCards() { + if (!elements.contextOptions) return; + + elements.contextOptions.innerHTML = Object.entries(state.contextGroups) + .map(([id, context]) => { + // Only check if the context is in selectedContexts + const isSelected = state.selectedContexts.has(id); + return ` +
+

${context.title}

+

${context.description}

+
+ `; + }).join(''); +} + +export function renderExceptions() { + if (!elements.exceptionsPanel || !elements.exceptionTags) return; + + // Show/hide panel based on context selection without clearing exceptions + if (state.selectedContexts.size > 0) { + elements.exceptionsPanel.classList.add('visible'); + } else { + elements.exceptionsPanel.classList.remove('visible'); + elements.muteButton?.classList.remove('visible'); + return; + } + + // Get categories only from contexts that are actually selected + const selectedCategories = new Set(); + for (const contextId of state.selectedContexts) { + const context = state.contextGroups[contextId]; + if (context?.categories) { + context.categories.forEach(category => { + // Only add categories from selected contexts + if (state.selectedContexts.has(contextId)) { + selectedCategories.add(category); + } + }); + } + } + + // Render exception tags, preserving selected state + elements.exceptionTags.innerHTML = Array.from(selectedCategories) + .map(category => { + return ` + + `; + }).join(''); +} diff --git a/js/renderers/index.js b/js/renderers/index.js new file mode 100644 index 0000000..cce8f8b --- /dev/null +++ b/js/renderers/index.js @@ -0,0 +1,28 @@ +import { updateBlueskyUI, updateEnableDisableButtons, updateLastUpdate, updateStatusCounts, updateMuteButton } from './uiRenderer.js'; +import { renderContextCards, renderExceptions } from './contextRenderer.js'; +import { renderAdvancedMode, renderCategoryList } from './categoryRenderer.js'; +import { state } from '../state.js'; + +// Import the updateModeToggles function from uiHandlers +import { updateModeToggles } from '../handlers/uiHandlers.js'; + +export function renderInterface() { + // Update Bluesky-specific UI elements + updateBlueskyUI(); + + if (state.mode === 'simple') { + renderContextCards(); + renderExceptions(); + } else { + renderAdvancedMode(); + renderCategoryList(); + } + + // Ensure mode toggles always reflect current state + updateModeToggles(); + + updateStatusCounts(); + updateMuteButton(); + updateEnableDisableButtons(); + updateLastUpdate(); +} diff --git a/js/renderers/uiRenderer.js b/js/renderers/uiRenderer.js new file mode 100644 index 0000000..a94c87a --- /dev/null +++ b/js/renderers/uiRenderer.js @@ -0,0 +1,84 @@ +import { elements } from '../dom.js'; +import { state } from '../state.js'; +import { blueskyService } from '../bluesky.js'; +import { filterKeywordGroups } from '../categoryManager.js'; +import { getButtonText } from '../handlers/muteHandlers.js'; + +export function updateBlueskyUI() { + // Update handle display if user is logged in + if (state.authenticated && blueskyService.session) { + const handle = blueskyService.session.handle || blueskyService.session.sub; + if (elements.bskyHandle) { + elements.bskyHandle.textContent = `@${handle}`; + } + } + + // Update auth button visibility + if (elements.authButton) { + elements.authButton.style.display = state.authenticated ? 'none' : 'block'; + } + + // Update user menu visibility + if (elements.userMenuDropdown) { + elements.userMenuDropdown.classList.toggle('visible', state.menuOpen && state.authenticated); + } +} + +export function updateEnableDisableButtons() { + const searchTerm = state.searchTerm.toLowerCase(); + if (searchTerm) { + const filteredCount = Object.values(filterKeywordGroups(false)) + .reduce((count, keywords) => count + keywords.length, 0); + if (elements.enableAllBtn) { + elements.enableAllBtn.textContent = `Enable (${filteredCount})`; + } + if (elements.disableAllBtn) { + elements.disableAllBtn.textContent = `Disable (${filteredCount})`; + } + } else { + if (elements.enableAllBtn) { + elements.enableAllBtn.textContent = 'Enable All'; + } + if (elements.disableAllBtn) { + elements.disableAllBtn.textContent = 'Disable All'; + } + } +} + +export function updateLastUpdate() { + if (elements.sidebarLastUpdate) { + if (state.lastModified) { + elements.sidebarLastUpdate.textContent = state.lastModified; + } else { + elements.sidebarLastUpdate.textContent = 'checking...'; + } + } +} + +export function updateStatusCounts() { + const total = Object.values(state.keywordGroups).flat().length; + const active = calculateKeywordCount(); + + if (elements.activeCount) { + elements.activeCount.textContent = `${active}/${total} terms`; + } +} + +export function updateMuteButton() { + const buttonText = getButtonText(); + const hasChanges = buttonText !== 'No changes'; + + if (elements.muteButton) { + elements.muteButton.textContent = buttonText; + elements.muteButton.classList.toggle('visible', hasChanges); + } + + if (elements.navMuteButton) { + elements.navMuteButton.textContent = buttonText; + elements.navMuteButton.classList.toggle('visible', hasChanges); + } +} + +function calculateKeywordCount() { + return state.activeKeywords.size; +} diff --git a/js/settings/appearanceSettings.js b/js/settings/appearanceSettings.js new file mode 100644 index 0000000..d7bae35 --- /dev/null +++ b/js/settings/appearanceSettings.js @@ -0,0 +1,94 @@ +const DEFAULT_APPEARANCE = { + colorMode: 'system', + darkTheme: 'dim', + font: 'system', + fontSize: 'default' +}; + +const FONT_SCALES = { + 'smaller': 0.867, // 13px equivalent + 'default': 1, // 15px base + 'larger': 1.133 // 17px equivalent +}; + +export function loadAppearanceSettings() { + try { + const saved = localStorage.getItem('appearanceSettings'); + if (saved) { + const settings = JSON.parse(saved); + return { ...DEFAULT_APPEARANCE, ...settings }; + } + } catch (error) { + console.error('Error loading appearance settings:', error); + } + return { ...DEFAULT_APPEARANCE }; +} + +export function saveAppearanceSettings(settings) { + try { + const newSettings = { + ...DEFAULT_APPEARANCE, + ...settings + }; + localStorage.setItem('appearanceSettings', JSON.stringify(newSettings)); + applyAppearanceSettings(newSettings); + } catch (error) { + console.error('Error saving appearance settings:', error); + } +} + +export function applyAppearanceSettings(settings = null) { + if (!settings) { + settings = loadAppearanceSettings(); + } + + const html = document.documentElement; + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + + // Store current UI state + const advancedMode = document.getElementById('advanced-mode'); + const wasAdvancedHidden = advancedMode ? advancedMode.classList.contains('hidden') : true; + + // Apply theme + let theme = 'light'; + if (settings.colorMode === 'dark' || (settings.colorMode === 'system' && prefersDark)) { + theme = 'dim'; + } + + // Apply theme immediately + html.setAttribute('data-theme', theme); + + // Update UI state + if (advancedMode) { + advancedMode.classList.toggle('hidden', wasAdvancedHidden); + } + + // Update footer toggle state + const footerToggle = document.getElementById('footer-theme-toggle'); + if (footerToggle) { + const isDark = theme === 'dim'; + footerToggle.classList.toggle('dark', isDark); + } + + // Apply font settings + html.style.fontFamily = settings.font === 'theme' + ? 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif' + : '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + + // Apply font scale using CSS variable + html.style.setProperty('--font-scale', FONT_SCALES[settings.fontSize]); + + updateAppearanceUI(settings); +} + +export function updateAppearanceUI(settings) { + requestAnimationFrame(() => { + document.querySelectorAll('.mode-switch, .theme-switch, .font-switch').forEach(btn => { + btn.classList.remove('active'); + }); + + document.querySelector(`.mode-switch[data-theme="${settings.colorMode}"]`)?.classList.add('active'); + document.querySelector(`.font-switch[data-font="${settings.font}"]`)?.classList.add('active'); + document.querySelector(`.font-switch[data-size="${settings.fontSize}"]`)?.classList.add('active'); + }); +} diff --git a/js/settings/init.js b/js/settings/init.js new file mode 100644 index 0000000..f5295c7 --- /dev/null +++ b/js/settings/init.js @@ -0,0 +1,44 @@ +import { applyAppearanceSettings, loadAppearanceSettings, saveAppearanceSettings } from './appearanceSettings.js'; +import { handleSettingsModalToggle } from '../handlers/modalHandlers.js'; +import { handleFooterThemeToggle } from '../handlers/themeHandlers.js'; + +export function initializeSettings() { + // Apply initial appearance settings + applyAppearanceSettings(); + + // Add click handlers for appearance settings + document.querySelectorAll('.mode-switch[data-theme]').forEach(btn => { + btn.addEventListener('click', (e) => { + const settings = loadAppearanceSettings(); + settings.colorMode = e.target.dataset.theme; + saveAppearanceSettings(settings); + }); + }); + + document.querySelectorAll('.font-switch[data-font]').forEach(btn => { + btn.addEventListener('click', (e) => { + const settings = loadAppearanceSettings(); + settings.font = e.target.dataset.font; + saveAppearanceSettings(settings); + }); + }); + + document.querySelectorAll('.font-switch[data-size]').forEach(btn => { + btn.addEventListener('click', (e) => { + const settings = loadAppearanceSettings(); + settings.fontSize = e.target.dataset.size; + saveAppearanceSettings(settings); + }); + }); + + // Add system theme change listener + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { + const settings = loadAppearanceSettings(); + if (settings.colorMode === 'system') { + applyAppearanceSettings(settings); + } + }); + + // Add footer theme toggle handler + document.getElementById('footer-theme-toggle')?.addEventListener('click', handleFooterThemeToggle); +} diff --git a/js/settings/muteSettings.js b/js/settings/muteSettings.js new file mode 100644 index 0000000..a5968bc --- /dev/null +++ b/js/settings/muteSettings.js @@ -0,0 +1,44 @@ +const DEFAULT_SETTINGS = { + duration: 'forever', + scope: 'text-and-tags', + excludeFollows: true +}; + +export function loadMuteSettings() { + try { + const saved = localStorage.getItem('muteSettings'); + if (saved) { + return { ...DEFAULT_SETTINGS, ...JSON.parse(saved) }; + } + } catch (error) { + console.error('Error loading mute settings:', error); + } + return { ...DEFAULT_SETTINGS }; +} + +export function saveMuteSettings(settings) { + try { + localStorage.setItem('muteSettings', JSON.stringify({ + ...DEFAULT_SETTINGS, + ...settings + })); + } catch (error) { + console.error('Error saving mute settings:', error); + } +} + +export function getExpirationDate(duration) { + if (duration === 'forever') return null; + + const now = new Date(); + switch (duration) { + case '24h': + return new Date(now.getTime() + 24 * 60 * 60 * 1000); + case '7d': + return new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + case '30d': + return new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); + default: + return null; + } +} diff --git a/js/state.js b/js/state.js new file mode 100644 index 0000000..bee5eb5 --- /dev/null +++ b/js/state.js @@ -0,0 +1,40 @@ +import { loadState, saveState, resetState, forceRefresh, getStorageKey } from './statePersistence.js'; +import { setUser } from './userState.js'; +import { canUnmuteKeyword, getMuteUnmuteCounts, setTargetKeywordCount } from './keywordState.js'; + +// Core state object +export const state = { + authenticated: false, + did: null, // Track current user's DID + mode: 'simple', + keywordGroups: {}, + contextGroups: {}, + displayConfig: {}, + activeKeywords: new Set(), // Currently checked keywords (only from our list) + originalMutedKeywords: new Set(), // All user's muted keywords (for safety check) + sessionMutedKeywords: new Set(), // New keywords muted this session + manuallyUnchecked: new Set(), // Keywords that user has manually unchecked + selectedContexts: new Set(), + selectedExceptions: new Set(), + selectedCategories: new Set(), + searchTerm: '', + filterMode: 'all', + menuOpen: false, + lastModified: null, // Last-Modified header from keywords file + targetKeywordCount: 100, // Default to minimal keywords since default mode is simple + filterLevel: 0, // Track current filter level + lastBulkAction: null // Track when enable/disable all is used +}; + +// Re-export core functionality +export { + loadState, + saveState, + resetState, + forceRefresh, + setUser, + canUnmuteKeyword, + getMuteUnmuteCounts, + setTargetKeywordCount, + getStorageKey +}; diff --git a/js/stateCache.js b/js/stateCache.js new file mode 100644 index 0000000..c78eb5e --- /dev/null +++ b/js/stateCache.js @@ -0,0 +1,18 @@ +// Optimized keyword cache with shorter timeout +export const keywordCache = { + ourKeywords: null, + lastUpdate: 0, + + clear() { + this.ourKeywords = null; + this.lastUpdate = 0; + }, + + shouldUpdate() { + // Reduced throttle time to 16ms (one frame) for more responsive updates + const now = Date.now(); + if (now - this.lastUpdate < 16) return false; + this.lastUpdate = now; + return true; + } +}; diff --git a/js/statePersistence.js b/js/statePersistence.js new file mode 100644 index 0000000..8b2ce0a --- /dev/null +++ b/js/statePersistence.js @@ -0,0 +1,166 @@ +import { KEYWORDS_BASE_URL, CONTEXT_GROUPS_URL, DISPLAY_CONFIG_URL } from './config.js'; +import { state } from './state.js'; +import { keywordCache } from './stateCache.js'; +import { getKeywordsWithCase } from './keywordState.js'; + +// Helper to get storage key for current user +export function getStorageKey() { + if (!state.did) { + throw new Error('No DID set in state'); + } + return `muteskyState-${state.did}`; +} + +// Debounced save state with shorter delay +const debouncedSave = (() => { + let timeout; + return () => { + if (timeout) clearTimeout(timeout); + timeout = setTimeout(() => { + const saveData = { + activeKeywords: Array.from(state.activeKeywords), + selectedCategories: Array.from(state.selectedCategories), + selectedContexts: Array.from(state.selectedContexts), + selectedExceptions: Array.from(state.selectedExceptions), + manuallyUnchecked: Array.from(state.manuallyUnchecked), + mode: state.mode, + lastModified: state.lastModified, + targetKeywordCount: state.targetKeywordCount, + filterLevel: state.filterLevel, + lastBulkAction: state.lastBulkAction + }; + try { + localStorage.setItem(getStorageKey(), JSON.stringify(saveData)); + } catch (error) { + console.error('Error saving state:', error); + } + }, 16); // One frame delay for more responsive saves + }; +})(); + +export function saveState() { + debouncedSave(); +} + +export function loadState() { + try { + // Clear all selections first + state.activeKeywords.clear(); + state.selectedContexts.clear(); + state.selectedExceptions.clear(); + state.selectedCategories.clear(); + // Don't clear manuallyUnchecked - let it persist + + const saved = localStorage.getItem(getStorageKey()); + if (saved) { + const data = JSON.parse(saved); + + // Get map of lowercase -> original case keywords + const keywordMap = getKeywordsWithCase(); + + // Ensure we're working with Sets and proper case + state.activeKeywords = new Set( + (data.activeKeywords || []).map(keyword => { + // Use original case from keyword map if available + return keywordMap.get(keyword.toLowerCase()) || keyword; + }) + ); + state.selectedCategories = new Set(data.selectedCategories || []); + state.selectedContexts = new Set(data.selectedContexts || []); + state.selectedExceptions = new Set(data.selectedExceptions || []); + state.manuallyUnchecked = new Set( + (data.manuallyUnchecked || []).map(keyword => { + // Use original case from keyword map if available + return keywordMap.get(keyword.toLowerCase()) || keyword; + }) + ); + + // Load other state properties + state.mode = data.mode || 'simple'; + state.lastModified = data.lastModified || null; + state.targetKeywordCount = data.targetKeywordCount || (state.mode === 'simple' ? 100 : 2000); + state.filterLevel = typeof data.filterLevel === 'number' ? data.filterLevel : 0; + state.lastBulkAction = data.lastBulkAction || null; + + // Force cache refresh + keywordCache.clear(); + } else { + // If no saved state, ensure targetKeywordCount matches mode + state.targetKeywordCount = state.mode === 'simple' ? 100 : 2000; + } + } catch (error) { + console.error('Error loading saved state:', error); + // If there's an error, ensure state is clean but preserve manuallyUnchecked + const unchecked = new Set(state.manuallyUnchecked); + resetState(); + state.manuallyUnchecked = unchecked; + } +} + +export function resetState() { + // Preserve auth state + const did = state.did; + const authenticated = state.authenticated; + // Preserve mute state + const originalMutedKeywords = new Set(state.originalMutedKeywords); + const sessionMutedKeywords = new Set(state.sessionMutedKeywords); + + // Reset all other state + state.mode = 'simple'; + state.activeKeywords.clear(); + state.originalMutedKeywords.clear(); + state.sessionMutedKeywords.clear(); + // Don't clear manuallyUnchecked - let it persist + state.selectedContexts.clear(); + state.selectedExceptions.clear(); + state.selectedCategories.clear(); + state.searchTerm = ''; + state.filterMode = 'all'; + state.menuOpen = false; + state.lastModified = null; + state.targetKeywordCount = 100; + state.filterLevel = 0; + state.lastBulkAction = null; + keywordCache.clear(); + + // Restore auth state + state.did = did; + state.authenticated = authenticated; + // Restore mute state + state.originalMutedKeywords = originalMutedKeywords; + state.sessionMutedKeywords = sessionMutedKeywords; + + saveState(); +} + +export function forceRefresh() { + // Preserve auth state + const did = state.did; + const authenticated = state.authenticated; + // Preserve mute state + const originalMutedKeywords = new Set(state.originalMutedKeywords); + const sessionMutedKeywords = new Set(state.sessionMutedKeywords); + + // Clear all cached data + localStorage.removeItem(getStorageKey()); + state.keywordGroups = {}; + state.contextGroups = {}; + state.displayConfig = {}; + keywordCache.clear(); + resetState(); + + // Restore auth state + state.did = did; + state.authenticated = authenticated; + // Restore mute state + state.originalMutedKeywords = originalMutedKeywords; + state.sessionMutedKeywords = sessionMutedKeywords; + + // Force browser to skip cache when fetching + const cacheBuster = `?t=${new Date().getTime()}`; + return { + keywordsBaseUrl: `${KEYWORDS_BASE_URL}${cacheBuster}`, + contextGroupsUrl: `${CONTEXT_GROUPS_URL}${cacheBuster}`, + displayConfigUrl: `${DISPLAY_CONFIG_URL}${cacheBuster}` + }; +} diff --git a/js/themeInit.js b/js/themeInit.js new file mode 100644 index 0000000..aa23e67 --- /dev/null +++ b/js/themeInit.js @@ -0,0 +1,81 @@ +// Early theme initialization to prevent flash +(function() { + try { + const html = document.documentElement; + let theme = 'light'; + + // Try to load saved settings + const savedSettings = localStorage.getItem('appearanceSettings'); + if (savedSettings) { + const settings = JSON.parse(savedSettings); + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + + if (settings.colorMode === 'system') { + theme = prefersDark ? 'dim' : 'light'; + } else if (settings.colorMode === 'dark') { + theme = 'dim'; + } + } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) { + theme = 'dim'; + } + + // Apply theme immediately + html.setAttribute('data-theme', theme); + + // Show content only after theme is set + window.addEventListener('DOMContentLoaded', () => { + document.body.classList.add('js-loaded'); + + // Set initial footer toggle state + const footerToggle = document.getElementById('footer-theme-toggle'); + if (footerToggle) { + footerToggle.classList.toggle('dark', theme === 'dim'); + } + }); + + // Listen for theme changes from system + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { + const currentSettings = localStorage.getItem('appearanceSettings'); + if (currentSettings) { + const settings = JSON.parse(currentSettings); + if (settings.colorMode === 'system') { + const newTheme = e.matches ? 'dim' : 'light'; + html.setAttribute('data-theme', newTheme); + + // Update footer toggle + const footerToggle = document.getElementById('footer-theme-toggle'); + if (footerToggle) { + footerToggle.classList.toggle('dark', e.matches); + } + } + } + }); + + // Listen for storage changes (theme updates from other tabs) + window.addEventListener('storage', (e) => { + if (e.key === 'appearanceSettings' && e.newValue) { + const settings = JSON.parse(e.newValue); + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + let newTheme = 'light'; + + if (settings.colorMode === 'system') { + newTheme = prefersDark ? 'dim' : 'light'; + } else if (settings.colorMode === 'dark') { + newTheme = 'dim'; + } + + html.setAttribute('data-theme', newTheme); + + // Update footer toggle + const footerToggle = document.getElementById('footer-theme-toggle'); + if (footerToggle) { + footerToggle.classList.toggle('dark', newTheme === 'dim'); + } + } + }); + } catch (error) { + console.error('Error in early theme initialization:', error); + // Fallback to light theme if something goes wrong + document.documentElement.setAttribute('data-theme', 'light'); + } +})(); diff --git a/js/ui.js b/js/ui.js new file mode 100644 index 0000000..078c666 --- /dev/null +++ b/js/ui.js @@ -0,0 +1,80 @@ +export class UIService { + updateLoginState(isLoggedIn, message = '') { + // Update DOM synchronously + this.updateDOMElements(isLoggedIn, message); + + // Dispatch event in background + setTimeout(() => { + window.dispatchEvent(new CustomEvent('blueskyLoginStateChanged', { + detail: { isLoggedIn, message } + })); + }, 0); + } + + updateDOMElements(isLoggedIn, message) { + const loginBtn = document.getElementById('bsky-login-btn'); + const logoutBtn = document.getElementById('bsky-logout-btn'); + const handleInput = document.getElementById('bsky-handle-input'); + const authMessage = document.getElementById('bsky-auth-message'); + + // Clear error states if logging in successfully + if (isLoggedIn) { + if (handleInput) { + handleInput.classList.remove('error'); + } + if (authMessage) { + authMessage.classList.remove('error'); + } + } + + // Batch DOM updates + if (loginBtn) { + loginBtn.style.display = isLoggedIn ? 'none' : 'block'; + loginBtn.disabled = false; + loginBtn.textContent = 'Connect to Bluesky'; + } + if (logoutBtn) logoutBtn.style.display = isLoggedIn ? 'block' : 'none'; + + if (handleInput) { + handleInput.style.display = isLoggedIn ? 'none' : 'block'; + handleInput.disabled = false; + handleInput.classList.toggle('error', !isLoggedIn && !!message); + } + + if (authMessage) { + if (message) { + authMessage.textContent = message; + authMessage.classList.toggle('error', !isLoggedIn && !!message); + } else if (isLoggedIn) { + // Clear error message on successful login + authMessage.textContent = 'The next page will prompt for your username and Bluesky account password, not your app password. Your credentials are securely handled by Bluesky\'s official authentication service.'; + authMessage.classList.remove('error'); + } + } + } + + getHandleInput() { + const handleInput = document.getElementById('bsky-handle-input'); + // Strip @ symbol if present and trim whitespace + return (handleInput?.value?.replace('@', '') || '').trim(); + } + + showError(message) { + const handleInput = document.getElementById('bsky-handle-input'); + const authMessage = document.getElementById('bsky-auth-message'); + const loginBtn = document.getElementById('bsky-login-btn'); + + if (handleInput) { + handleInput.classList.add('error'); + handleInput.disabled = false; + } + if (loginBtn) { + loginBtn.disabled = false; + loginBtn.textContent = 'Connect to Bluesky'; + } + if (authMessage) { + authMessage.textContent = message; + authMessage.classList.add('error'); + } + } +} diff --git a/js/userState.js b/js/userState.js new file mode 100644 index 0000000..ac14896 --- /dev/null +++ b/js/userState.js @@ -0,0 +1,13 @@ +import { state } from './state.js'; +import { resetState, loadState } from './statePersistence.js'; + +// Helper to set current user and load their state +export function setUser(did) { + // Clear current state first + resetState(); + // Set new user info + state.did = did; + state.authenticated = true; + // Load state for this user + loadState(); +} diff --git a/js/utils.js b/js/utils.js new file mode 100644 index 0000000..22022cd --- /dev/null +++ b/js/utils.js @@ -0,0 +1,11 @@ +export function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +} diff --git a/js/utils/categoryUtils.js b/js/utils/categoryUtils.js new file mode 100644 index 0000000..32cf354 --- /dev/null +++ b/js/utils/categoryUtils.js @@ -0,0 +1,91 @@ +import { state } from '../state.js'; +import { getWeightThreshold } from './weightManager.js'; +import { isKeywordActive } from '../handlers/keywordHandlers.js'; + +export function getDisplayName(category) { + return state.displayConfig.displayNames[category] || category; +} + +export function getCategoryState(category) { + const keywords = getAllKeywordsForCategory(category); + const activeCount = keywords.filter(k => isKeywordActive(k)).length; + + if (activeCount === 0) return 'none'; + if (activeCount === keywords.length) return 'all'; + return 'partial'; +} + +export function getCheckboxClass(state) { + switch (state) { + case 'all': return 'checked'; + case 'partial': return 'partial'; + default: return ''; + } +} + +export function extractKeywordsFromCategory(category, categoryData) { + if (!categoryData?.[category]?.keywords) return []; + + const categoryInfo = categoryData[category]; + return Object.entries(categoryInfo.keywords).map(([keyword, data]) => ({ + keyword, + weight: data.weight || 0, + categoryWeight: categoryInfo.weight || 0, + category + })); +} + +export function extractKeywordsFromCombinedSources(combinedSources, keywordGroups) { + return combinedSources.flatMap(source => { + const categoryData = keywordGroups[source]; + if (!categoryData?.[source]) return []; + return extractKeywordsFromCategory(source, categoryData); + }); +} + +export function getAllKeywordsForCategory(category, sortByWeight = false) { + let keywords = []; + + // Check if this is a combined category + const combinedSources = state.displayConfig.combinedCategories?.[category]; + if (combinedSources) { + keywords = extractKeywordsFromCombinedSources(combinedSources, state.keywordGroups); + } else { + // Regular category + const categoryData = state.keywordGroups[category]; + keywords = extractKeywordsFromCategory(category, categoryData); + } + + // Sort and filter by weight if requested + if (sortByWeight) { + keywords.sort((a, b) => b.weight - a.weight); + + if (state.targetKeywordCount) { + const before = keywords.length; + keywords = filterByWeight(keywords, category); + logFilterResults(category, keywords, before); + } + } + + // Return just the keyword strings + return keywords.map(k => k.keyword); +} + +function filterByWeight(keywords, category) { + return keywords.filter(k => { + const threshold = getWeightThreshold(k.categoryWeight, state.targetKeywordCount); + const passes = k.weight >= threshold; + if (passes) { + console.debug(`Including ${k.keyword} (weight: ${k.weight}) from ${k.category} (weight: ${k.categoryWeight})`); + } + return passes; + }); +} + +function logFilterResults(category, keywords, beforeCount) { + console.debug(`Category ${category} (weight ${keywords[0]?.categoryWeight || 'unknown'}): + - Target count: ${state.targetKeywordCount} + - Threshold: ${getWeightThreshold(keywords[0]?.categoryWeight, state.targetKeywordCount)} + - Filtered from ${beforeCount} to ${keywords.length} keywords + - Remaining keywords: ${keywords.map(k => `${k.keyword} (${k.weight})`).join(', ')}`); +} diff --git a/js/utils/keywordFilters.js b/js/utils/keywordFilters.js new file mode 100644 index 0000000..c5e10e4 --- /dev/null +++ b/js/utils/keywordFilters.js @@ -0,0 +1,85 @@ +import { state } from '../state.js'; +import { getAllKeywordsForCategory } from './categoryUtils.js'; +import { isKeywordActive } from '../handlers/keywordHandlers.js'; + +export function filterKeywordGroups(isRightPanel = false) { + const filtered = {}; + const searchTerm = state.searchTerm.toLowerCase(); + const categoriesToShow = state.selectedCategories.size > 0 + ? state.selectedCategories + : new Set(Object.keys(state.keywordGroups)); + + if (isRightPanel) { + filterRightPanel(filtered, categoriesToShow, searchTerm); + } else { + filterLeftPanel(filtered, categoriesToShow, searchTerm); + } + + return filtered; +} + +function filterRightPanel(filtered, categoriesToShow, searchTerm) { + Object.entries(state.keywordGroups).forEach(([category, categoryData]) => { + if (!categoriesToShow.has(category)) return; + + const categoryInfo = categoryData[category]; + if (!categoryInfo?.keywords) return; + + const keywords = Object.keys(categoryInfo.keywords); + const filteredKeywords = filterKeywords(keywords, category, searchTerm); + + if (filteredKeywords.length > 0) { + filtered[category] = filteredKeywords; + } + }); +} + +function filterLeftPanel(filtered, categoriesToShow, searchTerm) { + // Handle regular categories + Object.entries(state.keywordGroups).forEach(([category, categoryData]) => { + const isCombined = Object.values(state.displayConfig.combinedCategories || {}) + .some(sources => sources.includes(category)); + if (isCombined) return; + + if (!categoriesToShow.has(category)) return; + + const categoryInfo = categoryData[category]; + if (!categoryInfo?.keywords) return; + + const keywords = Object.keys(categoryInfo.keywords); + const filteredKeywords = filterKeywords(keywords, category, searchTerm); + + if (filteredKeywords.length > 0) { + filtered[category] = filteredKeywords; + } + }); + + // Handle combined categories + Object.entries(state.displayConfig.combinedCategories || {}).forEach(([combinedCategory, sourceCategories]) => { + const allKeywords = sourceCategories.flatMap(category => { + const categoryData = state.keywordGroups[category]; + if (!categoryData?.[category]?.keywords) return []; + return Object.keys(categoryData[category].keywords); + }); + + const filteredKeywords = filterKeywords(allKeywords, combinedCategory, searchTerm); + + if (filteredKeywords.length > 0) { + filtered[combinedCategory] = filteredKeywords; + } + }); +} + +function filterKeywords(keywords, category, searchTerm) { + return keywords.filter(keyword => { + const matchesSearch = !searchTerm || + keyword.toLowerCase().includes(searchTerm) || + category.toLowerCase().includes(searchTerm); + + const matchesFilter = state.filterMode === 'all' || + (state.filterMode === 'active' && isKeywordActive(keyword)) || + (state.filterMode === 'disabled' && !isKeywordActive(keyword)); + + return matchesSearch && matchesFilter; + }); +} diff --git a/js/utils/notifications.js b/js/utils/notifications.js new file mode 100644 index 0000000..ba8f8d2 --- /dev/null +++ b/js/utils/notifications.js @@ -0,0 +1,22 @@ +export function showNotification(message, type = 'success') { + // Remove any existing notifications + const existingNotification = document.querySelector('.notification'); + if (existingNotification) { + existingNotification.remove(); + } + + // Create notification element + const notification = document.createElement('div'); + notification.className = `notification ${type}`; + notification.textContent = message; + document.body.appendChild(notification); + + // Trigger animation + setTimeout(() => notification.classList.add('show'), 10); + + // Remove notification after delay + setTimeout(() => { + notification.classList.add('hide'); + setTimeout(() => notification.remove(), 300); + }, 3000); +} diff --git a/js/utils/weightManager.js b/js/utils/weightManager.js new file mode 100644 index 0000000..a8943c4 --- /dev/null +++ b/js/utils/weightManager.js @@ -0,0 +1,23 @@ +function getWeightThreshold(categoryWeight, targetCount) { + switch(targetCount) { + case 100: + return categoryWeight === 10 ? 8 : + categoryWeight === 9 ? 8 : + categoryWeight === 8 ? 8 : + categoryWeight === 7 ? 10 : 11; + case 300: + return categoryWeight === 10 ? 7 : + categoryWeight === 9 ? 7 : + categoryWeight === 8 ? 8 : + categoryWeight === 7 ? 9 : 11; + case 500: + return categoryWeight === 10 ? 4 : + categoryWeight === 9 ? 5 : + categoryWeight === 8 ? 6 : + categoryWeight === 7 ? 7 : 11; + default: + return 0; + } +} + +export { getWeightThreshold }; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..750264e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,5066 @@ +{ + "name": "mutesky", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mutesky", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@atproto/api": "^0.13.18", + "@atproto/oauth-client-browser": "^0.3.2" + }, + "devDependencies": { + "buffer": "6.0.3", + "copy-webpack-plugin": "11.0.0", + "crypto-browserify": "3.12.1", + "http-server": "^14.1.1", + "process": "0.11.10", + "stream-browserify": "3.0.0", + "util": "0.12.5", + "webpack": "5.96.1", + "webpack-cli": "5.1.4", + "webpack-dev-server": "^4.15.2" + } + }, + "node_modules/@atproto-labs/did-resolver": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.1.5.tgz", + "integrity": "sha512-uoCb+P0N4du5NiZt6ohVEbSDdijXBJlQwSlWLHX0rUDtEVV+g3aEGe7jUW94lWpqQmRlQ5xcyd9owleMibNxZw==", + "dependencies": { + "@atproto-labs/fetch": "0.1.1", + "@atproto-labs/pipe": "0.1.0", + "@atproto-labs/simple-store": "0.1.1", + "@atproto-labs/simple-store-memory": "0.1.1", + "@atproto/did": "0.1.3", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto-labs/fetch": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.1.1.tgz", + "integrity": "sha512-X1zO1MDoJzEurbWXMAe1H8EZ995Xam/aXdxhGVrXmOMyPDuvBa1oxwh/kQNZRCKcMQUbiwkk+Jfq6ZkTuvGbww==", + "dependencies": { + "@atproto-labs/pipe": "0.1.0" + }, + "optionalDependencies": { + "zod": "^3.23.8" + } + }, + "node_modules/@atproto-labs/handle-resolver": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.1.4.tgz", + "integrity": "sha512-tnGUD2mQ6c8xHs3eeVJgwYqM3FHoTZZbOcOGKqO1A5cuIG+gElwEhpWwpwX5LI7FF4J8eS9BOHLl3NFS7Q8QXg==", + "dependencies": { + "@atproto-labs/simple-store": "0.1.1", + "@atproto-labs/simple-store-memory": "0.1.1", + "@atproto/did": "0.1.3", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto-labs/identity-resolver": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.1.6.tgz", + "integrity": "sha512-kq1yhpImGG1IUE8QEKj2IjSfNrkG2VailZRuiFLYdcszDEBDzr9HN3ElV42ebxhofuSFgKOCrYWJIUiLuXo6Uw==", + "dependencies": { + "@atproto-labs/did-resolver": "0.1.5", + "@atproto-labs/handle-resolver": "0.1.4", + "@atproto/syntax": "0.3.1" + } + }, + "node_modules/@atproto-labs/pipe": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.1.0.tgz", + "integrity": "sha512-ghOqHFyJlQVFPESzlVHjKroP0tPzbmG5Jms0dNI9yLDEfL8xp4OFPWLX4f6T8mRq69wWs4nIDM3sSsFbFqLa1w==" + }, + "node_modules/@atproto-labs/simple-store": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz", + "integrity": "sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg==" + }, + "node_modules/@atproto-labs/simple-store-memory": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store-memory/-/simple-store-memory-0.1.1.tgz", + "integrity": "sha512-PCRqhnZ8NBNBvLku53O56T0lsVOtclfIrQU/rwLCc4+p45/SBPrRYNBi6YFq5rxZbK6Njos9MCmILV/KLQxrWA==", + "dependencies": { + "@atproto-labs/simple-store": "0.1.1", + "lru-cache": "^10.2.0" + } + }, + "node_modules/@atproto/api": { + "version": "0.13.18", + "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.13.18.tgz", + "integrity": "sha512-rrl5HhzGYWZ7fiC965TPBUOVItq9M4dxMb6qz8IvAVQliSkrJrKc7UD0QWL89QiiXaOBuX8w+4i5r4wrfBGddg==", + "dependencies": { + "@atproto/common-web": "^0.3.1", + "@atproto/lexicon": "^0.4.3", + "@atproto/syntax": "^0.3.1", + "@atproto/xrpc": "^0.6.4", + "await-lock": "^2.2.2", + "multiformats": "^9.9.0", + "tlds": "^1.234.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/common-web": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.3.1.tgz", + "integrity": "sha512-N7wiTnus5vAr+lT//0y8m/FaHHLJ9LpGuEwkwDAeV3LCiPif4m/FS8x/QOYrx1PdZQwKso95RAPzCGWQBH5j6Q==", + "dependencies": { + "graphemer": "^1.4.0", + "multiformats": "^9.9.0", + "uint8arrays": "3.0.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/did": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.1.3.tgz", + "integrity": "sha512-ULD8Gw/KRRwLFZ2Z2L4DjmdOMrg8IYYlcjdSc+GQ2/QJSVnD2zaJJVTLd3vls121wGt/583rNaiZTT2DpBze4w==", + "dependencies": { + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/jwk": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.1.1.tgz", + "integrity": "sha512-6h/bj1APUk7QcV9t/oA6+9DB5NZx9SZru9x+/pV5oHFI9Xz4ZuM5+dq1PfsJV54pZyqdnZ6W6M717cxoC7q7og==", + "dependencies": { + "multiformats": "^9.9.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/jwk-jose": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.1.2.tgz", + "integrity": "sha512-lDwc/6lLn2aZ/JpyyggyjLFsJPMntrVzryyGUx5aNpuTS8SIuc4Ky0REhxqfLopQXJJZCuRRjagHG3uP05/moQ==", + "dependencies": { + "@atproto/jwk": "0.1.1", + "jose": "^5.2.0" + } + }, + "node_modules/@atproto/jwk-webcrypto": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.1.2.tgz", + "integrity": "sha512-vTBUbUZXh0GI+6KJiPGukmI4BQEHFAij8fJJ4WnReF/hefAs3ISZtrWZHGBebz+q2EcExYlnhhlmxvDzV7veGw==", + "dependencies": { + "@atproto/jwk": "0.1.1", + "@atproto/jwk-jose": "0.1.2" + } + }, + "node_modules/@atproto/lexicon": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.4.3.tgz", + "integrity": "sha512-lFVZXe1S1pJP0dcxvJuHP3r/a+EAIBwwU7jUK+r8iLhIja+ml6NmYv8KeFHmIJATh03spEQ9s02duDmFVdCoXg==", + "dependencies": { + "@atproto/common-web": "^0.3.1", + "@atproto/syntax": "^0.3.1", + "iso-datestring-validator": "^2.2.2", + "multiformats": "^9.9.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/oauth-client": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.3.2.tgz", + "integrity": "sha512-/HUlv5dnR1am4BQlVYSuevGf4mKJ5RMkElnum8lbwRDewKyzqHwdtJWeNcfcPFtDhUKg0U2pWfRv8ZZd6kk9dQ==", + "dependencies": { + "@atproto-labs/did-resolver": "0.1.5", + "@atproto-labs/fetch": "0.1.1", + "@atproto-labs/handle-resolver": "0.1.4", + "@atproto-labs/identity-resolver": "0.1.6", + "@atproto-labs/simple-store": "0.1.1", + "@atproto-labs/simple-store-memory": "0.1.1", + "@atproto/did": "0.1.3", + "@atproto/jwk": "0.1.1", + "@atproto/oauth-types": "0.2.1", + "@atproto/xrpc": "0.6.4", + "multiformats": "^9.9.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/oauth-client-browser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@atproto/oauth-client-browser/-/oauth-client-browser-0.3.2.tgz", + "integrity": "sha512-Nt9tPxeJTwsX8i6du0dSMonymHHpOVnt67bfA49LpwAS39nNd9zY6yjOrqj0suRwFhoGpvO2e+I35lqe30L+Ig==", + "dependencies": { + "@atproto-labs/did-resolver": "0.1.5", + "@atproto-labs/handle-resolver": "0.1.4", + "@atproto-labs/simple-store": "0.1.1", + "@atproto/did": "0.1.3", + "@atproto/jwk": "0.1.1", + "@atproto/jwk-webcrypto": "0.1.2", + "@atproto/oauth-client": "0.3.2", + "@atproto/oauth-types": "0.2.1" + } + }, + "node_modules/@atproto/oauth-types": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.2.1.tgz", + "integrity": "sha512-hDisUXzcq5KU1HMuCYZ8Kcz7BePl7V11bFjjgZvND3mdSphiyBpJ8MCNn3QzAa6cXpFo0w9PDcYMAlCCRZHdVw==", + "dependencies": { + "@atproto/jwk": "0.1.1", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/syntax": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.3.1.tgz", + "integrity": "sha512-fzW0Mg1QUOVCWUD3RgEsDt6d1OZ6DdFmbKcDdbzUfh0t4rhtRAC05KbZYmxuMPWDAiJ4BbbQ5dkAc/mNypMXkw==" + }, + "node_modules/@atproto/xrpc": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.6.4.tgz", + "integrity": "sha512-9ZAJ8nsXTqC4XFyS0E1Wlg7bAvonhXQNQ3Ocs1L1LIwFLXvsw/4fNpIHXxvXvqTCVeyHLbImOnE9UiO1c/qIYA==", + "dependencies": { + "@atproto/lexicon": "^0.4.3", + "zod": "^3.23.8" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, + "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", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", + "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "dev": true + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dev": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/bonjour": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", + "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect-history-api-fallback": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", + "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", + "dev": true, + "dependencies": { + "@types/express-serve-static-core": "*", + "@types/node": "*" + } + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true + }, + "node_modules/@types/express": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "dev": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.2.tgz", + "integrity": "sha512-vluaspfvWEtE4vcSDlKRNer52DvOGrB2xv6diXy6UKyKW0lqZiWHGNApSyxOv+8DE5Z27IzVvE7hNkxg7EXIcg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/express/node_modules/@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "dev": true + }, + "node_modules/@types/http-proxy": { + "version": "1.17.15", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.15.tgz", + "integrity": "sha512-25g5atgiVNTIv0LBDTg1H74Hvayx0ajtJPLLcYE3whFv75J0pWNtOBzaXJQgDTmrX1bx5U9YC2w/n65BN1HwRQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true + }, + "node_modules/@types/node": { + "version": "22.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.1.tgz", + "integrity": "sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==", + "dev": true, + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/node-forge": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.11.tgz", + "integrity": "sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/qs": { + "version": "6.9.17", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.17.tgz", + "integrity": "sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ==", + "dev": true + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true + }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dev": true, + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-index": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", + "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "dev": true, + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, + "node_modules/@types/sockjs": { + "version": "0.3.36", + "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", + "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz", + "integrity": "sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webpack-cli/configtest": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-2.1.1.tgz", + "integrity": "sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw==", + "dev": true, + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + } + }, + "node_modules/@webpack-cli/info": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-2.0.2.tgz", + "integrity": "sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A==", + "dev": true, + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + } + }, + "node_modules/@webpack-cli/serve": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-2.0.5.tgz", + "integrity": "sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ==", + "dev": true, + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/ansi-html-community": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", + "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", + "dev": true, + "engines": [ + "node >= 0.8.0" + ], + "bin": { + "ansi-html": "bin/ansi-html" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dev": true + }, + "node_modules/asn1.js": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", + "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", + "dev": true, + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/asn1.js/node_modules/bn.js": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz", + "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==", + "dev": true + }, + "node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "dev": true, + "dependencies": { + "lodash": "^4.17.14" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/await-lock": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", + "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dev": true, + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/batch": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", + "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", + "dev": true + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bn.js": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz", + "integrity": "sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==", + "dev": true + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "dev": true, + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dev": true, + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/bonjour-service": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", + "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "dev": true + }, + "node_modules/browserify-aes": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", + "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", + "dev": true, + "dependencies": { + "buffer-xor": "^1.0.3", + "cipher-base": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.3", + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/browserify-cipher": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", + "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", + "dev": true, + "dependencies": { + "browserify-aes": "^1.0.4", + "browserify-des": "^1.0.0", + "evp_bytestokey": "^1.0.0" + } + }, + "node_modules/browserify-des": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", + "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", + "dev": true, + "dependencies": { + "cipher-base": "^1.0.1", + "des.js": "^1.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/browserify-rsa": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.1.tgz", + "integrity": "sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ==", + "dev": true, + "dependencies": { + "bn.js": "^5.2.1", + "randombytes": "^2.1.0", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/browserify-sign": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.3.tgz", + "integrity": "sha512-JWCZW6SKhfhjJxO8Tyiiy+XYB7cqd2S5/+WeYHsKdNKFlCBhKbblba1A/HN/90YwtxKc8tCErjffZl++UNmGiw==", + "dev": true, + "dependencies": { + "bn.js": "^5.2.1", + "browserify-rsa": "^4.1.0", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "elliptic": "^6.5.5", + "hash-base": "~3.0", + "inherits": "^2.0.4", + "parse-asn1": "^5.1.7", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/browserslist": { + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", + "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", + "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" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001669", + "electron-to-chromium": "^1.5.41", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "node_modules/buffer-xor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", + "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==", + "dev": true + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001685", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001685.tgz", + "integrity": "sha512-e/kJN1EMyHQzgcMEEgoo+YTCO1NGCmIYHk5Qk8jT6AazWemS5QFKJ5ShCJlH3GZrNIdZofcNCEwZqbMjjKzmnA==", + "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" + } + ] + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/cipher-base": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.6.tgz", + "integrity": "sha512-3Ek9H3X6pj5TgenXYtNWdaBon1tgYCaebd+XPg0keyjEbEfkD4KkmAxkQ/i1vYvxdcT5nscLBfq9VJRmCBcFSw==", + "dev": true, + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.5.tgz", + "integrity": "sha512-bQJ0YRck5ak3LgtnpKkiabX5pNF7tMUh1BSy2ZBOTh0Dim0BUu6aPPwByIns6/A5Prh8PufSPerMDUklpzes2Q==", + "dev": true, + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.0.2", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/connect-history-api-fallback": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", + "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", + "dev": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "dev": true + }, + "node_modules/copy-webpack-plugin": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz", + "integrity": "sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==", + "dev": true, + "dependencies": { + "fast-glob": "^3.2.11", + "glob-parent": "^6.0.1", + "globby": "^13.1.1", + "normalize-path": "^3.0.0", + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0" + }, + "engines": { + "node": ">= 14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true + }, + "node_modules/corser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", + "integrity": "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/create-ecdh": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz", + "integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==", + "dev": true, + "dependencies": { + "bn.js": "^4.1.0", + "elliptic": "^6.5.3" + } + }, + "node_modules/create-ecdh/node_modules/bn.js": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz", + "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==", + "dev": true + }, + "node_modules/create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "dev": true, + "dependencies": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "node_modules/create-hmac": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", + "dev": true, + "dependencies": { + "cipher-base": "^1.0.3", + "create-hash": "^1.1.0", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-browserify": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.1.tgz", + "integrity": "sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==", + "dev": true, + "dependencies": { + "browserify-cipher": "^1.0.1", + "browserify-sign": "^4.2.3", + "create-ecdh": "^4.0.4", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "diffie-hellman": "^5.0.3", + "hash-base": "~3.0.4", + "inherits": "^2.0.4", + "pbkdf2": "^3.1.2", + "public-encrypt": "^4.0.3", + "randombytes": "^2.1.0", + "randomfill": "^1.0.4" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/default-gateway": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", + "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==", + "dev": true, + "dependencies": { + "execa": "^5.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/des.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "dev": true, + "dependencies": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true + }, + "node_modules/diffie-hellman": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", + "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", + "dev": true, + "dependencies": { + "bn.js": "^4.1.0", + "miller-rabin": "^4.0.0", + "randombytes": "^2.0.0" + } + }, + "node_modules/diffie-hellman/node_modules/bn.js": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz", + "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==", + "dev": true + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "dev": true, + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.67", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.67.tgz", + "integrity": "sha512-nz88NNBsD7kQSAGGJyp8hS6xSPtWwqNogA0mjtc2nUYeEf3nURK9qpV18TuBdDmEDgVWotS8Wkzf+V52dSQ/LQ==", + "dev": true + }, + "node_modules/elliptic": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", + "dev": true, + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/elliptic/node_modules/bn.js": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz", + "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==", + "dev": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", + "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/envinfo": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.14.0.tgz", + "integrity": "sha512-CO40UI41xDQzhLB1hWyqUKgFhs250pNcGbyGKe1l/e4FSaI/+YE4IMG76GDt0In67WLPACIITC+sOi08x4wIvg==", + "dev": true, + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", + "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", + "dev": true + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/evp_bytestokey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", + "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", + "dev": true, + "dependencies": { + "md5.js": "^1.3.4", + "safe-buffer": "^5.1.1" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/express": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", + "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", + "dev": true, + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.10", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/express/node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dev": true, + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-uri": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", + "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==", + "dev": true + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "dev": true, + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "dev": true, + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "bin": { + "flat": "cli.js" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.3" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-monkey": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.6.tgz", + "integrity": "sha512-b1FMfwetIKymC0eioW7mTywihSQE4oLzQn1dB6rZB5fx/3NpNEdAWeCSMB+60/AeT0TCXsxzAlcYVEFCTAksWg==", + "dev": true + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, + "node_modules/globby": { + "version": "13.2.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-13.2.2.tgz", + "integrity": "sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==", + "dev": true, + "dependencies": { + "dir-glob": "^3.0.1", + "fast-glob": "^3.3.0", + "ignore": "^5.2.4", + "merge2": "^1.4.1", + "slash": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.1.0.tgz", + "integrity": "sha512-FQoVQnqcdk4hVM4JN1eromaun4iuS34oStkdlLENLdpULsuQcTyXj8w7ayhuUfPwEYZ1ZOooOTT6fdA9Vmx/RA==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" + }, + "node_modules/handle-thing": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", + "dev": true + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.1.0.tgz", + "integrity": "sha512-QLdzI9IIO1Jg7f9GT1gXpPpXArAn6cS31R1eEZqz08Gc+uQ8/XiqHWt17Fiw+2p6oTTIq5GXEpQkAlA88YRl/Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hash-base": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.5.tgz", + "integrity": "sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg==", + "dev": true, + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "bin": { + "he": "bin/he" + } + }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "dev": true, + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", + "dev": true, + "dependencies": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/html-entities": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.2.tgz", + "integrity": "sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ] + }, + "node_modules/http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", + "dev": true + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", + "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==", + "dev": true + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-middleware": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz", + "integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==", + "dev": true, + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, + "node_modules/http-server": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/http-server/-/http-server-14.1.1.tgz", + "integrity": "sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==", + "dev": true, + "dependencies": { + "basic-auth": "^2.0.1", + "chalk": "^4.1.2", + "corser": "^2.0.1", + "he": "^1.2.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy": "^1.18.1", + "mime": "^1.6.0", + "minimist": "^1.2.6", + "opener": "^1.5.1", + "portfinder": "^1.0.28", + "secure-compare": "3.0.1", + "union": "~0.5.0", + "url-join": "^4.0.1" + }, + "bin": { + "http-server": "bin/http-server" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ipaddr.js": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-arguments": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", + "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", + "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/iso-datestring-validator": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", + "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jose": { + "version": "5.9.6", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", + "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/launch-editor": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.9.1.tgz", + "integrity": "sha512-Gcnl4Bd+hRO9P9icCP/RVVT2o8SFlPXofuCxvA2SaZuH45whSvf5p8x5oih5ftLiVhEI4sp5xDY+R+b3zJBh5w==", + "dev": true, + "dependencies": { + "picocolors": "^1.0.0", + "shell-quote": "^1.8.1" + } + }, + "node_modules/loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true, + "engines": { + "node": ">=6.11.5" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + }, + "node_modules/md5.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", + "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "dev": true, + "dependencies": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "dev": true, + "dependencies": { + "fs-monkey": "^1.0.4" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/miller-rabin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", + "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", + "dev": true, + "dependencies": { + "bn.js": "^4.0.0", + "brorand": "^1.0.1" + }, + "bin": { + "miller-rabin": "bin/miller-rabin" + } + }, + "node_modules/miller-rabin/node_modules/bn.js": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz", + "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==", + "dev": true + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", + "dev": true + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "dev": true, + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, + "node_modules/multiformats": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "dev": true, + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "dev": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-inspect": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dev": true + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true, + "bin": { + "opener": "bin/opener-bin.js" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-asn1": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.7.tgz", + "integrity": "sha512-CTM5kuWR3sx9IFamcl5ErfPl6ea/N8IYwiJ+vpeB2g+1iknv7zBl5uPwbMbRVznRVbrNY6lGuDoE5b30grmbqg==", + "dev": true, + "dependencies": { + "asn1.js": "^4.10.1", + "browserify-aes": "^1.2.0", + "evp_bytestokey": "^1.0.3", + "hash-base": "~3.0", + "pbkdf2": "^3.1.2", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-to-regexp": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==", + "dev": true + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pbkdf2": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz", + "integrity": "sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==", + "dev": true, + "dependencies": { + "create-hash": "^1.1.2", + "create-hmac": "^1.1.4", + "ripemd160": "^2.0.1", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/portfinder": { + "version": "1.0.32", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.32.tgz", + "integrity": "sha512-on2ZJVVDXRADWE6jnQaX0ioEylzgBpQk8r55NE4wjXW1ZxO+BgDlY6DXwj20i0V8eB4SenDQ00WEaxfiIQPcxg==", + "dev": true, + "dependencies": { + "async": "^2.6.4", + "debug": "^3.2.7", + "mkdirp": "^0.5.6" + }, + "engines": { + "node": ">= 0.12.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/public-encrypt": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", + "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==", + "dev": true, + "dependencies": { + "bn.js": "^4.1.0", + "browserify-rsa": "^4.0.0", + "create-hash": "^1.1.0", + "parse-asn1": "^5.0.0", + "randombytes": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/public-encrypt/node_modules/bn.js": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz", + "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==", + "dev": true + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.1.tgz", + "integrity": "sha512-EJPeIn0CYrGu+hli1xilKAPXODtJ12T0sP63Ijx2/khC2JtuaN3JyNIpvmnkmaEtha9ocbG4A4cMcr+TvqvwQg==", + "dev": true, + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/randomfill": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz", + "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", + "dev": true, + "dependencies": { + "randombytes": "^2.0.5", + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dev": true, + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ripemd160": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", + "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", + "dev": true, + "dependencies": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "node_modules/schema-utils": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", + "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/secure-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz", + "integrity": "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==", + "dev": true + }, + "node_modules/select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", + "dev": true + }, + "node_modules/selfsigned": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", + "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", + "dev": true, + "dependencies": { + "@types/node-forge": "^1.3.0", + "node-forge": "^1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "dev": true, + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/serve-index": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", + "dev": true, + "dependencies": { + "accepts": "~1.3.4", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.6.2", + "mime-types": "~2.1.17", + "parseurl": "~1.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-index/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/serve-index/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", + "dev": true, + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "dev": true + }, + "node_modules/serve-index/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/serve-index/node_modules/setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", + "dev": true + }, + "node_modules/serve-index/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "dev": true, + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true + }, + "node_modules/sha.js": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", + "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "dev": true, + "dependencies": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + }, + "bin": { + "sha.js": "bin.js" + } + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", + "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/slash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", + "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sockjs": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", + "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", + "dev": true, + "dependencies": { + "faye-websocket": "^0.11.3", + "uuid": "^8.3.2", + "websocket-driver": "^0.7.4" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spdy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", + "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "dev": true, + "dependencies": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/spdy-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", + "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "dev": true, + "dependencies": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } + }, + "node_modules/spdy-transport/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/spdy-transport/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/spdy/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-browserify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", + "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", + "dev": true, + "dependencies": { + "inherits": "~2.0.4", + "readable-stream": "^3.5.0" + } + }, + "node_modules/stream-browserify/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/terser": { + "version": "5.36.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.36.0.tgz", + "integrity": "sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==", + "dev": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.10", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", + "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.20", + "jest-worker": "^27.4.5", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.1", + "terser": "^5.26.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "dev": true + }, + "node_modules/tlds": { + "version": "1.255.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.255.0.tgz", + "integrity": "sha512-tcwMRIioTcF/FcxLev8MJWxCp+GUALRhFEqbDoZrnowmKSGqPrl5pqS+Sut2m8BgJ6S4FExCSSpGffZ0Tks6Aw==", + "bin": { + "tlds": "bin.js" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/uint8arrays": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz", + "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", + "dependencies": { + "multiformats": "^9.4.2" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true + }, + "node_modules/union": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz", + "integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==", + "dev": true, + "dependencies": { + "qs": "^6.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", + "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "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" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true + }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/watchpack": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", + "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", + "dev": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wbuf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", + "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "dev": true, + "dependencies": { + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/webpack": { + "version": "5.96.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.96.1.tgz", + "integrity": "sha512-l2LlBSvVZGhL4ZrPwyr8+37AunkcYj5qh8o6u2/2rzoPc8gxFJkLj1WxNgooi9pnoc06jh0BjuXnamM4qlujZA==", + "dev": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.6", + "@webassemblyjs/ast": "^1.12.1", + "@webassemblyjs/wasm-edit": "^1.12.1", + "@webassemblyjs/wasm-parser": "^1.12.1", + "acorn": "^8.14.0", + "browserslist": "^4.24.0", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.1", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.2.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.10", + "watchpack": "^2.4.1", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", + "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", + "dev": true, + "dependencies": { + "@discoveryjs/json-ext": "^0.5.0", + "@webpack-cli/configtest": "^2.1.1", + "@webpack-cli/info": "^2.0.2", + "@webpack-cli/serve": "^2.0.5", + "colorette": "^2.0.14", + "commander": "^10.0.1", + "cross-spawn": "^7.0.3", + "envinfo": "^7.7.3", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", + "webpack-merge": "^5.7.3" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "5.x.x" + }, + "peerDependenciesMeta": { + "@webpack-cli/generators": { + "optional": true + }, + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/webpack-dev-middleware": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz", + "integrity": "sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==", + "dev": true, + "dependencies": { + "colorette": "^2.0.10", + "memfs": "^3.4.3", + "mime-types": "^2.1.31", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/webpack-dev-server": { + "version": "4.15.2", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz", + "integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==", + "dev": true, + "dependencies": { + "@types/bonjour": "^3.5.9", + "@types/connect-history-api-fallback": "^1.3.5", + "@types/express": "^4.17.13", + "@types/serve-index": "^1.9.1", + "@types/serve-static": "^1.13.10", + "@types/sockjs": "^0.3.33", + "@types/ws": "^8.5.5", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.0.11", + "chokidar": "^3.5.3", + "colorette": "^2.0.10", + "compression": "^1.7.4", + "connect-history-api-fallback": "^2.0.0", + "default-gateway": "^6.0.3", + "express": "^4.17.3", + "graceful-fs": "^4.2.6", + "html-entities": "^2.3.2", + "http-proxy-middleware": "^2.0.3", + "ipaddr.js": "^2.0.1", + "launch-editor": "^2.6.0", + "open": "^8.0.9", + "p-retry": "^4.5.0", + "rimraf": "^3.0.2", + "schema-utils": "^4.0.0", + "selfsigned": "^2.1.1", + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^5.3.4", + "ws": "^8.13.0" + }, + "bin": { + "webpack-dev-server": "bin/webpack-dev-server.js" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.37.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + }, + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-merge": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "dev": true, + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/webpack/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "dev": true, + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dev": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.16.tgz", + "integrity": "sha512-g+N+GAWiRj66DngFwHvISJd+ITsyphZvD1vChfVg6cEdnzy53GzB3oy0fUNlvhz7H7+MiqhYr26qxQShCpKTTQ==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..446bc2f --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "mutesky", + "version": "1.0.0", + "description": "A web app for managing and filtering unwanted content on Bluesky, featuring curated keyword groups and context-based filtering.", + "main": "index.js", + "scripts": { + "dev": "npx webpack serve --open", + "build": "npx webpack", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "bluesky", + "content-filter", + "muting", + "social-media", + "moderation", + "keyword-filtering", + "atproto" + ], + "author": "Chrissy LeMaire", + "license": "MIT", + "dependencies": { + "@atproto/api": "^0.13.18", + "@atproto/oauth-client-browser": "^0.3.2" + }, + "devDependencies": { + "buffer": "6.0.3", + "copy-webpack-plugin": "11.0.0", + "crypto-browserify": "3.12.1", + "http-server": "^14.1.1", + "process": "0.11.10", + "stream-browserify": "3.0.0", + "util": "0.12.5", + "webpack": "5.96.1", + "webpack-cli": "5.1.4", + "webpack-dev-server": "^4.15.2" + } +} diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..716893b --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,80 @@ +const path = require('path'); +const webpack = require('webpack'); +const CopyPlugin = require('copy-webpack-plugin'); +const fs = require('fs'); + +const isDevelopment = process.env.NODE_ENV !== 'production'; + +const devServerConfig = { + static: { + directory: path.join(__dirname, '/'), + publicPath: '/' + }, + port: 443, + hot: true, + host: 'mutesky.app', + open: { + target: ['https://mutesky.app'] + }, + devMiddleware: { + publicPath: '/' + }, + historyApiFallback: true +}; + +// Only add HTTPS configuration in development mode +if (isDevelopment) { + devServerConfig.server = { + type: 'https', + options: { + key: fs.readFileSync('mutesky.app+3-key.pem'), + cert: fs.readFileSync('mutesky.app+3.pem') + } + }; +} + +module.exports = { + mode: isDevelopment ? 'development' : 'production', + entry: { + main: './js/main.js' + }, + output: { + filename: 'js/bundle.js', + path: path.resolve(__dirname, 'dist'), + publicPath: '/', + clean: { + keep: /\.git/ + } + }, + devServer: devServerConfig, + resolve: { + extensions: ['.js'], + fallback: { + "crypto": require.resolve("crypto-browserify"), + "stream": require.resolve("stream-browserify"), + "buffer": require.resolve("buffer/"), + "util": require.resolve("util/"), + "path": false, + "fs": false + } + }, + plugins: [ + new webpack.ProvidePlugin({ + Buffer: ['buffer', 'Buffer'], + process: 'process/browser', + blueskyService: ['./js/bluesky.js', 'blueskyService'] + }), + new CopyPlugin({ + patterns: [ + { from: "index.html" }, + { from: "css", to: "css" }, + { from: "js", to: "js", globOptions: { ignore: ['**/main.js'] } }, + { from: "CNAME" }, + { from: "favicon.ico" }, + { from: "images", to: "images" }, + { from: "client-metadata.json" }, + { from: "callback.html" } + ] + }) + ] +}