diff --git a/.gitignore b/.gitignore
index 039c111..4f4c46c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -137,3 +137,4 @@ bundle.js
*.crx
*.zip
/keywords/categories
+repomix-output.txt
diff --git a/README.md b/README.md
index ab0709a..4dd7e5d 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,8 @@
# 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.
+ Mutesky is a web app that gives you granular control over what appears in your Bluesky feed through curated keyword collections. Working directly with Bluesky's native mute system, it provides an intuitive interface to filter out content you'd rather not see. With over 1,400 pre-populated keywords organized into 20+ smart categories, Mutesky makes content filtering both easy and effective.
+
+
## Key Features
@@ -46,4 +48,4 @@ Check out [US Politician Labeler](https://bsky.app/profile/did:plc:bxnuth7kms5l5
## 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
+AI-powered dynamic keyword updates: An optional service that automatically identifies and updates mute keywords hourly based on emerging trends and topics.
diff --git a/css/base.css b/css/base.css
index 33454e2..2172b69 100644
--- a/css/base.css
+++ b/css/base.css
@@ -9,7 +9,7 @@
--primary-hover: #0066cc;
--primary-light: #e6f3ff;
--surface: #ffffff;
- --background: #f8fafc;
+ --background: #eef2f6;
--background-light: #ffffff;
--text: #000000;
--text-secondary: #536471;
@@ -55,8 +55,8 @@
--button-transition: 200ms ease-in-out;
}
-/* Dark Theme (Dim) */
-[data-theme="dim"] {
+/* Dark Theme */
+[data-theme="dark"] {
--surface: #15202b;
--background: #1e2732;
--background-light: #1a2634;
@@ -154,7 +154,7 @@ a:hover {
/* System Theme Detection */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
- /* Default to dim theme when system prefers dark */
+ /* Default to dark theme when system prefers dark */
--surface: #15202b;
--background: #1e2732;
--background-light: #1a2634;
diff --git a/css/components/auth.css b/css/components/auth.css
index 5150f67..8d9674e 100644
--- a/css/components/auth.css
+++ b/css/components/auth.css
@@ -1,12 +1,11 @@
/* Auth Container */
.bsky-connect {
- background: var(--background);
- border-radius: 16px;
+ background: var(--surface);
+ border-radius: 20px;
padding: 24px;
- margin-top: 24px;
border: 1px solid var(--border);
position: relative;
- overflow: hidden;
+ overflow: visible;
}
.bsky-connect::before {
@@ -15,195 +14,94 @@
inset: 0;
background: var(--surface-gradient);
opacity: 0;
- transition: opacity 0.3s ease;
+ transition: opacity 0.2s ease-in-out;
+ border-radius: inherit;
+ pointer-events: none; /* Add this to fix click blocking */
}
.bsky-connect:hover::before {
opacity: 1;
}
+/* Sign In Header */
.sign-in-title {
- font-size: 22px;
+ font-size: 20px;
font-weight: 800;
- color: var(--text);
- margin: 0 0 4px 0;
- letter-spacing: -0.02em;
- position: relative;
+ margin: 0;
+ letter-spacing: -0.01em;
+ line-height: 1.3;
}
+/* Auth Container Layout */
.bsky-auth-container {
display: flex;
flex-direction: column;
- gap: 16px;
- position: relative;
- margin-top: 12px;
+ margin-top: 24px;
+ gap: 24px;
}
+/* Auth Section */
.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 {
+/* Explanatory Text */
+.bsky-auth-message {
color: var(--text-secondary);
- opacity: 0.7;
+ line-height: 1.5;
+ margin-bottom: 16px;
}
-/* Auth Button */
+/* Input & Button Overrides */
+.bsky-handle-input,
.btn-auth {
- width: 100%;
- padding: 12px 16px;
- font-size: 15px;
- font-weight: 700;
- color: #ffffff;
- background: var(--primary);
- border: none;
+ min-height: 48px;
+ height: auto;
+ padding: 14px 16px;
+ line-height: 1.3;
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);
+ width: 100%;
}
-/* Auth Message */
-.bsky-auth-message {
- margin-top: 4px;
- font-size: 14px;
- color: var(--text-secondary);
- padding: 0 4px;
- transition: all 0.3s ease;
+.bsky-handle-input {
+ background: var(--background);
+ padding-left: 36px;
+ border: 1px solid rgba(0, 122, 255, 0.15);
+ transition: border-color 0.2s ease-in-out;
}
+/* Error States */
.bsky-auth-message.error {
color: var(--error);
background: rgba(var(--error-rgb), 0.1);
- border-radius: 8px;
+ border-radius: 12px;
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;
+ border-radius: 16px;
}
- .sign-in-title {
- font-size: 20px;
+ .bsky-auth-container {
+ gap: 20px;
}
}
@media (max-width: 480px) {
.bsky-connect {
padding: 16px;
- margin-top: 16px;
- }
-
- .sign-in-title {
- font-size: 18px;
+ border-radius: 12px;
}
- .bsky-handle-input,
- .btn-auth {
- padding: 10px 14px 10px 32px;
+ .bsky-auth-container {
+ gap: 16px;
+ margin-top: 20px;
}
- .input-wrapper::before {
- left: 14px;
+ .bsky-auth-message {
+ font-size: 14px;
}
}
diff --git a/css/components/buttons.css b/css/components/buttons.css
index e3c45bb..ed1adf2 100644
--- a/css/components/buttons.css
+++ b/css/components/buttons.css
@@ -18,7 +18,6 @@
.btn-auth {
background: var(--primary);
color: #ffffff;
- margin: 0 auto;
padding: 12px 24px;
min-width: 120px;
}
diff --git a/css/components/cards.css b/css/components/cards.css
deleted file mode 100644
index 70dfe29..0000000
--- a/css/components/cards.css
+++ /dev/null
@@ -1,220 +0,0 @@
-/* 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/cards/base.css b/css/components/cards/base.css
new file mode 100644
index 0000000..56257bd
--- /dev/null
+++ b/css/components/cards/base.css
@@ -0,0 +1,29 @@
+/* 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);
+}
+
+/* Hover state for cards */
+.feature-card:hover,
+.context-card:hover {
+ background: var(--background);
+}
+
+/* Avatar */
+.avatar {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ object-fit: cover;
+}
+
+.avatar-small {
+ width: 32px;
+ height: 32px;
+}
diff --git a/css/components/cards/category.css b/css/components/cards/category.css
new file mode 100644
index 0000000..5f60fbd
--- /dev/null
+++ b/css/components/cards/category.css
@@ -0,0 +1,60 @@
+/* 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);
+}
diff --git a/css/components/cards/components.css b/css/components/cards/components.css
new file mode 100644
index 0000000..b431a9c
--- /dev/null
+++ b/css/components/cards/components.css
@@ -0,0 +1,46 @@
+/* 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;
+}
diff --git a/css/components/cards/index.css b/css/components/cards/index.css
new file mode 100644
index 0000000..49157ad
--- /dev/null
+++ b/css/components/cards/index.css
@@ -0,0 +1,5 @@
+/* Import all card-related styles */
+@import 'base.css';
+@import 'landing-feature.css';
+@import 'category.css';
+@import 'components.css';
diff --git a/css/components/cards/landing-feature.css b/css/components/cards/landing-feature.css
new file mode 100644
index 0000000..53308fd
--- /dev/null
+++ b/css/components/cards/landing-feature.css
@@ -0,0 +1,68 @@
+/* Landing Page Feature Cards */
+.landing-feature-card {
+ background: var(--surface);
+ padding: 24px;
+ border: 1px solid var(--border);
+ border-radius: 20px;
+ 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;
+}
+
+.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;
+}
+
+/* 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/footer.css b/css/components/footer.css
index 42b606b..931a3bd 100644
--- a/css/components/footer.css
+++ b/css/components/footer.css
@@ -111,7 +111,7 @@
.theme-toggle .moon-icon {
margin-left: auto;
- transform: translateX(2px);
+ transform: translateX(1px);
}
/* Hide emoji in dark mode */
diff --git a/css/components/forms.css b/css/components/forms.css
index 930bf37..1cb2ee0 100644
--- a/css/components/forms.css
+++ b/css/components/forms.css
@@ -1,198 +1,7 @@
-/* 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); }
-}
+/* Import split form components */
+@import './forms/search.css';
+@import './forms/handle-input.css';
+@import './forms/checkboxes.css';
+@import './forms/category-links.css';
+@import './forms/select.css';
+@import './forms/radio.css';
diff --git a/css/components/forms/category-links.css b/css/components/forms/category-links.css
new file mode 100644
index 0000000..6211b3b
--- /dev/null
+++ b/css/components/forms/category-links.css
@@ -0,0 +1,10 @@
+/* Category Links */
+.category-name {
+ color: var(--text);
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.category-name:hover {
+ color: var(--primary);
+}
diff --git a/css/components/forms/checkboxes.css b/css/components/forms/checkboxes.css
new file mode 100644
index 0000000..0a25000
--- /dev/null
+++ b/css/components/forms/checkboxes.css
@@ -0,0 +1,66 @@
+/* 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);
+}
diff --git a/css/components/forms/handle-input.css b/css/components/forms/handle-input.css
new file mode 100644
index 0000000..f16ca19
--- /dev/null
+++ b/css/components/forms/handle-input.css
@@ -0,0 +1,53 @@
+/* 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;
+}
+
+/* Animation for error state */
+@keyframes shake {
+ 0%, 100% { transform: translateX(0); }
+ 25% { transform: translateX(-5px); }
+ 75% { transform: translateX(5px); }
+}
diff --git a/css/components/forms/radio.css b/css/components/forms/radio.css
new file mode 100644
index 0000000..1d95741
--- /dev/null
+++ b/css/components/forms/radio.css
@@ -0,0 +1,28 @@
+/* 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);
+}
diff --git a/css/components/forms/search.css b/css/components/forms/search.css
new file mode 100644
index 0000000..a8ab937
--- /dev/null
+++ b/css/components/forms/search.css
@@ -0,0 +1,21 @@
+/* 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;
+}
diff --git a/css/components/forms/select.css b/css/components/forms/select.css
new file mode 100644
index 0000000..36270c8
--- /dev/null
+++ b/css/components/forms/select.css
@@ -0,0 +1,16 @@
+/* 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);
+}
diff --git a/css/components/index.css b/css/components/index.css
index 8d290a7..bb604d2 100644
--- a/css/components/index.css
+++ b/css/components/index.css
@@ -1,7 +1,7 @@
/* Component Styles */
@import 'buttons.css';
@import 'forms.css';
-@import 'cards.css';
+@import 'cards/index.css';
@import 'toggles.css';
@import 'auth.css';
@import 'media.css';
diff --git a/css/components/landing.css b/css/components/landing.css
index d7cb1d1..4235a52 100644
--- a/css/components/landing.css
+++ b/css/components/landing.css
@@ -1,166 +1,7 @@
-/* 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;
- }
-}
+/* Landing Page Styles - Split into modular files */
+@import 'landing/layout.landing.css';
+@import 'landing/branding.landing.css';
+@import 'landing/features.landing.css';
+@import 'landing/auth.landing.css';
+@import 'landing/theme-toggle.landing.css';
+@import 'landing/responsive.landing.css';
diff --git a/css/components/landing/auth.landing.css b/css/components/landing/auth.landing.css
new file mode 100644
index 0000000..fe6a71a
--- /dev/null
+++ b/css/components/landing/auth.landing.css
@@ -0,0 +1,35 @@
+/* Auth Section */
+.bsky-connect {
+ padding: 32px;
+ background: var(--background);
+ border-radius: 12px;
+ margin-bottom: 96px;
+ position: relative;
+}
+
+.bsky-connect:after {
+ content: "";
+ position: absolute;
+ bottom: -48px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 64px;
+ height: 4px;
+ background: var(--primary);
+ border-radius: 2px;
+ opacity: 0.3;
+}
+
+.sign-in-title {
+ font-size: 24px;
+ font-weight: 700;
+ margin-bottom: 24px;
+ color: var(--text);
+}
+
+.bsky-auth-message {
+ color: var(--text-secondary);
+ margin-bottom: 16px;
+ font-size: 16px;
+ line-height: 1.5;
+}
diff --git a/css/components/landing/branding.landing.css b/css/components/landing/branding.landing.css
new file mode 100644
index 0000000..fb04d39
--- /dev/null
+++ b/css/components/landing/branding.landing.css
@@ -0,0 +1,77 @@
+/* Branding Section (Left) */
+.branding-section {
+ flex: 0 0 var(--branding-width);
+ background: var(--background);
+ position: sticky;
+ top: 0;
+ height: 100vh;
+ 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;
+ 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;
+}
+
+/* Built by Section */
+.built-by-section {
+ text-align: center;
+ margin-top: 64px;
+ padding-top: 32px;
+ border-top: 1px solid var(--border);
+}
+
+.built-by-content {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 16px;
+}
+
+.built-by-content p {
+ font-size: 16px;
+ line-height: 1.5;
+ color: var(--text-secondary);
+ margin: 0;
+}
+
+.built-by-content a {
+ color: var(--primary);
+ text-decoration: none;
+ transition: color 0.2s ease;
+}
+
+.built-by-content a:hover {
+ color: var(--primary-hover);
+}
diff --git a/css/components/landing/features.landing.css b/css/components/landing/features.landing.css
new file mode 100644
index 0000000..e534131
--- /dev/null
+++ b/css/components/landing/features.landing.css
@@ -0,0 +1,178 @@
+/* Feature Grid */
+.feature-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 24px;
+ margin-bottom: 32px;
+}
+
+.landing-feature-card {
+ background: var(--background);
+ padding: 24px;
+ border-radius: 12px;
+ display: flex;
+ align-items: flex-start;
+ gap: 16px;
+}
+
+.feature-icon {
+ font-size: 24px;
+ line-height: 1;
+}
+
+.feature-text h3 {
+ font-size: 18px;
+ font-weight: 600;
+ margin-bottom: 8px;
+ color: var(--text);
+}
+
+.feature-text p {
+ font-size: 14px;
+ line-height: 1.5;
+ color: var(--text-secondary);
+ margin: 0;
+}
+
+/* Detailed Features Section */
+.detailed-features {
+ padding-top: 0;
+}
+
+.section-intro {
+ text-align: center;
+ margin-bottom: 48px;
+ max-width: 600px;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.section-intro h2 {
+ font-size: 36px;
+ font-weight: 800;
+ color: var(--text);
+ margin-bottom: 16px;
+ letter-spacing: -0.02em;
+}
+
+.section-intro p {
+ font-size: 18px;
+ line-height: 1.6;
+ color: var(--text-secondary);
+}
+
+.feature-blocks {
+ display: flex;
+ flex-direction: column;
+ gap: 48px;
+}
+
+.feature-block {
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+ opacity: 0;
+ transform: translateY(20px);
+ animation: fadeInUp 0.6s ease forwards;
+}
+
+.feature-block:nth-child(1) { animation-delay: 0.1s; }
+.feature-block:nth-child(2) { animation-delay: 0.2s; }
+.feature-block:nth-child(3) { animation-delay: 0.3s; }
+.feature-block:nth-child(4) { animation-delay: 0.4s; }
+
+@keyframes fadeInUp {
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.feature-image {
+ border-radius: 12px;
+ overflow: hidden;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
+ background: var(--background);
+ aspect-ratio: 16 / 9;
+ background-size: cover;
+ background-position: center;
+ background-repeat: no-repeat;
+ transition: background-image 0.3s ease;
+ position: relative;
+}
+
+/* Add loading state */
+.feature-image.loading::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background-color: var(--background);
+ animation: pulse 1.5s ease-in-out infinite;
+}
+
+@keyframes pulse {
+ 0% { opacity: 0.6; }
+ 50% { opacity: 0.8; }
+ 100% { opacity: 0.6; }
+}
+
+/* Error state styling */
+.feature-image.image-load-error {
+ background-color: var(--background);
+ border: 2px dashed var(--border-color);
+}
+
+.feature-image.image-load-error::after {
+ content: '⚠️ Image failed to load';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ color: var(--text);
+ font-size: 14px;
+ text-align: center;
+ padding: 8px 16px;
+ background-color: var(--background);
+ border-radius: 6px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.feature-description {
+ color: var(--text);
+}
+
+.feature-description h3 {
+ font-size: 24px;
+ font-weight: 700;
+ margin-bottom: 16px;
+ color: var(--text);
+}
+
+.feature-description p {
+ font-size: 16px;
+ line-height: 1.6;
+ color: var(--text-secondary);
+ margin-bottom: 16px;
+}
+
+.feature-description ul {
+ list-style: none;
+ padding: 0;
+ margin: 16px 0;
+}
+
+.feature-description li {
+ font-size: 16px;
+ line-height: 1.6;
+ color: var(--text-secondary);
+ margin-bottom: 8px;
+ padding-left: 24px;
+ position: relative;
+}
+
+.feature-description li:before {
+ content: "•";
+ position: absolute;
+ left: 8px;
+ color: var(--primary);
+}
diff --git a/css/components/landing/layout.landing.css b/css/components/landing/layout.landing.css
new file mode 100644
index 0000000..1dc978e
--- /dev/null
+++ b/css/components/landing/layout.landing.css
@@ -0,0 +1,21 @@
+/* Split Layout for Landing Page */
+.split-layout {
+ display: flex;
+ min-height: 100vh;
+ background: var(--surface);
+}
+
+/* Content Section (Right) */
+.content-section {
+ flex: 0 0 var(--content-width);
+ background: var(--surface);
+ min-height: 100vh;
+ overflow-y: auto;
+}
+
+.content-wrapper {
+ max-width: 720px;
+ margin: 0 auto;
+ padding: 24px 32px;
+ width: 100%;
+}
diff --git a/css/components/landing/responsive.landing.css b/css/components/landing/responsive.landing.css
new file mode 100644
index 0000000..00277bd
--- /dev/null
+++ b/css/components/landing/responsive.landing.css
@@ -0,0 +1,159 @@
+/* Responsive Layout */
+@media (max-width: 1200px) {
+ .content-wrapper {
+ padding: 32px 24px;
+ }
+}
+
+@media (max-width: 1024px) {
+ .split-layout {
+ flex-direction: column;
+ }
+
+ .branding-section {
+ position: relative;
+ flex: 0 0 auto;
+ width: 100%;
+ height: auto;
+ min-height: 50vh;
+ padding: 48px 24px;
+ }
+
+ .content-section {
+ flex: 0 0 auto;
+ width: 100%;
+ }
+
+ .content-wrapper {
+ padding: 32px 24px;
+ }
+
+ .branding-content {
+ max-width: 100%;
+ }
+
+ .tagline {
+ max-width: 480px;
+ }
+
+ .feature-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .feature-blocks {
+ gap: 64px;
+ }
+
+ .section-intro {
+ margin-bottom: 48px;
+ }
+
+ .bsky-connect {
+ margin-bottom: 80px;
+ }
+
+ .bsky-connect:after {
+ bottom: -40px;
+ width: 48px;
+ }
+
+ .built-by-section {
+ margin-top: 48px;
+ }
+}
+
+@media (max-width: 768px) {
+ .logo img {
+ width: 200px;
+ }
+
+ .section-intro h2 {
+ font-size: 32px;
+ }
+
+ .section-intro p {
+ font-size: 16px;
+ }
+
+ .feature-description h3 {
+ font-size: 20px;
+ }
+
+ .feature-description p,
+ .feature-description li {
+ font-size: 15px;
+ }
+
+ .feature-blocks {
+ gap: 48px;
+ }
+
+ .feature-block {
+ gap: 24px;
+ }
+
+ .built-by-section {
+ margin-top: 40px;
+ padding-top: 24px;
+ }
+
+ .built-by-content {
+ flex-direction: column;
+ gap: 12px;
+ }
+}
+
+@media (max-width: 480px) {
+ .branding-section {
+ padding: 32px 20px;
+ min-height: auto;
+ }
+
+ .content-wrapper {
+ padding: 24px 20px;
+ }
+
+ .logo img {
+ width: 160px;
+ }
+
+ .branding-content h1 {
+ font-size: 36px;
+ margin-bottom: 12px;
+ }
+
+ .tagline {
+ font-size: 18px;
+ }
+
+ .feature-blocks {
+ gap: 40px;
+ }
+
+ .section-intro {
+ margin-bottom: 40px;
+ }
+
+ .section-intro h2 {
+ font-size: 28px;
+ }
+
+ .bsky-connect {
+ margin-bottom: 64px;
+ padding: 24px;
+ }
+
+ .bsky-connect:after {
+ bottom: -32px;
+ width: 40px;
+ }
+
+ .built-by-section {
+ margin-top: 32px;
+ padding-top: 20px;
+ }
+
+ .built-by-content p {
+ font-size: 14px;
+ }
+}
diff --git a/css/components/landing/theme-toggle.landing.css b/css/components/landing/theme-toggle.landing.css
new file mode 100644
index 0000000..938eabb
--- /dev/null
+++ b/css/components/landing/theme-toggle.landing.css
@@ -0,0 +1,71 @@
+/* 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(1px);
+}
+
+/* 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;
+}
diff --git a/css/components/media.css b/css/components/media.css
index f494dce..57d03ca 100644
--- a/css/components/media.css
+++ b/css/components/media.css
@@ -19,7 +19,6 @@
.bsky-connect {
flex-direction: column;
width: 100%;
- padding: 0 var(--spacing-md);
}
.bsky-auth-container {
diff --git a/css/components/nav/user-menu-base.css b/css/components/nav/user-menu-base.css
new file mode 100644
index 0000000..b0cd31e
--- /dev/null
+++ b/css/components/nav/user-menu-base.css
@@ -0,0 +1,41 @@
+/* User Menu Base 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;
+}
+
+/* Override any parent constraints */
+.nav-group:last-child .user-menu {
+ min-width: auto !important;
+ width: auto !important;
+}
diff --git a/css/components/nav/user-menu-dropdown.css b/css/components/nav/user-menu-dropdown.css
new file mode 100644
index 0000000..7f6c628
--- /dev/null
+++ b/css/components/nav/user-menu-dropdown.css
@@ -0,0 +1,66 @@
+/* User Menu Dropdown Styles */
+
+/* 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;
+}
diff --git a/css/components/nav/user-menu-items.css b/css/components/nav/user-menu-items.css
new file mode 100644
index 0000000..3d3464e
--- /dev/null
+++ b/css/components/nav/user-menu-items.css
@@ -0,0 +1,69 @@
+/* User Menu Items Styles */
+.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);
+}
diff --git a/css/components/nav/user-menu-responsive.css b/css/components/nav/user-menu-responsive.css
new file mode 100644
index 0000000..45b7779
--- /dev/null
+++ b/css/components/nav/user-menu-responsive.css
@@ -0,0 +1,18 @@
+/* User Menu Responsive Styles */
+@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/nav/user-menu.css b/css/components/nav/user-menu.css
index c579797..3f13f86 100644
--- a/css/components/nav/user-menu.css
+++ b/css/components/nav/user-menu.css
@@ -1,193 +1,5 @@
/* 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;
- }
-}
+@import url('./user-menu-base.css');
+@import url('./user-menu-dropdown.css');
+@import url('./user-menu-items.css');
+@import url('./user-menu-responsive.css');
diff --git a/css/components/scrollbars.css b/css/components/scrollbars.css
index be332e4..efcface 100644
--- a/css/components/scrollbars.css
+++ b/css/components/scrollbars.css
@@ -1,57 +1,89 @@
-/* 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 {
+/* Light theme scrollbar colors */
+:root {
+ --scrollbar-track: #f0f0f0;
+ --scrollbar-thumb: #a8a8a8;
+ --scrollbar-thumb-hover: #8b98a5;
+}
+
+/* Dark theme scrollbar colors */
+[data-theme="dark"] {
+ --scrollbar-track: #1e2732;
+ --scrollbar-thumb: #38444d;
+ --scrollbar-thumb-hover: #536471;
+}
+
+/* Firefox scrollbar styles */
+* {
+ scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
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;
+}
+
+/* Webkit scrollbar styles */
+::-webkit-scrollbar {
+ width: 12px;
+ height: 12px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--scrollbar-track);
+ border-radius: 6px;
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--scrollbar-thumb);
+ border-radius: 6px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--scrollbar-thumb-hover);
+}
+
+/* Ensure styles apply to specific components */
+.categories-sidebar,
+.categories-grid,
+.keywords-section,
+.context-builder,
+.simple-mode-container,
+.advanced-mode-container {
+ scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
+ scrollbar-width: auto;
+}
+
+.categories-sidebar::-webkit-scrollbar,
+.categories-grid::-webkit-scrollbar,
+.keywords-section::-webkit-scrollbar,
+.context-builder::-webkit-scrollbar,
+.simple-mode-container::-webkit-scrollbar,
+.advanced-mode-container::-webkit-scrollbar {
+ width: 12px;
+ height: 12px;
+}
+
+.categories-sidebar::-webkit-scrollbar-track,
+.categories-grid::-webkit-scrollbar-track,
+.keywords-section::-webkit-scrollbar-track,
+.context-builder::-webkit-scrollbar-track,
+.simple-mode-container::-webkit-scrollbar-track,
+.advanced-mode-container::-webkit-scrollbar-track {
+ background: var(--scrollbar-track);
+ border-radius: 6px;
+}
+
+.categories-sidebar::-webkit-scrollbar-thumb,
+.categories-grid::-webkit-scrollbar-thumb,
+.keywords-section::-webkit-scrollbar-thumb,
+.context-builder::-webkit-scrollbar-thumb,
+.simple-mode-container::-webkit-scrollbar-thumb,
+.advanced-mode-container::-webkit-scrollbar-thumb {
+ background: var(--scrollbar-thumb);
+ border-radius: 6px;
+}
+
+.categories-sidebar::-webkit-scrollbar-thumb:hover,
+.categories-grid::-webkit-scrollbar-thumb:hover,
+.keywords-section::-webkit-scrollbar-thumb:hover,
+.context-builder::-webkit-scrollbar-thumb:hover,
+.simple-mode-container::-webkit-scrollbar-thumb:hover,
+.advanced-mode-container::-webkit-scrollbar-thumb:hover {
+ background: var(--scrollbar-thumb-hover);
}
diff --git a/css/components/settings/appearance-controls.css b/css/components/settings/appearance-controls.css
new file mode 100644
index 0000000..7863962
--- /dev/null
+++ b/css/components/settings/appearance-controls.css
@@ -0,0 +1,53 @@
+/* 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);
+}
diff --git a/css/components/settings/search-controls.css b/css/components/settings/search-controls.css
new file mode 100644
index 0000000..e8d94d1
--- /dev/null
+++ b/css/components/settings/search-controls.css
@@ -0,0 +1,67 @@
+/* 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);
+}
diff --git a/css/components/settings/settings-sidebar.css b/css/components/settings/settings-sidebar.css
index 2f21bdf..9b7f744 100644
--- a/css/components/settings/settings-sidebar.css
+++ b/css/components/settings/settings-sidebar.css
@@ -1,222 +1,5 @@
-/* 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);
- }
-}
+/* Settings Sidebar Component - Split into modular files */
+@import 'sidebar-layout.css';
+@import 'search-controls.css';
+@import 'settings-tabs.css';
+@import 'appearance-controls.css';
diff --git a/css/components/settings/settings-tabs.css b/css/components/settings/settings-tabs.css
new file mode 100644
index 0000000..7a245cf
--- /dev/null
+++ b/css/components/settings/settings-tabs.css
@@ -0,0 +1,47 @@
+/* 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;
+}
diff --git a/css/components/settings/sidebar-layout.css b/css/components/settings/sidebar-layout.css
new file mode 100644
index 0000000..f187971
--- /dev/null
+++ b/css/components/settings/sidebar-layout.css
@@ -0,0 +1,52 @@
+/* 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);
+}
+
+.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);
+}
+
+@media (max-width: 768px) {
+ .categories-sidebar {
+ width: 100%;
+ height: auto;
+ margin: var(--spacing-xs);
+ border-radius: var(--border-radius);
+ }
+}
diff --git a/css/components/slider.css b/css/components/slider.css
index 410a8ac..5526b30 100644
--- a/css/components/slider.css
+++ b/css/components/slider.css
@@ -81,11 +81,11 @@
}
/* Dark Theme Adjustments */
-[data-theme="dim"] .filter-card:hover {
+[data-theme="dark"] .filter-card:hover {
background: var(--background);
}
-[data-theme="dim"] .filter-card.active {
+[data-theme="dark"] .filter-card.active {
background: var(--primary);
border-color: var(--primary);
}
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/docs/1-architecture/1-core-concepts.md b/docs/1-architecture/1-core-concepts.md
index 70a95a1..0985954 100644
--- a/docs/1-architecture/1-core-concepts.md
+++ b/docs/1-architecture/1-core-concepts.md
@@ -39,8 +39,7 @@ export const 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)
+ filterLevel: 0, // Current filter level (0=Minimal to 3=Complete)
lastBulkAction: null // Track when enable/disable all is used
}
```
@@ -67,7 +66,6 @@ const saveData = {
manuallyUnchecked: Array.from(state.manuallyUnchecked),
mode: state.mode,
lastModified: state.lastModified,
- targetKeywordCount: state.targetKeywordCount,
filterLevel: state.filterLevel,
lastBulkAction: state.lastBulkAction
}
@@ -103,7 +101,7 @@ try {
- Mode (returns to 'simple')
- Selections (contexts, exceptions, categories)
- UI state (search, filter, menu)
- - Filter settings (level, target count)
+ - Filter level (returns to 0)
### Cache Management
@@ -145,10 +143,14 @@ const debouncedUpdate = (() => {
## Mode System
### Simple Mode
-- Context-based filtering with filter levels
+- Context-based filtering with filter levels (0-3)
- Keywords derived from selected contexts
- Exceptions for granular control
-- Filter levels determine target keyword count
+- Filter levels determine keyword thresholds:
+ * Level 0 (Minimal) = Most restrictive
+ * Level 1 (Moderate) = Balanced filtering
+ * Level 2 (Extensive) = Broader inclusion
+ * Level 3 (Complete) = Most inclusive
### Advanced Mode
- Direct keyword management
@@ -179,7 +181,7 @@ const debouncedUpdate = (() => {
3. Performance
- Use cache for expensive calculations
- Throttle rapid updates (50ms threshold)
- - Clear cache when target count changes
+ - Clear cache on filter level changes
- Batch process large operations
4. Mode Management
diff --git a/docs/1-architecture/11-simple-mode.md b/docs/1-architecture/11-simple-mode.md
index d9224e3..8fe4c01 100644
--- a/docs/1-architecture/11-simple-mode.md
+++ b/docs/1-architecture/11-simple-mode.md
@@ -10,25 +10,33 @@ Simple mode provides an intuitive interface for content filtering through contex
```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
- };
+ this.currentLevel = 0; // Default level (Minimal/most restrictive)
}
updateLevel(level) {
if (level === this.currentLevel) return;
this.currentLevel = level;
+ state.filterLevel = level;
this.updateFilterUI();
- setTargetKeywordCount(this.levelTargets[level]);
}
}
```
-### 2. Context Management System
+### 2. Weight Threshold System
+```javascript
+function getWeightThreshold(filterLevel) {
+ // Map levels to thresholds (0-3)
+ switch(filterLevel) {
+ case 0: return 3; // Minimal (most restrictive)
+ case 1: return 2; // Moderate
+ case 2: return 1; // Extensive
+ case 3: return 0; // Complete (most inclusive)
+ default: return 3; // Default to most restrictive
+ }
+}
+```
+
+### 3. Context Management System
#### Context Selection Handler
```javascript
@@ -94,7 +102,7 @@ export function handleContextToggle(contextId) {
}
```
-### 3. Exception System
+### 4. Exception System
#### Exception Toggle Handler
```javascript
diff --git a/docs/1-architecture/4-mode-system.md b/docs/1-architecture/4-mode-system.md
index 860fd82..008012c 100644
--- a/docs/1-architecture/4-mode-system.md
+++ b/docs/1-architecture/4-mode-system.md
@@ -3,11 +3,56 @@
## Overview
MuteSky operates in two distinct modes:
-- Simple Mode: Context-based filtering with filter levels
+- Simple Mode: Context-based filtering with filter levels (0-3)
- Advanced Mode: Direct keyword management
The system maintains consistency between these modes while preserving user preferences.
+## Weight System Implementation
+
+### 1. Filter Level System
+```javascript
+// Map filter levels to thresholds
+function getWeightThreshold(filterLevel) {
+ switch(filterLevel) {
+ case 0: return 3; // Minimal (most restrictive)
+ case 1: return 2; // Moderate
+ case 2: return 1; // Extensive
+ case 3: return 0; // Complete (most inclusive)
+ default: return 3;
+ }
+}
+```
+
+### 2. Filter Level Handler
+```javascript
+export function handleFilterLevelChange(event) {
+ const level = event.detail.level;
+ state.filterLevel = level;
+
+ // Store current exceptions
+ const currentExceptions = new Set(state.selectedExceptions);
+
+ // Clear and rebuild active keywords
+ state.activeKeywords.clear();
+ state.selectedContexts.forEach(contextId => {
+ const context = state.contextGroups[contextId];
+ if (context?.categories) {
+ context.categories.forEach(category => {
+ if (!currentExceptions.has(category)) {
+ const keywords = getAllKeywordsForCategory(category, true);
+ keywords.forEach(keyword => state.activeKeywords.add(keyword));
+ }
+ });
+ }
+ });
+
+ // Restore exceptions and update UI
+ state.selectedExceptions = currentExceptions;
+ renderInterface();
+}
+```
+
## Context System Implementation
### 1. Context Toggle Handler
diff --git a/docs/2-development/1-known-issues.md b/docs/2-development/1-known-issues.md
index f117d72..d1d7995 100644
--- a/docs/2-development/1-known-issues.md
+++ b/docs/2-development/1-known-issues.md
@@ -69,30 +69,51 @@ export async function updateSimpleModeState() {
### 1. Duplicate Keywords
-**Problem**: Keywords like "Paris Agreement" appearing multiple times with different cases.
+**Problem**: Keywords like "Paris Agreement" appearing multiple times with different cases, particularly when switching between modes.
-**Root Cause**: Case-sensitive keyword storage causing duplicates.
+**Root Cause**: Inconsistent case handling across different parts of the codebase, especially during mode transitions and context handling.
-**Solution**: Implemented case-insensitive storage with original case preservation:
+**Solution**: Implemented comprehensive case-insensitive handling across all keyword operations:
```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);
+// Helper function for case-insensitive removal
+export function removeKeyword(keyword) {
+ const lowerKeyword = keyword.toLowerCase();
+ for (const activeKeyword of state.activeKeywords) {
+ if (activeKeyword.toLowerCase() === lowerKeyword) {
+ state.activeKeywords.delete(activeKeyword);
+ break;
+ }
+ }
+}
+
+// Helper function for case-sensitive addition with deduplication
+function addKeywordWithCase(keyword) {
+ // First remove any existing case variations
+ removeKeyword(keyword);
+ // Then add with original case
+ state.activeKeywords.add(keyword);
}
```
+**Implementation Details**:
+- Case-insensitive checks using isKeywordActive()
+- Case-insensitive removal using removeKeyword()
+- Case-preserving addition using addKeywordWithCase()
+- Consistent handling across mode switches and context changes
+
+**Result**:
+- No more duplicate keywords with different cases
+- Maintains proper keyword counts during mode switches
+- Preserves original case for display purposes
+- Consistent behavior across all operations
+
### 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.
+**Solution**: Case-insensitive deduplication is now handled consistently across all operations, preventing duplicates from being added in the first place.
## Authentication Issues
diff --git a/docs/migration.md b/docs/migration.md
index be94300..d46fb09 100644
--- a/docs/migration.md
+++ b/docs/migration.md
@@ -1,172 +1,31 @@
-# 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
+# Migration Notes
+
+## Weight System Simplification (January 2024)
+
+### Changes Made
+1. Simplified weight system from complex thresholds to 0-3 scale:
+ - Level 0 (Minimal) = threshold 3 (most restrictive)
+ - Level 1 (Moderate) = threshold 2
+ - Level 2 (Extensive) = threshold 1
+ - Level 3 (Complete) = threshold 0 (most inclusive)
+
+2. Removed targetKeywordCount:
+ - Removed from state
+ - Removed setTargetKeywordCount function
+ - Updated state persistence
+ - Simplified filterLevel handling
+
+3. Removed category weights:
+ - Weight thresholds now based only on keyword weights
+ - Simplified filtering logic
+ - Maintained case sensitivity handling
+
+### Future Considerations
+1. Categories will be removed in a future update
+2. Current category-related code is maintained for backwards compatibility
+3. New features should use filterLevel and keyword weights only
+
+### Migration Path
+- Old state using targetKeywordCount will default to filterLevel 0
+- Existing keyword weights (0-3) work directly with new thresholds
+- Category weights are ignored but preserved in data structure for now
diff --git a/images/screenshots/dark-advanced-mode.png b/images/screenshots/dark-advanced-mode.png
new file mode 100644
index 0000000..2e59244
Binary files /dev/null and b/images/screenshots/dark-advanced-mode.png differ
diff --git a/images/screenshots/dark-search.png b/images/screenshots/dark-search.png
new file mode 100644
index 0000000..67b8f97
Binary files /dev/null and b/images/screenshots/dark-search.png differ
diff --git a/images/screenshots/dark-simple-mode.png b/images/screenshots/dark-simple-mode.png
new file mode 100644
index 0000000..3954523
Binary files /dev/null and b/images/screenshots/dark-simple-mode.png differ
diff --git a/images/screenshots/light-advanced-mode.png b/images/screenshots/light-advanced-mode.png
new file mode 100644
index 0000000..8f8cb26
Binary files /dev/null and b/images/screenshots/light-advanced-mode.png differ
diff --git a/images/screenshots/light-search.png b/images/screenshots/light-search.png
new file mode 100644
index 0000000..e2c8e6a
Binary files /dev/null and b/images/screenshots/light-search.png differ
diff --git a/images/screenshots/light-simple-mode.png b/images/screenshots/light-simple-mode.png
new file mode 100644
index 0000000..0545148
Binary files /dev/null and b/images/screenshots/light-simple-mode.png differ
diff --git a/js/api.js b/js/api.js
index e2aff02..8459324 100644
--- a/js/api.js
+++ b/js/api.js
@@ -1,246 +1,4 @@
-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;
- }
-}
+// Re-export everything from the new modular structure
+export { cache } from './api/cache.js';
+export { getLastModifiedDate, listCategoryFiles } from './api/github.js';
+export { fetchKeywordGroups, fetchContextGroups, fetchDisplayConfig, refreshAllData } from './api/index.js';
diff --git a/js/api/cache.js b/js/api/cache.js
new file mode 100644
index 0000000..61c3a70
--- /dev/null
+++ b/js/api/cache.js
@@ -0,0 +1,17 @@
+// Cache implementation
+export 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 });
+ }
+};
diff --git a/js/api/fetchers.js b/js/api/fetchers.js
new file mode 100644
index 0000000..361dafc
--- /dev/null
+++ b/js/api/fetchers.js
@@ -0,0 +1,79 @@
+import { KEYWORDS_BASE_URL, CONTEXT_GROUPS_URL, DISPLAY_CONFIG_URL } from '../config.js';
+import { state, forceRefresh } from '../state.js';
+import { listCategoryFiles, getLastModifiedDate } from './github.js';
+
+export async function fetchKeywordGroups(forceFresh = false) {
+ try {
+ // Get list of category files
+ const categoryFiles = await listCategoryFiles();
+ console.debug('Found category files:', categoryFiles);
+
+ // 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 ${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;
+ }
+}
diff --git a/js/api/github.js b/js/api/github.js
new file mode 100644
index 0000000..e52df9b
--- /dev/null
+++ b/js/api/github.js
@@ -0,0 +1,102 @@
+import { cache } from './cache.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';
+
+export 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;
+}
+
+export 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;
+ }
+}
diff --git a/js/api/index.js b/js/api/index.js
new file mode 100644
index 0000000..ef946a2
--- /dev/null
+++ b/js/api/index.js
@@ -0,0 +1,50 @@
+import { state, forceRefresh } from '../state.js';
+import { fetchKeywordGroups, fetchContextGroups, fetchDisplayConfig } from './fetchers.js';
+
+export { fetchKeywordGroups, fetchContextGroups, fetchDisplayConfig };
+
+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 filterLevel = state.filterLevel;
+ // 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.filterLevel = filterLevel;
+ // 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/bluesky.js b/js/bluesky.js
index 4eba254..cb240af 100644
--- a/js/bluesky.js
+++ b/js/bluesky.js
@@ -127,7 +127,16 @@ class BlueskyService {
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'}`);
+
+ // Check for common service availability errors
+ if (error.message && (
+ error.message.includes('invalid_client_metadata') ||
+ error.message.includes('Failed to resolve OAuth server metadata for issuer: bsky.social')
+ )) {
+ this.ui.updateLoginState(false, 'Bluesky service appears to be down. Please try again in a few minutes.');
+ } else {
+ this.ui.updateLoginState(false, `Sign in failed: ${error.message || 'Please try again'}`);
+ }
}
}
diff --git a/js/categoryManager.js b/js/categoryManager.js
index e6ff0dd..4ab2ee4 100644
--- a/js/categoryManager.js
+++ b/js/categoryManager.js
@@ -11,7 +11,7 @@ function calculateKeywordsToMute() {
if (context && context.categories) {
context.categories.forEach(category => {
if (!state.selectedExceptions.has(category)) {
- // Get keywords sorted by weight and limited by target count
+ // Get keywords sorted and filtered by weight based on current filter level
const keywords = getAllKeywordsForCategory(category, true);
console.debug(`Adding ${keywords.length} keywords from ${category} to mute list`);
keywords.forEach(keyword => keywordsToMute.add(keyword));
diff --git a/js/components/landing-page.js b/js/components/landing-page.js
index 53d8c57..0b01c63 100644
--- a/js/components/landing-page.js
+++ b/js/components/landing-page.js
@@ -1,110 +1 @@
-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
-
-
-
-
-
-
-
Sign in
-
-
-
-
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.
-
-
-
-
-
-
- Connect to Bluesky
-
-
-
-
-
-
- `;
-
- // 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;
+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
+
+
+
+
+
+
+
Sign in
+
+
+
+
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.
+
+
+
+
+
+
+ Connect to Bluesky
+
+
+
+
+
+
+
+
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.
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/js/components/modals.js b/js/components/modals.js
index 40c8947..e18ff0a 100644
--- a/js/components/modals.js
+++ b/js/components/modals.js
@@ -1,230 +1 @@
-import { updateWarningVisibility } from '../handlers/modalHandlers.js';
-import { loadAppearanceSettings, saveAppearanceSettings } from '../settings/appearanceSettings.js';
-
-class SettingsModal extends HTMLElement {
- constructor() {
- super();
- }
-
- connectedCallback() {
- this.innerHTML = `
-
-
-
×
-
-
- Muting
- Appearance
- About
-
-
-
-
-
Mute Duration
-
-
-
-
-
-
-
-
-
-
Exceptions
-
-
-
-
Don't mute people I follow
-
-
-
-
-
-
-
Color mode
-
- System
- Light
- Dark
-
-
-
-
-
Font
-
- System font
- Theme font
-
-
-
-
-
Font size
-
- Smaller
- Default
- Larger
-
-
-
-
-
-
-
-
-
Mutesky is based off of my old Twitter mental health mute list.
-
This project was built with Cline and used $300 in Openrouter.ai and Anthropic API credits. My wife says I can't get any more so pls help me keep this project going 😅
-
-
-
-
-
-
-
-
-
-
- `;
-
- // 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 };
+export { SettingsModal } from './modals/index.js';
diff --git a/js/components/modals/index.js b/js/components/modals/index.js
new file mode 100644
index 0000000..5c400d0
--- /dev/null
+++ b/js/components/modals/index.js
@@ -0,0 +1 @@
+export { SettingsModal } from './settings-modal.js';
diff --git a/js/components/modals/settings-appearance.js b/js/components/modals/settings-appearance.js
new file mode 100644
index 0000000..7195787
--- /dev/null
+++ b/js/components/modals/settings-appearance.js
@@ -0,0 +1,41 @@
+import { loadAppearanceSettings, saveAppearanceSettings } from '../../settings/appearanceSettings.js';
+
+export function 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);
+ });
+ });
+}
diff --git a/js/components/modals/settings-modal.js b/js/components/modals/settings-modal.js
new file mode 100644
index 0000000..50aec15
--- /dev/null
+++ b/js/components/modals/settings-modal.js
@@ -0,0 +1,24 @@
+import { updateWarningVisibility } from '../../handlers/modalHandlers.js';
+import { loadAppearanceSettings, saveAppearanceSettings } from '../../settings/appearanceSettings.js';
+import { settingsTemplate } from './settings-template.js';
+import { setupAppearanceHandlers } from './settings-appearance.js';
+import { setupTabHandlers } from './settings-tabs.js';
+
+class SettingsModal extends HTMLElement {
+ constructor() {
+ super();
+ }
+
+ connectedCallback() {
+ this.innerHTML = settingsTemplate;
+
+ // Add tab switching functionality
+ setupTabHandlers.call(this);
+ // Add appearance settings handlers
+ setupAppearanceHandlers.call(this);
+ }
+}
+
+customElements.define('settings-modal', SettingsModal);
+
+export { SettingsModal };
diff --git a/js/components/modals/settings-tabs.js b/js/components/modals/settings-tabs.js
new file mode 100644
index 0000000..243c172
--- /dev/null
+++ b/js/components/modals/settings-tabs.js
@@ -0,0 +1,32 @@
+export function setupTabHandlers() {
+ 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
+ }
+ }
+ });
+ });
+}
diff --git a/js/components/modals/settings-template.js b/js/components/modals/settings-template.js
new file mode 100644
index 0000000..37ad62c
--- /dev/null
+++ b/js/components/modals/settings-template.js
@@ -0,0 +1,137 @@
+export const settingsTemplate = `
+
+
+
×
+
+
+ Muting
+ Appearance
+ About
+
+
+
+
+
Mute Duration
+
+
+
+
+
+
+
+
+
+
Exceptions
+
+
+
+
Don't mute people I follow
+
+
+
+
+
+
+
Color mode
+
+ System
+ Light
+ Dark
+
+
+
+
+
Font
+
+ System font
+ Theme font
+
+
+
+
+
Font size
+
+ Smaller
+ Default
+ Larger
+
+
+
+
+
+
+
+
+
Mutesky is based off of my old Twitter mental health mute list.
+
This project was built with Cline and used $300 in Openrouter.ai and Anthropic API credits. My wife says I can't get any more so pls help me keep this project going 😅
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/js/events.js b/js/events.js
new file mode 100644
index 0000000..4cacb53
--- /dev/null
+++ b/js/events.js
@@ -0,0 +1,170 @@
+import { elements } from './dom.js';
+import { state, loadState } from './state.js';
+import { renderInterface } from './renderer.js';
+import { debounce } from './utils.js';
+import { getAllKeywordsForCategory } from './categoryManager.js';
+import { blueskyService } from './bluesky.js';
+import {
+ handleAuth,
+ handleLogout,
+ handleMuteSubmit,
+ switchMode,
+ handleEnableAll,
+ handleDisableAll,
+ handleRefreshData,
+ showApp,
+ initializeKeywordState,
+ applyAppearanceSettings
+} from './handlers/index.js';
+
+// Event Listeners
+export 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;
+
+ // Update filter level in state
+ state.filterLevel = level;
+
+ // Store current exceptions
+ const currentExceptions = new Set(state.selectedExceptions);
+
+ // 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
+ 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);
+ }
+ }
+ });
+
+ // 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');
+ }
+ }
+ });
+}
diff --git a/js/handlers/context/contextCache.js b/js/handlers/context/contextCache.js
index 2241f4a..d29d1b9 100644
--- a/js/handlers/context/contextCache.js
+++ b/js/handlers/context/contextCache.js
@@ -13,7 +13,7 @@ export const cache = {
updateThreshold: 16,
getKeywords(category, sortByWeight = false) {
- const key = `${category}-${sortByWeight}-${state.targetKeywordCount}`;
+ const key = `${category}-${sortByWeight}-${state.filterLevel}`;
if (!this.keywords.has(key)) {
this.manageCache(this.keywords);
const keywords = getAllKeywordsForCategory(category, sortByWeight);
@@ -23,7 +23,7 @@ export const cache = {
},
getActiveKeywordsForCategory(category) {
- const key = `active-${category}-${state.targetKeywordCount}`;
+ const key = `active-${category}-${state.filterLevel}`;
if (!this.activeKeywordsByCategory.has(key)) {
this.manageCache(this.activeKeywordsByCategory);
const keywords = this.getKeywords(category, true);
@@ -46,7 +46,7 @@ export const cache = {
},
getContextKeywords(contextId) {
- const key = `${contextId}-${state.targetKeywordCount}`;
+ const key = `${contextId}-${state.filterLevel}`;
if (!this.contextKeywords.has(key)) {
this.manageCache(this.contextKeywords);
const context = state.contextGroups[contextId];
diff --git a/js/handlers/context/contextHandlers.js b/js/handlers/context/contextHandlers.js
index 85c28e1..a1461d5 100644
--- a/js/handlers/context/contextHandlers.js
+++ b/js/handlers/context/contextHandlers.js
@@ -1,220 +1,10 @@
-import { renderInterface } from '../../renderer.js';
-import { state, saveState } from '../../state.js';
-import { cache } from './contextCache.js';
-import {
- activateContextKeywords,
- createDebouncedUpdate,
- notifyKeywordChanges
-} from './contextUtils.js';
+import { handleContextToggle } from './contextToggleHandler.js';
+import { handleExceptionToggle } from './exceptionToggleHandler.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 };
+export {
+ handleContextToggle,
+ handleExceptionToggle,
+ updateSimpleModeState,
+ initializeState
+};
diff --git a/js/handlers/context/contextState.js b/js/handlers/context/contextState.js
index 23d8f86..777b9ae 100644
--- a/js/handlers/context/contextState.js
+++ b/js/handlers/context/contextState.js
@@ -1,200 +1,4 @@
-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();
- }
- }
-}
+// Re-export functionality from split files
+export { addKeywordWithCase } from './keywordManager.js';
+export { updateSimpleModeState } from './simpleModeManager.js';
+export { initializeState } from './stateInitializer.js';
diff --git a/js/handlers/context/contextToggleHandler.js b/js/handlers/context/contextToggleHandler.js
new file mode 100644
index 0000000..d9078d0
--- /dev/null
+++ b/js/handlers/context/contextToggleHandler.js
@@ -0,0 +1,132 @@
+import { renderInterface } from '../../renderer.js';
+import { state, saveState } from '../../state.js';
+import { cache } from './contextCache.js';
+import {
+ createDebouncedUpdate,
+ notifyKeywordChanges
+} from './contextUtils.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');
+}
diff --git a/js/handlers/context/contextUtils.js b/js/handlers/context/contextUtils.js
index 0bf43a9..4486eab 100644
--- a/js/handlers/context/contextUtils.js
+++ b/js/handlers/context/contextUtils.js
@@ -1,4 +1,5 @@
import { state } from '../../state.js';
+import { isKeywordActive, removeKeyword } from '../keywordHandlers.js';
// Helper function to notify keyword changes
export function notifyKeywordChanges() {
@@ -45,6 +46,14 @@ export function processBatchKeywords(keywords, operation) {
processChunk();
}
+// Helper function to add keyword with case handling
+function addKeywordWithCase(keyword) {
+ // First remove any existing case variations
+ removeKeyword(keyword);
+ // Then add with original case
+ state.activeKeywords.add(keyword);
+}
+
// Helper function to activate context keywords
export function activateContextKeywords(contextId, cache) {
const context = state.contextGroups[contextId];
@@ -57,7 +66,7 @@ export function activateContextKeywords(contextId, cache) {
processBatchKeywords(keywords, keyword => {
// Only activate if not manually unchecked
if (!state.manuallyUnchecked.has(keyword)) {
- state.activeKeywords.add(keyword);
+ addKeywordWithCase(keyword);
}
});
}
@@ -78,14 +87,14 @@ export function rebuildActiveKeywords(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);
+ if (!isKeywordActive(keyword) && !state.manuallyUnchecked.has(keyword)) {
+ addKeywordWithCase(keyword);
}
}
// Re-apply unchecked status
for (const keyword of uncheckedKeywords) {
- state.activeKeywords.delete(keyword);
+ removeKeyword(keyword);
state.manuallyUnchecked.add(keyword);
}
}
diff --git a/js/handlers/context/exceptionToggleHandler.js b/js/handlers/context/exceptionToggleHandler.js
new file mode 100644
index 0000000..57c9d05
--- /dev/null
+++ b/js/handlers/context/exceptionToggleHandler.js
@@ -0,0 +1,91 @@
+import { renderInterface } from '../../renderer.js';
+import { state, saveState } from '../../state.js';
+import { cache } from './contextCache.js';
+import {
+ activateContextKeywords,
+ createDebouncedUpdate,
+ notifyKeywordChanges
+} from './contextUtils.js';
+
+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');
+ });
+}
diff --git a/js/handlers/context/keywordManager.js b/js/handlers/context/keywordManager.js
new file mode 100644
index 0000000..ed94680
--- /dev/null
+++ b/js/handlers/context/keywordManager.js
@@ -0,0 +1,10 @@
+import { state } from '../../state.js';
+import { removeKeyword } from '../keywordHandlers.js';
+
+// Helper function to add keyword with case handling
+export function addKeywordWithCase(keyword) {
+ // First remove any existing case variations
+ removeKeyword(keyword);
+ // Then add with original case
+ state.activeKeywords.add(keyword);
+}
diff --git a/js/handlers/context/simpleModeManager.js b/js/handlers/context/simpleModeManager.js
new file mode 100644
index 0000000..4592407
--- /dev/null
+++ b/js/handlers/context/simpleModeManager.js
@@ -0,0 +1,90 @@
+import { state, saveState } from '../../state.js';
+import { renderInterface } from '../../renderer.js';
+import { cache } from './contextCache.js';
+import { isKeywordActive, removeKeyword } from '../keywordHandlers.js';
+import {
+ createDebouncedUpdate,
+ activateContextKeywords
+} from './contextUtils.js';
+import { addKeywordWithCase } from './keywordManager.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 (!isKeywordActive(keyword) && !state.manuallyUnchecked.has(keyword)) {
+ addKeywordWithCase(keyword);
+ }
+ }
+
+ // Re-apply unchecked status
+ for (const keyword of uncheckedKeywords) {
+ removeKeyword(keyword);
+ state.manuallyUnchecked.add(keyword);
+ }
+ }
+
+ // Create a new debounced update for this call with state
+ const debouncedUpdate = createDebouncedUpdate();
+ await debouncedUpdate(async () => {
+ renderInterface();
+ await saveState();
+ });
+}
diff --git a/js/handlers/context/stateInitializer.js b/js/handlers/context/stateInitializer.js
new file mode 100644
index 0000000..8984acc
--- /dev/null
+++ b/js/handlers/context/stateInitializer.js
@@ -0,0 +1,118 @@
+import { state, saveState, getStorageKey } from '../../state.js';
+import { renderInterface } from '../../renderer.js';
+import { cache } from './contextCache.js';
+import { isKeywordActive, removeKeyword } from '../keywordHandlers.js';
+import {
+ createDebouncedUpdate,
+ activateContextKeywords
+} from './contextUtils.js';
+import { addKeywordWithCase } from './keywordManager.js';
+
+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 (!isKeywordActive(keyword) && !state.manuallyUnchecked.has(keyword)) {
+ addKeywordWithCase(keyword);
+ }
+ }
+
+ // Re-apply unchecked status
+ for (const keyword of Array.from(state.manuallyUnchecked)) {
+ removeKeyword(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/keywordHandlers.js b/js/handlers/keywordHandlers.js
index ec40bca..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)
-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();
+}
diff --git a/js/handlers/mute/index.js b/js/handlers/mute/index.js
new file mode 100644
index 0000000..6e433df
--- /dev/null
+++ b/js/handlers/mute/index.js
@@ -0,0 +1,10 @@
+import { handleMuteSubmit, initializeKeywordState } from './muteOperations.js';
+import { getButtonText } from './muteUIUtils.js';
+import { muteCache } from './muteCache.js';
+
+export {
+ handleMuteSubmit,
+ initializeKeywordState,
+ getButtonText,
+ muteCache
+};
diff --git a/js/handlers/mute/muteCache.js b/js/handlers/mute/muteCache.js
new file mode 100644
index 0000000..e4103c4
--- /dev/null
+++ b/js/handlers/mute/muteCache.js
@@ -0,0 +1,44 @@
+// 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;
+ }
+};
+
+import { state } from '../../state.js';
+
+export { muteCache };
diff --git a/js/handlers/mute/muteOperations.js b/js/handlers/mute/muteOperations.js
new file mode 100644
index 0000000..9d2ac8e
--- /dev/null
+++ b/js/handlers/mute/muteOperations.js
@@ -0,0 +1,123 @@
+import { state, saveState, getMuteUnmuteCounts } from '../../state.js';
+import { blueskyService } from '../../bluesky.js';
+import { renderInterface } from '../../renderer.js';
+import { showNotification } from '../../utils/notifications.js';
+import { muteCache } from './muteCache.js';
+import { debouncedUpdate } from './muteUIUtils.js';
+
+// 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');
+ }
+}
diff --git a/js/handlers/mute/muteUIUtils.js b/js/handlers/mute/muteUIUtils.js
new file mode 100644
index 0000000..02091a3
--- /dev/null
+++ b/js/handlers/mute/muteUIUtils.js
@@ -0,0 +1,39 @@
+import { getMuteUnmuteCounts } from '../../state.js';
+import { renderInterface } from '../../renderer.js';
+
+// 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);
+ };
+})();
+
+// Helper to update button text
+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;
+}
+
+export { debouncedUpdate, getButtonText };
diff --git a/js/handlers/muteHandlers.js b/js/handlers/muteHandlers.js
index d68d462..56c4390 100644
--- a/js/handlers/muteHandlers.js
+++ b/js/handlers/muteHandlers.js
@@ -1,197 +1,2 @@
-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;
-}
+// Re-export everything from the new mute module
+export { handleMuteSubmit, initializeKeywordState, getButtonText, muteCache } from './mute/index.js';
diff --git a/js/handlers/themeHandlers.js b/js/handlers/themeHandlers.js
index 2d76dd3..f6522cc 100644
--- a/js/handlers/themeHandlers.js
+++ b/js/handlers/themeHandlers.js
@@ -2,21 +2,63 @@ import { loadAppearanceSettings, saveAppearanceSettings } from '../settings/appe
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;
+ // Toggle between light and dark themes
+ const newTheme = currentTheme === 'light' ? 'dark' : 'light';
+ settings.colorMode = newTheme;
// 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');
- }
+ // Apply theme immediately
+ html.setAttribute('data-theme', newTheme);
+
+ // Update all theme toggles
+ const toggles = document.querySelectorAll('.theme-toggle');
+ toggles.forEach(toggle => {
+ toggle.classList.toggle('dark', newTheme === 'dark');
+ });
+
+ // Dispatch theme change event
+ document.dispatchEvent(new CustomEvent('themeChanged', {
+ detail: { theme: newTheme }
+ }));
}
+
+// Initialize theme on page load
+document.addEventListener('DOMContentLoaded', () => {
+ const settings = loadAppearanceSettings();
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
+ const theme = settings.colorMode === 'dark' || (settings.colorMode === 'system' && prefersDark) ? 'dark' : 'light';
+
+ document.documentElement.setAttribute('data-theme', theme);
+
+ // Update toggle states
+ const toggles = document.querySelectorAll('.theme-toggle');
+ toggles.forEach(toggle => {
+ toggle.classList.toggle('dark', theme === 'dark');
+ });
+});
+
+// Add system theme change listener
+const systemThemeQuery = window.matchMedia('(prefers-color-scheme: dark)');
+systemThemeQuery.addEventListener('change', (e) => {
+ const settings = loadAppearanceSettings();
+ if (settings.colorMode === 'system') {
+ const theme = e.matches ? 'dark' : 'light';
+ document.documentElement.setAttribute('data-theme', theme);
+
+ // Update toggle states
+ const toggles = document.querySelectorAll('.theme-toggle');
+ toggles.forEach(toggle => {
+ toggle.classList.toggle('dark', theme === 'dark');
+ });
+
+ // Dispatch theme change event
+ document.dispatchEvent(new CustomEvent('themeChanged', {
+ detail: { theme }
+ }));
+ }
+});
diff --git a/js/initialization.js b/js/initialization.js
new file mode 100644
index 0000000..ed853e3
--- /dev/null
+++ b/js/initialization.js
@@ -0,0 +1,93 @@
+import { elements } from './dom.js';
+import { state, loadState } from './state.js';
+import { fetchKeywordGroups, fetchContextGroups, fetchDisplayConfig } from './api.js';
+import { renderInterface } from './renderer.js';
+import { blueskyService } from './bluesky.js';
+import {
+ showApp,
+ updateSimpleModeState,
+ initializeKeywordState,
+ switchMode,
+ applyAppearanceSettings
+} from './handlers/index.js';
+
+// Initialize Application
+export 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');
+ }
+
+ // 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);
+ }
+ }
+}
diff --git a/js/keywordState.js b/js/keywordState.js
index 5593186..a490566 100644
--- a/js/keywordState.js
+++ b/js/keywordState.js
@@ -84,13 +84,3 @@ export function getMuteUnmuteCounts() {
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
index c4a0e7a..4b241b0 100644
--- a/js/main.js
+++ b/js/main.js
@@ -1,276 +1,15 @@
-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 { init } from './initialization.js';
+import { setupEventListeners } from './events.js';
import {
- handleAuth,
- handleLogout,
- handleMuteSubmit,
- switchMode,
- handleEnableAll,
- handleDisableAll,
handleContextToggle,
handleExceptionToggle,
handleCategoryToggle,
handleKeywordToggle,
- handleRefreshData,
- showApp,
- updateSimpleModeState,
- initializeKeywordState,
handleSettingsModalToggle,
handleFooterThemeToggle,
- applyAppearanceSettings
+ switchMode
} 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;
@@ -283,6 +22,7 @@ window.settingsHandlers = {
window.switchMode = switchMode;
// Initialize app
-document.addEventListener('DOMContentLoaded', () => {
- init();
+document.addEventListener('DOMContentLoaded', async () => {
+ await init();
+ setupEventListeners();
});
diff --git a/js/settings/appearanceSettings.js b/js/settings/appearanceSettings.js
index d7bae35..c045e4e 100644
--- a/js/settings/appearanceSettings.js
+++ b/js/settings/appearanceSettings.js
@@ -1,6 +1,5 @@
const DEFAULT_APPEARANCE = {
colorMode: 'system',
- darkTheme: 'dim',
font: 'system',
fontSize: 'default'
};
@@ -45,29 +44,22 @@ export function applyAppearanceSettings(settings = null) {
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';
- }
+ const theme = settings.colorMode === 'dark' || (settings.colorMode === 'system' && prefersDark) ? 'dark' : 'light';
// 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);
+ footerToggle.classList.toggle('dark', theme === 'dark');
+ }
+
+ // Update landing page toggle state
+ const landingToggle = document.getElementById('landing-theme-toggle');
+ if (landingToggle) {
+ landingToggle.classList.toggle('dark', theme === 'dark');
}
// Apply font settings
@@ -78,6 +70,11 @@ export function applyAppearanceSettings(settings = null) {
// Apply font scale using CSS variable
html.style.setProperty('--font-scale', FONT_SCALES[settings.fontSize]);
+ // Dispatch theme change event
+ document.dispatchEvent(new CustomEvent('themeChanged', {
+ detail: { theme }
+ }));
+
updateAppearanceUI(settings);
}
diff --git a/js/state.js b/js/state.js
index bee5eb5..dcc3664 100644
--- a/js/state.js
+++ b/js/state.js
@@ -1,6 +1,6 @@
import { loadState, saveState, resetState, forceRefresh, getStorageKey } from './statePersistence.js';
import { setUser } from './userState.js';
-import { canUnmuteKeyword, getMuteUnmuteCounts, setTargetKeywordCount } from './keywordState.js';
+import { canUnmuteKeyword, getMuteUnmuteCounts } from './keywordState.js';
// Core state object
export const state = {
@@ -21,8 +21,7 @@ export const state = {
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
+ filterLevel: 0, // Track current filter level (0=Minimal to 3=Complete)
lastBulkAction: null // Track when enable/disable all is used
};
@@ -35,6 +34,5 @@ export {
setUser,
canUnmuteKeyword,
getMuteUnmuteCounts,
- setTargetKeywordCount,
getStorageKey
};
diff --git a/js/statePersistence.js b/js/statePersistence.js
index 8b2ce0a..aaad525 100644
--- a/js/statePersistence.js
+++ b/js/statePersistence.js
@@ -25,7 +25,6 @@ const debouncedSave = (() => {
manuallyUnchecked: Array.from(state.manuallyUnchecked),
mode: state.mode,
lastModified: state.lastModified,
- targetKeywordCount: state.targetKeywordCount,
filterLevel: state.filterLevel,
lastBulkAction: state.lastBulkAction
};
@@ -78,15 +77,11 @@ export function loadState() {
// 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);
@@ -118,7 +113,6 @@ export function resetState() {
state.filterMode = 'all';
state.menuOpen = false;
state.lastModified = null;
- state.targetKeywordCount = 100;
state.filterLevel = 0;
state.lastBulkAction = null;
keywordCache.clear();
diff --git a/js/themeInit.js b/js/themeInit.js
index aa23e67..4f19f90 100644
--- a/js/themeInit.js
+++ b/js/themeInit.js
@@ -11,12 +11,12 @@
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (settings.colorMode === 'system') {
- theme = prefersDark ? 'dim' : 'light';
+ theme = prefersDark ? 'dark' : 'light';
} else if (settings.colorMode === 'dark') {
- theme = 'dim';
+ theme = 'dark';
}
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
- theme = 'dim';
+ theme = 'dark';
}
// Apply theme immediately
@@ -29,7 +29,7 @@
// Set initial footer toggle state
const footerToggle = document.getElementById('footer-theme-toggle');
if (footerToggle) {
- footerToggle.classList.toggle('dark', theme === 'dim');
+ footerToggle.classList.toggle('dark', theme === 'dark');
}
});
@@ -39,7 +39,7 @@
if (currentSettings) {
const settings = JSON.parse(currentSettings);
if (settings.colorMode === 'system') {
- const newTheme = e.matches ? 'dim' : 'light';
+ const newTheme = e.matches ? 'dark' : 'light';
html.setAttribute('data-theme', newTheme);
// Update footer toggle
@@ -59,9 +59,9 @@
let newTheme = 'light';
if (settings.colorMode === 'system') {
- newTheme = prefersDark ? 'dim' : 'light';
+ newTheme = prefersDark ? 'dark' : 'light';
} else if (settings.colorMode === 'dark') {
- newTheme = 'dim';
+ newTheme = 'dark';
}
html.setAttribute('data-theme', newTheme);
@@ -69,7 +69,7 @@
// Update footer toggle
const footerToggle = document.getElementById('footer-theme-toggle');
if (footerToggle) {
- footerToggle.classList.toggle('dark', newTheme === 'dim');
+ footerToggle.classList.toggle('dark', newTheme === 'dark');
}
}
});
diff --git a/js/utils/categoryUtils.js b/js/utils/categoryUtils.js
index 32cf354..87d7e52 100644
--- a/js/utils/categoryUtils.js
+++ b/js/utils/categoryUtils.js
@@ -30,7 +30,6 @@ export function extractKeywordsFromCategory(category, categoryData) {
return Object.entries(categoryInfo.keywords).map(([keyword, data]) => ({
keyword,
weight: data.weight || 0,
- categoryWeight: categoryInfo.weight || 0,
category
}));
}
@@ -60,7 +59,7 @@ export function getAllKeywordsForCategory(category, sortByWeight = false) {
if (sortByWeight) {
keywords.sort((a, b) => b.weight - a.weight);
- if (state.targetKeywordCount) {
+ if (state.filterLevel !== undefined) {
const before = keywords.length;
keywords = filterByWeight(keywords, category);
logFilterResults(category, keywords, before);
@@ -73,19 +72,19 @@ export function getAllKeywordsForCategory(category, sortByWeight = false) {
function filterByWeight(keywords, category) {
return keywords.filter(k => {
- const threshold = getWeightThreshold(k.categoryWeight, state.targetKeywordCount);
+ const threshold = getWeightThreshold(state.filterLevel);
const passes = k.weight >= threshold;
if (passes) {
- console.debug(`Including ${k.keyword} (weight: ${k.weight}) from ${k.category} (weight: ${k.categoryWeight})`);
+ console.debug(`Including ${k.keyword} (weight: ${k.weight}) from ${k.category}`);
}
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)}
+ console.debug(`Category ${category}:
+ - Filter level: ${state.filterLevel}
+ - Threshold: ${getWeightThreshold(state.filterLevel)}
- Filtered from ${beforeCount} to ${keywords.length} keywords
- Remaining keywords: ${keywords.map(k => `${k.keyword} (${k.weight})`).join(', ')}`);
}
diff --git a/js/utils/weightManager.js b/js/utils/weightManager.js
index a8943c4..850fb11 100644
--- a/js/utils/weightManager.js
+++ b/js/utils/weightManager.js
@@ -1,22 +1,21 @@
-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:
+import { state } from '../state.js';
+
+function getWeightThreshold(filterLevel) {
+ // Get filter level from state if not provided
+ const level = filterLevel ?? state?.filterLevel ?? 0;
+
+ // Map levels to thresholds based on keyword weight of 3
+ switch(level) {
+ case 0: // Minimal (most restrictive)
+ return 3;
+ case 1: // Moderate
+ return 2;
+ case 2: // Extensive
+ return 1;
+ case 3: // Complete (most inclusive)
return 0;
+ default:
+ return 3; // Default to most restrictive
}
}