diff --git a/bun.lockb b/bun.lockb index fddce98..42f83d7 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docs/REACT_MIGRATION.md b/docs/REACT_MIGRATION.md new file mode 100644 index 0000000..e2bd8f8 --- /dev/null +++ b/docs/REACT_MIGRATION.md @@ -0,0 +1,269 @@ +# React Migration Guide for docFiller + +This document explains how to work with React components in the docFiller project. + +## Overview + +The docFiller extension is being incrementally migrated from vanilla HTML/CSS/TypeScript to React with TypeScript. This migration enables better state management, component reusability, and maintainability. + +## Current Status + +### Migrated to React + +- ✅ Popup page (React version available at `src/popup/popup-react.tsx`) + +### Still in Vanilla TS + +- ⏳ Options page (migration in progress) +- ⏳ Other UI components + +## Architecture + +### File Structure + +``` +src/ +├── popup/ +│ ├── popup.ts # Original vanilla TS version +│ ├── popup-react.tsx # React entry point +│ └── PopupApp.tsx # Main React component +├── options/ +│ └── options.ts # Original vanilla TS version (to be migrated) +└── utils/ # Shared utilities (works with both vanilla and React) +``` + +### HTML Files + +``` +public/src/ +├── popup/ +│ ├── index.html # Original popup HTML (uses popup.js) +│ └── index-react.html # React popup HTML (uses popup-react.js) +└── options/ + └── index.html # Original options HTML +``` + +## Development + +### Building + +The build process automatically handles both TypeScript and React/TSX files: + +```bash +# Build for Firefox +bun run build:firefox + +# Build for Chromium +bun run build:chromium + +# Watch mode for development +bun run watch +``` + +### Hot Reloading + +Hot reloading works for both vanilla TS and React files: + +```bash +# Firefox with hot reload +bun run dev:firefox + +# Chromium with hot reload +bun run dev:chromium +``` + +When you modify a React component: + +1. The watcher detects the change +2. esbuild rebuilds the file +3. The extension reloads automatically + +## Creating React Components + +### Basic Component Structure + +```tsx +import React, { useState, useEffect } from 'react'; + +const MyComponent: React.FC = () => { + const [state, setState] = useState(''); + + useEffect(() => { + // Component initialization + return () => { + // Cleanup + }; + }, []); + + return
{/* Your JSX here */}
; +}; + +export default MyComponent; +``` + +### Using Extension APIs + +Extension APIs work the same way in React components: + +```tsx +import browser from 'webextension-polyfill'; +import { showToast } from '@utils/toastUtils'; + +const MyComponent: React.FC = () => { + const handleAction = async () => { + const tabs = await browser.tabs.query({ active: true }); + showToast('Action completed', 'success'); + }; + + return ; +}; +``` + +### Accessing Storage + +Use the existing storage utilities: + +```tsx +import { getIsEnabled } from '@utils/storage/getProperties'; +import { setIsEnabled } from '@utils/storage/setProperties'; + +const MyComponent: React.FC = () => { + const [enabled, setEnabledState] = useState(false); + + useEffect(() => { + const loadState = async () => { + const isEnabled = await getIsEnabled(); + setEnabledState(isEnabled); + }; + loadState(); + }, []); + + const toggleEnabled = async () => { + const newState = !enabled; + await setIsEnabled(newState); + setEnabledState(newState); + }; + + return ( + + ); +}; +``` + +## Best Practices + +### 1. Keep Logic in Utils + +Move complex logic to utility functions in `src/utils/`: + +- Keeps components focused on UI +- Allows code reuse between vanilla and React code +- Easier to test + +### 2. Use TypeScript Strictly + +- Always define prop types +- Use type inference where possible +- Avoid `any` types + +### 3. Handle Async Operations Properly + +```tsx +useEffect(() => { + const loadData = async () => { + try { + const data = await fetchData(); + setData(data); + } catch (error) { + console.error('Error loading data:', error); + } + }; + loadData(); +}, []); +``` + +### 4. Clean Up Resources + +```tsx +useEffect(() => { + const subscription = subscribeToUpdates(); + + return () => { + subscription.unsubscribe(); + }; +}, []); +``` + +## Migration Strategy + +### Phase 1: Infrastructure ✅ + +- Add React dependencies +- Configure build system +- Create basic React components + +### Phase 2: Popup Migration ✅ + +- Create React version of popup +- Test functionality +- Switch to React as default + +### Phase 3: Options Page Migration 🚧 + +- Break down options page into components +- Migrate tab by tab +- Test all features + +### Phase 4: Polish + +- Optimize bundle size +- Add state management if needed +- Update documentation + +## Testing + +### Manual Testing + +1. Load the extension in development mode +2. Test all features work correctly +3. Verify hot reloading works + +### Type Checking + +```bash +bun run typecheck +``` + +### Linting + +```bash +bun run lint +bun run format:check +``` + +## Troubleshooting + +### Build Errors + +- Ensure all imports use correct paths +- Check TSX syntax is valid +- Verify React is imported when using JSX + +### Hot Reload Not Working + +- Check watcher is running +- Verify file changes are saved +- Try rebuilding from scratch + +### Type Errors + +- Update tsconfig.json if needed +- Check types are properly imported +- Ensure @types/react is installed + +## Resources + +- [React Documentation](https://react.dev/) +- [TypeScript Documentation](https://www.typescriptlang.org/) +- [WebExtensions API](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions) +- [esbuild Documentation](https://esbuild.github.io/) diff --git a/package.json b/package.json index 07d0f51..3345515 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,8 @@ "@types/firefox-webext-browser": "^143.0.0", "@types/fs-extra": "^11.0.4", "@types/node": "^24.9.1", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", "@types/webextension-polyfill": "^0.12.4", "chokidar": "^4.0.3", "concurrently": "^9.2.1", @@ -74,6 +76,8 @@ "globals": "^16.4.0", "husky": "^9.1.7", "prettier": "^3.6.2", + "react": "^19.2.0", + "react-dom": "^19.2.0", "web-ext": "^9.1.0", "webextension-polyfill": "^0.12.0" } diff --git a/public/manifest.ts b/public/manifest.ts index c9809b3..2ed31c6 100644 --- a/public/manifest.ts +++ b/public/manifest.ts @@ -53,7 +53,7 @@ export async function getManifest() { 'https://docs.google.com/forms/d/e/*/viewform', ], action: { - default_popup: 'src/popup/index.html', + default_popup: 'src/popup/index-react.html', default_title: 'docFiller', }, options_ui: { diff --git a/public/src/options/index-react.html b/public/src/options/index-react.html new file mode 100644 index 0000000..685e47a --- /dev/null +++ b/public/src/options/index-react.html @@ -0,0 +1,19 @@ + + + + + + docFiller | Preferences + + + + + +
+ + + diff --git a/public/src/popup/index-react.html b/public/src/popup/index-react.html new file mode 100644 index 0000000..0f6d257 --- /dev/null +++ b/public/src/popup/index-react.html @@ -0,0 +1,18 @@ + + + + + + docFiller + + + + +
+ + + diff --git a/src/components/Tabs.tsx b/src/components/Tabs.tsx new file mode 100644 index 0000000..e9be697 --- /dev/null +++ b/src/components/Tabs.tsx @@ -0,0 +1,98 @@ +import React, { + createContext, + useContext, + useState, + type ReactNode, +} from 'react'; + +interface TabContextType { + activeTab: string; + setActiveTab: (tab: string) => void; +} + +const TabContext = createContext(undefined); + +interface TabsProps { + children: ReactNode; + defaultTab: string; +} + +export const Tabs: React.FC = ({ children, defaultTab }) => { + const [activeTab, setActiveTab] = useState(defaultTab); + + return ( + +
{children}
+
+ ); +}; + +interface TabListProps { + children: ReactNode; +} + +export const TabList: React.FC = ({ children }) => { + return ( + + ); +}; + +interface TabButtonProps { + tabId: string; + children: ReactNode; +} + +export const TabButton: React.FC = ({ tabId, children }) => { + const context = useContext(TabContext); + if (!context) throw new Error('TabButton must be used within Tabs'); + + const { activeTab, setActiveTab } = context; + const isActive = activeTab === tabId; + + return ( + + ); +}; + +interface TabPanelsProps { + children: ReactNode; +} + +export const TabPanels: React.FC = ({ children }) => { + return
{children}
; +}; + +interface TabPanelProps { + tabId: string; + children: ReactNode; +} + +export const TabPanel: React.FC = ({ tabId, children }) => { + const context = useContext(TabContext); + if (!context) throw new Error('TabPanel must be used within Tabs'); + + const { activeTab } = context; + const isActive = activeTab === tabId; + + return ( +
+ {children} +
+ ); +}; diff --git a/src/options/OptionsApp.tsx b/src/options/OptionsApp.tsx new file mode 100644 index 0000000..3494985 --- /dev/null +++ b/src/options/OptionsApp.tsx @@ -0,0 +1,1193 @@ +import React, { useEffect, useState } from 'react'; +import { + Tabs, + TabList, + TabButton, + TabPanels, + TabPanel, +} from '../components/Tabs'; +import { getModelName, LLMEngineType } from '@utils/llmEngineTypes'; +import { validateLLMConfiguration } from '@utils/missingApiKey'; +import { + getAnthropicApiKey, + getChatGptApiKey, + getEnableConsensus, + getEnableDarkTheme, + getGeminiApiKey, + getLLMModel, + getLLMWeights, + getMistralApiKey, + getSkipMarkedSetting, + getSleepDuration, +} from '@utils/storage/getProperties'; +import { + setAnthropicApiKey, + setChatGptApiKey, + setEnableConsensus, + setEnableDarkTheme, + setGeminiApiKey, + setLLMModel, + setLLMWeights, + setMistralApiKey, + setSleepDuration, + setToggleSkipMarkedStatus, +} from '@utils/storage/setProperties'; +import { showToast } from '@utils/toastUtils'; +import { + deleteProfile, + getSelectedProfileKey, + loadProfiles, + saveCustomProfile, + saveSelectedProfileKey, +} from '@utils/storage/profiles/profileManager'; +import { MetricsCalculator } from '@utils/metricsCalculator'; +import { MetricsManager } from '@utils/storage/metricsManager'; + +/** + * OptionsApp Component - Complete React implementation of options page + */ +const OptionsApp: React.FC = () => { + // Theme state + const [isDarkTheme, setIsDarkTheme] = useState(false); + const [skipMarked, setSkipMarked] = useState(false); + + // Profile state + const [profiles, setProfiles] = useState({}); + const [selectedProfileKey, setSelectedProfileKey] = useState(''); + const [showProfileModal, setShowProfileModal] = useState(false); + const [editingProfile, setEditingProfile] = useState<{ + key: string; + name: string; + imageUrl: string; + prompt: string; + shortDesc: string; + } | null>(null); + + // API settings state + const [llmModel, setLLMModelState] = useState(''); + const [enableConsensus, setEnableConsensusState] = useState(false); + const [chatGptApiKey, setChatGptApiKeyState] = useState(''); + const [geminiApiKey, setGeminiApiKeyState] = useState(''); + const [mistralApiKey, setMistralApiKeyState] = useState(''); + const [anthropicApiKey, setAnthropicApiKeyState] = useState(''); + const [weights, setWeights] = useState>( + {} as Record, + ); + const [showPasswords, setShowPasswords] = useState>( + {}, + ); + + // Metrics state + const [metrics, setMetrics] = useState({ + totalForms: 0, + successRate: 0, + timeSaved: 0, + streak: 0, + }); + const [showResetMetricsModal, setShowResetMetricsModal] = useState(false); + + // Advanced settings + const [sleepDuration, setSleepDurationState] = useState(2000); + + // Load all settings on mount + useEffect(() => { + loadAllSettings(); + const metricsInterval = setInterval(loadMetrics, 5000); + return () => clearInterval(metricsInterval); + }, []); + + const loadAllSettings = async () => { + try { + // Load theme and behavior + const darkTheme = await getEnableDarkTheme(); + const skipMarkedSetting = await getSkipMarkedSetting(); + setIsDarkTheme(darkTheme); + setSkipMarked(skipMarkedSetting); + + if (darkTheme) { + document.documentElement.classList.add('dark-theme'); + } + + // Load profiles + const loadedProfiles = await loadProfiles(); + const selectedKey = await getSelectedProfileKey(); + setProfiles(loadedProfiles); + setSelectedProfileKey(selectedKey); + + // Load API settings + const consensus = await getEnableConsensus(); + const model = await getLLMModel(); + const weightsData = await getLLMWeights(); + + setEnableConsensusState(consensus); + setLLMModelState(model); + setWeights(weightsData); + + setChatGptApiKeyState(await getChatGptApiKey()); + setGeminiApiKeyState(await getGeminiApiKey()); + setMistralApiKeyState(await getMistralApiKey()); + setAnthropicApiKeyState(await getAnthropicApiKey()); + + // Load advanced settings + const duration = await getSleepDuration(); + setSleepDurationState(duration); + + // Load metrics + await loadMetrics(); + } catch (error) { + console.error('Error loading settings:', error); + } + }; + + const loadMetrics = async () => { + try { + const manager = MetricsManager.getInstance(); + const data = await manager.getMetrics(); + + const formMetrics = MetricsCalculator.calculateFormMetrics(data.history); + const successRateMetrics = MetricsCalculator.calculateSuccessRate( + data.history, + ); + const timeSavedMetrics = MetricsCalculator.calculateTimeSaved( + data.history, + ); + const streakMetrics = MetricsCalculator.calculateStreaks( + data.history, + data.formMetrics, + ); + + setMetrics({ + totalForms: formMetrics.total, + successRate: successRateMetrics.rate, + timeSaved: timeSavedMetrics.totalMin, + streak: streakMetrics.currentStreak, + }); + } catch (error) { + console.error('Error loading metrics:', error); + } + }; + + // Profile handlers + const handleProfileSelect = async (profileKey: string) => { + await saveSelectedProfileKey(profileKey); + setSelectedProfileKey(profileKey); + showToast('Profile selected!', 'success'); + }; + + const handleProfileEdit = (profileKey: string, profile: Profile) => { + setEditingProfile({ + key: profileKey, + name: profile.name, + imageUrl: profile.image_url, + prompt: profile.system_prompt, + shortDesc: profile.short_description, + }); + setShowProfileModal(true); + }; + + const handleProfileDelete = async (profileKey: string) => { + if (confirm('Are you sure you want to delete this profile?')) { + await deleteProfile(profileKey); + const loadedProfiles = await loadProfiles(); + setProfiles(loadedProfiles); + showToast('Profile deleted!', 'success'); + } + }; + + const handleProfileSave = async (e: React.FormEvent) => { + e.preventDefault(); + if (!editingProfile) return; + + try { + const newProfile: Profile = { + name: editingProfile.name, + image_url: editingProfile.imageUrl, + system_prompt: editingProfile.prompt, + short_description: editingProfile.shortDesc, + is_custom: true, + }; + await saveCustomProfile(newProfile); + const loadedProfiles = await loadProfiles(); + setProfiles(loadedProfiles); + setShowProfileModal(false); + setEditingProfile(null); + showToast('Profile saved!', 'success'); + } catch (error) { + showToast('Failed to save profile', 'error'); + } + }; + + // Theme handlers + const handleDarkThemeToggle = async () => { + const newValue = !isDarkTheme; + try { + await setEnableDarkTheme(newValue); + setIsDarkTheme(newValue); + if (newValue) { + document.documentElement.classList.add('dark-theme'); + } else { + document.documentElement.classList.remove('dark-theme'); + } + } catch (error) { + showToast( + `Failed to save theme. ${error instanceof Error ? error.message : String(error)}`, + 'error', + ); + } + }; + + const handleSkipMarkedToggle = async () => { + try { + await setToggleSkipMarkedStatus(); + const newValue = await getSkipMarkedSetting(); + setSkipMarked(newValue); + showToast(`Skip already filled: ${newValue ? 'On' : 'Off'}`, 'success'); + } catch (error) { + showToast('Failed to update skip-filled setting.', 'error'); + } + }; + + // API handlers + const handleConsensusToggle = async () => { + const newValue = !enableConsensus; + await setEnableConsensus(newValue); + setEnableConsensusState(newValue); + }; + + const handleModelChange = async (modelName: string) => { + await setLLMModel(modelName); + setLLMModelState(modelName); + }; + + const handleApiSave = async () => { + try { + await setChatGptApiKey(chatGptApiKey); + await setGeminiApiKey(geminiApiKey); + await setMistralApiKey(mistralApiKey); + await setAnthropicApiKey(anthropicApiKey); + + if (enableConsensus) { + await setLLMWeights(weights); + } + + showToast('API settings saved successfully!', 'success'); + } catch (error) { + showToast('Failed to save API settings', 'error'); + } + }; + + const togglePasswordVisibility = (fieldId: string) => { + setShowPasswords((prev) => ({ ...prev, [fieldId]: !prev[fieldId] })); + }; + + const needsApiKey = (modelName: string) => { + return ( + modelName !== getModelName(LLMEngineType.Ollama) && + modelName !== getModelName(LLMEngineType.ChromeAI) + ); + }; + + const getAPIKeyLink = (modelName: string): string => { + switch (modelName) { + case getModelName(LLMEngineType.ChatGPT): + return 'https://platform.openai.com/api-keys'; + case getModelName(LLMEngineType.Gemini): + return 'https://aistudio.google.com/app/apikey'; + case getModelName(LLMEngineType.Mistral): + return 'https://console.mistral.ai/api-keys/'; + case getModelName(LLMEngineType.Anthropic): + return 'https://console.anthropic.com/settings/keys'; + default: + return ''; + } + }; + + // Metrics handlers + const handleMetricsExport = () => { + MetricsManager.getInstance() + .getMetrics() + .then((data) => { + const dataStr = JSON.stringify(data, null, 2); + const dataBlob = new Blob([dataStr], { type: 'application/json' }); + const url = URL.createObjectURL(dataBlob); + const link = document.createElement('a'); + link.href = url; + link.download = `docfiller-metrics-${new Date().toISOString()}.json`; + link.click(); + URL.revokeObjectURL(url); + showToast('Metrics exported successfully!', 'success'); + }) + .catch(() => { + showToast('Failed to export metrics', 'error'); + }); + }; + + const handleMetricsReset = async () => { + try { + await MetricsManager.getInstance().resetMetrics(); + setShowResetMetricsModal(false); + showToast('Metrics reset successfully!', 'success'); + await loadMetrics(); + } catch (error) { + showToast('Failed to reset metrics', 'error'); + } + }; + + // Advanced handlers + const handleAdvancedSave = async () => { + try { + await setSleepDuration(sleepDuration); + showToast('Advanced settings saved successfully!', 'success'); + } catch (error) { + showToast('Failed to save advanced settings', 'error'); + } + }; + + // Sort profiles + const orderedProfiles = Object.entries(profiles).sort((a, b) => { + if (a[1].is_magic) return -1; + if (b[1].is_magic) return 1; + return 0; + }); + + // Get single API key based on model + const getSingleApiKey = () => { + switch (llmModel) { + case getModelName(LLMEngineType.ChatGPT): + return chatGptApiKey; + case getModelName(LLMEngineType.Gemini): + return geminiApiKey; + case getModelName(LLMEngineType.Mistral): + return mistralApiKey; + case getModelName(LLMEngineType.Anthropic): + return anthropicApiKey; + default: + return ''; + } + }; + + const setSingleApiKey = (value: string) => { + switch (llmModel) { + case getModelName(LLMEngineType.ChatGPT): + setChatGptApiKeyState(value); + break; + case getModelName(LLMEngineType.Gemini): + setGeminiApiKeyState(value); + break; + case getModelName(LLMEngineType.Mistral): + setMistralApiKeyState(value); + break; + case getModelName(LLMEngineType.Anthropic): + setAnthropicApiKeyState(value); + break; + } + }; + + return ( + <> +

Settings

+ + + Profiles & Theme + API Keys & Consensus + Metrics + Advanced + About + + + + {/* Profiles & Theme Tab */} + +
+

Profiles

+
+
+ {orderedProfiles.map(([profileKey, profile]) => ( +
handleProfileSelect(profileKey)} + > +
+ {profile.is_custom && ( +
{ + e.stopPropagation(); + handleProfileDelete(profileKey); + }} + > + × +
+ )} +
+ {profile.is_custom && ( +
{ + e.stopPropagation(); + handleProfileEdit(profileKey, profile); + }} + > + ✎ +
+ )} + {profileKey === selectedProfileKey && ( +
+ )} +
+
+ {profile.name} +

{profile.name}

+

{profile.short_description}

+
+ ))} +
+
+
+ +
+

Theme & Behavior

+
+
+ Dark Theme +
+
+
+ + + + + + +
+
+
+
+
+
+
+ + Skip Already Filled Questions + +
+
+
+ + + + + + +
+
+
+
+
+
+
+ + {/* API Keys & Consensus Tab */} + +
+

AI Model Settings

+
+ + +
+ + {!enableConsensus ? ( +
+ + +
+ +
+ setSingleApiKey(e.target.value)} + className="password-input" + disabled={!needsApiKey(llmModel)} + /> + +
+ {!needsApiKey(llmModel) && ( +
+ {llmModel} doesn't require an API key +
+ )} + {getAPIKeyLink(llmModel) && ( + + Get API Key + + )} +
+
+ ) : ( +
+

+ Consensus mode uses multiple AI models. Configure weights + below: +

+ + {/* ChatGPT */} +
+
+ +
+ + setChatGptApiKeyState(e.target.value) + } + className="password-input" + /> + +
+ + Get API Key + +
+
+ + + setWeights({ + ...weights, + [LLMEngineType.ChatGPT]: parseFloat(e.target.value), + }) + } + min="0" + max="1" + step="0.01" + /> +
+
+ + {/* Gemini */} +
+
+ +
+ setGeminiApiKeyState(e.target.value)} + className="password-input" + /> + +
+ + Get API Key + +
+
+ + + setWeights({ + ...weights, + [LLMEngineType.Gemini]: parseFloat(e.target.value), + }) + } + min="0" + max="1" + step="0.01" + /> +
+
+ + {/* Ollama */} +
+
+ + +
+
+ + + setWeights({ + ...weights, + [LLMEngineType.Ollama]: parseFloat(e.target.value), + }) + } + min="0" + max="1" + step="0.01" + /> +
+
+ + {/* Chrome AI */} +
+
+ + +
+
+ + + setWeights({ + ...weights, + [LLMEngineType.ChromeAI]: parseFloat( + e.target.value, + ), + }) + } + min="0" + max="1" + step="0.01" + /> +
+
+ + {/* Mistral */} +
+
+ +
+ + setMistralApiKeyState(e.target.value) + } + className="password-input" + /> + +
+ + Get API Key + +
+
+ + + setWeights({ + ...weights, + [LLMEngineType.Mistral]: parseFloat(e.target.value), + }) + } + min="0" + max="1" + step="0.01" + /> +
+
+ + {/* Anthropic */} +
+
+ +
+ + setAnthropicApiKeyState(e.target.value) + } + className="password-input" + /> + +
+ + Get API Key + +
+
+ + + setWeights({ + ...weights, + [LLMEngineType.Anthropic]: parseFloat( + e.target.value, + ), + }) + } + min="0" + max="1" + step="0.01" + /> +
+
+
+ )} + +
+ +
+
+
+ + {/* Metrics Tab */} + +
+

Usage Metrics

+
+
+
+

Total Forms Filled

+

{metrics.totalForms}

+
+
+

Success Rate

+

+ {metrics.successRate.toFixed(1)}% +

+
+
+

Time Saved

+

{metrics.timeSaved} mins

+
+
+

Streaks

+

{metrics.streak}

+
Current streak
+
+
+ +
+
+ 📊 Export Metrics +
+
setShowResetMetricsModal(true)} + > + 🔄 Reset Metrics +
+
+
+
+
+ + {/* Advanced Tab */} + +
+

Advanced Settings

+
+
+ + + setSleepDurationState(parseInt(e.target.value)) + } + min="100" + step="100" + /> +
+
+
+ +
+
+
+ + {/* About Tab */} + +
+ docFiller logo +
+
+

About docFiller

+

+ docFiller is an open-source browser extension that automates + filling repetitive forms using GenAI. It supports multiple LLM + providers, optional consensus across models, and a profiles + system to tailor prompts and behavior to your workflow. +

+
+ + + +
+

Contributing

+
    +
  • + This project is community-driven; no formal support is + provided. +
  • +
  • + Contributions are welcome—please read the guidelines before + opening a PR. +
  • +
  • Be respectful and follow the Code of Conduct.
  • +
+

+ + Contributing Guide + + {' · '} + + Code of Conduct + + {' · '} + + Contributors + +

+
+ +
+

Report bugs

+
    +
  1. Search existing issues to avoid duplicates.
  2. +
  3. + Include clear reproduction steps and expected vs. actual + behavior. +
  4. +
  5. + Provide your browser, OS, and extension version (see the + manifest). +
  6. +
  7. Attach screenshots or minimal examples if possible.
  8. +
+

+ + Open a new issue + +

+
+ +
+

Security & privacy

+

+ For vulnerabilities, please review the security policy and + report responsibly. See our privacy policy for data practices. +

+

+ + Security Policy + + {' · '} + + Privacy Policy + + {' · '} + + Terms of Use + + {' · '} + + License + +

+
+
+
+
+ + {/* Profile Edit Modal */} + {showProfileModal && editingProfile && ( +
+
+ { + setShowProfileModal(false); + setEditingProfile(null); + }} + > + × + +

Edit Profile

+
+
+ + + setEditingProfile({ + ...editingProfile, + name: e.target.value, + }) + } + required + /> +
+
+ + + setEditingProfile({ + ...editingProfile, + imageUrl: e.target.value, + }) + } + required + /> +
+
+ +