From 4054a8f06d6dedcbce5c9377e94b6c05af01d755 Mon Sep 17 00:00:00 2001 From: Chrissy LeMaire Date: Wed, 11 Dec 2024 15:05:58 +0100 Subject: [PATCH] refactor part 1 --- css/components/toggles.css | 247 +-------------- .../toggles/appearance-settings.css | 46 +++ css/components/toggles/checkboxes.css | 47 +++ css/components/toggles/index.css | 6 + css/components/toggles/mode-toggle.css | 35 +++ css/components/toggles/radio-buttons.css | 73 +++++ css/components/toggles/settings-groups.css | 11 + css/components/toggles/toggle-all.css | 29 ++ js/components/landing-page.js | 259 +--------------- js/components/landing/auth-handler.js | 24 ++ js/components/landing/image-handler.js | 80 +++++ js/components/landing/landing-page.js | 37 +++ js/components/landing/template.js | 131 ++++++++ js/handlers/keywordHandlers.js | 287 +----------------- js/handlers/keywords/bulk-handlers.js | 101 ++++++ js/handlers/keywords/cache.js | 27 ++ js/handlers/keywords/core-handlers.js | 57 ++++ js/handlers/keywords/index.js | 20 ++ js/handlers/keywords/keyword-utils.js | 51 ++++ js/handlers/keywords/ui-utils.js | 57 ++++ 20 files changed, 849 insertions(+), 776 deletions(-) create mode 100644 css/components/toggles/appearance-settings.css create mode 100644 css/components/toggles/checkboxes.css create mode 100644 css/components/toggles/index.css create mode 100644 css/components/toggles/mode-toggle.css create mode 100644 css/components/toggles/radio-buttons.css create mode 100644 css/components/toggles/settings-groups.css create mode 100644 css/components/toggles/toggle-all.css create mode 100644 js/components/landing/auth-handler.js create mode 100644 js/components/landing/image-handler.js create mode 100644 js/components/landing/landing-page.js create mode 100644 js/components/landing/template.js create mode 100644 js/handlers/keywords/bulk-handlers.js create mode 100644 js/handlers/keywords/cache.js create mode 100644 js/handlers/keywords/core-handlers.js create mode 100644 js/handlers/keywords/index.js create mode 100644 js/handlers/keywords/keyword-utils.js create mode 100644 js/handlers/keywords/ui-utils.js diff --git a/css/components/toggles.css b/css/components/toggles.css index 92cac50..8a64ece 100644 --- a/css/components/toggles.css +++ b/css/components/toggles.css @@ -1,246 +1 @@ -/* 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; -} +@import './toggles/index.css'; diff --git a/css/components/toggles/appearance-settings.css b/css/components/toggles/appearance-settings.css new file mode 100644 index 0000000..7473be1 --- /dev/null +++ b/css/components/toggles/appearance-settings.css @@ -0,0 +1,46 @@ +/* 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/components/toggles/checkboxes.css b/css/components/toggles/checkboxes.css new file mode 100644 index 0000000..9c200d6 --- /dev/null +++ b/css/components/toggles/checkboxes.css @@ -0,0 +1,47 @@ +/* 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); +} diff --git a/css/components/toggles/index.css b/css/components/toggles/index.css new file mode 100644 index 0000000..7547be9 --- /dev/null +++ b/css/components/toggles/index.css @@ -0,0 +1,6 @@ +@import './mode-toggle.css'; +@import './toggle-all.css'; +@import './radio-buttons.css'; +@import './checkboxes.css'; +@import './settings-groups.css'; +@import './appearance-settings.css'; diff --git a/css/components/toggles/mode-toggle.css b/css/components/toggles/mode-toggle.css new file mode 100644 index 0000000..f84c09b --- /dev/null +++ b/css/components/toggles/mode-toggle.css @@ -0,0 +1,35 @@ +/* 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; +} diff --git a/css/components/toggles/radio-buttons.css b/css/components/toggles/radio-buttons.css new file mode 100644 index 0000000..3c4f1f4 --- /dev/null +++ b/css/components/toggles/radio-buttons.css @@ -0,0 +1,73 @@ +/* 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; +} diff --git a/css/components/toggles/settings-groups.css b/css/components/toggles/settings-groups.css new file mode 100644 index 0000000..2e72978 --- /dev/null +++ b/css/components/toggles/settings-groups.css @@ -0,0 +1,11 @@ +/* Settings Groups */ +.settings-group { + margin-bottom: 16px; +} + +.settings-group h3 { + font-size: 15px; + font-weight: 600; + color: var(--text); + margin-bottom: 12px; +} diff --git a/css/components/toggles/toggle-all.css b/css/components/toggles/toggle-all.css new file mode 100644 index 0000000..7140743 --- /dev/null +++ b/css/components/toggles/toggle-all.css @@ -0,0 +1,29 @@ +/* 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); +} diff --git a/js/components/landing-page.js b/js/components/landing-page.js index e34abaf..0b01c63 100644 --- a/js/components/landing-page.js +++ b/js/components/landing-page.js @@ -1,258 +1 @@ -class LandingPage extends HTMLElement { - constructor() { - super(); - // Store preloaded images - this.imageCache = new Map(); - // Store theme observer - this.themeObserver = null; - } - - 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.
-
- -
-
- -
- -
-
-
- - -
-
-

How It Works

-

Take control of your Bluesky experience with Mutesky's intuitive filtering system

-
- -
-
-
-
-

Start with Simple Mode

-

Quickly filter content across major topics like politics, healthcare, and global affairs. Choose what you don't want to see with just a few clicks.

-
-
- -
-
-
-

Extensive Categories

-

Select from over 20 content categories, from climate to international coverage. Each category comes pre-populated with carefully curated keywords, continuously updated to reflect current events.

-
-
- -
-
-
-

Advanced Control

-

Need more control? Switch to Advanced Mode for direct access to over 1,400 keywords. Fine-tune your filters with individual toggles or bulk actions.

-
-
- -
-
-

Perfect Balance

-

Choose your perfect balance with four filtering levels:

-
    -
  • Minimal for light touch filtering
  • -
  • Moderate for balanced content management
  • -
  • Extensive for comprehensive filtering
  • -
  • Complete for maximum control
  • -
-

Changes take effect instantly on your feed, and you can adjust your settings anytime.

-
-
-
-
- - -
-
-

Made with 🤖 and Sponsor on GitHub by Chrissy LeMaire

- -
-
-
-
-
- `; - - // Initialize theme-aware images after component is mounted - this.initThemeAwareImages(); - - // Listen for theme changes - this.themeObserver = (event) => this.updateThemeAwareImages(event?.detail?.theme); - document.addEventListener('themeChanged', this.themeObserver); - - // Check for auth errors after component is mounted - this.checkAuthErrors(); - } - - disconnectedCallback() { - // Clean up event listeners and cache - if (this.themeObserver) { - document.removeEventListener('themeChanged', this.themeObserver); - } - this.imageCache.clear(); - } - - async initThemeAwareImages() { - const images = this.querySelectorAll('.theme-aware-image'); - const preloadPromises = []; - - // Preload all images - images.forEach(img => { - const lightSrc = img.dataset.lightSrc; - const darkSrc = img.dataset.darkSrc; - - if (lightSrc && !this.imageCache.has(lightSrc)) { - preloadPromises.push(this.preloadImage(lightSrc)); - } - if (darkSrc && !this.imageCache.has(darkSrc)) { - preloadPromises.push(this.preloadImage(darkSrc)); - } - }); - - try { - await Promise.all(preloadPromises); - this.updateThemeAwareImages(); - } catch (error) { - console.error('Error preloading images:', error); - } - } - - async preloadImage(src) { - if (!src || this.imageCache.has(src)) return; - - try { - const img = new Image(); - const loadPromise = new Promise((resolve, reject) => { - img.onload = () => resolve(src); - img.onerror = () => reject(new Error(`Failed to load image: ${src}`)); - }); - - img.src = src; - await loadPromise; - this.imageCache.set(src, true); - } catch (error) { - console.error(`Error preloading image ${src}:`, error); - // Cache the failure to avoid repeated attempts - this.imageCache.set(src, false); - } - } - - updateThemeAwareImages(theme = null) { - if (!theme) { - theme = document.documentElement.getAttribute('data-theme'); - } - const isDarkMode = theme === 'dark'; - - requestAnimationFrame(() => { - this.querySelectorAll('.theme-aware-image').forEach(async (img) => { - const src = isDarkMode ? img.dataset.darkSrc : img.dataset.lightSrc; - - // Skip if image hasn't been preloaded or failed to preload - if (!this.imageCache.has(src)) { - await this.preloadImage(src); - } - - if (this.imageCache.get(src)) { - img.style.backgroundImage = `url('${src}')`; - } else { - // Use fallback image or add error class - img.classList.add('image-load-error'); - } - }); - }); - } - - 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; +export { default } from './landing/landing-page.js'; diff --git a/js/components/landing/auth-handler.js b/js/components/landing/auth-handler.js new file mode 100644 index 0000000..a18d002 --- /dev/null +++ b/js/components/landing/auth-handler.js @@ -0,0 +1,24 @@ +export class AuthHandler { + static 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'); + } + } +} diff --git a/js/components/landing/image-handler.js b/js/components/landing/image-handler.js new file mode 100644 index 0000000..55a851e --- /dev/null +++ b/js/components/landing/image-handler.js @@ -0,0 +1,80 @@ +export class ImageHandler { + constructor() { + this.imageCache = new Map(); + this.themeObserver = null; + } + + async initThemeAwareImages(component) { + const images = component.querySelectorAll('.theme-aware-image'); + const preloadPromises = []; + + // Preload all images + images.forEach(img => { + const lightSrc = img.dataset.lightSrc; + const darkSrc = img.dataset.darkSrc; + + if (lightSrc && !this.imageCache.has(lightSrc)) { + preloadPromises.push(this.preloadImage(lightSrc)); + } + if (darkSrc && !this.imageCache.has(darkSrc)) { + preloadPromises.push(this.preloadImage(darkSrc)); + } + }); + + try { + await Promise.all(preloadPromises); + this.updateThemeAwareImages(component); + } catch (error) { + console.error('Error preloading images:', error); + } + } + + async preloadImage(src) { + if (!src || this.imageCache.has(src)) return; + + try { + const img = new Image(); + const loadPromise = new Promise((resolve, reject) => { + img.onload = () => resolve(src); + img.onerror = () => reject(new Error(`Failed to load image: ${src}`)); + }); + + img.src = src; + await loadPromise; + this.imageCache.set(src, true); + } catch (error) { + console.error(`Error preloading image ${src}:`, error); + // Cache the failure to avoid repeated attempts + this.imageCache.set(src, false); + } + } + + updateThemeAwareImages(component, theme = null) { + if (!theme) { + theme = document.documentElement.getAttribute('data-theme'); + } + const isDarkMode = theme === 'dark'; + + requestAnimationFrame(() => { + component.querySelectorAll('.theme-aware-image').forEach(async (img) => { + const src = isDarkMode ? img.dataset.darkSrc : img.dataset.lightSrc; + + // Skip if image hasn't been preloaded or failed to preload + if (!this.imageCache.has(src)) { + await this.preloadImage(src); + } + + if (this.imageCache.get(src)) { + img.style.backgroundImage = `url('${src}')`; + } else { + // Use fallback image or add error class + img.classList.add('image-load-error'); + } + }); + }); + } + + cleanup() { + this.imageCache.clear(); + } +} diff --git a/js/components/landing/landing-page.js b/js/components/landing/landing-page.js new file mode 100644 index 0000000..e56b2e8 --- /dev/null +++ b/js/components/landing/landing-page.js @@ -0,0 +1,37 @@ +import { ImageHandler } from './image-handler.js'; +import { AuthHandler } from './auth-handler.js'; +import { landingPageTemplate } from './template.js'; + +class LandingPage extends HTMLElement { + constructor() { + super(); + this.imageHandler = new ImageHandler(); + this.themeObserver = null; + } + + connectedCallback() { + this.innerHTML = landingPageTemplate; + + // Initialize theme-aware images after component is mounted + this.imageHandler.initThemeAwareImages(this); + + // Listen for theme changes + this.themeObserver = (event) => this.imageHandler.updateThemeAwareImages(this, event?.detail?.theme); + document.addEventListener('themeChanged', this.themeObserver); + + // Check for auth errors after component is mounted + AuthHandler.checkAuthErrors(); + } + + disconnectedCallback() { + // Clean up event listeners and cache + if (this.themeObserver) { + document.removeEventListener('themeChanged', this.themeObserver); + } + this.imageHandler.cleanup(); + } +} + +customElements.define('landing-page', LandingPage); + +export default LandingPage; diff --git a/js/components/landing/template.js b/js/components/landing/template.js new file mode 100644 index 0000000..5d247bb --- /dev/null +++ b/js/components/landing/template.js @@ -0,0 +1,131 @@ +export const landingPageTemplate = ` +
+ +
+
+ +

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.
+
+ +
+
+ +
+ +
+
+
+ + +
+
+

How It Works

+

Take control of your Bluesky experience with Mutesky's intuitive filtering system

+
+ +
+
+
+
+

Start with Simple Mode

+

Quickly filter content across major topics like politics, healthcare, and global affairs. Choose what you don't want to see with just a few clicks.

+
+
+ +
+
+
+

Extensive Categories

+

Select from over 20 content categories, from climate to international coverage. Each category comes pre-populated with carefully curated keywords, continuously updated to reflect current events.

+
+
+ +
+
+
+

Advanced Control

+

Need more control? Switch to Advanced Mode for direct access to over 1,400 keywords. Fine-tune your filters with individual toggles or bulk actions.

+
+
+ +
+
+

Perfect Balance

+

Choose your perfect balance with four filtering levels:

+
    +
  • Minimal for light touch filtering
  • +
  • Moderate for balanced content management
  • +
  • Extensive for comprehensive filtering
  • +
  • Complete for maximum control
  • +
+

Changes take effect instantly on your feed, and you can adjust your settings anytime.

+
+
+
+
+ + +
+
+

Made with 🤖 and Sponsor on GitHub by Chrissy LeMaire

+ +
+
+
+
+
+`; diff --git a/js/handlers/keywordHandlers.js b/js/handlers/keywordHandlers.js index 8b50f75..94374b6 100644 --- a/js/handlers/keywordHandlers.js +++ b/js/handlers/keywordHandlers.js @@ -1,272 +1,15 @@ -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) -export 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(); - }); -} +// Re-export everything from the new modular structure +export { + keywordCache, + debouncedUpdate, + notifyKeywordChanges, + updateCheckboxes, + standardUpdate, + isKeywordActive, + removeKeyword, + processBatchKeywords, + handleKeywordToggle, + handleCategoryToggle, + handleEnableAll, + handleDisableAll +} from './keywords/index.js'; diff --git a/js/handlers/keywords/bulk-handlers.js b/js/handlers/keywords/bulk-handlers.js new file mode 100644 index 0000000..eb6437d --- /dev/null +++ b/js/handlers/keywords/bulk-handlers.js @@ -0,0 +1,101 @@ +import { state, saveState } from '../../state.js'; +import { filterKeywordGroups } from '../../categoryManager.js'; +import { debouncedUpdate } from './ui-utils.js'; +import { keywordCache } from './cache.js'; +import { removeKeyword, isKeywordActive, processBatchKeywords } from './keyword-utils.js'; +import { updateSimpleModeState } from '../contextHandlers.js'; +import { renderInterface } from '../../renderer.js'; + +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/keywords/cache.js b/js/handlers/keywords/cache.js new file mode 100644 index 0000000..485c28e --- /dev/null +++ b/js/handlers/keywords/cache.js @@ -0,0 +1,27 @@ +import { getAllKeywordsForCategory } from '../../categoryManager.js'; + +// Enhanced keyword cache with shorter timeout +export 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; + } +}; diff --git a/js/handlers/keywords/core-handlers.js b/js/handlers/keywords/core-handlers.js new file mode 100644 index 0000000..3ca50e0 --- /dev/null +++ b/js/handlers/keywords/core-handlers.js @@ -0,0 +1,57 @@ +import { state, saveState } from '../../state.js'; +import { debouncedUpdate, updateCheckboxes } from './ui-utils.js'; +import { keywordCache } from './cache.js'; +import { removeKeyword, isKeywordActive, processBatchKeywords } from './keyword-utils.js'; +import { updateSimpleModeState } from '../contextHandlers.js'; +import { renderInterface } from '../../renderer.js'; + +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(); + }); +} diff --git a/js/handlers/keywords/index.js b/js/handlers/keywords/index.js new file mode 100644 index 0000000..160e63a --- /dev/null +++ b/js/handlers/keywords/index.js @@ -0,0 +1,20 @@ +export { keywordCache } from './cache.js'; +export { + debouncedUpdate, + notifyKeywordChanges, + updateCheckboxes, + standardUpdate +} from './ui-utils.js'; +export { + isKeywordActive, + removeKeyword, + processBatchKeywords +} from './keyword-utils.js'; +export { + handleKeywordToggle, + handleCategoryToggle +} from './core-handlers.js'; +export { + handleEnableAll, + handleDisableAll +} from './bulk-handlers.js'; diff --git a/js/handlers/keywords/keyword-utils.js b/js/handlers/keywords/keyword-utils.js new file mode 100644 index 0000000..bcd56ea --- /dev/null +++ b/js/handlers/keywords/keyword-utils.js @@ -0,0 +1,51 @@ +import { state, saveState } from '../../state.js'; + +// 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) +export function removeKeyword(keyword) { + const lowerKeyword = keyword.toLowerCase(); + for (const activeKeyword of state.activeKeywords) { + if (activeKeyword.toLowerCase() === lowerKeyword) { + state.activeKeywords.delete(activeKeyword); + break; + } + } +} + +// 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) { + // 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(); +} diff --git a/js/handlers/keywords/ui-utils.js b/js/handlers/keywords/ui-utils.js new file mode 100644 index 0000000..2d05c89 --- /dev/null +++ b/js/handlers/keywords/ui-utils.js @@ -0,0 +1,57 @@ +import { state, saveState } from '../../state.js'; +import { updateSimpleModeState } from '../contextHandlers.js'; +import { renderInterface } from '../../renderer.js'; + +// Debounced UI updates with frame timing +export const debouncedUpdate = (() => { + let timeout; + let frameRequest; + return (fn) => { + if (timeout) clearTimeout(timeout); + if (frameRequest) cancelAnimationFrame(frameRequest); + + timeout = setTimeout(() => { + frameRequest = requestAnimationFrame(() => { + fn(); + notifyKeywordChanges(); + }); + }, 16); + }; +})(); + +// Helper function to notify keyword changes +export function notifyKeywordChanges() { + document.dispatchEvent(new CustomEvent('keywordsUpdated', { + detail: { count: state.activeKeywords.size } + })); +} + +// Optimized checkbox update with proper CSS escaping +export 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; + }); + }); +} + +// Standard update function used by handlers +export function standardUpdate() { + updateSimpleModeState(); + renderInterface(); + saveState(); +}