commit 7065a2db2ea6b1b672106d75f25543e4b429b64d Author: Chris Punches Date: Tue Dec 30 18:01:34 2025 -0500 first commit diff --git a/CWS/CWS.md b/CWS/CWS.md new file mode 100644 index 0000000..416a1d0 --- /dev/null +++ b/CWS/CWS.md @@ -0,0 +1,127 @@ +# CrowdProof Observe - Chrome Web Store Submission + +## Extension Details + +**Name:** CrowdProof Observe + +**Summary (132 chars max):** +Quick-add observations to your CrowdProof instance. Submit photos, URLs, emails, logs, and statements from your browser. + +**Category:** Productivity + +**Language:** English + +--- + +## Description + +CrowdProof Observe is the official browser companion for CrowdProof, a collaborative evidence, observation management, intelligence collection, and publication platform. + +### Features + +- **Side Panel Interface** - Submit observations without leaving your current page +- **Multiple Observation Types** - Photos, URLs, emails (.msg/.eml), logs, and statements +- **Quick Capture** - Right-click images or links to send directly to CrowdProof +- **Paste to Submit** - Paste screenshots directly from clipboard +- **Access Management** - Set public access levels for your observations +- **Server Flexibility** - Connect to any CrowdProof instance + +### How It Works + +1. Configure your CrowdProof server URL in settings +2. Log in with your CrowdProof credentials +3. Use the side panel or right-click context menus to submit observations +4. Manage public access settings for your observations + +Note: This extension defaults to use the public instance of CrowdProof by design and intent, however, requires the configurability to point to other instances for SaaS and self-hosted clients and their users. + +--- + +## Permissions Justification + +| Permission | Reason | +|------------|--------| +| `storage` | Stores the user's configured server URL locally in browser storage. | +| `activeTab` | Captures the current page's URL and title when user clicks "Capture Current Page" for URL observations. | +| `cookies` | Maintains authenticated session cookies with the user's CrowdProof server. | +| `contextMenus` | Provides right-click menu options ("Send Image to CrowdProof", "Send Link to CrowdProof"). | +| `sidePanel` | Displays the observation submission interface in Chrome's native side panel. | + +### Host Permissions + +| Permission | Reason | +|------------|--------| +| `` | Necessary to communicate with user-configured CrowdProof server instances hosted at arbitrary domains. The extension does not access or modify page content on other sites. | + +--- + +## Privacy Policy + +**Last Updated:** 2025-12-30 + +### Data Collection + +CrowdProof Observe does not collect, store, or transmit any user data to third parties. + +### Server Communication + +The extension communicates exclusively with the CrowdProof server URL configured by the user. All data submitted through the extension (observations, images, credentials) is sent only to this user-specified server. + +### Local Storage + +The extension stores only the following data locally in browser storage: +- The configured server URL +- Temporary pending image/URL data for context menu actions (cleared after use) + +### Cookies + +Session cookies are used to maintain authentication with the configured CrowdProof server. These cookies are managed by the browser and scoped to the server domain. + +### No Tracking + +This extension does not include any analytics, telemetry, or tracking functionality. + +### Contact + +For privacy inquiries, contact SILO GROUP at `pr@silogroup.org` + +--- + +## Screenshots + +Recommended screenshots for submission (located in `screenshots/` directory): + +1. **main_sidebar.png** - Main side panel showing the photo observation form +2. **access_manager.png** - Access management screen with observation list +3. **context-menu-1.png** - Right-click "Send Image to CrowdProof" option +4. **context-menu-2.png** - Right-click "Send Link to CrowdProof" option + +--- + +## Technical Notes for Reviewers + +- Manifest V3 compliant +- Source code is unobfuscated and readable +- No external scripts or CDN dependencies +- No data exfiltration - only communicates with user-configured server +- Uses Chrome Side Panel API for native browser integration +- Server URL defaults to `https://crowdproof.silogroup.org` but is user-configurable + +--- + +## Links + +- **Homepage:** https://crowdproof.silogroup.org +- **Support Email:** crowdproof-support@silogroup.org +- **Privacy Policy URL:** https://www.silogroup.org/legal/privacy/ + +--- + +## Packaging + +To create the submission zip (excludes CWS directory and markdown files): + +```bash +cd crowdproof-observe-chrome +zip -r crowdproof-observe-chrome.zip . -x "*.git*" -x "CWS/*" -x "CWS" -x "*.md" +``` diff --git a/CWS/screenshots/access_manager.png b/CWS/screenshots/access_manager.png new file mode 100644 index 0000000..4e778ad Binary files /dev/null and b/CWS/screenshots/access_manager.png differ diff --git a/CWS/screenshots/context-menu-1.png b/CWS/screenshots/context-menu-1.png new file mode 100644 index 0000000..1979e09 Binary files /dev/null and b/CWS/screenshots/context-menu-1.png differ diff --git a/CWS/screenshots/context-menu-2.png b/CWS/screenshots/context-menu-2.png new file mode 100644 index 0000000..056a747 Binary files /dev/null and b/CWS/screenshots/context-menu-2.png differ diff --git a/CWS/screenshots/main_sidebar.png b/CWS/screenshots/main_sidebar.png new file mode 100644 index 0000000..05a8609 Binary files /dev/null and b/CWS/screenshots/main_sidebar.png differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..4417541 --- /dev/null +++ b/README.md @@ -0,0 +1,118 @@ +# CrowdProof Observe (Chrome) + +A Chrome browser extension for quickly submitting observations to your CrowdProof instance. + +## Features + +- **Side Panel Interface** - Submit observations without leaving your current page +- **Multiple Observation Types** - Photos, URLs, emails (.msg/.eml), logs, and statements +- **Quick Capture** - Right-click images or links to send directly to CrowdProof +- **Paste to Submit** - Paste screenshots directly from clipboard +- **Access Management** - Set public access levels for your observations +- **Theme Integration** - Adapts to system light/dark mode + +## Installation + +### From Chrome Web Store (Recommended) +Visit the [Chrome Web Store page](#) and click "Add to Chrome". + +### Manual Installation (Development) +1. Open Chrome and navigate to `chrome://extensions` +2. Enable "Developer mode" in the top right +3. Click "Load unpacked" +4. Select this directory + +## Usage + +### Initial Setup +1. Click the CrowdProof Observe icon in the toolbar to open the side panel +2. The extension defaults to `https://crowdproof.silogroup.org` +3. To use a different instance, click "Settings" and enter your server URL +4. Log in with your CrowdProof credentials + +### Submitting Observations + +**Photos:** +- Select a file using the file picker, OR +- Click the paste zone and press Ctrl+V to paste from clipboard, OR +- Right-click any image on a webpage and select "Send Image to CrowdProof" + +**URLs:** +- Click "Capture Current Page" to grab the active tab's URL, OR +- Enter a URL manually, OR +- Right-click any link and select "Send Link to CrowdProof" + +**Emails:** +- Select a .msg or .eml file, OR +- Drag and drop an email file onto the drop zone + +**Logs & Statements:** +- Fill in the form fields and submit + +### Managing Access +Click the "Access" button to view your observations and set public access levels: +- **None** - Only explicitly added users can access +- **Editor** - All active CrowdProof users can view and edit +- **Viewer** - All active CrowdProof users can view only + +## Development + +### Project Structure +``` +crowdproof-observe-chrome/ +├── manifest.json # Extension manifest (MV3) +├── background/ +│ └── background.js # Service worker for context menus +├── sidepanel/ +│ ├── sidepanel.html # Side panel UI structure +│ ├── sidepanel.css # Side panel styles +│ └── sidepanel.js # Side panel logic +├── options/ +│ ├── options.html # Options page +│ └── options.js # Options logic +├── icons/ # Extension icons +├── README.md # This file +└── CWS/ # Chrome Web Store submission info +``` + +### Building +No build step required. The extension runs directly from source. + +### Testing +1. Load the extension via `chrome://extensions` (Developer mode) +2. Make changes to source files +3. Click the refresh icon on the extension card to reload + +### Packaging +```bash +cd crowdproof-observe-chrome +zip -r crowdproof-observe-chrome.zip . -x "*.git*" -x "CWS/*" -x "CWS" -x "*.md" +``` + +## Permissions + +| Permission | Purpose | +|------------|---------| +| `storage` | Store configured server URL | +| `activeTab` | Capture current page URL/title | +| `cookies` | Maintain server authentication | +| `contextMenus` | Right-click menu integration | +| `sidePanel` | Native side panel UI | +| `` | Connect to user-configured servers | + +## Privacy + +- No data collection or third-party transmission +- Communicates only with your configured CrowdProof server +- No analytics or tracking + +See [CWS/CWS.md](CWS/CWS.md) for the full privacy policy. + +## Support + +- **Email:** crowdproof-support@silogroup.org +- **Homepage:** https://crowdproof.silogroup.org + +## License + +Copyright SILO GROUP. All rights reserved. diff --git a/background/background.js b/background/background.js new file mode 100644 index 0000000..d1e6a0a --- /dev/null +++ b/background/background.js @@ -0,0 +1,144 @@ +// CrowdProof Observe - Chrome Background Service Worker +// Handles side panel toggle and context menus + +// Create context menus on install +chrome.runtime.onInstalled.addListener(() => { + chrome.contextMenus.create({ + id: "send-url-to-crowdproof", + title: "Send URL to CrowdProof", + contexts: ["link"] + }); + + chrome.contextMenus.create({ + id: "send-image-to-crowdproof", + title: "Send Image to CrowdProof", + contexts: ["image"] + }); +}); + +// Open side panel when action button is clicked +chrome.action.onClicked.addListener((tab) => { + chrome.sidePanel.open({ windowId: tab.windowId }); +}); + +// Handle context menu click +chrome.contextMenus.onClicked.addListener((info, tab) => { + // Open side panel + chrome.sidePanel.open({ windowId: tab.windowId }); + + if (info.menuItemId === "send-url-to-crowdproof") { + chrome.storage.local.set({ pendingUrl: info.linkUrl }); + } else if (info.menuItemId === "send-image-to-crowdproof") { + // Store the image source URL - sidepanel will fetch it + chrome.storage.local.set({ + pendingImage: { + sourceUrl: info.srcUrl + } + }); + } +}); + +// API helper +const API = { + async getServerUrl() { + const result = await chrome.storage.local.get('serverUrl'); + return result.serverUrl || null; + }, + + async request(endpoint, options = {}) { + const serverUrl = await this.getServerUrl(); + if (!serverUrl) { + throw new Error('Server URL not configured. Please set it in extension options.'); + } + + const url = `${serverUrl}${endpoint}`; + const fetchOptions = { + credentials: 'include', + ...options + }; + + const response = await fetch(url, fetchOptions); + return response; + }, + + async login(username, password) { + const formData = new FormData(); + formData.append('username', username); + formData.append('password', password); + + const response = await this.request('/login', { + method: 'POST', + body: formData + }); + + return { + success: response.ok && !response.url.includes('/login'), + response + }; + }, + + async logout() { + const response = await this.request('/logout', { + method: 'GET' + }); + return response.ok; + }, + + async checkAuth() { + const response = await this.request('/profile', { + method: 'GET' + }); + return !response.url.includes('/login'); + }, + + async createObservation(type, formData) { + const response = await this.request(`/create_observation/${type}`, { + method: 'POST', + body: formData + }); + + return { + success: response.ok, + response + }; + } +}; + +// Handle messages from sidepanel +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + (async () => { + try { + switch (message.action) { + case 'getServerUrl': + sendResponse({ serverUrl: await API.getServerUrl() }); + break; + + case 'checkAuth': + const isAuthenticated = await API.checkAuth(); + sendResponse({ authenticated: isAuthenticated }); + break; + + case 'login': + const loginResult = await API.login(message.username, message.password); + sendResponse({ success: loginResult.success }); + break; + + case 'logout': + const logoutSuccess = await API.logout(); + sendResponse({ success: logoutSuccess }); + break; + + case 'createObservation': + const obsResult = await API.createObservation(message.type, message.formData); + sendResponse({ success: obsResult.success }); + break; + + default: + sendResponse({ error: 'Unknown action' }); + } + } catch (error) { + sendResponse({ error: error.message }); + } + })(); + return true; // Keep message channel open for async response +}); diff --git a/icons/icon-128.png b/icons/icon-128.png new file mode 100644 index 0000000..5d5abc6 Binary files /dev/null and b/icons/icon-128.png differ diff --git a/icons/icon-48.png b/icons/icon-48.png new file mode 100644 index 0000000..ba34fac Binary files /dev/null and b/icons/icon-48.png differ diff --git a/icons/icon-96.png b/icons/icon-96.png new file mode 100644 index 0000000..3c4a721 Binary files /dev/null and b/icons/icon-96.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..a4a1371 --- /dev/null +++ b/manifest.json @@ -0,0 +1,43 @@ +{ + "manifest_version": 3, + "name": "CrowdProof Observe", + "version": "1.0.0", + "description": "Send observations to CrowdProof from any webpage", + + "permissions": [ + "storage", + "activeTab", + "cookies", + "contextMenus", + "sidePanel" + ], + + "host_permissions": [ + "" + ], + + "action": { + "default_icon": { + "48": "icons/icon-48.png" + }, + "default_title": "Open CrowdProof Observe" + }, + + "side_panel": { + "default_path": "sidepanel/sidepanel.html" + }, + + "options_ui": { + "page": "options/options.html" + }, + + "background": { + "service_worker": "background/background.js" + }, + + "icons": { + "48": "icons/icon-48.png", + "96": "icons/icon-96.png", + "128": "icons/icon-128.png" + } +} diff --git a/options/options.html b/options/options.html new file mode 100644 index 0000000..f3f0308 --- /dev/null +++ b/options/options.html @@ -0,0 +1,85 @@ + + + + + CrowdProof Observe Settings + + + +

CrowdProof Observe Settings

+ +
+ + +

Enter the full URL of your CrowdProof server (e.g., https://crowdproof.example.com)

+
+ + + +
+ + + + diff --git a/options/options.js b/options/options.js new file mode 100644 index 0000000..5066068 --- /dev/null +++ b/options/options.js @@ -0,0 +1,42 @@ +// Load saved settings on page load +document.addEventListener('DOMContentLoaded', async () => { + const result = await chrome.storage.local.get('serverUrl'); + if (result.serverUrl) { + document.getElementById('serverUrl').value = result.serverUrl; + } +}); + +// Save settings +document.getElementById('save').addEventListener('click', async () => { + const serverUrl = document.getElementById('serverUrl').value.trim(); + const statusEl = document.getElementById('status'); + + // Validate URL + if (!serverUrl) { + statusEl.textContent = 'Please enter a server URL'; + statusEl.className = 'status error'; + return; + } + + try { + new URL(serverUrl); + } catch { + statusEl.textContent = 'Please enter a valid URL'; + statusEl.className = 'status error'; + return; + } + + // Remove trailing slash for consistency + const normalizedUrl = serverUrl.replace(/\/+$/, ''); + + // Save to storage + await chrome.storage.local.set({ serverUrl: normalizedUrl }); + + statusEl.textContent = 'Settings saved!'; + statusEl.className = 'status success'; + + // Hide status after 2 seconds + setTimeout(() => { + statusEl.className = 'status'; + }, 2000); +}); diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css new file mode 100644 index 0000000..67c2751 --- /dev/null +++ b/sidepanel/sidepanel.css @@ -0,0 +1,553 @@ +/* Theme Variables - CSS-based theming for Chrome */ +@media (prefers-color-scheme: dark) { + :root { + --theme-bg: #1c1b22; + --theme-text: #fbfbfe; + --theme-border: #5b5b66; + --theme-button-bg: #42414d; + --theme-highlight: #00ddff; + --theme-highlight-text: #1c1b22; + } +} + +@media (prefers-color-scheme: light) { + :root { + --theme-bg: #ffffff; + --theme-text: #1a1a1a; + --theme-border: #cccccc; + --theme-button-bg: #f0f0f0; + --theme-highlight: #0060df; + --theme-highlight-text: #ffffff; + } +} + +/* Reset */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* Base */ +html, body { + height: 100%; + background: var(--theme-bg, #1c1b22); + color: var(--theme-text, #fbfbfe); + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + font-size: 14px; + line-height: 1.5; +} + +/* Utility */ +.hidden { + display: none !important; +} + +/* Screens */ +.screen { + padding: 16px; +} + +/* Header */ +.header { + display: flex; + flex-direction: column; + align-items: center; + padding-bottom: 12px; + margin-bottom: 16px; + border-bottom: 1px solid var(--theme-border, #5b5b66); +} + +.header-title { + font-size: 16px; + font-weight: 600; + text-align: center; +} + +.header-logo { + height: 80px; + max-width: 240px; + object-fit: contain; +} + +.header-logo.hidden { + display: none; +} + +.header-row { + flex-direction: row; + justify-content: space-between; +} + +.header-actions { + display: flex; + gap: 8px; + align-self: flex-end; + margin-bottom: 12px; +} + +.instance-name { + font-size: 14px; + font-weight: 500; + opacity: 0.8; + margin-top: 8px; +} + +/* Tabs */ +.tabs { + display: flex; + gap: 4px; + margin-bottom: 20px; + padding: 4px; + background: rgba(128, 128, 128, 0.15); + border-radius: 6px; +} + +.tab { + flex: 1; + padding: 8px 6px; + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + color: var(--theme-text, #fbfbfe) !important; + font-family: inherit; + font-size: 12px; + font-weight: 500; + cursor: pointer; + opacity: 0.7; + transition: all 0.15s; + text-align: center; +} + +.tab:hover { + opacity: 1; + background: rgba(128, 128, 128, 0.2); +} + +.tab.active { + opacity: 1; + background: rgba(10, 132, 255, 0.2); + border-color: #0a84ff; + color: #0a84ff !important; +} + +/* Forms */ +.form { + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Form Groups */ +.form-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.form-group label { + font-size: 12px; + font-weight: 500; + color: var(--theme-text, #fbfbfe); + opacity: 0.85; +} + +/* Inputs */ +input[type="text"], +input[type="url"], +input[type="password"], +input[type="email"], +textarea, +select { + width: 100%; + padding: 10px 12px; + background: var(--theme-button-bg, #42414d); + color: var(--theme-text, #fbfbfe); + border: 1px solid var(--theme-border, #5b5b66); + border-radius: 4px; + font-family: inherit; + font-size: 13px; + transition: border-color 0.15s, box-shadow 0.15s; +} + +input[type="text"]:focus, +input[type="url"]:focus, +input[type="password"]:focus, +input[type="email"]:focus, +textarea:focus, +select:focus { + outline: none; + border-color: var(--theme-highlight, #00ddff); + box-shadow: 0 0 0 1px var(--theme-highlight, #00ddff); +} + +input::placeholder, +textarea::placeholder { + color: var(--theme-text, #fbfbfe); + opacity: 0.4; +} + +textarea { + min-height: 80px; + resize: vertical; +} + +input[type="file"] { + padding: 8px 0; + font-size: 13px; + border: none; + background: none; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 10px 16px; + background: rgba(128, 128, 128, 0.2); + color: inherit; + border: 1px solid rgba(128, 128, 128, 0.4); + border-radius: 4px; + font-family: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.15s; +} + +.btn:hover { + background: rgba(128, 128, 128, 0.3); +} + +.btn:active { + background: rgba(128, 128, 128, 0.4); +} + +.btn-primary { + background: #0a84ff; + color: #ffffff; + border-color: #0a84ff; +} + +.btn-primary:hover { + background: #0060df; + border-color: #0060df; +} + +.btn-primary:active { + background: #003eaa; + border-color: #003eaa; +} + +.btn-block { + width: 100%; +} + +.btn-sm { + padding: 6px 12px; + font-size: 12px; +} + +/* Submit button specific */ +.btn-submit { + margin-top: 8px; + padding: 12px 16px; + font-size: 14px; +} + +/* Loading */ +#loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 200px; + gap: 12px; + opacity: 0.6; +} + +/* Setup screen */ +#setup { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 48px 24px; + gap: 16px; +} + +#setup .header-title { + font-size: 18px; +} + +#setup p { + opacity: 0.7; + max-width: 240px; +} + +/* Login form */ +#login .header { + justify-content: center; +} + +/* Error messages */ +.error-message { + color: #ff6b6b; + font-size: 13px; + padding: 8px 0; +} + +/* Status messages */ +.status { + margin-top: 16px; + padding: 12px; + border-radius: 4px; + text-align: center; + font-size: 13px; + font-weight: 500; +} + +.status.success { + background: var(--theme-highlight, #00ddff); + color: var(--theme-highlight-text, #1c1b22); +} + +.status.error { + background: rgba(255, 107, 107, 0.15); + border: 1px solid rgba(255, 107, 107, 0.3); + color: #ff6b6b; +} + +/* Observation forms */ +.obs-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Form divider */ +.form-divider { + border: none; + border-top: 1px solid rgba(128, 128, 128, 0.3); + margin: 4px 0; +} + +/* Paste target */ +.paste-target { + display: flex; + align-items: center; + justify-content: center; + min-height: 80px; + border: 2px dashed rgba(128, 128, 128, 0.5); + border-radius: 4px; + color: inherit; + opacity: 0.6; + cursor: pointer; + transition: border-color 0.15s, opacity 0.15s; + text-align: center; + font-size: 13px; +} + +.paste-target:hover { + border-color: #0a84ff; + opacity: 1; +} + +.paste-target:focus { + border-color: #30d158; + border-style: solid; + background: rgba(48, 209, 88, 0.1); + opacity: 1; + outline: none; +} + +.paste-target.ready { + border-color: #0a84ff; + border-style: solid; + background: rgba(10, 132, 255, 0.1); +} + +/* Drop target */ +.drop-target { + display: flex; + align-items: center; + justify-content: center; + min-height: 80px; + border: 2px dashed rgba(128, 128, 128, 0.5); + border-radius: 4px; + color: inherit; + opacity: 0.6; + cursor: pointer; + transition: border-color 0.15s, opacity 0.15s; + text-align: center; + font-size: 13px; +} + +.drop-target:hover, +.drop-target:focus, +.drop-target.dragover { + border-color: #0a84ff; + opacity: 1; + outline: none; +} + +.drop-target.ready { + border-color: #0a84ff; + border-style: solid; + background: rgba(10, 132, 255, 0.1); +} + +/* Image preview */ +.image-preview { + display: flex; + justify-content: center; + padding: 8px 0; +} + +.image-preview img { + max-width: 100%; + max-height: 150px; + border-radius: 4px; + object-fit: contain; +} + +/* Access screen */ +.access-hint { + font-size: 13px; + opacity: 0.7; + margin-bottom: 16px; +} + +.observations-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.loading-text { + text-align: center; + opacity: 0.6; + padding: 24px 0; +} + +.observation-item { + padding: 12px; + background: rgba(128, 128, 128, 0.1); + border: 1px solid rgba(128, 128, 128, 0.2); + border-radius: 6px; +} + +.observation-item.disabled { + opacity: 0.5; +} + +.observation-header { + display: flex; + gap: 10px; + margin-bottom: 8px; +} + +.observation-thumb { + width: 60px; + height: 60px; + border-radius: 4px; + object-fit: cover; + flex-shrink: 0; + background: rgba(128, 128, 128, 0.2); +} + +.observation-info { + flex: 1; + min-width: 0; +} + +.observation-name { + display: block; + font-size: 13px; + font-weight: 500; + margin-bottom: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: #0a84ff; + text-decoration: none; +} + +.observation-name:hover { + text-decoration: underline; +} + +.observation-type { + font-size: 11px; + opacity: 0.6; + text-transform: capitalize; + margin-bottom: 4px; +} + +.observation-detail { + font-size: 11px; + opacity: 0.7; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 2px; +} + +.observation-detail strong { + opacity: 0.9; +} + +.access-toggle { + display: flex; + gap: 4px; +} + +.access-toggle button { + flex: 1; + padding: 6px 8px; + background: rgba(128, 128, 128, 0.2); + border: 1px solid rgba(128, 128, 128, 0.3); + border-radius: 4px; + color: inherit; + font-family: inherit; + font-size: 11px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; +} + +.access-toggle button:hover:not(:disabled) { + background: rgba(128, 128, 128, 0.3); +} + +.access-toggle button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.access-toggle button.active-none { + background: rgba(255, 179, 0, 0.25); + border-color: #ffb300; + color: #ffb300; +} + +.access-toggle button.active-editor { + background: rgba(255, 179, 0, 0.25); + border-color: #ffb300; + color: #ffb300; +} + +.access-toggle button.active-viewer { + background: rgba(48, 209, 88, 0.2); + border-color: #30d158; + color: #30d158; +} + +.no-observations { + text-align: center; + opacity: 0.6; + padding: 24px 0; + font-size: 13px; +} diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html new file mode 100644 index 0000000..450c0e5 --- /dev/null +++ b/sidepanel/sidepanel.html @@ -0,0 +1,243 @@ + + + + + CrowdProof Observe + + + + +
+

Loading...

+
+ + + + + + + + + + + + + + + + + + + diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js new file mode 100644 index 0000000..ca93352 --- /dev/null +++ b/sidepanel/sidepanel.js @@ -0,0 +1,812 @@ +// CrowdProof Observe - Chrome Side Panel Script + +let serverUrl = null; +let pendingImageFile = null; // Stores image file from context menu + +// Screen management +function showScreen(screenId) { + document.querySelectorAll('.screen').forEach(s => s.classList.add('hidden')); + document.getElementById(screenId).classList.remove('hidden'); +} + +function showStatus(message, isError = false) { + const status = document.getElementById('status'); + status.textContent = message; + status.className = `status ${isError ? 'error' : 'success'}`; + setTimeout(() => { + status.className = 'status hidden'; + }, 4000); +} + +// API helper +async function apiRequest(endpoint, options = {}) { + if (!serverUrl) { + throw new Error('Server URL not configured'); + } + + const url = `${serverUrl}${endpoint}`; + const fetchOptions = { + credentials: 'include', + ...options + }; + + return fetch(url, fetchOptions); +} + +// Form submission handler +async function handleFormSubmit(e, type) { + e.preventDefault(); + console.log('=== FORM SUBMIT START ==='); + console.log('Type:', type); + console.log('Server URL:', serverUrl); + + const form = e.target; + const formData = new FormData(form); + + // For photo submissions, use pendingImageFile if available + if (type === 'photo' && pendingImageFile) { + formData.set('file', pendingImageFile); + pendingImageFile = null; + hideImagePreview(); + } + + console.log('Form data entries:'); + for (let [key, value] of formData.entries()) { + console.log(' ', key, '=', value); + } + + const submitBtn = form.querySelector('button[type="submit"]'); + const originalText = submitBtn.textContent; + submitBtn.textContent = 'Creating...'; + submitBtn.disabled = true; + + const url = `${serverUrl}/create_observation/${type}`; + console.log('Fetching URL:', url); + + try { + console.log('About to fetch...'); + const response = await fetch(url, { + method: 'POST', + body: formData, + credentials: 'include', + headers: { + 'Accept': 'application/json' + } + }); + + console.log('Fetch completed'); + console.log('Response status:', response.status); + + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + const result = await response.json(); + if (result.success) { + showStatus('Observation created successfully!'); + form.reset(); + if (type === 'photo') hideImagePreview(); + } else if (result.error === 'login_required') { + showStatus('Session expired. Please login again.', true); + showScreen('login'); + } else { + showStatus(result.error || 'Failed to create observation', true); + } + } else { + // Fallback to old redirect-based detection + if (response.url.includes('/login')) { + showStatus('Session expired. Please login again.', true); + showScreen('login'); + } else if (response.url.includes('/observations')) { + showStatus('Observation created successfully!'); + form.reset(); + if (type === 'photo') hideImagePreview(); + } else { + showStatus('Failed to create observation', true); + } + } + } catch (error) { + console.error('Fetch error:', error); + showStatus('Error: ' + error.message, true); + } finally { + submitBtn.textContent = originalText; + submitBtn.disabled = false; + console.log('=== FORM SUBMIT END ==='); + } +} + +// Check for pending URL from context menu +async function checkPendingUrl() { + const result = await chrome.storage.local.get('pendingUrl'); + if (result.pendingUrl) { + // Clear it + await chrome.storage.local.remove('pendingUrl'); + // Switch to URL tab + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + document.querySelector('.tab[data-type="url"]').classList.add('active'); + document.querySelectorAll('.obs-form').forEach(f => f.classList.add('hidden')); + document.getElementById('form-url').classList.remove('hidden'); + // Populate URL field + document.getElementById('url-reference_url').value = result.pendingUrl; + } +} + +// Check for pending image from context menu +async function checkPendingImage() { + const result = await chrome.storage.local.get('pendingImage'); + if (result.pendingImage) { + const { sourceUrl } = result.pendingImage; + // Clear it + await chrome.storage.local.remove('pendingImage'); + // Switch to Photo tab + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + document.querySelector('.tab[data-type="photo"]').classList.add('active'); + document.querySelectorAll('.obs-form').forEach(f => f.classList.add('hidden')); + document.getElementById('form-photo').classList.remove('hidden'); + // Populate the reference URL field + if (sourceUrl) { + document.getElementById('photo-photo_url').value = sourceUrl; + // Fetch image from source URL and convert to blob + try { + const response = await fetch(sourceUrl); + const blob = await response.blob(); + pendingImageFile = new File([blob], `image-${Date.now()}.png`, { type: blob.type || 'image/png' }); + // Show preview + document.getElementById('image-preview-img').src = sourceUrl; + document.getElementById('image-preview').classList.remove('hidden'); + showStatus('Image captured and ready to submit!'); + } catch (e) { + console.error('Could not fetch image:', e); + showStatus('Could not fetch image. You can still submit with the URL.', true); + } + } + } +} + +// Show image preview +function showImagePreview(src) { + document.getElementById('image-preview-img').src = src; + document.getElementById('image-preview').classList.remove('hidden'); +} + +// Hide image preview +function hideImagePreview() { + document.getElementById('image-preview').classList.add('hidden'); + document.getElementById('image-preview-img').src = ''; +} + +// Update logos and instance name from server +async function updateLogos() { + if (!serverUrl) return; + + const loginLogo = document.getElementById('login-logo'); + const mainLogo = document.getElementById('main-logo'); + const loginTitle = document.getElementById('login-title'); + const mainTitle = document.getElementById('main-title'); + const loginInstanceName = document.getElementById('login-instance-name'); + const mainInstanceName = document.getElementById('main-instance-name'); + + // Fetch instance name from login page + try { + const pageResponse = await fetch(`${serverUrl}/login`, { + credentials: 'include' + }); + if (pageResponse.ok) { + const html = await pageResponse.text(); + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const brand = doc.querySelector('.navbar-brand'); + if (brand) { + const instanceName = brand.textContent.trim(); + loginInstanceName.textContent = instanceName; + mainInstanceName.textContent = instanceName; + } else { + throw new Error('Brand not found'); + } + } else { + throw new Error('Page fetch failed'); + } + } catch (e) { + loginInstanceName.textContent = 'CrowdProof (Error)'; + mainInstanceName.textContent = 'CrowdProof (Error)'; + } + + // Fetch logo + try { + const response = await fetch(`${serverUrl}/static/logo.svg`, { + credentials: 'include' + }); + + if (response.ok) { + const svgText = await response.text(); + const dataUrl = 'data:image/svg+xml;base64,' + btoa(svgText); + + loginLogo.src = dataUrl; + mainLogo.src = dataUrl; + loginLogo.classList.remove('hidden'); + mainLogo.classList.remove('hidden'); + loginTitle.classList.add('hidden'); + mainTitle.classList.add('hidden'); + } else { + throw new Error('Logo not found'); + } + } catch (e) { + // Fallback to text + loginLogo.classList.add('hidden'); + mainLogo.classList.add('hidden'); + loginTitle.classList.remove('hidden'); + mainTitle.classList.remove('hidden'); + } +} + +// Load observations list for access screen +async function loadObservations() { + const listEl = document.getElementById('observations-list'); + listEl.innerHTML = '

Loading...

'; + + try { + const response = await fetch(`${serverUrl}/observations`, { + credentials: 'include', + headers: { + 'Accept': 'application/json' + } + }); + + if (response.url.includes('/login')) { + showScreen('login'); + return; + } + + const data = await response.json(); + if (data.success && data.observations) { + renderObservationsList(data.observations); + } else { + listEl.innerHTML = '

Failed to load observations

'; + } + } catch (error) { + console.error('Load observations error:', error); + listEl.innerHTML = '

Error loading observations

'; + } +} + +// Render observations list with access toggles +function renderObservationsList(observations) { + const listEl = document.getElementById('observations-list'); + + if (!observations || observations.length === 0) { + listEl.innerHTML = '

No observations found

'; + return; + } + + listEl.innerHTML = observations.map(obs => { + const isOwner = obs.is_owner; + const disabledClass = isOwner ? '' : 'disabled'; + const disabledAttr = isOwner ? '' : 'disabled'; + const details = obs.details || {}; + + // Determine which button is active based on public_access value + const noneActive = obs.public_access === 0 ? 'active-none' : ''; + const editorActive = obs.public_access === 1 ? 'active-editor' : ''; + const viewerActive = obs.public_access === 2 ? 'active-viewer' : ''; + + // Build thumbnail for photos + let thumbHtml = ''; + if (obs.observation_type === 'photo' && details.image_url) { + thumbHtml = ``; + } + + // Build type-specific details + let detailsHtml = ''; + if (obs.observation_type === 'photo') { + if (details.description) detailsHtml += `
${escapeHtml(details.description)}
`; + if (details.date) detailsHtml += `
Date: ${escapeHtml(details.date)}
`; + } else if (obs.observation_type === 'email') { + if (details.subject) detailsHtml += `
Subject: ${escapeHtml(details.subject)}
`; + if (details.sender) detailsHtml += `
From: ${escapeHtml(details.sender)}
`; + if (details.date) detailsHtml += `
Date: ${escapeHtml(details.date)}
`; + } else if (obs.observation_type === 'url') { + if (details.url) detailsHtml += `
${escapeHtml(details.url)}
`; + if (details.date) detailsHtml += `
Date: ${escapeHtml(details.date)}
`; + } else if (obs.observation_type === 'log') { + if (details.source) detailsHtml += `
Source: ${escapeHtml(details.source)}
`; + if (details.date) detailsHtml += `
Date: ${escapeHtml(details.date)}
`; + } else if (obs.observation_type === 'statement') { + if (details.issuer) detailsHtml += `
Issuer: ${escapeHtml(details.issuer)}
`; + if (details.recipient) detailsHtml += `
To: ${escapeHtml(details.recipient)}
`; + if (details.date) detailsHtml += `
Date: ${escapeHtml(details.date)}
`; + } + + return ` +
+
+ ${thumbHtml} +
+ ${escapeHtml(obs.display_name)} +
${obs.observation_type}
+ ${detailsHtml} +
+
+
+ + + +
+
+ `; + }).join(''); + + // Add click handlers for toggle buttons + listEl.querySelectorAll('.access-toggle button:not([disabled])').forEach(btn => { + btn.addEventListener('click', async (e) => { + const item = e.target.closest('.observation-item'); + const uuid = item.dataset.uuid; + const level = parseInt(e.target.dataset.level); + await setPublicAccess(uuid, level, item); + }); + }); +} + +// Escape HTML to prevent XSS +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Set public access for an observation +async function setPublicAccess(uuid, level, itemEl) { + const buttons = itemEl.querySelectorAll('.access-toggle button'); + buttons.forEach(btn => btn.disabled = true); + + try { + const response = await fetch(`${serverUrl}/set_public_access/observation/${uuid}/${level}`, { + method: 'POST', + credentials: 'include' + }); + + if (response.ok) { + // Update button states + buttons.forEach(btn => { + btn.classList.remove('active-none', 'active-editor', 'active-viewer'); + const btnLevel = parseInt(btn.dataset.level); + if (btnLevel === level) { + if (level === 0) btn.classList.add('active-none'); + else if (level === 1) btn.classList.add('active-editor'); + else if (level === 2) btn.classList.add('active-viewer'); + } + }); + } else { + showStatus('Failed to update access', true); + } + } catch (error) { + console.error('Set public access error:', error); + showStatus('Error updating access', true); + } finally { + buttons.forEach(btn => btn.disabled = false); + } +} + +// Listen for storage changes to catch pending URL/image even when sidepanel is already open +chrome.storage.onChanged.addListener((changes, areaName) => { + if (areaName === 'local') { + if (changes.pendingUrl && changes.pendingUrl.newValue) { + checkPendingUrl(); + } + if (changes.pendingImage && changes.pendingImage.newValue) { + checkPendingImage(); + } + } +}); + +// Initialize +async function init() { + console.log('Init starting...'); + const result = await chrome.storage.local.get('serverUrl'); + serverUrl = result.serverUrl || 'https://crowdproof.silogroup.org'; + console.log('Server URL:', serverUrl); + + // Set create account link + document.getElementById('createAccountLink').href = `${serverUrl}/register`; + + updateLogos(); + + try { + const response = await apiRequest('/profile'); + if (response.url.includes('/login')) { + showScreen('login'); + } else { + showScreen('main'); + // Check for pending URL/image after showing main screen + await checkPendingUrl(); + await checkPendingImage(); + } + } catch (error) { + console.error('Auth check failed:', error); + showScreen('login'); + } +} + +// Setup all event handlers when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + console.log('=== DOM READY ==='); + + // Open settings + document.getElementById('openSettings').addEventListener('click', () => { + showScreen('settings'); + }); + + // Login form + document.getElementById('loginForm').addEventListener('submit', async (e) => { + e.preventDefault(); + console.log('Login form submitted'); + + const username = document.getElementById('username').value; + const password = document.getElementById('password').value; + const errorEl = document.getElementById('loginError'); + + try { + const formData = new FormData(); + formData.append('username', username); + formData.append('password', password); + + const response = await apiRequest('/login', { + method: 'POST', + body: formData + }); + + if (!response.url.includes('/login')) { + showScreen('main'); + } else { + errorEl.textContent = 'Invalid username or password'; + errorEl.classList.remove('hidden'); + } + } catch (error) { + errorEl.textContent = error.message; + errorEl.classList.remove('hidden'); + } + }); + + // Login settings button + document.getElementById('loginSettingsBtn').addEventListener('click', async () => { + const result = await chrome.storage.local.get('serverUrl'); + document.getElementById('settings-serverUrl').value = result.serverUrl || 'https://crowdproof.silogroup.org'; + showScreen('settings'); + }); + + // Logout + document.getElementById('logoutBtn').addEventListener('click', async () => { + try { + await apiRequest('/logout'); + showScreen('login'); + } catch (error) { + showStatus('Logout failed: ' + error.message, true); + } + }); + + // Settings button on main screen + document.getElementById('mainSettingsBtn').addEventListener('click', async () => { + const result = await chrome.storage.local.get('serverUrl'); + document.getElementById('settings-serverUrl').value = result.serverUrl || ''; + showScreen('settings'); + }); + + // Settings back button + document.getElementById('settingsBackBtn').addEventListener('click', async () => { + // Go back to appropriate screen + if (serverUrl) { + try { + const response = await apiRequest('/profile'); + if (response.url.includes('/login')) { + showScreen('login'); + } else { + showScreen('main'); + } + } catch { + showScreen('login'); + } + } else { + showScreen('setup'); + } + }); + + // Access button + document.getElementById('accessBtn').addEventListener('click', () => { + showScreen('access'); + loadObservations(); + }); + + // Access back button + document.getElementById('accessBackBtn').addEventListener('click', () => { + showScreen('main'); + }); + + // Settings form + document.getElementById('settingsForm').addEventListener('submit', async (e) => { + e.preventDefault(); + const newUrl = document.getElementById('settings-serverUrl').value.replace(/\/+$/, ''); + await chrome.storage.local.set({ serverUrl: newUrl }); + serverUrl = newUrl; + document.getElementById('createAccountLink').href = `${serverUrl}/register`; + updateLogos(); + + const statusEl = document.getElementById('settingsStatus'); + statusEl.textContent = 'Settings saved'; + statusEl.className = 'status success'; + statusEl.classList.remove('hidden'); + + setTimeout(() => { + statusEl.classList.add('hidden'); + }, 2000); + }); + + // Tab switching + document.querySelectorAll('.tab').forEach(tab => { + tab.addEventListener('click', () => { + const type = tab.dataset.type; + console.log('Tab clicked:', type); + + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + + document.querySelectorAll('.obs-form').forEach(f => f.classList.add('hidden')); + document.getElementById(`form-${type}`).classList.remove('hidden'); + }); + }); + + // Capture URL button + document.getElementById('captureUrl').addEventListener('click', async () => { + console.log('Capture URL clicked'); + try { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + if (tabs[0]) { + document.getElementById('url-reference_url').value = tabs[0].url; + if (!document.getElementById('url-observation_name').value) { + document.getElementById('url-observation_name').value = tabs[0].title; + } + showStatus('URL captured!'); + } + } catch (error) { + console.error('Capture URL error:', error); + showStatus('Could not capture URL', true); + } + }); + + // Log file import + document.getElementById('log-file-import').addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file) return; + + try { + const text = await file.text(); + document.getElementById('log-log_content').value = text; + showStatus('File imported'); + } catch (error) { + showStatus('Could not read file', true); + } + }); + + // Photo file input - show preview + document.getElementById('photo-file').addEventListener('change', (e) => { + const file = e.target.files[0]; + if (file && file.type.startsWith('image/')) { + const reader = new FileReader(); + reader.onload = (event) => { + showImagePreview(event.target.result); + }; + reader.readAsDataURL(file); + // Clear any pending context menu image since user selected a file + pendingImageFile = null; + } else { + hideImagePreview(); + } + }); + + // Form submissions + const forms = ['url', 'statement', 'log', 'photo', 'email']; + forms.forEach(type => { + const form = document.getElementById(`form-${type}`); + console.log(`Attaching handler for form-${type}:`, form); + form.addEventListener('submit', (e) => { + console.log(`Form ${type} submit event fired`); + handleFormSubmit(e, type); + }); + }); + + // Paste target for photo form - click to enable paste + const pasteTarget = document.getElementById('paste-target'); + console.log('Paste target element:', pasteTarget); + + // Click to focus (enables paste) + pasteTarget.addEventListener('click', () => { + console.log('Paste target clicked, focusing...'); + pasteTarget.focus(); + }); + + // Update text on focus/blur + pasteTarget.addEventListener('focus', () => { + console.log('Paste target focused'); + pasteTarget.querySelector('span').textContent = 'Ready - paste now (Ctrl+V)'; + }); + + pasteTarget.addEventListener('blur', () => { + console.log('Paste target blurred'); + pasteTarget.querySelector('span').textContent = 'Click to enable paste'; + }); + + // Handle paste when paste target is focused (document-level since divs don't receive paste) + document.addEventListener('paste', async (e) => { + // Only handle if paste target is focused + if (document.activeElement !== pasteTarget) return; + console.log('Paste event received, paste target is focused'); + const photoForm = document.getElementById('form-photo'); + if (photoForm.classList.contains('hidden')) return; + console.log('Clipboard data:', e.clipboardData); + e.preventDefault(); + + const items = e.clipboardData?.items; + if (!items) return; + + console.log('Items:', items.length); + for (const item of items) { + console.log('Item type:', item.type); + if (item.type.startsWith('image/')) { + const blob = item.getAsFile(); + console.log('Blob:', blob); + if (!blob) continue; + + // Create a File from the blob + const file = new File([blob], `screenshot-${Date.now()}.png`, { type: blob.type }); + console.log('File created:', file.name, file.size); + + // Build FormData from the photo form + const form = document.getElementById('form-photo'); + const formData = new FormData(); + + formData.append('file', file); + formData.append('observation_name', form.querySelector('[name="observation_name"]').value); + formData.append('photo_description', form.querySelector('[name="photo_description"]').value); + formData.append('photo_url', form.querySelector('[name="photo_url"]').value); + formData.append('photo_date', form.querySelector('[name="photo_date"]').value); + + // Submit + pasteTarget.classList.add('ready'); + pasteTarget.querySelector('span').textContent = 'Submitting...'; + + try { + const response = await fetch(`${serverUrl}/create_observation/photo`, { + method: 'POST', + body: formData, + credentials: 'include', + headers: { + 'Accept': 'application/json' + } + }); + + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + const result = await response.json(); + if (result.success) { + showStatus('Photo observation created!'); + form.reset(); + hideImagePreview(); + pendingImageFile = null; + } else if (result.error === 'login_required') { + showStatus('Session expired. Please login again.', true); + showScreen('login'); + } else { + showStatus(result.error || 'Failed to create observation', true); + } + } else { + // Fallback + if (response.url.includes('/login')) { + showStatus('Session expired. Please login again.', true); + showScreen('login'); + } else if (response.url.includes('/observations')) { + showStatus('Photo observation created!'); + form.reset(); + hideImagePreview(); + pendingImageFile = null; + } else { + showStatus('Failed to create observation', true); + } + } + } catch (error) { + showStatus('Error: ' + error.message, true); + } finally { + pasteTarget.classList.remove('ready'); + pasteTarget.querySelector('span').textContent = 'Click to enable paste'; + } + + return; + } + } + + showStatus('No image found in clipboard', true); + }); + + // Email drop target + const emailDropTarget = document.getElementById('email-drop-target'); + + emailDropTarget.addEventListener('dragover', (e) => { + e.preventDefault(); + emailDropTarget.classList.add('dragover'); + }); + + emailDropTarget.addEventListener('dragleave', () => { + emailDropTarget.classList.remove('dragover'); + }); + + emailDropTarget.addEventListener('drop', async (e) => { + e.preventDefault(); + emailDropTarget.classList.remove('dragover'); + + const files = e.dataTransfer?.files; + if (!files || files.length === 0) return; + + const file = files[0]; + const validExtensions = ['.msg', '.eml']; + const hasValidExt = validExtensions.some(ext => file.name.toLowerCase().endsWith(ext)); + + if (!hasValidExt) { + showStatus('Please drop a .msg or .eml file', true); + return; + } + + // Build FormData from the email form + const form = document.getElementById('form-email'); + const formData = new FormData(); + + formData.append('msg_file', file); + formData.append('observation_name', form.querySelector('[name="observation_name"]').value); + formData.append('email_description', form.querySelector('[name="email_description"]').value); + + // Submit + emailDropTarget.classList.add('ready'); + emailDropTarget.querySelector('span').textContent = 'Submitting...'; + + try { + const response = await fetch(`${serverUrl}/create_observation/email`, { + method: 'POST', + body: formData, + credentials: 'include', + headers: { + 'Accept': 'application/json' + } + }); + + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + const result = await response.json(); + if (result.success) { + showStatus('Email observation created!'); + form.reset(); + } else if (result.error === 'login_required') { + showStatus('Session expired. Please login again.', true); + showScreen('login'); + } else { + showStatus(result.error || 'Failed to create observation', true); + } + } else { + // Fallback + if (response.url.includes('/login')) { + showStatus('Session expired. Please login again.', true); + showScreen('login'); + } else if (response.url.includes('/observations')) { + showStatus('Email observation created!'); + form.reset(); + } else { + showStatus('Failed to create observation', true); + } + } + } catch (error) { + showStatus('Error: ' + error.message, true); + } finally { + emailDropTarget.classList.remove('ready'); + emailDropTarget.querySelector('span').textContent = 'Drop .msg or .eml file to submit'; + } + }); + + console.log('=== ALL HANDLERS ATTACHED ==='); + + // Initialize + init(); +});