Livrare Lot 3 (Frontend): aplicație web, aplicație mobilă Android, extensie browser
- Surse complete web (React/Vite) + mobil (React Native/Expo) + extensie (MV3) - Documentație de livrare: ghid utilizare, matrice trasabilitate cerințe, raport testare furnizor - Artefacte binare: imagine Docker didi-frontend:lot3-1.0, APK, extensie v3.2.6 + SHA256SUMS - Configurare adresă platformă externalizată (build args / .env / config.js) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
cec967f953
321 changed files with 80506 additions and 0 deletions
190
extension/README.md
Normal file
190
extension/README.md
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
# DIDI Content Extractor - Browser Extension
|
||||
|
||||
Professional browser extension for extracting structured content from social media posts.
|
||||
|
||||
## 🎯 Features
|
||||
|
||||
### Platform-Specific Extraction
|
||||
- **Facebook**: Clean extraction with intelligent text filtering and primary image detection
|
||||
- **Twitter/X**: Tweet-specific extraction with proper content identification
|
||||
- **LinkedIn**: Coming soon
|
||||
- **Custom Websites**: Future OCR-based extraction
|
||||
|
||||
### Smart Content Detection
|
||||
- **Post Container Detection**: Walks DOM tree to find exact post boundaries
|
||||
- **Author Extraction**: Multiple selector patterns for reliable author identification
|
||||
- **Text Filtering**: Removes UI noise (Like, Comment, Share buttons, etc.)
|
||||
- **Primary Image**: Extracts main image only (200x200px minimum, excludes icons/emojis/profile pics)
|
||||
- **Metadata**: Timestamp and post URL extraction
|
||||
|
||||
### Content Types
|
||||
- `text` - Text only posts
|
||||
- `image` - Image only posts
|
||||
- `text+image` - Combined content posts
|
||||
- `text+video` - Coming soon
|
||||
- `video` - Coming soon
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
1. Open Chrome/Edge and navigate to `chrome://extensions`
|
||||
2. Enable **Developer mode** (top right toggle)
|
||||
3. Click **Load unpacked**
|
||||
4. Select the `browser-extension` folder
|
||||
5. Extension installed!
|
||||
|
||||
## 🚀 Usage
|
||||
|
||||
### Facebook Extraction
|
||||
1. Navigate to Facebook
|
||||
2. Click extension icon
|
||||
3. Click **"Activate Selection Mode"** under Facebook tab
|
||||
4. Hover over any post (entire post highlighted with blue border)
|
||||
5. Click to extract content
|
||||
6. View extracted content in popup
|
||||
7. Click **"💾 Save"** to store for later
|
||||
|
||||
### Twitter/X Extraction
|
||||
1. Navigate to Twitter/X
|
||||
2. Click extension icon
|
||||
3. Click **"Activate Selection Mode"** under X/Twitter tab
|
||||
4. Hover over any tweet (entire tweet highlighted)
|
||||
5. Click to extract content
|
||||
6. View and save as above
|
||||
|
||||
### Saved Extractions
|
||||
- Switch to **"Saved"** tab to view all saved content
|
||||
- **"👁️ View"** - View full details
|
||||
- **"📋 Copy"** - Copy JSON to clipboard
|
||||
- **"🗑️ Delete"** - Delete individual item
|
||||
- **"Clear All"** - Remove all saved extractions
|
||||
|
||||
## 📊 Data Structure
|
||||
|
||||
```javascript
|
||||
{
|
||||
platform: 'facebook' | 'twitter',
|
||||
type: 'text' | 'image' | 'text+image',
|
||||
author: 'Author Name',
|
||||
text: 'Post content...',
|
||||
image: {
|
||||
url: 'https://...',
|
||||
width: 1200,
|
||||
height: 800,
|
||||
alt: 'Description'
|
||||
},
|
||||
timestamp: '2024-01-15T10:30:00',
|
||||
postUrl: 'https://facebook.com/...',
|
||||
extractedAt: '2024-01-15T10:35:00.000Z'
|
||||
}
|
||||
```
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
### Platform Extractors
|
||||
|
||||
#### FacebookExtractor
|
||||
- **Post Detection**: `[role="article"]` containers
|
||||
- **Author**: `h2 a[role="link"]`, `h3 a[role="link"]`
|
||||
- **Text**: `div[dir="auto"][style*="text-align"]` with UI noise filtering
|
||||
- **Image**: Primary image detection (excludes icons/emoji/profiles)
|
||||
- **Metadata**: Timestamp from `a[aria-label*="ago"]`, URL from permalinks
|
||||
|
||||
#### TwitterExtractor
|
||||
- **Tweet Detection**: `article[data-testid="tweet"]` containers
|
||||
- **Author**: `[data-testid="User-Name"] span`
|
||||
- **Text**: `[data-testid="tweetText"]`
|
||||
- **Image**: `[data-testid="tweetPhoto"] img`
|
||||
- **Metadata**: Timestamp from `<time>` element
|
||||
|
||||
### Extractor Interface
|
||||
All extractors follow this interface:
|
||||
```javascript
|
||||
class PlatformExtractor {
|
||||
findPostContainer(element) // Locate post container
|
||||
extractAuthor(container) // Get author name
|
||||
extractText(container) // Get clean text
|
||||
extractPrimaryImage(container) // Get main image
|
||||
extractTimestamp(container) // Get post time
|
||||
extractPostUrl(container) // Get permalink
|
||||
extract(element) // Main extraction method
|
||||
}
|
||||
```
|
||||
|
||||
## 📁 File Structure
|
||||
|
||||
```
|
||||
browser-extension/
|
||||
├── manifest.json # Extension configuration
|
||||
├── popup.html # Extension popup UI
|
||||
├── popup.css # DIDI-themed styling
|
||||
├── popup.js # Popup logic and state management
|
||||
├── content.js # Content script with extractors
|
||||
├── content.css # Selection box and notification styles
|
||||
├── background.js # Background service worker
|
||||
├── extractors/ # Platform-specific extractors (standalone files)
|
||||
│ ├── facebook.js
|
||||
│ └── twitter.js
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## 🎨 Design System
|
||||
|
||||
Uses DIDI's design system:
|
||||
- **Colors**:
|
||||
- Trust Blue: `#0052CC`
|
||||
- Honest Teal: `#00BFCC`
|
||||
- Caution Red: `#E63946`
|
||||
- Background: `#0A0E27`
|
||||
- **Font**: Plus Jakarta Sans (with fallbacks)
|
||||
- **Effects**: Gradient transitions, smooth animations
|
||||
|
||||
## 🔒 Permissions
|
||||
|
||||
- `activeTab` - Access current tab content
|
||||
- `storage` - Local storage for saved extractions
|
||||
- `clipboardWrite` - Copy to clipboard functionality
|
||||
- Host permissions for supported platforms
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Extension won't load
|
||||
- Check if all files are present
|
||||
- Verify manifest.json is valid JSON
|
||||
- Try removing and re-adding the extension
|
||||
|
||||
### No extraction happening
|
||||
- Refresh the page after installing extension
|
||||
- Check browser console (F12) for errors
|
||||
- Verify you're on a supported platform
|
||||
|
||||
### Selection box not appearing
|
||||
- Click "Activate Selection Mode" again
|
||||
- Hover directly over post content
|
||||
- Check if posts are using expected DOM structure
|
||||
|
||||
## 🚀 Future Enhancements
|
||||
|
||||
- [ ] LinkedIn extraction
|
||||
- [ ] Custom website extraction with OCR
|
||||
- [ ] Video content extraction
|
||||
- [ ] Export to various formats (CSV, JSON, Markdown)
|
||||
- [ ] Cloud sync with main DIDI app
|
||||
- [ ] Keycloak authentication integration
|
||||
- [ ] Batch extraction mode
|
||||
- [ ] Advanced filtering options
|
||||
|
||||
## 📝 Development Notes
|
||||
|
||||
- Extension uses Manifest V3 (latest Chrome standard)
|
||||
- Platform extractors embedded in `content.js` (Chrome doesn't support ES modules in content scripts)
|
||||
- Standalone extractor files in `extractors/` folder for code organization
|
||||
- All extraction logic uses defensive programming (checks for null/undefined)
|
||||
- Extensive console logging with prefixes: `[FB]`, `[Twitter]`, `[DIDI]`
|
||||
|
||||
## 🔗 Integration with DIDI App
|
||||
|
||||
Future integration will allow:
|
||||
- Direct content send to Analysis tab
|
||||
- Authentication via Keycloak
|
||||
- Saved extractions sync across devices
|
||||
- Export to DIDI's content database
|
||||
235
extension/SNIPPING_TOOL_GUIDE.md
Normal file
235
extension/SNIPPING_TOOL_GUIDE.md
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
# 📸 DIDI Snipping Tool - Complete Guide
|
||||
|
||||
## ✨ Feature: Auto-Save Screenshots
|
||||
|
||||
Când folosești Snipping Tool, screenshot-ul se **salvează automat** în folderul Downloads!
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Cum folosești:
|
||||
|
||||
### 1. **Activează Snipping Tool**
|
||||
```
|
||||
Click icon extensie → "✂️ Snipping Tool (Screenshot)"
|
||||
```
|
||||
|
||||
### 2. **Selectează zona**
|
||||
- **Click și DRAG** mouse-ul pentru a selecta zona dorită
|
||||
- Vezi **chenarul TEAL** care crește live
|
||||
- Vezi **dimensiunile** în colț (ex: "500 × 300")
|
||||
|
||||
### 3. **Release mouse-ul**
|
||||
Screenshot-ul se salvează AUTOMAT!
|
||||
|
||||
### 4. **Găsește fișierul**
|
||||
```
|
||||
📂 C:\Users\YourName\Downloads\
|
||||
📁 didi-snip-2025-01-15T14-30-45.png
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 Format nume fișier:
|
||||
|
||||
```
|
||||
didi-snip-YYYY-MM-DDTHH-MM-SS.png
|
||||
|
||||
Exemplu:
|
||||
didi-snip-2025-01-15T14-30-45.png
|
||||
└─ 2025-01-15 la 14:30:45
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Ce se întâmplă după snip:
|
||||
|
||||
### **Notificare pe pagină:**
|
||||
```
|
||||
✅ Screenshot saved!
|
||||
📁 didi-snip-2025-01-15T14-30-45.png
|
||||
📂 Check your Downloads folder
|
||||
```
|
||||
|
||||
### **În Console (F12):**
|
||||
```
|
||||
[BACKGROUND] Snip captured: {left: 100, top: 200, width: 500, height: 300}
|
||||
[BACKGROUND] Screenshot captured, length: 75432
|
||||
[BACKGROUND] Screenshot saved as: didi-snip-2025-01-15T14-30-45.png Download ID: 123
|
||||
```
|
||||
|
||||
### **În Chrome Downloads (Ctrl+J):**
|
||||
Vezi fișierul descărcat recent cu iconița ✅
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Test Rapid:
|
||||
|
||||
### **1. Reload extensia**
|
||||
```
|
||||
chrome://extensions → Reload DIDI
|
||||
```
|
||||
|
||||
### **2. Mergi pe orice pagină** (Facebook, Google, etc.)
|
||||
|
||||
### **3. Click extensie → "✂️ Snipping Tool"**
|
||||
|
||||
### **4. Drag un dreptunghi**
|
||||
|
||||
### **5. Release**
|
||||
|
||||
### **6. Verifică Downloads:**
|
||||
```
|
||||
Windows: C:\Users\YourName\Downloads\
|
||||
Mac: ~/Downloads/
|
||||
Linux: ~/Downloads/
|
||||
```
|
||||
|
||||
**SAU**
|
||||
|
||||
Chrome Downloads page:
|
||||
```
|
||||
chrome://downloads/
|
||||
sau
|
||||
Ctrl+J (Windows/Linux)
|
||||
Cmd+Shift+J (Mac)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Screenshot Details:
|
||||
|
||||
- **Format:** PNG (high quality)
|
||||
- **Conținut:** Întreaga fereastră vizibilă a tab-ului
|
||||
- **Rezoluție:** Screen resolution (ex: 1920x1080)
|
||||
- **Compresie:** None (lossless PNG)
|
||||
|
||||
**Note:** Screenshot-ul capturează întreaga fereastră vizibilă, NU doar zona selectată cu dreptunghiul. Coordonatele dreptunghiului sunt salvate în `chrome.storage` pentru procesare ulterioară.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Debugging:
|
||||
|
||||
### **Verifică dacă s-a salvat:**
|
||||
|
||||
**În Console (F12):**
|
||||
```javascript
|
||||
chrome.storage.local.get(['lastScreenshot', 'screenshotRect'], (data) => {
|
||||
console.log('Screenshot info:');
|
||||
console.log('- Data URL length:', data.lastScreenshot?.length);
|
||||
console.log('- Rectangle:', data.screenshotRect);
|
||||
|
||||
// Deschide screenshot în tab nou
|
||||
if (data.lastScreenshot) {
|
||||
window.open(data.lastScreenshot);
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### **Verifică Downloads:**
|
||||
```javascript
|
||||
chrome.downloads.search({
|
||||
filename: 'didi-snip'
|
||||
}, (items) => {
|
||||
console.log('DIDI Screenshots:', items);
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Visual Features:
|
||||
|
||||
### **În timpul selecției:**
|
||||
- ✅ Overlay semi-transparent (rgba(0, 0, 0, 0.3))
|
||||
- ✅ Chenar teal (#00BFA6) 3px
|
||||
- ✅ Background semi-transparent în interiorul dreptunghiului
|
||||
- ✅ Shadow effect: `0 0 20px rgba(0, 191, 166, 0.6)`
|
||||
- ✅ **Dimensiuni LIVE în colț:** "500 × 300"
|
||||
|
||||
### **După capture:**
|
||||
- ✅ Notificare verde cu detalii
|
||||
- ✅ Fișier PNG salvat automat
|
||||
- ✅ Coordonate salvate în storage
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Settings & Customization:
|
||||
|
||||
### **Vrei să schimbi unde se salvează?**
|
||||
|
||||
Editează `background.js`:
|
||||
```javascript
|
||||
chrome.downloads.download({
|
||||
url: screenshotUrl,
|
||||
filename: `custom-folder/${filename}`, // Adaugă subfolder
|
||||
saveAs: true // TRUE = cere user unde să salveze
|
||||
})
|
||||
```
|
||||
|
||||
### **Vrei alt format de nume?**
|
||||
|
||||
Editează `background.js`:
|
||||
```javascript
|
||||
const filename = `my-custom-name-${timestamp}.png`;
|
||||
// sau
|
||||
const filename = `screenshot-${Date.now()}.png`;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Next Steps - Backend Integration:
|
||||
|
||||
Screenshot-ul salvat poate fi trimis la backend pentru analiză:
|
||||
|
||||
```javascript
|
||||
// În background.js, după download:
|
||||
fetch('http://localhost:3001/api/analyze-screenshot', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
screenshot: screenshotUrl, // base64 data URL
|
||||
rect: message.rect,
|
||||
pageUrl: message.pageUrl,
|
||||
timestamp: message.timestamp
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❓ Troubleshooting:
|
||||
|
||||
### **Nu se salvează în Downloads**
|
||||
|
||||
**Verifică permisiunile:**
|
||||
```json
|
||||
// manifest.json
|
||||
"permissions": [
|
||||
"downloads" // ← Trebuie să fie aici!
|
||||
]
|
||||
```
|
||||
|
||||
**Reload extensia după orice modificare în manifest.json!**
|
||||
|
||||
### **Screenshot e negru/gol**
|
||||
|
||||
- Facebook poate bloca `captureVisibleTab` pe anumite zone
|
||||
- Încearcă pe alte site-uri (Google, Twitter)
|
||||
- Verifică Console pentru erori
|
||||
|
||||
### **Notificarea nu apare**
|
||||
|
||||
- Verifică dacă `response.filename` există în Console
|
||||
- Verifică dacă `showNotification()` primește mesajul corect
|
||||
|
||||
---
|
||||
|
||||
## 📝 Summary:
|
||||
|
||||
✅ **Snipping Tool funcțional**
|
||||
✅ **Auto-save în Downloads**
|
||||
✅ **Nume fișier cu timestamp**
|
||||
✅ **Notificare cu detalii**
|
||||
✅ **Visual feedback live**
|
||||
✅ **Coordonate salvate pentru backend**
|
||||
|
||||
**Gata de integrare cu backend pentru analiză AI!** 🎉
|
||||
207
extension/TESTING.md
Normal file
207
extension/TESTING.md
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
# 🧪 DIDI Extension - Testing Guide
|
||||
|
||||
## 📦 Instalare Extensie
|
||||
|
||||
1. Deschide Chrome/Edge
|
||||
2. Navighează la `chrome://extensions`
|
||||
3. Activează **Developer mode** (toggle în dreapta sus)
|
||||
4. Click **Load unpacked**
|
||||
5. Selectează folderul `browser-extension`
|
||||
|
||||
## ✅ Test 1: Enhanced Post Extraction
|
||||
|
||||
### Pași:
|
||||
|
||||
1. **Mergi pe Facebook** (https://facebook.com)
|
||||
2. **Deschide extensia** (click pe icon în toolbar)
|
||||
3. **Click pe "📋 Extract Post Content"**
|
||||
4. **Hover peste un post** - ar trebui să vezi bordură albastră
|
||||
5. **Click pe post**
|
||||
|
||||
### Ce ar trebui să vezi:
|
||||
|
||||
✅ **Notificare pe pagină** (colț dreapta sus):
|
||||
```
|
||||
✓ Extracted: text+image
|
||||
📝 Text: 250 chars
|
||||
🖼️ Images: 2
|
||||
🔗 Links: 1
|
||||
|
||||
Click extension icon to view details
|
||||
```
|
||||
|
||||
✅ **În extensie** (când o redeschizi):
|
||||
- Tab-ul Facebook activ
|
||||
- Preview cu tot conținutul extras:
|
||||
```
|
||||
📝 Type: text+image
|
||||
👤 Author: Numele Autorului
|
||||
|
||||
💬 Text:
|
||||
Textul postării... (primele 200 caractere)
|
||||
|
||||
🖼️ Images: 2 found
|
||||
1. 1200x800px
|
||||
2. 800x600px
|
||||
|
||||
🔗 External Links: 1 found
|
||||
1. https://example.com/...
|
||||
|
||||
🔗 Post URL: https://facebook.com/...
|
||||
⏰ 2h ago
|
||||
```
|
||||
|
||||
✅ **Butoane disponibile**:
|
||||
- 🚀 Send to Analysis
|
||||
- 💾 Save
|
||||
- 🗑️ Clear
|
||||
|
||||
### Debug (dacă nu merge):
|
||||
|
||||
1. **Deschide Console** (F12) pe Facebook
|
||||
2. Caută logging de tip:
|
||||
```
|
||||
[DIDI] Content script initialized
|
||||
[FB] Starting Facebook extraction...
|
||||
[FB] Extraction complete: {...}
|
||||
[DIDI] Content stored in chrome.storage
|
||||
```
|
||||
|
||||
3. **Verifică chrome storage**:
|
||||
- În Console scrie:
|
||||
```javascript
|
||||
chrome.storage.local.get(null, (data) => console.log(data))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✂️ Test 2: Snipping Tool
|
||||
|
||||
### Pași:
|
||||
|
||||
1. **Mergi pe orice site** (inclusiv Facebook)
|
||||
2. **Deschide extensia**
|
||||
3. **Click pe "✂️ Snipping Tool (Screenshot)"**
|
||||
4. **Click și drag** pentru a selecta o zonă
|
||||
|
||||
### Ce ar trebui să vezi:
|
||||
|
||||
✅ **Overlay semi-transparent** peste toată pagina
|
||||
✅ **Dreptunghi de selecție** (bordură teal) când tragi mouse-ul
|
||||
✅ **Notificare**: "📸 Screenshot captured! Coordinates: {...}"
|
||||
✅ **ESC** pentru cancel
|
||||
|
||||
### Debug (dacă nu merge):
|
||||
|
||||
1. **Console logging**:
|
||||
```
|
||||
[DIDI] Snipping mode activating...
|
||||
[DIDI] Snip complete: {left: 100, top: 200, width: 500, height: 300}
|
||||
[DIDI] Snip sent to background: {...}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Test 3: Send to Backend
|
||||
|
||||
### Pași:
|
||||
|
||||
1. **Extrage conținut** (Test 1)
|
||||
2. **Click "🚀 Send to Analysis"**
|
||||
|
||||
### Ce ar trebui să vezi:
|
||||
|
||||
✅ Status message: "Sending to analysis..."
|
||||
❌ Apoi error (normal, backend nu e pornit):
|
||||
```
|
||||
❌ Failed to send: Backend returned 404
|
||||
sau
|
||||
❌ Failed to send: Failed to fetch
|
||||
```
|
||||
|
||||
### Pentru ca să meargă:
|
||||
|
||||
Trebuie să ai backend pornit la:
|
||||
```
|
||||
POST http://localhost:3001/api/analyze
|
||||
|
||||
Body:
|
||||
{
|
||||
"platform": "facebook",
|
||||
"data": {
|
||||
"platform": "facebook",
|
||||
"type": "text+image",
|
||||
"author": "...",
|
||||
"text": "...",
|
||||
"images": [...],
|
||||
"links": [...],
|
||||
...
|
||||
},
|
||||
"timestamp": "2025-01-15T..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Common Issues
|
||||
|
||||
### "Please refresh the page"
|
||||
- **Fix**: Refresh Facebook după ce instalezi extensia
|
||||
|
||||
### "No content found"
|
||||
- **Verifică**: Click exact pe zona postării, nu pe butonul Like/Comment
|
||||
- **Verifică**: Console pentru erori
|
||||
|
||||
### Popup-ul nu arată conținutul
|
||||
- **Fix**: Click din nou pe icon extensie
|
||||
- **Verifică**: Chrome storage (instrucțiuni mai sus)
|
||||
|
||||
### Snipping nu apare overlay-ul
|
||||
- **Fix**: Refresh pagina
|
||||
- **Verifică**: Console pentru erori
|
||||
|
||||
---
|
||||
|
||||
## 📊 Chrome DevTools Tips
|
||||
|
||||
### Debugging Popup:
|
||||
1. Click dreapta pe icon extensie
|
||||
2. "Inspect popup"
|
||||
3. Console pentru logging
|
||||
|
||||
### Debugging Content Script:
|
||||
1. F12 pe Facebook
|
||||
2. Console tab
|
||||
3. Caută `[DIDI]`, `[FB]`
|
||||
|
||||
### View Storage:
|
||||
```javascript
|
||||
// Get all storage
|
||||
chrome.storage.local.get(null, console.log)
|
||||
|
||||
// Clear storage
|
||||
chrome.storage.local.clear()
|
||||
|
||||
// Get specific key
|
||||
chrome.storage.local.get(['facebook_content'], console.log)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist Complet
|
||||
|
||||
- [ ] Extensia se încarcă fără erori
|
||||
- [ ] Logo "didi" apare corect în header
|
||||
- [ ] Butonul "Extract Post Content" funcționează
|
||||
- [ ] Butonul "Snipping Tool" funcționează
|
||||
- [ ] Notificări apar pe pagină
|
||||
- [ ] Content-ul apare în popup
|
||||
- [ ] Toate imaginile sunt extrase
|
||||
- [ ] Link-urile externe sunt extrase
|
||||
- [ ] "Send to Analysis" trimite request (chiar dacă fail)
|
||||
- [ ] "Save" salvează în storage
|
||||
- [ ] Tab "Saved" arată extractions
|
||||
|
||||
---
|
||||
|
||||
**Întrebări? Issues? Check Console logging!** 🔍
|
||||
189
extension/auth.js
Normal file
189
extension/auth.js
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
// DIDI Analysis Tool — OIDC/OAuth2 PKCE auth module (Keycloak, realm didi-clients)
|
||||
// Loaded in the MV3 service worker via importScripts. Exposes self.DidiAuth.
|
||||
//
|
||||
// Flow: chrome.identity.launchWebAuthFlow → Keycloak authorization endpoint
|
||||
// (PKCE S256 + state) → code exchange on the token endpoint → tokens persisted
|
||||
// in chrome.storage.local. Access token auto-refreshes; logout revokes the
|
||||
// Keycloak session (end_session with refresh_token).
|
||||
|
||||
(() => {
|
||||
// Adresele mediului vin exclusiv din config.js (self.DidiConfig) — vezi acolo și
|
||||
// explicația schemei HTTPS (pagina de login) / HTTP (token & API) pentru LAN.
|
||||
const { KC_NAV_BASE, KC_API_BASE, REALM, CLIENT_ID } = self.DidiConfig;
|
||||
const REDIRECT_URI = `https://${chrome.runtime.id}.chromiumapp.org/oidc`;
|
||||
|
||||
const OIDC = {
|
||||
auth: `${KC_NAV_BASE}/realms/${REALM}/protocol/openid-connect/auth`,
|
||||
token: `${KC_API_BASE}/realms/${REALM}/protocol/openid-connect/token`,
|
||||
logout: `${KC_API_BASE}/realms/${REALM}/protocol/openid-connect/logout`,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'didiTokens';
|
||||
const REFRESH_SKEW_MS = 30 * 1000; // refresh 30s before expiry
|
||||
|
||||
// ===== helpers =====
|
||||
|
||||
function base64UrlEncode(bytes) {
|
||||
let str = '';
|
||||
for (const b of bytes) str += String.fromCharCode(b);
|
||||
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function randomString(byteLen = 32) {
|
||||
const bytes = new Uint8Array(byteLen);
|
||||
crypto.getRandomValues(bytes);
|
||||
return base64UrlEncode(bytes);
|
||||
}
|
||||
|
||||
async function sha256Base64Url(input) {
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
|
||||
return base64UrlEncode(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token) {
|
||||
try {
|
||||
const payload = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
return JSON.parse(atob(payload));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function getStoredTokens() {
|
||||
const data = await chrome.storage.local.get([STORAGE_KEY]);
|
||||
return data[STORAGE_KEY] || null;
|
||||
}
|
||||
|
||||
async function storeTokens(tokenResponse) {
|
||||
const claims = decodeJwtPayload(tokenResponse.access_token);
|
||||
const tokens = {
|
||||
access_token: tokenResponse.access_token,
|
||||
refresh_token: tokenResponse.refresh_token,
|
||||
expires_at: Date.now() + (tokenResponse.expires_in || 60) * 1000,
|
||||
sub: claims.sub || null,
|
||||
email: claims.email || null,
|
||||
name: claims.name || claims.preferred_username || null,
|
||||
};
|
||||
await chrome.storage.local.set({ [STORAGE_KEY]: tokens });
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// ===== login (interactive) =====
|
||||
|
||||
async function login() {
|
||||
const codeVerifier = randomString(48);
|
||||
const codeChallenge = await sha256Base64Url(codeVerifier);
|
||||
const state = randomString(16);
|
||||
const nonce = randomString(16);
|
||||
|
||||
const authUrl = `${OIDC.auth}?` + new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email',
|
||||
state,
|
||||
nonce,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
}).toString();
|
||||
|
||||
const redirectUrl = await chrome.identity.launchWebAuthFlow({ url: authUrl, interactive: true });
|
||||
if (!redirectUrl) throw new Error('Autentificare anulată');
|
||||
|
||||
const params = new URL(redirectUrl).searchParams;
|
||||
if (params.get('error')) {
|
||||
throw new Error(params.get('error_description') || params.get('error'));
|
||||
}
|
||||
if (params.get('state') !== state) {
|
||||
throw new Error('State mismatch (posibil CSRF) — autentificare respinsă');
|
||||
}
|
||||
const code = params.get('code');
|
||||
if (!code) throw new Error('Răspuns fără cod de autorizare');
|
||||
|
||||
const resp = await fetch(OIDC.token, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: CLIENT_ID,
|
||||
code,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Schimbul de cod a eșuat: ${resp.status} ${await resp.text()}`);
|
||||
}
|
||||
return storeTokens(await resp.json());
|
||||
}
|
||||
|
||||
// ===== token refresh (single-flight) =====
|
||||
|
||||
let refreshPromise = null;
|
||||
|
||||
async function refreshTokens(refreshToken) {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = (async () => {
|
||||
const resp = await fetch(OIDC.token, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: CLIENT_ID,
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
await chrome.storage.local.remove([STORAGE_KEY]);
|
||||
throw new Error('Sesiune expirată — autentifică-te din nou');
|
||||
}
|
||||
return storeTokens(await resp.json());
|
||||
})().finally(() => { refreshPromise = null; });
|
||||
}
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
/** Returns a valid access token, refreshing it if needed. Throws if logged out. */
|
||||
async function getValidToken() {
|
||||
let tokens = await getStoredTokens();
|
||||
if (!tokens?.access_token) {
|
||||
throw new Error('Neautentificat. Deschide extensia și apasă Login.');
|
||||
}
|
||||
if (Date.now() >= tokens.expires_at - REFRESH_SKEW_MS) {
|
||||
tokens = await refreshTokens(tokens.refresh_token);
|
||||
}
|
||||
return tokens.access_token;
|
||||
}
|
||||
|
||||
// ===== state / logout =====
|
||||
|
||||
async function getAuthState() {
|
||||
const tokens = await getStoredTokens();
|
||||
if (!tokens?.access_token) return { authenticated: false };
|
||||
return { authenticated: true, email: tokens.email, name: tokens.name, sub: tokens.sub };
|
||||
}
|
||||
|
||||
/** User identity claims for API payloads (user_id/user_email). */
|
||||
async function getUserClaims() {
|
||||
const tokens = await getStoredTokens();
|
||||
return { user_id: tokens?.sub || '', user_email: tokens?.email || '' };
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
const tokens = await getStoredTokens();
|
||||
if (tokens?.refresh_token) {
|
||||
try {
|
||||
await fetch(OIDC.logout, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ client_id: CLIENT_ID, refresh_token: tokens.refresh_token }),
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[AUTH] Keycloak logout failed (clearing local session anyway):', e.message);
|
||||
}
|
||||
}
|
||||
await chrome.storage.local.remove([STORAGE_KEY]);
|
||||
}
|
||||
|
||||
self.DidiAuth = { login, logout, getValidToken, getAuthState, getUserClaims };
|
||||
})();
|
||||
492
extension/background.js
Normal file
492
extension/background.js
Normal file
|
|
@ -0,0 +1,492 @@
|
|||
// DIDI Analysis Tool - Background Service Worker (V3 API prin Kong, auth OIDC)
|
||||
// BUILD: 2026-07-15 — OIDC/PKCE (Keycloak) + Bearer prin Kong; context menus; URL/selecție
|
||||
|
||||
console.log('[DIDI BUILD] background.js loaded — build 2026-07-15 (OIDC)');
|
||||
|
||||
// config.js FIRST (defines self.DidiConfig used by auth.js), then the renderer
|
||||
// and the auth module. render.js is needed in the SW to build the standalone
|
||||
// result page (popup is closed by the time async polling finishes).
|
||||
importScripts('config.js', 'render.js', 'auth.js');
|
||||
|
||||
// Same JWT-protected routes the web app uses (nginx → Kong → agent-v3).
|
||||
const DIDI_API_BASE = self.DidiConfig.API_BASE;
|
||||
const DEFAULT_PLAN_TYPE = 1;
|
||||
|
||||
// === Result delivery + history (centralized) ===
|
||||
// Push the analysis to the originator tab (content modal) when applicable, and
|
||||
// open a new tab with a rendered standalone page for popup-origin requests
|
||||
// (popup is closed by the time async polling completes, so its callback dies).
|
||||
async function deliverAnalysis({ analysis, filename, originatorTabId, originatorContext }) {
|
||||
// 1) Save to local history (used by history.html). Ignore errors.
|
||||
try {
|
||||
const data = await chrome.storage.local.get(['didiHistory']);
|
||||
const history = data.didiHistory || [];
|
||||
history.unshift({
|
||||
analysis, filename,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: originatorContext === 'popup' ? 'popup' : 'tab',
|
||||
});
|
||||
if (history.length > 50) history.length = 50;
|
||||
await chrome.storage.local.set({ didiHistory: history });
|
||||
} catch (e) {
|
||||
console.warn('[BACKGROUND] history save failed:', e.message);
|
||||
}
|
||||
|
||||
// 2) Content-script origin (snip + video + context menu): push the modal back
|
||||
// into the page; if the tab is gone, fall back to a standalone result tab.
|
||||
if (originatorTabId != null && originatorContext === 'content') {
|
||||
try {
|
||||
await chrome.tabs.sendMessage(originatorTabId, { action: 'showAnalysis', analysis, filename });
|
||||
} catch (e) {
|
||||
console.warn('[BACKGROUND] tabs.sendMessage failed (tab closed?), opening result tab:', e.message);
|
||||
originatorContext = 'popup';
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Popup origin (text/url): popup is closed, open a new tab with the rendered page.
|
||||
if (originatorContext === 'popup') {
|
||||
try {
|
||||
const html = self.DidiRender
|
||||
? self.DidiRender.renderStandalonePage(analysis)
|
||||
: `<pre>${JSON.stringify(analysis, null, 2)}</pre>`;
|
||||
// Service workers can't use URL.createObjectURL — build a data URL instead.
|
||||
const dataUrl = 'data:text/html;charset=utf-8,' + encodeURIComponent(html);
|
||||
await chrome.tabs.create({ url: dataUrl });
|
||||
} catch (e) {
|
||||
console.error('[BACKGROUND] failed to open result tab:', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== AUTH (OIDC/PKCE via auth.js) ==========
|
||||
|
||||
/** Authorization header for API calls; refreshes the access token when needed. */
|
||||
async function authHeaders() {
|
||||
const token = await self.DidiAuth.getValidToken();
|
||||
return { 'Authorization': `Bearer ${token}` };
|
||||
}
|
||||
|
||||
// ========== UPLOAD CONSENT (cerință caiet: consimțământ explicit fișiere/capturi) ==========
|
||||
|
||||
async function hasUploadConsent() {
|
||||
const data = await chrome.storage.local.get(['didiUploadConsent']);
|
||||
return data.didiUploadConsent === true;
|
||||
}
|
||||
|
||||
/** Ask the page (content script) to show the consent modal; resolves true if accepted. */
|
||||
async function ensureUploadConsent(tabId) {
|
||||
if (await hasUploadConsent()) return true;
|
||||
if (tabId == null) return false;
|
||||
try {
|
||||
const resp = await chrome.tabs.sendMessage(tabId, { action: 'requestUploadConsent' });
|
||||
return resp?.accepted === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== MESSAGE LISTENER ==========
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
console.log('[BACKGROUND] Message received:', message.action);
|
||||
|
||||
if (message.action === 'login') {
|
||||
self.DidiAuth.login()
|
||||
.then((tokens) => sendResponse({ success: true, email: tokens.email, name: tokens.name }))
|
||||
.catch((error) => sendResponse({ success: false, error: error.message }));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.action === 'getAuthStatus') {
|
||||
self.DidiAuth.getAuthState()
|
||||
.then((state) => sendResponse(state))
|
||||
.catch(() => sendResponse({ authenticated: false }));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.action === 'logout') {
|
||||
self.DidiAuth.logout()
|
||||
.then(() => sendResponse({ success: true }))
|
||||
.catch(() => sendResponse({ success: true })); // local session is cleared regardless
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.action === 'snipCaptured') {
|
||||
console.log('[BACKGROUND] Snip captured:', message.rect);
|
||||
const tabId = sender.tab?.id ?? null;
|
||||
|
||||
// Ack the originator immediately so it doesn't sit on a stale callback for ~90s.
|
||||
// The actual result is delivered via chrome.tabs.sendMessage when polling completes.
|
||||
sendResponse({ success: true, accepted: true });
|
||||
|
||||
chrome.tabs.captureVisibleTab(null, { format: 'png' }, async (screenshotUrl) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
console.error('[BACKGROUND] Screenshot error:', chrome.runtime.lastError);
|
||||
if (tabId != null) chrome.tabs.sendMessage(tabId, { action: 'analysisFailed', error: chrome.runtime.lastError.message }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const croppedDataUrl = await cropScreenshot(screenshotUrl, message.rect, message.devicePixelRatio || 1);
|
||||
const timestamp = new Date().toISOString().replace(/:/g, '-').split('.')[0];
|
||||
const filename = `didi-snip-${timestamp}.png`;
|
||||
|
||||
console.log('[BACKGROUND] Sending screenshot to DIDI API…');
|
||||
const analysis = await analyzeScreenshot(croppedDataUrl, filename);
|
||||
console.log('[BACKGROUND] Analysis complete:', analysis);
|
||||
await deliverAnalysis({ analysis, filename, originatorTabId: tabId, originatorContext: 'content' });
|
||||
} catch (error) {
|
||||
console.error('[BACKGROUND] Analysis failed:', error);
|
||||
if (tabId != null) chrome.tabs.sendMessage(tabId, { action: 'analysisFailed', error: error.message }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.action === 'analyzeText') {
|
||||
console.log('[BACKGROUND] Analyzing text, length:', message.text?.length);
|
||||
sendResponse({ success: true, accepted: true }); // popup will likely close — result delivered via new tab
|
||||
|
||||
analyzeTextContent(message.text)
|
||||
.then((analysis) => {
|
||||
const filename = `text-analysis-${new Date().toISOString().replace(/:/g, '-').split('.')[0]}.txt`;
|
||||
return deliverAnalysis({ analysis, filename, originatorTabId: null, originatorContext: 'popup' });
|
||||
})
|
||||
.catch((error) => console.error('[BACKGROUND] Text analysis failed:', error));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.action === 'analyzeVideo') {
|
||||
console.log('[BACKGROUND] Analyzing video, filename:', message.filename);
|
||||
const tabId = sender.tab?.id ?? null;
|
||||
sendResponse({ success: true, accepted: true });
|
||||
|
||||
analyzeVideo(message.dataUrl, message.filename)
|
||||
.then((analysis) => deliverAnalysis({ analysis, filename: message.filename, originatorTabId: tabId, originatorContext: 'content' }))
|
||||
.catch((error) => {
|
||||
console.error('[BACKGROUND] Video analysis failed:', error);
|
||||
if (tabId != null) chrome.tabs.sendMessage(tabId, { action: 'analysisFailed', error: error.message }).catch(() => {});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Social post extracted in-page (Facebook/Twitter extractors) → analyze it.
|
||||
if (message.action === 'contentExtracted') {
|
||||
const tabId = sender.tab?.id ?? null;
|
||||
const content = message.data || {};
|
||||
sendResponse({ success: true, accepted: true });
|
||||
|
||||
(async () => {
|
||||
const text = (content.text || '').trim();
|
||||
const externalLink = (content.links || []).find((l) => typeof l === 'string' && /^https?:/i.test(l));
|
||||
let analysis;
|
||||
if (text) {
|
||||
analysis = await analyzeTextContent(text);
|
||||
} else if (externalLink) {
|
||||
analysis = await analyzeUrl(externalLink);
|
||||
} else {
|
||||
throw new Error('Postarea selectată nu conține text sau link analizabil');
|
||||
}
|
||||
await deliverAnalysis({ analysis, filename: `${message.platform || 'social'}-post`, originatorTabId: tabId, originatorContext: 'content' });
|
||||
})().catch((error) => {
|
||||
console.error('[BACKGROUND] Social post analysis failed:', error);
|
||||
notifyTab(tabId, 'Analiza postării a eșuat: ' + error.message, 'error');
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.action === 'analyzeUrl') {
|
||||
console.log('[BACKGROUND] Analyzing URL:', message.url);
|
||||
sendResponse({ success: true, accepted: true });
|
||||
|
||||
analyzeUrl(message.url)
|
||||
.then((analysis) => {
|
||||
const filename = `url-analysis-${new Date().toISOString().replace(/:/g, '-').split('.')[0]}.txt`;
|
||||
return deliverAnalysis({ analysis, filename, originatorTabId: null, originatorContext: 'popup' });
|
||||
})
|
||||
.catch((error) => console.error('[BACKGROUND] URL analysis failed:', error));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// ========== INITIALIZATION + CONTEXT MENUS ==========
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
console.log('[DIDI] Extension V3 installed');
|
||||
chrome.contextMenus.removeAll(() => {
|
||||
chrome.contextMenus.create({
|
||||
id: 'didi-analyze-image',
|
||||
title: 'Analizează imaginea cu DiDi',
|
||||
contexts: ['image'],
|
||||
});
|
||||
chrome.contextMenus.create({
|
||||
id: 'didi-analyze-selection',
|
||||
title: 'Analizează textul selectat cu DiDi',
|
||||
contexts: ['selection'],
|
||||
});
|
||||
chrome.contextMenus.create({
|
||||
id: 'didi-analyze-page',
|
||||
title: 'Analizează pagina curentă cu DiDi',
|
||||
contexts: ['page'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** Toast helper for context-menu flows (best-effort; page may block injection). */
|
||||
function notifyTab(tabId, message, type = 'info') {
|
||||
if (tabId == null) return;
|
||||
chrome.tabs.sendMessage(tabId, { action: 'showToast', message, type }).catch(() => {});
|
||||
}
|
||||
|
||||
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
||||
const tabId = tab?.id ?? null;
|
||||
try {
|
||||
// Make sure the content script is present (tabs opened before install/reload).
|
||||
if (tabId != null) {
|
||||
try {
|
||||
await chrome.scripting.executeScript({ target: { tabId }, files: ['render.js', 'content.js'] });
|
||||
} catch { /* injection blocked (chrome:// etc.) — fall back to result tab */ }
|
||||
}
|
||||
|
||||
if (info.menuItemId === 'didi-analyze-image') {
|
||||
if (!info.srcUrl) return;
|
||||
if (!(await ensureUploadConsent(tabId))) {
|
||||
notifyTab(tabId, 'Analiza a fost anulată — este necesar consimțământul pentru trimiterea imaginii.', 'error');
|
||||
return;
|
||||
}
|
||||
notifyTab(tabId, 'DiDi analizează imaginea… rezultatul apare aici în 30–90s.');
|
||||
const analysis = await analyzeImageFromSrc(info.srcUrl);
|
||||
await deliverAnalysis({ analysis, filename: 'context-image', originatorTabId: tabId, originatorContext: 'content' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (info.menuItemId === 'didi-analyze-selection') {
|
||||
const text = (info.selectionText || '').trim();
|
||||
if (!text) return;
|
||||
notifyTab(tabId, 'DiDi analizează textul selectat… rezultatul apare aici în 30–90s.');
|
||||
const analysis = await analyzeTextContent(text);
|
||||
await deliverAnalysis({ analysis, filename: 'context-selection', originatorTabId: tabId, originatorContext: 'content' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (info.menuItemId === 'didi-analyze-page') {
|
||||
const pageUrl = info.pageUrl || tab?.url;
|
||||
if (!pageUrl || !/^https?:/i.test(pageUrl)) return;
|
||||
notifyTab(tabId, 'DiDi analizează pagina curentă… rezultatul apare aici în 30–90s.');
|
||||
const analysis = await analyzeUrl(pageUrl);
|
||||
await deliverAnalysis({ analysis, filename: 'context-url', originatorTabId: tabId, originatorContext: 'content' });
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BACKGROUND] Context menu analysis failed:', error);
|
||||
notifyTab(tabId, 'Analiza a eșuat: ' + error.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// ========== SCREENSHOT CROP ==========
|
||||
|
||||
async function cropScreenshot(dataUrl, rect, devicePixelRatio = 1) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fetch(dataUrl)
|
||||
.then(res => res.blob())
|
||||
.then(blob => createImageBitmap(blob))
|
||||
.then(imageBitmap => {
|
||||
const scaledLeft = rect.left * devicePixelRatio;
|
||||
const scaledTop = rect.top * devicePixelRatio;
|
||||
const scaledWidth = rect.width * devicePixelRatio;
|
||||
const scaledHeight = rect.height * devicePixelRatio;
|
||||
|
||||
const canvas = new OffscreenCanvas(rect.width, rect.height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(imageBitmap, scaledLeft, scaledTop, scaledWidth, scaledHeight, 0, 0, rect.width, rect.height);
|
||||
return canvas.convertToBlob({ type: 'image/png' });
|
||||
})
|
||||
.then(blob => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result);
|
||||
reader.onerror = (error) => reject(error);
|
||||
reader.readAsDataURL(blob);
|
||||
})
|
||||
.catch(error => reject(error));
|
||||
});
|
||||
}
|
||||
|
||||
// Manual data URL → Blob decoder.
|
||||
// fetch(dataUrl) breaks in the MV3 service worker when MIME contains an
|
||||
// unquoted comma (e.g. MediaRecorder's `video/webm;codecs=vp9,opus`): the URL
|
||||
// parser splits on that first comma and treats the rest as the body, so the
|
||||
// "blob" ends up containing the literal text `opus;base64,...` instead of
|
||||
// decoded bytes. Splitting on the unambiguous `;base64,` marker avoids it.
|
||||
async function dataUrlToBlob(dataUrl) {
|
||||
const marker = ';base64,';
|
||||
const b64Idx = dataUrl.indexOf(marker);
|
||||
if (b64Idx >= 0) {
|
||||
const mime = dataUrl.substring(5, b64Idx); // strip leading "data:"
|
||||
const b64 = dataUrl.substring(b64Idx + marker.length);
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return new Blob([bytes], { type: mime });
|
||||
}
|
||||
const comma = dataUrl.indexOf(',');
|
||||
if (comma < 0) throw new Error('Invalid data URL');
|
||||
const mime = dataUrl.substring(5, comma);
|
||||
return new Blob([decodeURIComponent(dataUrl.substring(comma + 1))], { type: mime });
|
||||
}
|
||||
|
||||
// ========== V3 API: ANALYZE (async + polling, Bearer prin Kong) ==========
|
||||
// Same contract as the web app: POST /pipeline/analyze-async returns 202 +
|
||||
// session_id, poll /pipeline/:id/queue-status every 2s, then GET /:id/result.
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes hard cap (video upper bound)
|
||||
|
||||
/** Submit an analyze-async request and poll until completion. */
|
||||
async function submitAndPoll(payload, kind) {
|
||||
const claims = await self.DidiAuth.getUserClaims();
|
||||
const body = { ...payload, ...claims, plan_type: DEFAULT_PLAN_TYPE };
|
||||
console.log(`[DIDI API] ${kind} async submit`, JSON.stringify(body).substring(0, 200));
|
||||
|
||||
const submitResp = await fetch(`${DIDI_API_BASE}/pipeline/analyze-async`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8', ...(await authHeaders()) },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!submitResp.ok) {
|
||||
const errText = await submitResp.text();
|
||||
throw new Error(`${kind} submit failed: ${submitResp.status} - ${errText}`);
|
||||
}
|
||||
|
||||
const submit = await submitResp.json();
|
||||
// Sync fallback: backend already returned a complete analysis (queue was unavailable)
|
||||
if (submit.async === false && submit.data) {
|
||||
console.log(`[DIDI API] ${kind} sync fallback (queue down) — returning result directly`);
|
||||
return submit.data;
|
||||
}
|
||||
|
||||
const sessionId = submit.data?.session_id;
|
||||
if (!sessionId) throw new Error(`${kind} submit response missing session_id: ${JSON.stringify(submit)}`);
|
||||
|
||||
console.log(`[DIDI API] ${kind} dispatched, session_id=${sessionId}, polling…`);
|
||||
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
|
||||
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
||||
let data;
|
||||
try {
|
||||
const statusResp = await fetch(`${DIDI_API_BASE}/pipeline/${sessionId}/queue-status`, {
|
||||
method: 'GET',
|
||||
headers: await authHeaders(),
|
||||
});
|
||||
if (!statusResp.ok) {
|
||||
console.warn(`[DIDI API] poll ${sessionId}: HTTP ${statusResp.status}`);
|
||||
continue;
|
||||
}
|
||||
const statusJson = await statusResp.json();
|
||||
data = statusJson.data || {};
|
||||
} catch (e) {
|
||||
// Network blip — log and try again; do NOT swallow a server-side 'failed' status
|
||||
console.warn(`[DIDI API] poll network error (will retry): ${e.message}`);
|
||||
continue;
|
||||
}
|
||||
console.log(`[DIDI API] poll ${sessionId}: status=${data.status} progress=${data._queue?.progress ?? '?'}%`);
|
||||
if (data.status === 'completed') {
|
||||
// queue-status may omit heavy result fields — fetch the full session
|
||||
try {
|
||||
const resultResp = await fetch(`${DIDI_API_BASE}/pipeline/${sessionId}/result`, {
|
||||
method: 'GET',
|
||||
headers: await authHeaders(),
|
||||
});
|
||||
if (resultResp.ok) {
|
||||
const resultJson = await resultResp.json();
|
||||
if (resultJson.data) return resultJson.data;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[DIDI API] result fetch failed, falling back to queue-status payload: ${e.message}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
if (data.status === 'failed') {
|
||||
throw new Error(`Analysis failed server-side: ${data.reason || 'no result returned'}`);
|
||||
}
|
||||
}
|
||||
throw new Error(`Analysis timed out after ${POLL_TIMEOUT_MS / 1000}s (session ${sessionId})`);
|
||||
}
|
||||
|
||||
/** Upload a media blob via /media/upload (Bearer) → public_url. */
|
||||
async function uploadMediaBlob(blob, filename) {
|
||||
const claims = await self.DidiAuth.getUserClaims();
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob, filename);
|
||||
formData.append('user_id', claims.user_id);
|
||||
|
||||
const resp = await fetch(`${DIDI_API_BASE}/media/upload`, {
|
||||
method: 'POST',
|
||||
headers: await authHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text();
|
||||
throw new Error(`Upload failed: ${resp.status} - ${errText}`);
|
||||
}
|
||||
|
||||
const json = await resp.json();
|
||||
const d = json.data || {};
|
||||
const url = d.public_url || d.download_url || d.url;
|
||||
if (!url) {
|
||||
throw new Error(`Upload succeeded but no URL in response: ${JSON.stringify(json).substring(0, 300)}`);
|
||||
}
|
||||
console.log('[DIDI API] Upload OK:', url);
|
||||
return url;
|
||||
}
|
||||
|
||||
async function analyzeTextContent(text) {
|
||||
return submitAndPoll({ text, media_type: 'text' }, 'text');
|
||||
}
|
||||
|
||||
async function analyzeUrl(url) {
|
||||
// Normalize www. prefix like the web app does
|
||||
const normalized = /^www\./i.test(url) ? `https://${url}` : url;
|
||||
return submitAndPoll({ url: normalized, media_type: 'url' }, 'url');
|
||||
}
|
||||
|
||||
async function analyzeScreenshot(dataUrl, filename) {
|
||||
console.log('[DIDI API] Screenshot upload + analyze…');
|
||||
const blob = await dataUrlToBlob(dataUrl);
|
||||
const imageUrl = await uploadMediaBlob(blob, filename);
|
||||
return submitAndPoll({ media_url: imageUrl, media_type: 'image' }, 'image');
|
||||
}
|
||||
|
||||
/** Analyze an image already hosted somewhere (context menu on <img>). */
|
||||
async function analyzeImageFromSrc(srcUrl) {
|
||||
console.log('[DIDI API] Image fetch + upload + analyze…', srcUrl.substring(0, 120));
|
||||
let blob;
|
||||
if (srcUrl.startsWith('data:')) {
|
||||
blob = await dataUrlToBlob(srcUrl);
|
||||
} else {
|
||||
const resp = await fetch(srcUrl);
|
||||
if (!resp.ok) throw new Error(`Nu am putut prelua imaginea (HTTP ${resp.status})`);
|
||||
blob = await resp.blob();
|
||||
}
|
||||
const ext = (blob.type.split('/')[1] || 'png').split(';')[0];
|
||||
const timestamp = new Date().toISOString().replace(/:/g, '-').split('.')[0];
|
||||
const imageUrl = await uploadMediaBlob(blob, `didi-image-${timestamp}.${ext}`);
|
||||
return submitAndPoll({ media_url: imageUrl, media_type: 'image' }, 'image');
|
||||
}
|
||||
|
||||
async function analyzeVideo(dataUrl, filename) {
|
||||
console.log('[DIDI API] Video upload + analyze…');
|
||||
const blob = await dataUrlToBlob(dataUrl);
|
||||
console.log('[DIDI API] Video blob size:', blob.size, 'bytes, type:', blob.type);
|
||||
const videoUrl = await uploadMediaBlob(blob, filename);
|
||||
return submitAndPoll({ media_url: videoUrl, media_type: 'video' }, 'video');
|
||||
}
|
||||
52
extension/build.sh
Executable file
52
extension/build.sh
Executable file
|
|
@ -0,0 +1,52 @@
|
|||
#!/usr/bin/env bash
|
||||
# Build a distributable .zip of the DIDI browser extension.
|
||||
# Output: ../web/public/downloads/didi-extension-v<VERSION>.zip
|
||||
# ../web/public/downloads/didi-extension-latest.zip (copy)
|
||||
#
|
||||
# Usage: ./build.sh
|
||||
#
|
||||
# Excluded from the zip: docs (*.md), build artifacts (*.zip), icon generators,
|
||||
# the legacy extractors/ folder (dead code — extraction logic lives in content.js).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SRC_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
OUT_DIR="$(cd "$SRC_DIR/../web/public/downloads" && pwd)"
|
||||
|
||||
VERSION=$(grep '"version"' "$SRC_DIR/manifest.json" | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "ERROR: cannot read version from manifest.json" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ZIP_NAME="didi-extension-v${VERSION}.zip"
|
||||
LATEST_NAME="didi-extension-latest.zip"
|
||||
|
||||
echo "[ext-build] Version: $VERSION"
|
||||
echo "[ext-build] Source: $SRC_DIR"
|
||||
echo "[ext-build] Output: $OUT_DIR/$ZIP_NAME"
|
||||
|
||||
cd "$SRC_DIR"
|
||||
rm -f "$OUT_DIR/$ZIP_NAME" "$OUT_DIR/$LATEST_NAME"
|
||||
|
||||
# Build the zip excluding docs, build artifacts, and dead code.
|
||||
# Try native zip first, fall back to docker (alpine has zip).
|
||||
if command -v zip >/dev/null 2>&1; then
|
||||
zip -r "$OUT_DIR/$ZIP_NAME" . \
|
||||
-x "*.md" \
|
||||
-x "*.zip" \
|
||||
-x "build.sh" \
|
||||
-x "generate_icons.*" \
|
||||
-x "extractors/*" \
|
||||
-x ".git/*"
|
||||
else
|
||||
docker run --rm -v "$SRC_DIR":/src -v "$OUT_DIR":/out -w /src alpine:latest \
|
||||
sh -c "apk add --no-cache zip >/dev/null && zip -r /out/$ZIP_NAME . \
|
||||
-x '*.md' -x '*.zip' -x 'build.sh' -x 'generate_icons.*' -x 'extractors/*' -x '.git/*'"
|
||||
fi
|
||||
|
||||
cp "$OUT_DIR/$ZIP_NAME" "$OUT_DIR/$LATEST_NAME"
|
||||
|
||||
echo "[ext-build] Done."
|
||||
echo "[ext-build] $OUT_DIR/$ZIP_NAME ($(du -h "$OUT_DIR/$ZIP_NAME" | cut -f1))"
|
||||
echo "[ext-build] $OUT_DIR/$LATEST_NAME"
|
||||
26
extension/config.js
Normal file
26
extension/config.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// DIDI Analysis Tool — Configurarea mediului.
|
||||
// ACESTA ESTE SINGURUL FIȘIER în care se setează adresa platformei DiDi.
|
||||
// La schimbarea mediului (alt server / domeniu de producție) se modifică doar
|
||||
// DIDI_HOST_HTTP / DIDI_HOST_HTTPS de mai jos, apoi se reîncarcă extensia.
|
||||
//
|
||||
// Notă pentru mediul LAN cu certificat autosemnat: pagina de login (navigare)
|
||||
// trebuie servită pe HTTPS (Chrome HTTPS-First), iar apelurile API/token din
|
||||
// service worker pe HTTP. Pe un mediu cu certificat valid, ambele valori se
|
||||
// setează pe același https://…
|
||||
(() => {
|
||||
const DIDI_HOST_HTTP = 'http://10.11.10.18';
|
||||
const DIDI_HOST_HTTPS = 'https://10.11.10.18';
|
||||
|
||||
const REALM = 'didi-clients';
|
||||
|
||||
self.DidiConfig = {
|
||||
// API-urile platformei (prin API Gateway) — apelate din service worker
|
||||
API_BASE: `${DIDI_HOST_HTTP}/agent-v3/api/v3`,
|
||||
// Pagina de autentificare Keycloak (navigare în fereastra de login)
|
||||
KC_NAV_BASE: `${DIDI_HOST_HTTPS}/auth`,
|
||||
// Endpoint-urile token/logout Keycloak (fetch din service worker)
|
||||
KC_API_BASE: `${DIDI_HOST_HTTP}/auth`,
|
||||
REALM,
|
||||
CLIENT_ID: 'didi-extension',
|
||||
};
|
||||
})();
|
||||
3
extension/content.css
Normal file
3
extension/content.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/* DIDI Content Extractor - Content Script Styles */
|
||||
|
||||
/* This file intentionally left minimal - all styles are injected via JS */
|
||||
1428
extension/content.js
Normal file
1428
extension/content.js
Normal file
File diff suppressed because it is too large
Load diff
136
extension/generate_icons.html
Normal file
136
extension/generate_icons.html
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>DIDI Icon Generator</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
padding: 40px;
|
||||
background: #1F2933;
|
||||
color: white;
|
||||
}
|
||||
h1 { color: #00BFA6; }
|
||||
.preview {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin: 30px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.icon-preview {
|
||||
text-align: center;
|
||||
background: #2D3748;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
canvas {
|
||||
border: 2px solid #00BFA6;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
button {
|
||||
background: linear-gradient(135deg, #0052CC 0%, #00BFA6 100%);
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.instructions {
|
||||
background: rgba(0, 191, 166, 0.1);
|
||||
border: 1px solid #00BFA6;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🎨 DIDI Extension Icon Generator</h1>
|
||||
<p>Click the buttons below to download icons for your Chrome extension.</p>
|
||||
|
||||
<div class="preview" id="preview"></div>
|
||||
|
||||
<div class="instructions">
|
||||
<h3>📝 Instructions:</h3>
|
||||
<ol>
|
||||
<li>Click each "Download" button to save all 4 icon sizes</li>
|
||||
<li>Save them in the <code>browser-extension</code> folder</li>
|
||||
<li>The icons will be automatically used by the extension</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const sizes = [16, 32, 48, 128];
|
||||
|
||||
function createIcon(size) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Draw black background with rounded corners
|
||||
const radius = Math.max(2, size / 8);
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, 0, size, size, radius);
|
||||
ctx.fill();
|
||||
|
||||
// Draw white 'd' letter
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.font = `700 ${size * 0.65}px Arial, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('d', size / 2, size / 2);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function downloadIcon(canvas, size) {
|
||||
canvas.toBlob((blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `icon${size}.png`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
}
|
||||
|
||||
// Generate previews
|
||||
const previewDiv = document.getElementById('preview');
|
||||
|
||||
sizes.forEach(size => {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'icon-preview';
|
||||
|
||||
const canvas = createIcon(size);
|
||||
container.appendChild(canvas);
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.innerHTML = `
|
||||
<strong>${size}x${size}</strong><br>
|
||||
<button onclick="downloadIcon(this.parentElement.parentElement.querySelector('canvas'), ${size})">
|
||||
Download icon${size}.png
|
||||
</button>
|
||||
`;
|
||||
container.appendChild(info);
|
||||
|
||||
previewDiv.appendChild(container);
|
||||
});
|
||||
|
||||
// Helper function to download
|
||||
window.downloadIcon = downloadIcon;
|
||||
|
||||
console.log('✅ DIDI Icon Generator loaded!');
|
||||
console.log('📦 Generate all 4 icon sizes:', sizes);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
81
extension/generate_icons.py
Normal file
81
extension/generate_icons.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate DIDI extension icons with white 'd' on black background
|
||||
"""
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import os
|
||||
|
||||
# Icon sizes needed for Chrome extension
|
||||
SIZES = [16, 32, 48, 128]
|
||||
|
||||
def create_icon(size):
|
||||
"""Create a square icon with 'd' letter"""
|
||||
# Create black background
|
||||
img = Image.new('RGB', (size, size), color='#000000')
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Calculate font size (roughly 60% of icon size)
|
||||
font_size = int(size * 0.65)
|
||||
|
||||
# Try to use a nice font, fallback to default
|
||||
try:
|
||||
# Try common system fonts
|
||||
font_paths = [
|
||||
'C:/Windows/Fonts/arial.ttf',
|
||||
'C:/Windows/Fonts/segoeui.ttf',
|
||||
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
|
||||
'/System/Library/Fonts/Helvetica.ttc'
|
||||
]
|
||||
|
||||
font = None
|
||||
for font_path in font_paths:
|
||||
if os.path.exists(font_path):
|
||||
font = ImageFont.truetype(font_path, font_size)
|
||||
break
|
||||
|
||||
if font is None:
|
||||
# Fallback to default font
|
||||
font = ImageFont.load_default()
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Draw white 'd' in the center
|
||||
text = "d"
|
||||
|
||||
# Get text bounding box for centering
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
# Calculate position to center text
|
||||
x = (size - text_width) // 2 - bbox[0]
|
||||
y = (size - text_height) // 2 - bbox[1]
|
||||
|
||||
# Draw the text
|
||||
draw.text((x, y), text, fill='#FFFFFF', font=font)
|
||||
|
||||
return img
|
||||
|
||||
def main():
|
||||
"""Generate all icon sizes"""
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
print("Generating DIDI extension icons...")
|
||||
|
||||
for size in SIZES:
|
||||
icon = create_icon(size)
|
||||
filename = f"icon{size}.png"
|
||||
filepath = os.path.join(script_dir, filename)
|
||||
icon.save(filepath, 'PNG')
|
||||
print(f"[OK] Created {filename} ({size}x{size})")
|
||||
|
||||
print("\n[SUCCESS] All icons generated successfully!")
|
||||
print("\nAdd these to manifest.json:")
|
||||
print('"icons": {')
|
||||
for size in SIZES:
|
||||
print(f' "{size}": "icon{size}.png"{"," if size != SIZES[-1] else ""}')
|
||||
print('}')
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
385
extension/history.css
Normal file
385
extension/history.css
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
/* DIDI History - Purple Theme */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0d1424;
|
||||
color: #FFFFFF;
|
||||
padding: 40px 20px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
background: linear-gradient(135deg, #009198 0%, #1fb6bd 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #9CA3AF;
|
||||
}
|
||||
|
||||
.auth-info {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.auth-connected {
|
||||
color: #1fb6bd;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.auth-disconnected {
|
||||
color: #EF4444;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.auth-disconnected a {
|
||||
color: #1fb6bd;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.auth-disconnected a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.history-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
background: #231832;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(0,145,152, 0.1);
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.history-item:hover {
|
||||
border-color: #009198;
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0,145,152, 0.2);
|
||||
}
|
||||
|
||||
/* Risk Badge */
|
||||
.risk-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.risk-score {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.risk-category {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Item Preview */
|
||||
.item-preview {
|
||||
color: #D1D5DB;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 16px;
|
||||
padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 6px;
|
||||
border-left: 2px solid rgba(0,145,152, 0.3);
|
||||
}
|
||||
|
||||
/* Stats Row */
|
||||
.item-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: #6B7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Item Actions */
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.view-details-btn {
|
||||
flex: 1;
|
||||
padding: 8px 16px;
|
||||
background: linear-gradient(135deg, #009198 0%, #1fb6bd 100%);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.view-details-btn:hover {
|
||||
opacity: 0.9;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
padding: 8px 12px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 6px;
|
||||
color: #EF4444;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
border-color: #EF4444;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: #6B7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
z-index: 99999;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #231832;
|
||||
border-radius: 16px;
|
||||
max-width: 800px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.6);
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid rgba(0,145,152, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #231832;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
color: #FFFFFF;
|
||||
font-size: 24px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: rgba(0,145,152, 0.3);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* Analysis Result Styles */
|
||||
.verdict-box {
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.verdict-label {
|
||||
font-size: 14px;
|
||||
color: #9CA3AF;
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.verdict-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1fb6bd;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
font-size: 14px;
|
||||
color: #D1D5DB;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.claims-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.claim-item {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-left: 3px solid #6B7280;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.claim-text {
|
||||
color: #D1D5DB;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.claim-sources {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #9CA3AF;
|
||||
}
|
||||
|
||||
.tags-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #6B7280;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin-bottom: 12px;
|
||||
color: #9CA3AF;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
40
extension/history.html
Normal file
40
extension/history.html
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DIDI Analysis History</title>
|
||||
<link rel="stylesheet" href="history.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Analysis History</h1>
|
||||
<p class="subtitle" id="subtitle">Loading...</p>
|
||||
</div>
|
||||
<div id="authInfo" class="auth-info"></div>
|
||||
</div>
|
||||
|
||||
<div class="history-grid" id="historyGrid">
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for detailed view -->
|
||||
<div id="analysisModal" class="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Detalii analiză</h2>
|
||||
<button class="modal-close" id="modalClose">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody">
|
||||
<!-- Content will be injected here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="render.js"></script>
|
||||
<script src="history.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
131
extension/history.js
Normal file
131
extension/history.js
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// DIDI History Page Script (Full V3 API)
|
||||
console.log('[DIDI History] Script loaded');
|
||||
|
||||
let historyData = [];
|
||||
|
||||
function getRiskColorByScore(score) {
|
||||
if (score >= 80) return '#B91C1C';
|
||||
if (score >= 60) return '#EF4444';
|
||||
if (score >= 40) return '#F97316';
|
||||
if (score >= 20) return '#EAB308';
|
||||
return '#22C55E';
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
chrome.runtime.sendMessage({ action: 'getAuthStatus' }, (authResponse) => {
|
||||
const authInfo = document.getElementById('authInfo');
|
||||
if (authResponse?.authenticated) {
|
||||
authInfo.innerHTML = `<span class="auth-connected">Connected</span>`;
|
||||
} else {
|
||||
authInfo.innerHTML = `<span class="auth-disconnected">Not connected</span>`;
|
||||
}
|
||||
});
|
||||
|
||||
chrome.storage.local.get(['didiHistory'], (data) => {
|
||||
historyData = data.didiHistory || [];
|
||||
console.log('[DIDI History] Loaded', historyData.length, 'items');
|
||||
renderHistory();
|
||||
});
|
||||
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
function renderHistory() {
|
||||
const grid = document.getElementById('historyGrid');
|
||||
const subtitle = document.getElementById('subtitle');
|
||||
|
||||
if (!historyData || historyData.length === 0) {
|
||||
subtitle.textContent = 'No analyses yet';
|
||||
grid.innerHTML = `<div class="empty-state"><h3>No Analysis History</h3><p>Take a screenshot or analyze text to see results here.</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
subtitle.textContent = `${historyData.length} analysis result(s)`;
|
||||
|
||||
grid.innerHTML = historyData.map((item, index) => {
|
||||
const analysis = item.analysis || {};
|
||||
// Backend schema is FLAT: components live directly on `analysis`, not nested under `components`.
|
||||
const verdict = analysis.verdict || {};
|
||||
const techData = analysis.techniques || {};
|
||||
const techniques = techData.techniques_detected || [];
|
||||
const claimsData = analysis.claims || {};
|
||||
const aiData = analysis.ai_tampered || {};
|
||||
const riskScore = verdict.risk_score ?? analysis.risk_score ?? 0;
|
||||
const riskCategory = verdict.risk_category || analysis.risk_category || 'Unknown';
|
||||
const riskColor = getRiskColorByScore(riskScore);
|
||||
const aiProb = aiData.ai_probability ?? 0;
|
||||
const analysisType = analysis.analysis_type || analysis.input_type || 'text';
|
||||
const createdAt = item.timestamp || '';
|
||||
|
||||
return `
|
||||
<div class="history-item" data-index="${index}">
|
||||
<div class="risk-badge" style="background: ${riskColor}20; border-color: ${riskColor};">
|
||||
<span class="risk-score" style="color: ${riskColor};">${riskScore}</span>
|
||||
<span class="risk-category" style="color: ${riskColor};">${riskCategory}</span>
|
||||
</div>
|
||||
<div class="item-preview">
|
||||
<span style="color: #1fb6bd; font-size: 11px; text-transform: uppercase; font-weight: 600;">${analysisType}</span>
|
||||
</div>
|
||||
<div class="item-stats">
|
||||
<div class="stat"><span class="stat-label">Techniques</span><span class="stat-value">${techniques.length}</span></div>
|
||||
<div class="stat"><span class="stat-label">Claims</span><span class="stat-value">${claimsData.total_claims || 0}</span></div>
|
||||
<div class="stat"><span class="stat-label">AI Prob.</span><span class="stat-value">${aiProb}%</span></div>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<button class="view-details-btn" data-index="${index}">View Details</button>
|
||||
<button class="delete-btn" data-index="${index}">Delete</button>
|
||||
</div>
|
||||
${createdAt ? `<div class="timestamp">${new Date(createdAt).toLocaleString()}</div>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
const modal = document.getElementById('analysisModal');
|
||||
const modalClose = document.getElementById('modalClose');
|
||||
|
||||
document.getElementById('historyGrid').addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('view-details-btn')) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
openModal(parseInt(e.target.getAttribute('data-index'), 10));
|
||||
return;
|
||||
}
|
||||
if (e.target.classList.contains('delete-btn')) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
deleteItem(parseInt(e.target.getAttribute('data-index'), 10));
|
||||
return;
|
||||
}
|
||||
const card = e.target.closest('.history-item');
|
||||
if (card && !e.target.closest('button')) {
|
||||
openModal(parseInt(card.getAttribute('data-index'), 10));
|
||||
}
|
||||
});
|
||||
|
||||
modalClose.addEventListener('click', () => closeModal());
|
||||
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
|
||||
}
|
||||
|
||||
function deleteItem(index) {
|
||||
if (!confirm('Delete this analysis result?')) return;
|
||||
historyData.splice(index, 1);
|
||||
chrome.storage.local.set({ didiHistory: historyData }, () => renderHistory());
|
||||
}
|
||||
|
||||
function openModal(index) {
|
||||
const item = historyData[index];
|
||||
if (!item) return;
|
||||
const modal = document.getElementById('analysisModal');
|
||||
document.getElementById('modalBody').innerHTML = generateAnalysisHTML(item);
|
||||
modal.classList.add('active');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('analysisModal').classList.remove('active');
|
||||
}
|
||||
|
||||
function generateAnalysisHTML(item) {
|
||||
// Shared renderer mirrors web app PipelineAnalysis layout.
|
||||
return window.DidiRender.renderAnalysisHTML(item.analysis || {});
|
||||
}
|
||||
|
||||
22
extension/icon.svg
Normal file
22
extension/icon.svg
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#009198"/>
|
||||
<stop offset="0.55" stop-color="#1fb6bd"/>
|
||||
<stop offset="1" stop-color="#cfd9e8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Ink background -->
|
||||
<rect width="128" height="128" fill="#0d1424" rx="26"/>
|
||||
<!-- Wordmark 'd' with brand gradient -->
|
||||
<text
|
||||
x="64"
|
||||
y="66"
|
||||
font-family="Inter, Arial, sans-serif"
|
||||
font-size="86"
|
||||
font-weight="800"
|
||||
letter-spacing="-3"
|
||||
fill="url(#g)"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="central">d</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 684 B |
BIN
extension/icon128.png
Normal file
BIN
extension/icon128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
BIN
extension/icon16.png
Normal file
BIN
extension/icon16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 353 B |
BIN
extension/icon32.png
Normal file
BIN
extension/icon32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 698 B |
BIN
extension/icon48.png
Normal file
BIN
extension/icon48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1 KiB |
65
extension/manifest.json
Normal file
65
extension/manifest.json
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "DIDI Analysis Tool",
|
||||
"version": "3.2.6",
|
||||
"minimum_chrome_version": "116",
|
||||
"description": "Analyze screenshots, pages and text for misinformation using DIDI AI",
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0R6hqHWAHV8anPic3ZvtJygm4ZL+qfTnh9wax/1yd32p2iEQY06MvVSradKs1KAdS1RcuCdVQgKr8G3kMa7aFHSKeqtPS25Luk66qZCVcqPs+26LWOoG4xK/MVUOa3eD2DEb7nAepwO6AFZafRBcQPhQLdf5VoCAiF40I17XlsnmOsMwUx0E66Vz0+omNdI6dcMnyKa0ouUJxoC681FUVSj3QJ+0bM7Zipk7yYiX+2DKZBQ31Qir5r2DROzg2b9AXJIx2Y0p+VJ2kl/HwogXRgYI70eVY6GWtCAhW3c8moW8TLZlJmMyiMLHvw36AJAMFEHlZiMzfdzK0zTRP4wRvwIDAQAB",
|
||||
"icons": {
|
||||
"16": "icon16.png",
|
||||
"32": "icon32.png",
|
||||
"48": "icon48.png",
|
||||
"128": "icon128.png"
|
||||
},
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"storage",
|
||||
"identity",
|
||||
"contextMenus",
|
||||
"tabs",
|
||||
"downloads",
|
||||
"scripting"
|
||||
],
|
||||
"host_permissions": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icon16.png",
|
||||
"32": "icon32.png",
|
||||
"48": "icon48.png",
|
||||
"128": "icon128.png"
|
||||
}
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"js": [
|
||||
"render.js",
|
||||
"content.js"
|
||||
],
|
||||
"css": [
|
||||
"content.css"
|
||||
],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": [
|
||||
"history.html",
|
||||
"history.css",
|
||||
"history.js"
|
||||
],
|
||||
"matches": [
|
||||
"<all_urls>"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
633
extension/popup.css
Normal file
633
extension/popup.css
Normal file
|
|
@ -0,0 +1,633 @@
|
|||
/* DIDI Analysis Tool - Purple Theme */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 380px;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0d1424;
|
||||
color: #FFFFFF;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-bottom: 1px solid rgba(0,145,152, 0.2);
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 6px;
|
||||
color: #FFFFFF;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
/* Auth Status */
|
||||
.auth-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: #9CA3AF;
|
||||
}
|
||||
|
||||
.auth-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #6B7280;
|
||||
}
|
||||
|
||||
.auth-dot.connected {
|
||||
background: #1fb6bd;
|
||||
box-shadow: 0 0 6px rgba(31,182,189, 0.6);
|
||||
}
|
||||
|
||||
.auth-dot.disconnected {
|
||||
background: #EF4444;
|
||||
}
|
||||
|
||||
.auth-text {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Login Section */
|
||||
.login-section {
|
||||
padding: 32px 28px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-icon {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.login-desc {
|
||||
font-size: 13px;
|
||||
color: #9CA3AF;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.api-key-input-group {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.api-key-input {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
color: #FFFFFF;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.api-key-input:focus {
|
||||
border-color: #009198;
|
||||
box-shadow: 0 0 0 2px rgba(0,145,152, 0.15);
|
||||
}
|
||||
|
||||
.api-key-input::placeholder {
|
||||
color: #4B5563;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
color: #6B7280;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
color: #1fb6bd;
|
||||
border-color: rgba(0,145,152, 0.3);
|
||||
}
|
||||
|
||||
.btn-connect {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 11px 16px;
|
||||
background: linear-gradient(135deg, #009198 0%, #1fb6bd 100%);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #FFFFFF;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-connect:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0,145,152, 0.4);
|
||||
}
|
||||
|
||||
.btn-connect:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-status {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.login-status.success { color: #1fb6bd; }
|
||||
.login-status.error { color: #EF4444; }
|
||||
|
||||
/* Footer Section */
|
||||
.footer-section {
|
||||
padding: 0 28px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-disconnect {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
background: none;
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: 8px;
|
||||
color: #EF4444;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.btn-disconnect:hover {
|
||||
opacity: 1;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
/* Main Screen */
|
||||
.main-screen {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.hero-section {
|
||||
padding: 32px 28px;
|
||||
text-align: center;
|
||||
background: linear-gradient(180deg, rgba(0,145,152, 0.08) 0%, rgba(31,182,189, 0.04) 100%);
|
||||
}
|
||||
|
||||
.hero-icon {
|
||||
margin: 0 auto 20px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-section h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.hero-description {
|
||||
font-size: 14px;
|
||||
color: #9CA3AF;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Button Group */
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Capture Button */
|
||||
.btn-capture {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: linear-gradient(135deg, #009198 0%, #1fb6bd 100%);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: #FFFFFF;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 4px 12px rgba(0,145,152, 0.3);
|
||||
}
|
||||
|
||||
.btn-capture:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0,145,152, 0.4);
|
||||
}
|
||||
|
||||
.btn-capture:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
rgba(0,145,152, 0.2) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
margin: 0 20px;
|
||||
}
|
||||
|
||||
/* Info Section */
|
||||
.info-section {
|
||||
padding: 24px 28px;
|
||||
}
|
||||
|
||||
.info-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #1fb6bd;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.info-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.info-step:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(0,145,152, 0.2);
|
||||
}
|
||||
|
||||
.step-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
background: linear-gradient(135deg, rgba(0,145,152, 0.2) 0%, rgba(31,182,189, 0.2) 100%);
|
||||
border: 1px solid rgba(0,145,152, 0.3);
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #1fb6bd;
|
||||
}
|
||||
|
||||
.step-text {
|
||||
font-size: 13px;
|
||||
color: #D1D5DB;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Stats Section */
|
||||
.stats-section {
|
||||
padding: 20px 28px 28px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(0,145,152, 0.03);
|
||||
}
|
||||
|
||||
.stats-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stats-content {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
font-size: 13px;
|
||||
color: #E4E7EB;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.stats-content strong {
|
||||
color: #1fb6bd;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Status Messages */
|
||||
.status {
|
||||
margin: 16px 28px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
display: none;
|
||||
animation: slideDown 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.status.success {
|
||||
display: block;
|
||||
background: rgba(0,145,152, 0.15);
|
||||
border: 1px solid rgba(0,145,152, 0.3);
|
||||
color: #1fb6bd;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
display: block;
|
||||
background: rgba(230, 57, 70, 0.15);
|
||||
border: 1px solid rgba(230, 57, 70, 0.3);
|
||||
color: #FF6B7A;
|
||||
}
|
||||
|
||||
.status.info {
|
||||
display: block;
|
||||
background: rgba(0,145,152, 0.15);
|
||||
border: 1px solid rgba(0,145,152, 0.3);
|
||||
color: #C4B5FD;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0,145,152, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0,145,152, 0.5);
|
||||
}
|
||||
|
||||
/* Text Analysis Section */
|
||||
.text-analysis-section {
|
||||
padding: 24px 28px;
|
||||
background: rgba(0,145,152, 0.03);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1fb6bd;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-title svg path {
|
||||
stroke: #1fb6bd;
|
||||
}
|
||||
|
||||
#textInput {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
color: #FFFFFF;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
#textInput::placeholder {
|
||||
color: #6B7280;
|
||||
}
|
||||
|
||||
#textInput:focus {
|
||||
outline: none;
|
||||
border-color: #009198;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 0 0 2px rgba(0,145,152, 0.15);
|
||||
}
|
||||
|
||||
.text-info {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 11px;
|
||||
color: #6B7280;
|
||||
}
|
||||
|
||||
#charCount {
|
||||
color: #1fb6bd;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-analyze {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
background: linear-gradient(135deg, #009198 0%, #1fb6bd 100%);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #FFFFFF;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-analyze:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0,145,152, 0.3);
|
||||
}
|
||||
|
||||
.btn-analyze:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-analyze:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-analyze.loading {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn-analyze.loading::after {
|
||||
content: '';
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #FFFFFF;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* History Section */
|
||||
.history-section {
|
||||
padding: 0 28px 28px;
|
||||
}
|
||||
|
||||
/* Secondary Button */
|
||||
.btn-secondary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
color: #D1D5DB;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(0,145,152, 0.3);
|
||||
color: #1fb6bd;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* === URL analysis section === */
|
||||
.url-input-group {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.url-input {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
color: #FFFFFF;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.url-input:focus {
|
||||
border-color: #009198;
|
||||
box-shadow: 0 0 0 2px rgba(0,145,152, 0.15);
|
||||
}
|
||||
|
||||
.url-input::placeholder {
|
||||
color: #4B5563;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* === Privacy note (informare confidențialitate) === */
|
||||
.privacy-note {
|
||||
margin: 0;
|
||||
padding: 0 2px;
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
color: #6B7280;
|
||||
}
|
||||
|
||||
/* === Accessibility: visible focus on all interactive controls === */
|
||||
button:focus-visible,
|
||||
textarea:focus-visible,
|
||||
input:focus-visible {
|
||||
outline: 2px solid #1fb6bd;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
162
extension/popup.html
Normal file
162
extension/popup.html
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DIDI Analysis Tool</title>
|
||||
<link rel="stylesheet" href="popup.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="logo">
|
||||
<h1>didi</h1>
|
||||
</div>
|
||||
<div id="authStatus" class="auth-status">
|
||||
<span class="auth-dot"></span>
|
||||
<span class="auth-text">Checking...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Section (shown when not authenticated) -->
|
||||
<div id="loginSection" class="login-section" style="display: none;">
|
||||
<div class="login-icon">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" fill="#1fb6bd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="login-title">Conectare la DiDi</h3>
|
||||
<p class="login-desc">Autentifică-te cu contul tău DiDi pentru a analiza conținut</p>
|
||||
<button id="loginBtn" class="btn-connect" aria-label="Autentificare cu contul DiDi">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M11 7L9.6 8.4l2.6 2.6H2v2h10.2l-2.6 2.6L11 17l5-5-5-5zm9 12h-8v2h8c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2h-8v2h8v14z" fill="currentColor"/>
|
||||
</svg>
|
||||
Autentificare
|
||||
</button>
|
||||
<div id="loginStatus" class="login-status" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
<!-- Main Screen (shown when API key is set) -->
|
||||
<div id="mainSection" class="main-screen" style="display: none;">
|
||||
<div class="hero-section">
|
||||
<div class="hero-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="8" y="8" width="32" height="32" rx="4" stroke="url(#gradient)" stroke-width="2.5" stroke-dasharray="4 4"/>
|
||||
<circle cx="24" cy="24" r="6" fill="url(#gradient)"/>
|
||||
<defs>
|
||||
<linearGradient id="gradient" x1="8" y1="8" x2="40" y2="40">
|
||||
<stop offset="0%" stop-color="#009198"/>
|
||||
<stop offset="100%" stop-color="#1fb6bd"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Analizează conținut</h2>
|
||||
<p class="hero-description">Captură de ecran, pagină, URL sau text — verificate anti-dezinformare</p>
|
||||
|
||||
<div class="button-group">
|
||||
<button id="activateSnipping" class="btn-capture" aria-label="Pornește captura de ecran cu selecție de regiune">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M3.33 5.83V3.33C3.33 2.87 3.71 2.5 4.17 2.5H6.67M13.33 2.5H15.83C16.29 2.5 16.67 2.87 16.67 3.33V5.83M16.67 14.17V16.67C16.67 17.13 16.29 17.5 15.83 17.5H13.33M6.67 17.5H4.17C3.71 17.5 3.33 17.13 3.33 16.67V14.17" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<circle cx="10" cy="10" r="3" stroke="currentColor" stroke-width="1.5"/>
|
||||
</svg>
|
||||
Screenshot
|
||||
</button>
|
||||
<button id="selectSocialPost" class="btn-capture" style="display: none;"
|
||||
aria-label="Selectează o postare din pagină pentru analiză">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<rect x="3" y="4" width="14" height="12" rx="2" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M6 8h8M6 11h5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Selectează postare
|
||||
</button>
|
||||
<button id="activateVideoSnipping" class="btn-capture" aria-label="Pornește înregistrarea video a unei regiuni">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<rect x="2.5" y="5" width="11" height="10" rx="1.5" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M13.5 8.5L17 6V14L13.5 11.5V8.5Z" fill="currentColor"/>
|
||||
<circle cx="6" cy="10" r="1.5" fill="#EF4444"/>
|
||||
</svg>
|
||||
Record Video
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- URL Analysis Section -->
|
||||
<div class="text-analysis-section">
|
||||
<div class="section-title" id="urlSectionTitle">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M6.5 9.5a3 3 0 004.24 0l2.83-2.83a3 3 0 00-4.24-4.24L8 3.76M9.5 6.5a3 3 0 00-4.24 0L2.43 9.33a3 3 0 004.24 4.24L8 12.24" stroke="#1fb6bd" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Analizează URL
|
||||
</div>
|
||||
<div class="url-input-group">
|
||||
<input type="url" id="urlInput" class="url-input" placeholder="https://exemplu.ro/articol"
|
||||
aria-labelledby="urlSectionTitle" autocomplete="off">
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<button id="analyzeCurrentTab" class="btn-secondary" aria-label="Analizează adresa paginii curente">
|
||||
Pagina curentă
|
||||
</button>
|
||||
<button id="analyzeUrlBtn" class="btn-analyze" disabled aria-label="Analizează URL-ul introdus">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M8 2L10 6L14 7L10 8L8 14L6 8L2 7L6 6L8 2Z" fill="currentColor"/>
|
||||
</svg>
|
||||
Analizează URL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Text Analysis Section -->
|
||||
<div class="text-analysis-section">
|
||||
<div class="section-title" id="textSectionTitle">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M2 4h12M2 8h12M2 12h8" stroke="#1fb6bd" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Analizează text
|
||||
</div>
|
||||
<textarea id="textInput" placeholder="Lipește sau selectează în pagină textul de analizat..."
|
||||
maxlength="5000" aria-labelledby="textSectionTitle"></textarea>
|
||||
<div class="text-info">
|
||||
<span id="charCount">0</span> / 5000 caractere
|
||||
</div>
|
||||
<button id="analyzeText" class="btn-analyze" disabled aria-label="Analizează textul introdus">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M8 2L10 6L14 7L10 8L8 14L6 8L2 7L6 6L8 2Z" fill="currentColor"/>
|
||||
</svg>
|
||||
Analizează text
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Privacy note -->
|
||||
<p class="privacy-note">
|
||||
Conținutul trimis (text, URL, capturi) este folosit exclusiv pentru analiza anti-dezinformare.
|
||||
Capturile de ecran necesită consimțământul tău explicit la prima utilizare.
|
||||
</p>
|
||||
|
||||
<!-- History + Disconnect -->
|
||||
<div class="footer-section">
|
||||
<button id="viewHistory" class="btn-secondary" aria-label="Deschide istoricul analizelor">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M8 2V8L11 11M14 8C14 11.31 11.31 14 8 14C4.69 14 2 11.31 2 8C2 4.69 4.69 2 8 2C11.31 2 14 4.69 14 8Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Istoric
|
||||
</button>
|
||||
<button id="disconnectBtn" class="btn-disconnect" aria-label="Deconectare din contul DiDi">Deconectare</button>
|
||||
</div>
|
||||
|
||||
<!-- Status Messages -->
|
||||
<div id="status" class="status" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="render.js"></script>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
327
extension/popup.js
Normal file
327
extension/popup.js
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
// DIDI Analysis Tool - Popup Script (OIDC auth + URL/text analysis)
|
||||
|
||||
console.log('[POPUP] Script loaded');
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
console.log('[POPUP] DOM loaded');
|
||||
|
||||
const loginSection = document.getElementById('loginSection');
|
||||
const mainSection = document.getElementById('mainSection');
|
||||
const authStatusEl = document.getElementById('authStatus');
|
||||
const loginBtn = document.getElementById('loginBtn');
|
||||
const loginStatus = document.getElementById('loginStatus');
|
||||
const activateSnippingBtn = document.getElementById('activateSnipping');
|
||||
const activateVideoSnippingBtn = document.getElementById('activateVideoSnipping');
|
||||
const urlInput = document.getElementById('urlInput');
|
||||
const analyzeUrlBtn = document.getElementById('analyzeUrlBtn');
|
||||
const analyzeCurrentTabBtn = document.getElementById('analyzeCurrentTab');
|
||||
const textInput = document.getElementById('textInput');
|
||||
const analyzeTextBtn = document.getElementById('analyzeText');
|
||||
const charCount = document.getElementById('charCount');
|
||||
const statusDiv = document.getElementById('status');
|
||||
const viewHistoryBtn = document.getElementById('viewHistory');
|
||||
const disconnectBtn = document.getElementById('disconnectBtn');
|
||||
|
||||
// === AUTH UI ===
|
||||
|
||||
function showLogin() {
|
||||
loginSection.style.display = 'block';
|
||||
mainSection.style.display = 'none';
|
||||
const dot = authStatusEl.querySelector('.auth-dot');
|
||||
const text = authStatusEl.querySelector('.auth-text');
|
||||
dot.className = 'auth-dot disconnected';
|
||||
text.textContent = 'Neconectat';
|
||||
}
|
||||
|
||||
function showMain(email) {
|
||||
loginSection.style.display = 'none';
|
||||
mainSection.style.display = 'block';
|
||||
const dot = authStatusEl.querySelector('.auth-dot');
|
||||
const text = authStatusEl.querySelector('.auth-text');
|
||||
dot.className = 'auth-dot connected';
|
||||
text.textContent = email || 'Conectat';
|
||||
}
|
||||
|
||||
// Check auth on load
|
||||
chrome.runtime.sendMessage({ action: 'getAuthStatus' }, (response) => {
|
||||
if (response?.authenticated) {
|
||||
showMain(response.email);
|
||||
prefillFromActiveTab();
|
||||
} else {
|
||||
showLogin();
|
||||
}
|
||||
});
|
||||
|
||||
// === LOGIN (OIDC/PKCE prin Keycloak) ===
|
||||
|
||||
loginBtn.addEventListener('click', () => {
|
||||
loginBtn.disabled = true;
|
||||
loginBtn.textContent = 'Se deschide autentificarea…';
|
||||
loginStatus.textContent = '';
|
||||
|
||||
chrome.runtime.sendMessage({ action: 'login' }, (response) => {
|
||||
if (response?.success) {
|
||||
loginStatus.className = 'login-status success';
|
||||
loginStatus.textContent = 'Autentificat!';
|
||||
setTimeout(() => {
|
||||
showMain(response.email);
|
||||
prefillFromActiveTab();
|
||||
}, 400);
|
||||
} else {
|
||||
loginStatus.className = 'login-status error';
|
||||
loginStatus.textContent = 'Autentificare eșuată: ' + (response?.error || 'eroare necunoscută');
|
||||
}
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.innerHTML = `
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M11 7L9.6 8.4l2.6 2.6H2v2h10.2l-2.6 2.6L11 17l5-5-5-5zm9 12h-8v2h8c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2h-8v2h8v14z" fill="currentColor"/>
|
||||
</svg>
|
||||
Autentificare`;
|
||||
});
|
||||
});
|
||||
|
||||
// === DISCONNECT ===
|
||||
|
||||
disconnectBtn.addEventListener('click', () => {
|
||||
chrome.runtime.sendMessage({ action: 'logout' }, () => {
|
||||
showLogin();
|
||||
});
|
||||
});
|
||||
|
||||
// === PREFILL: URL-ul tabului activ + textul selectat în pagină ===
|
||||
|
||||
async function prefillFromActiveTab() {
|
||||
try {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tab) return;
|
||||
|
||||
if (tab.url && /^https?:/i.test(tab.url)) {
|
||||
urlInput.value = tab.url;
|
||||
analyzeUrlBtn.disabled = false;
|
||||
}
|
||||
|
||||
// Selected text from the page → prefill the textarea (cerință caiet 2a)
|
||||
try {
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => window.getSelection().toString(),
|
||||
});
|
||||
const selected = (results?.[0]?.result || '').trim();
|
||||
if (selected && !textInput.value) {
|
||||
textInput.value = selected.substring(0, 5000);
|
||||
textInput.dispatchEvent(new Event('input'));
|
||||
showStatus('Textul selectat în pagină a fost preluat.', 'info');
|
||||
}
|
||||
} catch { /* restricted page (chrome://, PDF) — nothing to prefill */ }
|
||||
} catch (e) {
|
||||
console.warn('[POPUP] prefill failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// === SCREENSHOT ===
|
||||
|
||||
activateSnippingBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
showStatus('Se activează modul captură…', 'info');
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tab) { showStatus('Nu există tab activ', 'error'); return; }
|
||||
|
||||
try {
|
||||
// Inject render.js BEFORE content.js so window.DidiRender exists when
|
||||
// content.js's 'showAnalysis' handler tries to render the modal. Required
|
||||
// for tabs opened before the extension was last reloaded — manifest's
|
||||
// content_scripts only inject on fresh page loads.
|
||||
await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['render.js', 'content.js'] });
|
||||
} catch (e) { /* already injected */ }
|
||||
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
chrome.tabs.sendMessage(tab.id, { action: 'activateSnipping' }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
showStatus('Activare eșuată. Reîncarcă pagina.', 'error');
|
||||
return;
|
||||
}
|
||||
if (response?.success) {
|
||||
showStatus('Mod captură activ!', 'success');
|
||||
setTimeout(() => window.close(), 800);
|
||||
} else {
|
||||
showStatus('Captura necesită consimțământul pentru trimiterea imaginii.', 'error');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
showStatus('Eroare: ' + error.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// === VIDEO REGION RECORDING ===
|
||||
|
||||
activateVideoSnippingBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
showStatus('Se activează modul video…', 'info');
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tab) { showStatus('Nu există tab activ', 'error'); return; }
|
||||
|
||||
try {
|
||||
// Same injection ordering rationale as the screenshot flow above.
|
||||
await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['render.js', 'content.js'] });
|
||||
} catch (e) { /* already injected */ }
|
||||
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
chrome.tabs.sendMessage(tab.id, { action: 'activateVideoSnipping' }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
showStatus('Activare eșuată. Reîncarcă pagina.', 'error');
|
||||
return;
|
||||
}
|
||||
if (response?.success) {
|
||||
showStatus('Mod video activ!', 'success');
|
||||
setTimeout(() => window.close(), 800);
|
||||
} else {
|
||||
showStatus('Înregistrarea necesită consimțământul pentru trimiterea capturii.', 'error');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
showStatus('Eroare: ' + error.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// === SOCIAL POST SELECTION (extractoare Facebook / Twitter-X) ===
|
||||
|
||||
const selectSocialPostBtn = document.getElementById('selectSocialPost');
|
||||
|
||||
function socialPlatformFor(url) {
|
||||
if (/https?:\/\/([a-z0-9-]+\.)?facebook\.com\//i.test(url)) return 'facebook';
|
||||
if (/https?:\/\/([a-z0-9-]+\.)?(twitter|x)\.com\//i.test(url)) return 'twitter';
|
||||
return null;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (tab?.url && socialPlatformFor(tab.url)) {
|
||||
selectSocialPostBtn.style.display = '';
|
||||
}
|
||||
})();
|
||||
|
||||
selectSocialPostBtn.addEventListener('click', async () => {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
const platform = tab?.url ? socialPlatformFor(tab.url) : null;
|
||||
if (!platform) { showStatus('Deschide o pagină Facebook sau X/Twitter.', 'error'); return; }
|
||||
|
||||
try {
|
||||
await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['render.js', 'content.js'] });
|
||||
} catch (e) { /* already injected */ }
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
chrome.tabs.sendMessage(tab.id, { action: 'activateSelection', platform }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
showStatus('Activare eșuată. Reîncarcă pagina.', 'error');
|
||||
return;
|
||||
}
|
||||
if (response?.success) {
|
||||
showStatus('Mod selecție activ — dă click pe o postare.', 'success');
|
||||
setTimeout(() => window.close(), 800);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// === URL ANALYSIS (cerință caiet 1a/1b) ===
|
||||
|
||||
function isValidHttpUrl(value) {
|
||||
try {
|
||||
const u = new URL(/^www\./i.test(value) ? `https://${value}` : value);
|
||||
return u.protocol === 'http:' || u.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
urlInput.addEventListener('input', () => {
|
||||
analyzeUrlBtn.disabled = !isValidHttpUrl(urlInput.value.trim());
|
||||
});
|
||||
|
||||
analyzeCurrentTabBtn.addEventListener('click', async () => {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tab?.url || !/^https?:/i.test(tab.url)) {
|
||||
showStatus('Pagina curentă nu are un URL analizabil.', 'error');
|
||||
return;
|
||||
}
|
||||
urlInput.value = tab.url;
|
||||
analyzeUrlBtn.disabled = false;
|
||||
submitUrl(tab.url);
|
||||
});
|
||||
|
||||
analyzeUrlBtn.addEventListener('click', () => {
|
||||
const url = urlInput.value.trim();
|
||||
if (!isValidHttpUrl(url)) {
|
||||
showStatus('Introdu un URL valid (http/https).', 'error');
|
||||
return;
|
||||
}
|
||||
submitUrl(url);
|
||||
});
|
||||
|
||||
function submitUrl(url) {
|
||||
analyzeUrlBtn.disabled = true;
|
||||
showStatus('Trimis către DiDi… rezultatul se deschide într-un tab nou în 30–90s.', 'info');
|
||||
chrome.runtime.sendMessage({ action: 'analyzeUrl', url }, (response) => {
|
||||
analyzeUrlBtn.disabled = false;
|
||||
if (response?.success) {
|
||||
showStatus('Trimis! Rezultatul se va deschide când e gata.', 'success');
|
||||
setTimeout(() => window.close(), 1500);
|
||||
} else {
|
||||
showStatus('Eroare: ' + (response?.error || 'necunoscută'), 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// === TEXT ANALYSIS ===
|
||||
|
||||
textInput.addEventListener('input', () => {
|
||||
const len = textInput.value.trim().length;
|
||||
charCount.textContent = len;
|
||||
analyzeTextBtn.disabled = len === 0 || len > 5000;
|
||||
});
|
||||
|
||||
analyzeTextBtn.addEventListener('click', () => {
|
||||
const text = textInput.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
analyzeTextBtn.disabled = true;
|
||||
analyzeTextBtn.textContent = 'Se trimite…';
|
||||
showStatus('Trimis către DiDi… rezultatul se deschide într-un tab nou în 30–90s.', 'info');
|
||||
|
||||
// Background acks immediately, then runs the polling in the service worker
|
||||
// and opens a new tab with the rendered standalone result page when done.
|
||||
chrome.runtime.sendMessage({ action: 'analyzeText', text }, (response) => {
|
||||
analyzeTextBtn.innerHTML = `
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M8 2L10 6L14 7L10 8L8 14L6 8L2 7L6 6L8 2Z" fill="currentColor"/>
|
||||
</svg>
|
||||
Analizează text`;
|
||||
analyzeTextBtn.disabled = false;
|
||||
|
||||
if (response?.success) {
|
||||
showStatus('Trimis! Rezultatul se va deschide când e gata.', 'success');
|
||||
textInput.value = '';
|
||||
charCount.textContent = '0';
|
||||
setTimeout(() => window.close(), 1500);
|
||||
} else {
|
||||
showStatus('Eroare: ' + (response?.error || 'necunoscută'), 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// === HISTORY ===
|
||||
|
||||
viewHistoryBtn.addEventListener('click', () => {
|
||||
chrome.tabs.create({ url: chrome.runtime.getURL('history.html') });
|
||||
});
|
||||
|
||||
// === STATUS ===
|
||||
|
||||
function showStatus(message, type) {
|
||||
statusDiv.textContent = message;
|
||||
statusDiv.className = `status ${type}`;
|
||||
statusDiv.style.display = 'block';
|
||||
setTimeout(() => { statusDiv.style.display = 'none'; }, 3000);
|
||||
}
|
||||
});
|
||||
490
extension/render.js
Normal file
490
extension/render.js
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
/**
|
||||
* DIDI Extension — Shared analysis renderer (vanilla JS, ~80% web-app parity).
|
||||
*
|
||||
* Drop-in for popup tab, content modal, history page. Backend-FLAT shape:
|
||||
* `analysis.{verdict, techniques, ai_tampered, claims, source_assessment, ...}`.
|
||||
*
|
||||
* Mirrors web app PipelineAnalysis: same RISK_COLORS map (by `verdict.risk_category_color`,
|
||||
* NOT by score), same HeadlineCard + ActionCallout + per-component cards layout.
|
||||
*
|
||||
* Exposes globally as `globalThis.DidiRender = { renderAnalysisHTML, ... }`,
|
||||
* accessible as `window.DidiRender` (DOM contexts) or `self.DidiRender` (service worker).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// === Risk color map — identical to web app utils.ts ===
|
||||
const RISK_COLORS = {
|
||||
green: '#22c55e',
|
||||
lightgreen: '#84cc16',
|
||||
yellow: '#eab308',
|
||||
orange: '#f97316',
|
||||
red: '#ef4444',
|
||||
darkred: '#dc2626',
|
||||
};
|
||||
const FALLBACK_ACCENT = '#94a3b8';
|
||||
|
||||
// Severity / probability colors for inner cards
|
||||
const sevColor = (s) => (s >= 70 ? '#ef4444' : s >= 40 ? '#f97316' : '#eab308');
|
||||
const probColor = (p) => (p >= 80 ? '#ef4444' : p >= 60 ? '#f97316' : p >= 40 ? '#eab308' : '#22c55e');
|
||||
const stanceColor = (s) => (s === 'SUPPORTS' ? '#22c55e' : s === 'CONTRADICTS' ? '#ef4444' : '#94a3b8');
|
||||
const claimStatusColor = (st) => ({ VT: '#22c55e', LT: '#84cc16', VF: '#ef4444', LF: '#f97316', UV: '#eab308', OP: '#94a3b8', NV: '#6b7280' }[st] || '#94a3b8');
|
||||
|
||||
// Category emoji icon (extension keeps emoji vs web's lucide for 80% parity)
|
||||
const categoryIcon = (color) => {
|
||||
if (color === 'red' || color === 'darkred') return '⛔';
|
||||
if (color === 'orange') return '⚠️';
|
||||
if (color === 'yellow') return 'ℹ️';
|
||||
if (color === 'green' || color === 'lightgreen') return '🛡️';
|
||||
return 'ℹ️';
|
||||
};
|
||||
|
||||
const escapeHtml = (s) => String(s ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
|
||||
const safeHostname = (url) => {
|
||||
try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return ''; }
|
||||
};
|
||||
|
||||
const truncate = (s, n) => (s && s.length > n ? s.substring(0, n) + '…' : s || '');
|
||||
|
||||
// Convert SCREAMING_SNAKE_CASE / snake_case to "Sentence case" for display.
|
||||
// Leaves already-readable strings (with spaces or mixed case) untouched.
|
||||
const humanize = (s) => {
|
||||
if (!s || typeof s !== 'string') return s;
|
||||
if (!/[_]/.test(s) && /[a-z]/.test(s)) return s; // already mixed-case prose
|
||||
const lower = s.replace(/_/g, ' ').toLowerCase().trim();
|
||||
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
||||
};
|
||||
|
||||
// === i18n — oglindă a web app (utils/i18n-enums.ts + utils/i18n-fields.ts) ===
|
||||
// Extensia e în română; enum-urile backend fără câmpuri _ro se traduc prin ENUM_RO.
|
||||
const ENUM_RO = {
|
||||
DISINFORMATION: 'Dezinformare', UNRELIABLE: 'Nedemn de încredere', QUESTIONABLE: 'Îndoielnic',
|
||||
UNCERTAIN: 'Incert', MIXED: 'Mixt', MOSTLY_TRUSTWORTHY: 'Preponderent credibil',
|
||||
MOSTLY_RELIABLE: 'Preponderent credibil', TRUSTWORTHY: 'Demn de încredere', RELIABLE: 'De încredere',
|
||||
INCONCLUSIVE: 'Neconcludent',
|
||||
CRITICAL: 'Critic', VERY_HIGH: 'Foarte ridicat', HIGH: 'Ridicat', MODERATE: 'Moderat',
|
||||
MEDIUM: 'Mediu', LOW: 'Scăzut', VERY_LOW: 'Foarte scăzut',
|
||||
LIKELY_AI: 'Probabil AI', POSSIBLY_AI: 'Posibil AI', UNLIKELY_AI: 'Improbabil AI',
|
||||
POSSIBLY_HUMAN: 'Posibil uman', LIKELY_HUMAN: 'Probabil uman', HUMAN: 'Uman',
|
||||
TRUSTED: 'De încredere', NEUTRAL: 'Neutru', SUSPICIOUS: 'Suspect', UNTRUSTED: 'Nedemn de încredere',
|
||||
SUPPORTS: 'Susține', CONTRADICTS: 'Contrazice',
|
||||
official: 'Oficial', news: 'Știri', blog: 'Blog', unknown: 'Necunoscut',
|
||||
NONE: 'Niciuna', CAUTION: 'Precauție', WARNING: 'Avertizare', URGENT: 'Urgent',
|
||||
ESCALATE: 'Escaladează', VIRAL: 'Viral',
|
||||
};
|
||||
const enumLabel = (v) => (v && ENUM_RO[v]) || humanize(v || '');
|
||||
|
||||
// Câmp localizat cu convenția backend _ro/_en (ex. technique_name_ro).
|
||||
const loc = (obj, base, fallback) => {
|
||||
if (!obj) return '';
|
||||
return obj[`${base}_ro`] || (fallback ? obj[`${fallback}_ro`] : '') ||
|
||||
obj[`${base}_en`] || (fallback ? obj[`${fallback}_en`] : '') ||
|
||||
obj[base] || (fallback ? obj[fallback] : '') || '';
|
||||
};
|
||||
|
||||
// Nume de tehnică lizibil: preferă câmpurile localizate; dacă rămâne cheia brută
|
||||
// din taxonomie (ex. "content.type_misleading"), taie prefixul de dimensiune și umanizează.
|
||||
const techniqueName = (tk) => {
|
||||
const named = loc(tk, 'technique_name', 'name');
|
||||
if (named && !/^[a-z_]+\.[a-z_.]+$/.test(named)) return named;
|
||||
const raw = named || tk.name || '';
|
||||
const lastSegment = raw.split('.').pop() || raw;
|
||||
return humanize(lastSegment);
|
||||
};
|
||||
|
||||
const INPUT_TYPE_RO = { text: 'TEXT', url: 'URL', image: 'IMAGINE', audio: 'AUDIO', video: 'VIDEO' };
|
||||
|
||||
// === Gauge — circular SVG, identical structure to web Gauge component ===
|
||||
function gaugeSVG(scorePercent, accent) {
|
||||
const R = 80;
|
||||
const C = 2 * Math.PI * R; // circumference
|
||||
const offset = C * (1 - Math.max(0, Math.min(100, scorePercent)) / 100);
|
||||
return `
|
||||
<div style="width:168px;height:168px;position:relative;color:${accent};flex-shrink:0;">
|
||||
<svg viewBox="0 0 168 168" style="width:100%;height:100%;transform:rotate(-90deg);">
|
||||
<circle cx="84" cy="84" r="${R}" fill="none" stroke="currentColor" stroke-width="6" opacity="0.12"/>
|
||||
<circle cx="84" cy="84" r="${R}" fill="none" stroke="currentColor" stroke-width="6"
|
||||
stroke-linecap="round" stroke-dasharray="${C.toFixed(2)}" stroke-dashoffset="${offset.toFixed(2)}"
|
||||
style="transition:stroke-dashoffset 0.9s cubic-bezier(0.2,0.8,0.2,1);"/>
|
||||
</svg>
|
||||
<div style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;">
|
||||
<div style="font-size:42px;font-weight:700;color:#fff;font-family:Inter,system-ui,sans-serif;line-height:1;">${Math.round(scorePercent)}</div>
|
||||
<div style="font-size:11px;color:rgba(255,255,255,0.55);text-transform:uppercase;letter-spacing:1.5px;margin-top:6px;font-weight:600;">/ 100</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// === HeadlineCard — gradient bg + gauge + category info ===
|
||||
function headlineCard(verdict, accent) {
|
||||
const cat = enumLabel(verdict.risk_category || 'UNKNOWN');
|
||||
const lvl = enumLabel(verdict.risk_level || '');
|
||||
const conf = verdict.confidence != null ? `certitudine ${Math.round(verdict.confidence)}%` : '';
|
||||
const score = verdict.risk_score ?? 0;
|
||||
const descr = [lvl, conf].filter(Boolean).join(' · ');
|
||||
// Explicația se afișează integral (ca în web) — trunchierea tăia exact nuanțele
|
||||
// de tip „afirmațiile sunt adevărate, dar prezentarea e falsificată”.
|
||||
const tldrShort = (verdict.explanation_ro || verdict.explanation_en || '').trim();
|
||||
|
||||
return `
|
||||
<div style="display:flex;align-items:stretch;gap:28px;padding:32px 36px;
|
||||
background:linear-gradient(180deg, ${accent}1a, rgba(255,255,255,0.025));
|
||||
border:1px solid ${accent}33;border-radius:20px;position:relative;overflow:hidden;
|
||||
font-family:Inter,system-ui,sans-serif;">
|
||||
<div style="display:flex;align-items:center;justify-content:center;">
|
||||
${gaugeSVG(score, accent)}
|
||||
</div>
|
||||
<div style="flex:1;display:flex;flex-direction:column;justify-content:center;gap:10px;min-width:0;">
|
||||
<div style="font-size:11px;color:rgba(255,255,255,0.55);text-transform:uppercase;letter-spacing:1.6px;font-weight:600;">RISC ANALIZAT</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:10px;background:${accent}26;color:${accent};font-size:18px;flex-shrink:0;">${categoryIcon(verdict.risk_category_color)}</span>
|
||||
<span style="font-size:24px;font-weight:700;color:#fff;line-height:1.2;">${escapeHtml(cat)}</span>
|
||||
${descr ? `<span style="font-size:13px;color:rgba(255,255,255,0.6);">${escapeHtml(descr)}</span>` : ''}
|
||||
</div>
|
||||
${tldrShort ? `<div style="font-family:Merriweather,Georgia,serif;font-style:italic;color:rgba(255,255,255,0.78);font-size:14px;line-height:1.55;margin-top:4px;">${escapeHtml(tldrShort)}</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// === ActionCallout — recommended_action with severity-tinted accent ===
|
||||
function actionCallout(verdict, accent) {
|
||||
const action = (verdict.recommended_action || '').trim();
|
||||
if (!action) return '';
|
||||
const sev = verdict.severity || '';
|
||||
const eyebrow = sev === 'CRITICAL' || sev === 'HIGH' ? 'NU DISTRIBUI'
|
||||
: sev === 'MEDIUM' ? 'CITEȘTE CRITIC' : 'NOTĂ';
|
||||
return `
|
||||
<div style="display:flex;gap:20px;align-items:flex-start;padding:22px 26px;
|
||||
background:linear-gradient(135deg, ${accent}40, ${accent}1a);
|
||||
border:1px solid ${accent}66;border-radius:16px;
|
||||
font-family:Inter,system-ui,sans-serif;">
|
||||
<span style="flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;
|
||||
width:44px;height:44px;border-radius:14px;background:${accent}33;color:${accent};font-size:22px;">⚡</span>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-size:11px;color:${accent};text-transform:uppercase;letter-spacing:1.4px;font-weight:700;margin-bottom:6px;">${escapeHtml(eyebrow)}</div>
|
||||
<div style="font-size:15px;color:#fff;font-weight:600;line-height:1.5;">${escapeHtml(enumLabel(action))}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// === Per-component cards ===
|
||||
// Subtle technical footer shown at the bottom of each component card.
|
||||
// Lists model(s) used, total LLM duration. Empty string if no signals.
|
||||
function metaFooter(items) {
|
||||
const parts = items.filter(Boolean);
|
||||
if (!parts.length) return '';
|
||||
return `<div style="margin-top:14px;padding-top:10px;border-top:1px solid rgba(255,255,255,0.05);
|
||||
font-size:10px;color:rgba(255,255,255,0.4);text-transform:uppercase;letter-spacing:0.5px;
|
||||
display:flex;flex-wrap:wrap;gap:14px;">
|
||||
${parts.map((p) => `<span>${escapeHtml(p)}</span>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function techniquesCard(tech) {
|
||||
if (!tech) return '';
|
||||
const list = tech.techniques_detected || [];
|
||||
if (list.length === 0) return '';
|
||||
const score = Math.round((tech.manipulation_score || 0));
|
||||
const items = list.slice(0, 8).map((tk) => {
|
||||
const sc = sevColor(tk.severity || 0);
|
||||
const conf = tk.confidence ?? '';
|
||||
const sub = loc(tk, 'subdimension_name', 'subdimension') || loc(tk, 'dimension_name', 'dimension');
|
||||
const ev = tk.evidence ? truncate(tk.evidence, 140) : '';
|
||||
return `
|
||||
<div style="background:rgba(255,255,255,0.04);border-left:3px solid ${sc};padding:10px 14px;border-radius:8px;margin-bottom:8px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;">
|
||||
<span style="color:#fff;font-weight:600;font-size:13px;">${escapeHtml(techniqueName(tk))}</span>
|
||||
<span style="background:${sc}26;border:1px solid ${sc};color:${sc};padding:2px 9px;border-radius:5px;font-size:11px;font-weight:700;flex-shrink:0;">${tk.severity || 0}%</span>
|
||||
</div>
|
||||
<div style="color:rgba(255,255,255,0.5);font-size:11px;margin-top:4px;">${escapeHtml(humanize(sub))}${conf !== '' ? ` · încredere ${conf}%` : ''}</div>
|
||||
${ev ? `<div style="color:rgba(255,255,255,0.6);font-size:11px;margin-top:6px;font-style:italic;border-top:1px solid rgba(255,255,255,0.06);padding-top:6px;">"${escapeHtml(ev)}"</div>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
const more = list.length > 8 ? `<div style="text-align:center;font-size:11px;color:rgba(255,255,255,0.4);margin-top:6px;">+ încă ${list.length - 8} tehnici</div>` : '';
|
||||
const dims = (tech.dimensions_affected || []).slice(0, 6).map((d) => humanize(String(d).split('.').pop())).join(', ');
|
||||
const footer = metaFooter([
|
||||
tech.llm_screening && `screening: ${tech.llm_screening}`,
|
||||
tech.llm_deep && `deep: ${tech.llm_deep}`,
|
||||
tech.total_duration_ms && `${(tech.total_duration_ms / 1000).toFixed(1)}s`,
|
||||
dims && `dimensiuni: ${dims}`,
|
||||
]);
|
||||
return componentCardWrap('Tehnici de manipulare', `${list.length} detectate`, score, '#f97316', items + more + footer);
|
||||
}
|
||||
|
||||
function aiTamperedCard(ai) {
|
||||
if (!ai || ai.ai_probability == null) return '';
|
||||
const prob = Math.round(ai.ai_probability);
|
||||
const accent = probColor(prob);
|
||||
const verdict = enumLabel(ai.verdict || 'Unknown');
|
||||
const disclosure = ai.disclosure_detected
|
||||
? `<div style="font-size:12px;color:#22c55e;margin-top:8px;">Declarație de utilizare AI detectată${ai.disclosure_text ? ': "' + escapeHtml(truncate(ai.disclosure_text, 80)) + '"' : ''}</div>`
|
||||
: '';
|
||||
const inner = `
|
||||
<div style="display:flex;align-items:center;gap:18px;padding:8px 4px;">
|
||||
<div style="width:64px;height:64px;border-radius:50%;border:3px solid ${accent};display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:${accent};flex-shrink:0;">${prob}%</div>
|
||||
<div style="flex:1;">
|
||||
<div style="color:#fff;font-weight:600;font-size:14px;">${escapeHtml(verdict)}</div>
|
||||
<div style="color:rgba(255,255,255,0.55);font-size:12px;margin-top:2px;">Probabilitate de conținut generat de AI</div>
|
||||
${disclosure}
|
||||
</div>
|
||||
</div>`;
|
||||
const cats = (ai.categories_affected || []).slice(0, 5).join(', ');
|
||||
const footer = metaFooter([
|
||||
ai.content_type && `tip: ${ai.content_type}`,
|
||||
cats && `categorii: ${cats}`,
|
||||
ai.indicators_count != null && `${ai.indicators_count} indicatori`,
|
||||
ai.llm_screening && `screening: ${ai.llm_screening}`,
|
||||
ai.llm_deep && ai.llm_deep !== 'none' && `deep: ${ai.llm_deep}`,
|
||||
ai.total_duration_ms && `${(ai.total_duration_ms / 1000).toFixed(1)}s`,
|
||||
]);
|
||||
return componentCardWrap('Detecție AI', verdict, prob, accent, inner + footer);
|
||||
}
|
||||
|
||||
function claimsCard(c) {
|
||||
if (!c || !c.claims_verified) return '';
|
||||
const list = c.claims_verified || [];
|
||||
if (list.length === 0) return '';
|
||||
const cred = c.credibility_score != null ? Math.round(c.credibility_score) : null;
|
||||
const accent = cred == null ? '#94a3b8' : (cred >= 70 ? '#22c55e' : cred >= 40 ? '#eab308' : '#ef4444');
|
||||
const summary = `
|
||||
<div style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap;">
|
||||
${c.verified_true ? `<span style="background:rgba(34,197,94,0.18);border:1px solid #22c55e;color:#22c55e;padding:4px 10px;border-radius:5px;font-size:11px;font-weight:600;">✓ Adevărate: ${c.verified_true}</span>` : ''}
|
||||
${c.verified_false ? `<span style="background:rgba(239,68,68,0.18);border:1px solid #ef4444;color:#ef4444;padding:4px 10px;border-radius:5px;font-size:11px;font-weight:600;">✗ False: ${c.verified_false}</span>` : ''}
|
||||
${c.unverified ? `<span style="background:rgba(234,179,8,0.18);border:1px solid #eab308;color:#eab308;padding:4px 10px;border-radius:5px;font-size:11px;font-weight:600;">? Neverificate: ${c.unverified}</span>` : ''}
|
||||
${c.opinions ? `<span style="background:rgba(148,163,184,0.18);border:1px solid #94a3b8;color:#94a3b8;padding:4px 10px;border-radius:5px;font-size:11px;font-weight:600;">Opinii: ${c.opinions}</span>` : ''}
|
||||
</div>`;
|
||||
const items = list.slice(0, 6).map((cl) => {
|
||||
const sc = claimStatusColor(cl.status);
|
||||
const sources = (cl.sources || []).slice(0, 3);
|
||||
const srcRow = sources.length ? `
|
||||
<div style="margin-top:8px;border-top:1px solid rgba(255,255,255,0.06);padding-top:8px;display:flex;flex-wrap:wrap;gap:8px;">
|
||||
${sources.map((s) => {
|
||||
const stc = stanceColor(s.stance);
|
||||
const dom = safeHostname(s.url);
|
||||
return `<a href="${escapeHtml(s.url)}" target="_blank" rel="noopener" style="font-size:11px;color:${stc};text-decoration:none;">${escapeHtml(dom)} <span style="opacity:0.6;">(${escapeHtml(enumLabel(s.stance || 'NEUTRAL'))})</span></a>`;
|
||||
}).join('')}
|
||||
${(cl.sources || []).length > 3 ? `<span style="font-size:11px;color:rgba(255,255,255,0.4);">+ încă ${(cl.sources || []).length - 3}</span>` : ''}
|
||||
</div>` : '';
|
||||
return `
|
||||
<div style="background:rgba(255,255,255,0.04);border-left:3px solid ${sc};padding:12px 14px;border-radius:8px;margin-bottom:8px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;">
|
||||
<span style="color:#e5e7eb;font-size:13px;line-height:1.5;flex:1;">${escapeHtml(cl.text || '')}</span>
|
||||
<span style="background:${sc}26;border:1px solid ${sc};color:${sc};padding:3px 9px;border-radius:5px;font-size:10px;font-weight:700;white-space:nowrap;flex-shrink:0;">${escapeHtml(loc(cl, 'status_name') || enumLabel(cl.status || '?'))}</span>
|
||||
</div>
|
||||
${cl.reasoning ? `<div style="color:rgba(255,255,255,0.55);font-size:12px;margin-top:8px;line-height:1.5;">${escapeHtml(truncate(cl.reasoning, 220))}</div>` : ''}
|
||||
${srcRow}
|
||||
</div>`;
|
||||
}).join('');
|
||||
const more = list.length > 6 ? `<div style="text-align:center;font-size:11px;color:rgba(255,255,255,0.4);margin-top:6px;">+ încă ${list.length - 6} afirmații</div>` : '';
|
||||
const meta = cred != null ? `credibilitate ${cred}%` : '';
|
||||
const footer = metaFooter([
|
||||
c.llm_extraction && `extragere: ${c.llm_extraction}`,
|
||||
c.llm_verification && `verificare: ${c.llm_verification}`,
|
||||
c.web_searches_made != null && `${c.web_searches_made} căutări web`,
|
||||
c.total_duration_ms && `${(c.total_duration_ms / 1000).toFixed(1)}s`,
|
||||
]);
|
||||
return componentCardWrap('Verificarea afirmațiilor', meta, cred, accent, summary + items + more + footer);
|
||||
}
|
||||
|
||||
function sourceCard(s) {
|
||||
if (!s) return '';
|
||||
// V3 source_assessment OR legacy domain. `s.domain` may be a string (legacy)
|
||||
// or an object (extended source assessment with sub-fields) — never render
|
||||
// a raw object via template interpolation.
|
||||
const trust = s.trust_score ?? s.credibility_score ?? null;
|
||||
const verdict = enumLabel(s.verdict || s.assessment_verdict || '');
|
||||
let domain = '';
|
||||
if (typeof s.domain === 'string' && s.domain) domain = s.domain;
|
||||
else if (s.publication?.url) domain = safeHostname(s.publication.url);
|
||||
else if (s.url) domain = safeHostname(s.url);
|
||||
if (!verdict && !domain && trust == null) return '';
|
||||
const accent = trust == null ? '#94a3b8' : (trust >= 70 ? '#22c55e' : trust >= 40 ? '#eab308' : '#ef4444');
|
||||
const flags = (s.red_flags || []).slice(0, 5);
|
||||
const warnings = (s.warnings || []).slice(0, 5);
|
||||
|
||||
// Pull rich publication/author/platform metadata from extended source assessment.
|
||||
const pub = s.publication || {};
|
||||
const author = s.author || {};
|
||||
const platform = s.platform || {};
|
||||
const pubName = pub.name || pub.publisher || '';
|
||||
const pubTier = pub.tier || pub.credibility || '';
|
||||
const authorName = author.name || author.author_name || '';
|
||||
const authorClass = author.classification || author.class || author.credibility || '';
|
||||
const platformName = platform.name || platform.platform_name || '';
|
||||
|
||||
const detailCells = [
|
||||
domain && { label: 'Domeniu', value: domain, color: '#fff' },
|
||||
verdict && { label: 'Verdict', value: verdict, color: accent },
|
||||
s.age_category && { label: 'Vechime', value: humanize(s.age_category), color: '#fff' },
|
||||
pubName && { label: 'Publicație', value: pubName + (pubTier ? ` · ${pubTier}` : ''), color: '#fff' },
|
||||
authorName && { label: 'Autor', value: authorName + (authorClass ? ` · ${authorClass}` : ''), color: '#fff' },
|
||||
platformName && { label: 'Platformă', value: platformName, color: '#fff' },
|
||||
].filter(Boolean);
|
||||
|
||||
const inner = `
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:10px;margin-bottom:${flags.length || warnings.length ? 14 : 0}px;">
|
||||
${detailCells.map(c => `<div style="background:rgba(255,255,255,0.04);padding:10px 12px;border-radius:8px;">
|
||||
<div style="font-size:10px;color:rgba(255,255,255,0.45);text-transform:uppercase;letter-spacing:1px;">${escapeHtml(c.label)}</div>
|
||||
<div style="color:${c.color};font-size:13px;font-weight:600;margin-top:2px;word-break:break-word;">${escapeHtml(String(c.value))}</div>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
${flags.length ? `<div style="margin-bottom:8px;"><div style="font-size:11px;color:#ef4444;font-weight:600;margin-bottom:6px;text-transform:uppercase;letter-spacing:0.5px;">Semnale de alarmă</div>${flags.map(f => {
|
||||
const raw = typeof f === 'string' ? f : (f.description || f.flag || JSON.stringify(f));
|
||||
return `<div style="font-size:12px;color:rgba(255,255,255,0.75);background:rgba(239,68,68,0.08);padding:6px 10px;border-radius:5px;margin-bottom:4px;">${escapeHtml(humanize(raw))}</div>`;
|
||||
}).join('')}</div>` : ''}
|
||||
${warnings.length ? `<div><div style="font-size:11px;color:#f97316;font-weight:600;margin-bottom:6px;text-transform:uppercase;letter-spacing:0.5px;">Avertismente</div>${warnings.map(w => {
|
||||
const raw = typeof w === 'string' ? w : (w.description || w.warning || JSON.stringify(w));
|
||||
return `<div style="font-size:12px;color:rgba(255,255,255,0.75);background:rgba(249,115,22,0.08);padding:6px 10px;border-radius:5px;margin-bottom:4px;">${escapeHtml(humanize(raw))}</div>`;
|
||||
}).join('')}</div>` : ''}`;
|
||||
const footer = metaFooter([
|
||||
s.llm_extraction && `extragere: ${s.llm_extraction}`,
|
||||
s.llm_evaluation && `evaluare: ${s.llm_evaluation}`,
|
||||
s.total_duration_ms && `${(s.total_duration_ms / 1000).toFixed(1)}s`,
|
||||
]);
|
||||
return componentCardWrap('Evaluarea sursei', verdict, trust, accent, inner + footer);
|
||||
}
|
||||
|
||||
// Generic card wrapper used by all per-component renderers.
|
||||
function componentCardWrap(title, sublabel, score, accent, innerHTML) {
|
||||
const scoreBlock = score != null
|
||||
? `<span style="background:${accent}26;border:1px solid ${accent};color:${accent};padding:3px 11px;border-radius:6px;font-size:12px;font-weight:700;">${score}%</span>`
|
||||
: '';
|
||||
return `
|
||||
<div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06);border-radius:14px;padding:20px 22px;font-family:Inter,system-ui,sans-serif;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;gap:8px;">
|
||||
<div style="font-size:14px;font-weight:600;color:#fff;">${escapeHtml(title)}</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
${sublabel ? `<span style="font-size:12px;color:rgba(255,255,255,0.5);">${escapeHtml(sublabel)}</span>` : ''}
|
||||
${scoreBlock}
|
||||
</div>
|
||||
</div>
|
||||
${innerHTML}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// === Input preview — small card at top showing what was analyzed ===
|
||||
function inputPreview(analysis) {
|
||||
const inputType = analysis.input_type || analysis.analysis_type || '';
|
||||
const text = analysis.input_text;
|
||||
const url = analysis.input_url;
|
||||
const media = analysis.input_media_url;
|
||||
if (!text && !url && !media) return '';
|
||||
const label = `CONȚINUT (${INPUT_TYPE_RO[inputType] || (inputType || 'INPUT').toUpperCase()})`;
|
||||
let body = '';
|
||||
if (text) body = `<div style="color:rgba(255,255,255,0.85);font-size:13px;line-height:1.5;">${escapeHtml(truncate(text, 280))}</div>`;
|
||||
else if (url) body = `<a href="${escapeHtml(url)}" target="_blank" rel="noopener" style="color:#1fb6bd;font-size:13px;word-break:break-all;text-decoration:none;">${escapeHtml(url)}</a>`;
|
||||
else if (media && (inputType === 'image' || /\.(png|jpe?g|gif|webp)(\?|$)/i.test(media))) {
|
||||
// Imagine analizată → miniatură, nu URL-ul brut de storage
|
||||
body = `<a href="${escapeHtml(media)}" target="_blank" rel="noopener" style="display:inline-block;">
|
||||
<img src="${escapeHtml(media)}" alt="Imaginea analizată"
|
||||
style="max-width:100%;max-height:180px;border-radius:8px;border:1px solid rgba(255,255,255,0.1);display:block;"></a>`;
|
||||
}
|
||||
else if (media) body = `<a href="${escapeHtml(media)}" target="_blank" rel="noopener" style="color:#1fb6bd;font-size:13px;text-decoration:none;">Deschide fișierul analizat</a>`;
|
||||
return `
|
||||
<div style="background:rgba(255,255,255,0.025);border:1px solid rgba(255,255,255,0.05);border-radius:12px;padding:14px 18px;font-family:Inter,system-ui,sans-serif;">
|
||||
<div style="font-size:10px;color:rgba(255,255,255,0.45);text-transform:uppercase;letter-spacing:1.4px;font-weight:600;margin-bottom:6px;">${escapeHtml(label)}</div>
|
||||
${body}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// === Main entry — return the full inner HTML for the analysis panel ===
|
||||
function renderAnalysisHTML(analysis) {
|
||||
if (!analysis || typeof analysis !== 'object') {
|
||||
return '<div style="padding:24px;color:#ef4444;">Invalid analysis payload.</div>';
|
||||
}
|
||||
const verdict = analysis.verdict || {};
|
||||
const accent = RISK_COLORS[verdict.risk_category_color] || FALLBACK_ACCENT;
|
||||
const dur = analysis.total_duration_ms;
|
||||
const inputType = analysis.input_type || analysis.analysis_type || 'text';
|
||||
|
||||
const sections = [
|
||||
inputPreview(analysis),
|
||||
headlineCard(verdict, accent),
|
||||
actionCallout(verdict, accent),
|
||||
techniquesCard(analysis.techniques),
|
||||
aiTamperedCard(analysis.ai_tampered),
|
||||
claimsCard(analysis.claims),
|
||||
sourceCard(analysis.source_assessment || analysis.domain),
|
||||
].filter(Boolean).join('');
|
||||
|
||||
const sid = analysis.session_id ? ` · sesiune ${analysis.session_id.substring(0, 8)}` : '';
|
||||
const meta = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;font-size:12px;color:rgba(255,255,255,0.45);margin-top:16px;font-family:Inter,system-ui,sans-serif;">
|
||||
<span>${new Date().toLocaleString('ro-RO')} · ${escapeHtml((INPUT_TYPE_RO[inputType] || inputType).toLowerCase())}${sid}</span>
|
||||
${dur ? `<span>${(dur / 1000).toFixed(1)}s</span>` : ''}
|
||||
</div>`;
|
||||
|
||||
return `<div style="display:flex;flex-direction:column;gap:16px;font-family:Inter,system-ui,sans-serif;">${sections}${meta}</div>`;
|
||||
}
|
||||
|
||||
// === Standalone tab page — full <html> shell wrapping the panel ===
|
||||
function renderStandalonePage(analysis) {
|
||||
const inner = renderAnalysisHTML(analysis);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>didi · raport de analiză</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Merriweather:ital@1&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*,*::before,*::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background:#0d1424; color:#fff; padding:32px 16px; min-height:100vh;
|
||||
font-family: Inter, system-ui, -apple-system, sans-serif; }
|
||||
.container { max-width: 820px; margin: 0 auto; }
|
||||
h1 { font-size: 28px; font-weight: 700; margin-bottom: 6px;
|
||||
background: linear-gradient(135deg, #009198 0%, #1fb6bd 100%);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
|
||||
.subtitle { color: rgba(255,255,255,0.5); margin-bottom: 24px; font-size: 13px; }
|
||||
a { color: inherit; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>didi · raport de analiză</h1>
|
||||
<div class="subtitle">Rezultatul analizei anti-dezinformare</div>
|
||||
${inner}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// === In-page modal — open from content script (overlay + close handlers) ===
|
||||
function renderModalHTML(analysis) {
|
||||
return `
|
||||
<div style="position:fixed;inset:0;background:rgba(13,20,36,0.88);z-index:99999999;
|
||||
display:flex;align-items:center;justify-content:center;animation:didiFadeIn 240ms ease;">
|
||||
<div style="background:#0d1424;border:1px solid rgba(0,145,152,0.25);border-radius:20px;
|
||||
max-width:760px;width:92%;max-height:92vh;overflow-y:auto;
|
||||
box-shadow:0 24px 80px rgba(0,0,0,0.7);padding:28px;
|
||||
font-family:Inter,system-ui,sans-serif;animation:didiSlideUp 320ms cubic-bezier(0.2,0.8,0.2,1);">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
|
||||
<h2 style="font-size:22px;font-weight:700;background:linear-gradient(135deg,#009198,#1fb6bd);
|
||||
-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;">didi · raport de analiză</h2>
|
||||
<button class="didi-modal-close" style="background:rgba(255,255,255,0.06);border:none;color:#fff;
|
||||
font-size:22px;width:34px;height:34px;border-radius:9px;cursor:pointer;">×</button>
|
||||
</div>
|
||||
${renderAnalysisHTML(analysis)}
|
||||
<div style="margin-top:18px;padding-top:18px;border-top:1px solid rgba(255,255,255,0.06);">
|
||||
<button class="didi-modal-gotit" style="width:100%;padding:13px;background:linear-gradient(135deg,#009198,#009198);
|
||||
border:none;border-radius:10px;color:#fff;font:inherit;font-size:14px;font-weight:600;cursor:pointer;">Am înțeles</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Public API — exposed on globalThis so the same file works in every context:
|
||||
// - popup.html / history.html: window === globalThis (DOM page)
|
||||
// - content scripts (isolated world): window === globalThis
|
||||
// - service worker (background.js importScripts): self === globalThis (NO window)
|
||||
globalThis.DidiRender = {
|
||||
RISK_COLORS,
|
||||
renderAnalysisHTML,
|
||||
renderStandalonePage,
|
||||
renderModalHTML,
|
||||
escapeHtml,
|
||||
};
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue