commit 3a178542422698d0058f02b6caf2f2efe1a6d8ad Author: MDeeApp <6595194+MDeeApp@users.noreply.github.com> Date: Sat Oct 4 16:30:22 2025 +0200 Initial commit diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..e740ffa --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "Bash(mkdir:*)", + "Bash(cat:*)", + "Bash(tr '>' '\\n')", + "Bash(docker-compose restart:*)", + "Bash(docker compose:*)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a17ba5e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +backend/data/*.db +backend/data/*.db-shm +backend/data/*.db-wal +.DS_Store +*.log +.env \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..65a715e --- /dev/null +++ b/README.md @@ -0,0 +1,191 @@ +# Facebook Post Tracker + +Eine Chrome/Firefox-Extension mit Docker-Backend zum Verwalten und Abhaken von Facebook-Beiträgen über mehrere Browser-Profile hinweg. + +## Features + +- ✅ **Browser-Extension**: Fügt jedem Facebook-Beitrag Tracking-Funktionalität hinzu +- 📊 **Multi-Profile-Support**: Verwalte 5 verschiedene Browser-Profile +- 🌐 **Web-Interface**: Übersicht über alle zu bearbeitenden Beiträge +- 🔄 **Auto-Check**: Beiträge werden automatisch beim Öffnen abgehakt +- 🐳 **Docker-Setup**: Einfaches Backend-Deployment + +## Systemarchitektur + +``` +┌─────────────────┐ +│ Browser │ +│ Extension │◄─────┐ +└────────┬────────┘ │ + │ │ + ▼ │ +┌─────────────────┐ │ +│ Docker Backend │ │ +│ (Node.js API) │ │ +│ + SQLite DB │ │ +└────────┬────────┘ │ + │ │ + ▼ │ +┌─────────────────┐ │ +│ Web Interface │──────┘ +└─────────────────┘ +``` + +## Installation + +### 1. Backend starten (Docker) + +```bash +# In das Projektverzeichnis wechseln +cd /mnt/c/fb + +# Docker Container bauen und starten +docker-compose up -d + +# Logs ansehen (optional) +docker-compose logs -f +``` + +Das Backend läuft nun auf `http://localhost:3000` +Das Web-Interface ist erreichbar unter `http://localhost:8080` + +### 2. Browser-Extension installieren + +#### Chrome: +1. Öffne `chrome://extensions/` +2. Aktiviere "Entwicklermodus" (oben rechts) +3. Klicke auf "Entpackte Erweiterung laden" +4. Wähle den Ordner `/mnt/c/fb/extension` + +#### Firefox: +1. Öffne `about:debugging#/runtime/this-firefox` +2. Klicke auf "Temporäres Add-on laden" +3. Wähle die Datei `/mnt/c/fb/extension/manifest.json` + +### 3. Extension konfigurieren + +1. Klicke auf das Extension-Icon 📋 in der Browser-Toolbar +2. Wähle dein aktuelles Profil (1-5) aus +3. Klicke auf "Speichern" + +## Nutzung + +### In der Browser-Extension (auf Facebook): + +1. **Beitrag hinzufügen**: Bei jedem Facebook-Beitrag erscheint automatisch ein Tracking-UI + - Gib die Anzahl ein (1-5), wie viele Profile den Beitrag abhaken sollen + - Klicke auf "Hinzufügen" + - Der Beitrag wird automatisch für das aktuelle Profil abgehakt + +2. **Beitrag abhaken**: Bei bereits getrackten Beiträgen + - Siehst du den Status: `X/Y Profile ✓/✗` + - Klicke auf "Abhaken", wenn noch nicht für dein Profil erledigt + +### Im Web-Interface: + +1. Öffne `http://localhost:8080` +2. Wähle dein aktuelles Profil aus +3. **Offene Beiträge**: Zeigt alle Beiträge, die noch nicht von allen Profilen abgehakt wurden +4. **Alle Beiträge**: Zeigt alle erfassten Beiträge +5. Klicke auf "Beitrag öffnen & abhaken", um: + - Den Beitrag in neuem Tab zu öffnen + - Automatisch für dein Profil abzuhaken + +## API-Endpunkte + +Das Backend stellt folgende REST-API bereit: + +- `GET /api/posts` - Alle Beiträge abrufen +- `GET /api/posts/by-url?url=...` - Beitrag über URL abrufen +- `POST /api/posts` - Neuen Beitrag erstellen +- `POST /api/posts/:id/check` - Beitrag für Profil abhaken +- `POST /api/check-by-url` - Beitrag über URL abhaken +- `DELETE /api/posts/:id` - Beitrag löschen + +## Datenbankstruktur + +```sql +posts: + - id (TEXT, PRIMARY KEY) + - url (TEXT, UNIQUE) + - title (TEXT) + - target_count (INTEGER) + - created_at (DATETIME) + +checks: + - id (INTEGER, PRIMARY KEY) + - post_id (TEXT, FOREIGN KEY) + - profile_number (INTEGER) + - checked_at (DATETIME) + - UNIQUE(post_id, profile_number) +``` + +## Docker-Befehle + +```bash +# Container starten +docker-compose up -d + +# Container stoppen +docker-compose down + +# Logs ansehen +docker-compose logs -f + +# Container neu bauen +docker-compose build + +# Datenbank zurücksetzen (ACHTUNG: Löscht alle Daten!) +docker-compose down -v +``` + +## Entwicklung + +### Backend lokal entwickeln (ohne Docker): + +```bash +cd backend +npm install +npm run dev +``` + +### Extension während Entwicklung neu laden: + +- **Chrome**: Gehe zu `chrome://extensions/` und klicke auf das Reload-Symbol +- **Firefox**: Gehe zu `about:debugging` und klicke auf "Neu laden" + +## Troubleshooting + +### Backend nicht erreichbar + +- Prüfe, ob Docker läuft: `docker ps` +- Prüfe die Logs: `docker-compose logs backend` +- Stelle sicher, dass Port 3000 nicht bereits belegt ist + +### Extension funktioniert nicht + +- Öffne die Browser-Konsole (F12) und prüfe auf Fehler +- Stelle sicher, dass das Backend läuft +- Lade die Extension neu +- Prüfe, ob du auf Facebook.com bist + +### CORS-Fehler + +- Das Backend hat CORS aktiviert +- Stelle sicher, dass du `http://localhost:3000` verwendest (nicht `127.0.0.1`) + +### Icons fehlen in Extension + +- Die Extension verwendet Emoji-Icons als Fallback +- Für richtige Icons kannst du PNG-Dateien in den Größen 16x16, 48x48, 128x128 erstellen und in `extension/icons/` ablegen + +## Technologie-Stack + +- **Backend**: Node.js, Express, SQLite (better-sqlite3) +- **Web-Interface**: Vanilla JavaScript, HTML, CSS +- **Extension**: Manifest V3 (Chrome & Firefox kompatibel) +- **Deployment**: Docker, docker-compose + +## Lizenz + +MIT \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..32a7eed --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,15 @@ +FROM node:18-alpine + +WORKDIR /app + +COPY package*.json ./ + +RUN npm install --production + +COPY . . + +RUN mkdir -p /app/data + +EXPOSE 3000 + +CMD ["node", "server.js"] \ No newline at end of file diff --git a/backend/noScreenshot.png b/backend/noScreenshot.png new file mode 100644 index 0000000..b5d5573 Binary files /dev/null and b/backend/noScreenshot.png differ diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..1ba2b61 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,19 @@ +{ + "name": "fb-tracker-backend", + "version": "1.0.0", + "description": "Backend for Facebook post tracker", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "better-sqlite3": "^9.2.2", + "uuid": "^9.0.1" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } +} \ No newline at end of file diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..0e28192 --- /dev/null +++ b/backend/server.js @@ -0,0 +1,1944 @@ +const express = require('express'); +const cors = require('cors'); +const Database = require('better-sqlite3'); +const { v4: uuidv4 } = require('uuid'); +const path = require('path'); +const fs = require('fs'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +const MAX_PROFILES = 5; +const DEFAULT_PROFILE_NAMES = { + 1: 'Profil 1', + 2: 'Profil 2', + 3: 'Profil 3', + 4: 'Profil 4', + 5: 'Profil 5' +}; +const PROFILE_SCOPE_COOKIE = 'fb_tracker_scope'; +const PROFILE_SCOPE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days +const FACEBOOK_TRACKING_PARAM_PREFIXES = ['__cft__', '__tn__', '__eep__', 'mibextid']; +const SEARCH_POST_HIDE_THRESHOLD = 3; +const SEARCH_POST_RETENTION_DAYS = 90; + +const screenshotDir = path.join(__dirname, 'data', 'screenshots'); +if (!fs.existsSync(screenshotDir)) { + fs.mkdirSync(screenshotDir, { recursive: true }); +} + +// Middleware - Enhanced CORS for extension +app.use(cors({ + origin: (origin, callback) => { + callback(null, origin || false); + }, + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'], + credentials: true +})); +// Allow larger payloads because screenshots from high-res monitors can easily exceed 10 MB +app.use(express.json({ limit: '30mb' })); + +// Additional CORS headers for extension compatibility +app.use((req, res, next) => { + const origin = req.headers.origin; + const host = req.headers.host; + const fallbackOrigin = host ? `${isSecureRequest(req) ? 'https' : 'http'}://${host}` : '*'; + res.header('Access-Control-Allow-Origin', origin || fallbackOrigin); + res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + res.header('Access-Control-Allow-Credentials', 'true'); + + if (req.method === 'OPTIONS') { + res.sendStatus(204); + return; + } + + next(); +}); + +// Assign per-browser profile scopes via cookies +app.use(ensureProfileScope); + +// Database setup +const dbPath = path.join(__dirname, 'data', 'tracker.db'); +const db = new Database(dbPath); + +function parseCookies(header) { + if (!header || typeof header !== 'string') { + return {}; + } + + return header.split(';').reduce((acc, part) => { + const index = part.indexOf('='); + if (index === -1) { + const key = part.trim(); + if (key) { + acc[key] = ''; + } + return acc; + } + + const key = part.slice(0, index).trim(); + const value = part.slice(index + 1).trim(); + if (key) { + acc[key] = decodeURIComponent(value); + } + return acc; + }, {}); +} + +function isSecureRequest(req) { + if (req.secure) { + return true; + } + const forwardedProto = req.headers['x-forwarded-proto']; + if (typeof forwardedProto === 'string') { + return forwardedProto.split(',').map(value => value.trim().toLowerCase()).includes('https'); + } + return false; +} + +function buildScopeCookieValue(scopeId, req) { + const secure = isSecureRequest(req); + const attributes = [ + `${PROFILE_SCOPE_COOKIE}=${encodeURIComponent(scopeId)}`, + 'Path=/', + `Max-Age=${PROFILE_SCOPE_MAX_AGE}` + ]; + + if (secure) { + attributes.push('Secure', 'SameSite=None'); + } else { + attributes.push('SameSite=Lax'); + } + + return attributes.join('; '); +} + +function appendScopeCookie(res, value) { + const existing = res.getHeader('Set-Cookie'); + if (!existing) { + res.setHeader('Set-Cookie', value); + } else if (Array.isArray(existing)) { + res.setHeader('Set-Cookie', [...existing, value]); + } else { + res.setHeader('Set-Cookie', [existing, value]); + } +} + +function ensureProfileScope(req, res, next) { + const cookies = parseCookies(req.headers.cookie); + let scopeId = cookies[PROFILE_SCOPE_COOKIE]; + + if (!scopeId) { + scopeId = uuidv4(); + } + + appendScopeCookie(res, buildScopeCookieValue(scopeId, req)); + + req.profileScope = scopeId; + next(); +} + +function getScopedProfileNumber(scopeId) { + if (!scopeId) { + return null; + } + const row = db.prepare('SELECT profile_number FROM profile_state_scoped WHERE scope_id = ?').get(scopeId); + if (!row) { + return null; + } + return sanitizeProfileNumber(row.profile_number); +} + +function setScopedProfileNumber(scopeId, profileNumber) { + if (!scopeId || !profileNumber) { + return; + } + + db.prepare(` + INSERT INTO profile_state_scoped (scope_id, profile_number) + VALUES (?, ?) + ON CONFLICT(scope_id) DO UPDATE SET profile_number = excluded.profile_number + `).run(scopeId, profileNumber); +} + +function clampTargetCount(value) { + const parsed = parseInt(value, 10); + if (Number.isNaN(parsed)) { + return 1; + } + return Math.min(MAX_PROFILES, Math.max(1, parsed)); +} + +function validateTargetCount(value) { + const parsed = parseInt(value, 10); + if (Number.isNaN(parsed) || parsed < 1 || parsed > MAX_PROFILES) { + return null; + } + return parsed; +} + +function sanitizeProfileNumber(value) { + const parsed = parseInt(value, 10); + if (Number.isNaN(parsed) || parsed < 1 || parsed > MAX_PROFILES) { + return null; + } + return parsed; +} + +function normalizeDeadline(value) { + if (!value && value !== 0) { + return null; + } + + if (value instanceof Date) { + if (!Number.isNaN(value.getTime())) { + return value.toISOString(); + } + return null; + } + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + const parsed = new Date(trimmed); + if (!Number.isNaN(parsed.getTime())) { + return parsed.toISOString(); + } + return null; + } + + if (typeof value === 'number') { + const parsed = new Date(value); + if (!Number.isNaN(parsed.getTime())) { + return parsed.toISOString(); + } + return null; + } + + return null; +} + +function getProfileName(profileNumber) { + return DEFAULT_PROFILE_NAMES[profileNumber] || `Profil ${profileNumber}`; +} + +function normalizeCreatorName(value) { + if (typeof value !== 'string') { + return null; + } + + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + return trimmed.slice(0, 160); +} + +function normalizeFacebookPostUrl(rawValue) { + if (typeof rawValue !== 'string') { + return null; + } + + let value = rawValue.trim(); + if (!value) { + return null; + } + + const trackingIndex = value.indexOf('__cft__'); + if (trackingIndex !== -1) { + value = value.slice(0, trackingIndex); + } + + value = value.replace(/[?&]$/, ''); + + let parsed; + try { + parsed = new URL(value); + } catch (error) { + try { + parsed = new URL(value, 'https://www.facebook.com'); + } catch (fallbackError) { + return null; + } + } + + if (!parsed.hostname.toLowerCase().endsWith('facebook.com')) { + return null; + } + + const cleanedParams = new URLSearchParams(); + parsed.searchParams.forEach((paramValue, paramKey) => { + const lowerKey = paramKey.toLowerCase(); + if (FACEBOOK_TRACKING_PARAM_PREFIXES.some((prefix) => lowerKey.startsWith(prefix)) || lowerKey === 'set' || lowerKey === 'comment_id') { + return; + } + if (lowerKey === 'hoisted_section_header_type') { + return; + } + cleanedParams.append(paramKey, paramValue); + }); + + const multiPermalinkId = cleanedParams.get('multi_permalinks'); + if (multiPermalinkId) { + cleanedParams.delete('multi_permalinks'); + + const groupMatch = parsed.pathname.match(/^\/groups\/([A-Za-z0-9\.\-_]+)/); + if (groupMatch && /^[0-9]+$/.test(multiPermalinkId)) { + parsed.pathname = `/groups/${groupMatch[1]}/posts/${multiPermalinkId}`; + } else if (groupMatch) { + parsed.pathname = `/groups/${groupMatch[1]}/permalink/${multiPermalinkId}`; + } + } + + const search = cleanedParams.toString(); + const formatted = `${parsed.origin}${parsed.pathname}${search ? `?${search}` : ''}`; + return formatted.replace(/[?&]$/, ''); +} + +function getRequiredProfiles(targetCount) { + const count = clampTargetCount(targetCount); + return Array.from({ length: count }, (_, index) => index + 1); +} + +function buildProfileStatuses(requiredProfiles, checks) { + const validChecks = checks + .map(check => { + const profileNumber = sanitizeProfileNumber(check.profile_number); + if (!profileNumber) { + return null; + } + return { + ...check, + profile_number: profileNumber, + profile_name: getProfileName(profileNumber) + }; + }) + .filter(Boolean); + + const completedSet = new Set(validChecks.map(check => check.profile_number)); + const checkByProfile = new Map(validChecks.map(check => [check.profile_number, check])); + + const statuses = requiredProfiles.map((profileNumber, index) => { + const prerequisites = requiredProfiles.slice(0, index); + const prerequisitesMet = prerequisites.every(num => completedSet.has(num)); + const isChecked = completedSet.has(profileNumber); + return { + profile_number: profileNumber, + profile_name: getProfileName(profileNumber), + status: isChecked ? 'done' : (prerequisitesMet ? 'available' : 'locked'), + checked_at: isChecked && checkByProfile.get(profileNumber) + ? checkByProfile.get(profileNumber).checked_at + : null + }; + }); + + return { + statuses, + completedChecks: validChecks, + completedSet + }; +} + +function recalcCheckedCount(postId) { + const post = db.prepare('SELECT id, target_count, checked_count FROM posts WHERE id = ?').get(postId); + if (!post) { + return null; + } + + const checks = db.prepare('SELECT id, profile_number, checked_at FROM checks WHERE post_id = ?').all(postId); + const requiredProfiles = getRequiredProfiles(post.target_count); + const { statuses } = buildProfileStatuses(requiredProfiles, checks); + const checkedCount = statuses.filter(status => status.status === 'done').length; + + const updates = []; + const params = []; + + if (post.checked_count !== checkedCount) { + updates.push('checked_count = ?'); + params.push(checkedCount); + } + + if (post.target_count !== requiredProfiles.length) { + updates.push('target_count = ?'); + params.push(requiredProfiles.length); + } + + if (updates.length) { + updates.push('last_change = CURRENT_TIMESTAMP'); + params.push(postId); + db.prepare(`UPDATE posts SET ${updates.join(', ')} WHERE id = ?`).run(...params); + } + + return checkedCount; +} + +// Initialize database tables +db.exec(` + CREATE TABLE IF NOT EXISTS posts ( + id TEXT PRIMARY KEY, + url TEXT NOT NULL UNIQUE, + title TEXT, + target_count INTEGER NOT NULL, + checked_count INTEGER DEFAULT 0, + screenshot_path TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_change DATETIME DEFAULT CURRENT_TIMESTAMP + ); +`); + +db.exec(` + CREATE TABLE IF NOT EXISTS checks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id TEXT NOT NULL, + profile_number INTEGER, + checked_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (post_id) REFERENCES posts(id) + ); +`); + +db.exec(` + CREATE TABLE IF NOT EXISTS profile_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + profile_number INTEGER NOT NULL + ); +`); + +db.exec(` + CREATE TABLE IF NOT EXISTS profile_state_scoped ( + scope_id TEXT PRIMARY KEY, + profile_number INTEGER NOT NULL + ); +`); + +db.exec(` + CREATE TABLE IF NOT EXISTS ai_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + active_credential_id INTEGER, + prompt_prefix TEXT, + enabled INTEGER DEFAULT 0, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); +`); + +db.exec(` + CREATE TABLE IF NOT EXISTS ai_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + provider TEXT NOT NULL, + api_key TEXT NOT NULL, + model TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); +`); + +db.exec(` + CREATE TABLE IF NOT EXISTS profile_friends ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + profile_number INTEGER NOT NULL, + friend_names TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(profile_number) + ); +`); + +db.exec(` + CREATE TABLE IF NOT EXISTS search_seen_posts ( + url TEXT PRIMARY KEY, + seen_count INTEGER NOT NULL DEFAULT 1, + manually_hidden INTEGER NOT NULL DEFAULT 0, + first_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); +`); + +db.exec(` + CREATE INDEX IF NOT EXISTS idx_search_seen_posts_last_seen_at + ON search_seen_posts(last_seen_at); +`); + +const ensureColumn = (table, column, definition) => { + const columns = db.prepare(`PRAGMA table_info(${table})`).all(); + if (!columns.some(col => col.name === column)) { + db.exec(`ALTER TABLE ${table} ADD COLUMN ${definition}`); + } +}; + +ensureColumn('posts', 'checked_count', 'checked_count INTEGER DEFAULT 0'); +ensureColumn('posts', 'screenshot_path', 'screenshot_path TEXT'); +ensureColumn('posts', 'created_by_profile', 'created_by_profile INTEGER'); +ensureColumn('posts', 'deadline_at', 'deadline_at DATETIME'); +ensureColumn('posts', 'created_by_name', 'created_by_name TEXT'); +ensureColumn('posts', 'last_change', 'last_change DATETIME'); +ensureColumn('posts', 'is_successful', 'is_successful INTEGER DEFAULT 0'); +ensureColumn('ai_settings', 'active_credential_id', 'active_credential_id INTEGER'); +ensureColumn('ai_settings', 'prompt_prefix', 'prompt_prefix TEXT'); +ensureColumn('ai_settings', 'enabled', 'enabled INTEGER DEFAULT 0'); +ensureColumn('ai_credentials', 'is_active', 'is_active INTEGER DEFAULT 1'); +ensureColumn('ai_credentials', 'priority', 'priority INTEGER DEFAULT 0'); +ensureColumn('ai_credentials', 'base_url', 'base_url TEXT'); +ensureColumn('search_seen_posts', 'manually_hidden', 'manually_hidden INTEGER NOT NULL DEFAULT 0'); +db.prepare(` + UPDATE posts + SET last_change = COALESCE( + last_change, + (SELECT MAX(checked_at) FROM checks WHERE checks.post_id = posts.id), + created_at, + CURRENT_TIMESTAMP + ) + WHERE last_change IS NULL +`).run(); + +function touchPost(postId) { + if (!postId) { + return; + } + try { + db.prepare('UPDATE posts SET last_change = CURRENT_TIMESTAMP WHERE id = ?').run(postId); + } catch (error) { + console.warn(`Failed to update last_change for post ${postId}:`, error.message); + } +} + +function normalizeExistingPostUrls() { + const rows = db.prepare('SELECT id, url FROM posts').all(); + let updatedCount = 0; + + for (const row of rows) { + const cleaned = normalizeFacebookPostUrl(row.url); + if (!cleaned || cleaned === row.url) { + continue; + } + + const conflict = db.prepare('SELECT id FROM posts WHERE url = ?').get(cleaned); + if (conflict && conflict.id !== row.id) { + console.warn(`Skipping URL normalization for post ${row.id} due to existing post ${conflict.id}`); + continue; + } + + try { + db.prepare('UPDATE posts SET url = ? WHERE id = ?').run(cleaned, row.id); + updatedCount += 1; + } catch (error) { + if (error && error.code === 'SQLITE_CONSTRAINT_UNIQUE') { + console.warn(`Skipping URL normalization for post ${row.id} because the cleaned URL is already used.`); + continue; + } + console.warn(`Failed to normalize URL for post ${row.id}:`, error.message); + } + } + + if (updatedCount) { + console.log(`Normalized URLs for ${updatedCount} stored posts.`); + } +} + +normalizeExistingPostUrls(); + +function cleanupExpiredSearchPosts() { + try { + const threshold = `-${SEARCH_POST_RETENTION_DAYS} day`; + db.prepare(` + DELETE FROM search_seen_posts + WHERE last_seen_at < DATETIME('now', ?) + `).run(threshold); + } catch (error) { + console.warn('Failed to cleanup expired search posts:', error.message); + } +} + +function collectNormalizedFacebookUrls(primaryUrl, candidates = []) { + const normalized = []; + + const pushNormalized = (value) => { + const normalizedUrl = normalizeFacebookPostUrl(value); + if (normalizedUrl && !normalized.includes(normalizedUrl)) { + normalized.push(normalizedUrl); + } + }; + + if (primaryUrl) { + pushNormalized(primaryUrl); + } + + if (Array.isArray(candidates)) { + for (const candidate of candidates) { + pushNormalized(candidate); + } + } + + return normalized; +} + +function removeSearchSeenEntries(urls) { + if (!Array.isArray(urls) || urls.length === 0) { + return; + } + + const uniqueValidUrls = Array.from(new Set(urls.filter(url => typeof url === 'string' && url.trim()))); + if (!uniqueValidUrls.length) { + return; + } + + const stmt = db.prepare('DELETE FROM search_seen_posts WHERE url = ?'); + const runDeletion = db.transaction((values) => { + for (const value of values) { + stmt.run(value); + } + }); + + try { + runDeletion(uniqueValidUrls); + } catch (error) { + console.warn('Failed to remove search seen entries:', error.message); + } +} + +cleanupExpiredSearchPosts(); + +const selectSearchSeenStmt = db.prepare('SELECT url, seen_count, manually_hidden, first_seen_at, last_seen_at FROM search_seen_posts WHERE url = ?'); +const insertSearchSeenStmt = db.prepare(` + INSERT INTO search_seen_posts (url, seen_count, manually_hidden, first_seen_at, last_seen_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +`); +const updateSearchSeenStmt = db.prepare(` + UPDATE search_seen_posts + SET seen_count = ?, manually_hidden = ?, last_seen_at = CURRENT_TIMESTAMP + WHERE url = ? +`); +const deleteSearchSeenStmt = db.prepare('DELETE FROM search_seen_posts WHERE url = ?'); +const selectTrackedPostStmt = db.prepare('SELECT id FROM posts WHERE url = ?'); + +const checkIndexes = db.prepare("PRAGMA index_list('checks')").all(); +for (const idx of checkIndexes) { + if (idx.unique) { + // Skip auto indexes created from PRIMARY KEY/UNIQUE constraints; SQLite refuses to drop them + if (idx.origin !== 'c' || (idx.name && idx.name.startsWith('sqlite_autoindex'))) { + continue; + } + + const info = db.prepare(`PRAGMA index_info('${idx.name}')`).all(); + const columns = info.map(i => i.name).join(','); + if (columns === 'post_id,profile_number' || columns === 'profile_number,post_id') { + db.exec(`DROP INDEX IF EXISTS "${idx.name}"`); + } + } +} + +function sqliteTimestampToUTC(timestamp) { + if (!timestamp) { + return null; + } + // SQLite CURRENT_TIMESTAMP returns UTC time in format "YYYY-MM-DD HH:MM:SS" + // Convert to ISO-8601 with Z suffix to indicate UTC + return timestamp.replace(' ', 'T') + 'Z'; +} + +function mapPostRow(post) { + if (!post) { + return null; + } + + const checks = db.prepare('SELECT id, profile_number, checked_at FROM checks WHERE post_id = ? ORDER BY checked_at ASC').all(post.id); + const requiredProfiles = getRequiredProfiles(post.target_count); + const { statuses, completedChecks } = buildProfileStatuses(requiredProfiles, checks); + const checkedCount = statuses.filter(status => status.status === 'done').length; + const screenshotFile = post.screenshot_path ? path.join(screenshotDir, post.screenshot_path) : null; + const screenshotPath = screenshotFile && fs.existsSync(screenshotFile) + ? `/api/posts/${post.id}/screenshot` + : null; + + let postLastChange = post.last_change; + + if (post.checked_count !== checkedCount || post.target_count !== requiredProfiles.length) { + const updates = []; + const params = []; + + if (post.checked_count !== checkedCount) { + updates.push('checked_count = ?'); + params.push(checkedCount); + } + + if (post.target_count !== requiredProfiles.length) { + updates.push('target_count = ?'); + params.push(requiredProfiles.length); + } + + updates.push('last_change = CURRENT_TIMESTAMP'); + params.push(post.id); + db.prepare(`UPDATE posts SET ${updates.join(', ')} WHERE id = ?`).run(...params); + + const refreshed = db.prepare('SELECT last_change FROM posts WHERE id = ?').get(post.id); + if (refreshed && refreshed.last_change) { + postLastChange = refreshed.last_change; + } + } + + const nextRequired = statuses.find(status => status.status === 'available'); + const creatorProfile = sanitizeProfileNumber(post.created_by_profile); + const creatorName = normalizeCreatorName(post.created_by_name); + + // Convert SQLite timestamps to UTC ISO-8601 format + const checksWithUTC = completedChecks.map(check => ({ + ...check, + checked_at: sqliteTimestampToUTC(check.checked_at) + })); + + const statusesWithUTC = statuses.map(status => ({ + ...status, + checked_at: sqliteTimestampToUTC(status.checked_at) + })); + + return { + ...post, + created_at: sqliteTimestampToUTC(post.created_at), + target_count: requiredProfiles.length, + checked_count: checkedCount, + last_change: sqliteTimestampToUTC(postLastChange), + checks: checksWithUTC, + is_complete: checkedCount >= requiredProfiles.length, + screenshot_path: screenshotPath, + required_profiles: requiredProfiles, + profile_statuses: statusesWithUTC, + next_required_profile: nextRequired ? nextRequired.profile_number : null, + created_by_profile: creatorProfile, + created_by_profile_name: creatorProfile ? getProfileName(creatorProfile) : null, + created_by_name: creatorName, + deadline_at: post.deadline_at || null + }; +} + +// Get all posts +app.get('/api/posts', (req, res) => { + try { + const posts = db.prepare(` + SELECT * + FROM posts + ORDER BY created_at DESC + `).all(); + + res.json(posts.map(mapPostRow)); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Get post by URL +app.get('/api/posts/by-url', (req, res) => { + try { + const { url } = req.query; + if (!url) { + return res.status(400).json({ error: 'URL parameter required' }); + } + + const normalizedUrl = normalizeFacebookPostUrl(url); + if (!normalizedUrl) { + return res.status(400).json({ error: 'URL parameter must be a valid Facebook link' }); + } + + const post = db.prepare('SELECT * FROM posts WHERE url = ?').get(normalizedUrl); + + if (!post) { + return res.json(null); + } + + res.json(mapPostRow(post)); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.post('/api/search-posts', (req, res) => { + try { + const { url, candidates, skip_increment, force_hide } = req.body || {}; + + const normalizedUrls = collectNormalizedFacebookUrls(url, candidates); + if (!normalizedUrls.length) { + return res.status(400).json({ error: 'url must be a valid Facebook link' }); + } + + cleanupExpiredSearchPosts(); + + let isTracked = false; + for (const candidate of normalizedUrls) { + const tracked = selectTrackedPostStmt.get(candidate); + if (tracked) { + isTracked = true; + deleteSearchSeenStmt.run(candidate); + } + } + + if (isTracked) { + return res.json({ seen_count: 0, should_hide: false, tracked: true }); + } + + let existingRow = null; + let existingUrl = null; + + for (const candidate of normalizedUrls) { + const row = selectSearchSeenStmt.get(candidate); + if (row) { + existingRow = row; + existingUrl = candidate; + break; + } + } + + const targetUrl = existingUrl || normalizedUrls[0]; + const existingManualHidden = existingRow ? !!existingRow.manually_hidden : false; + + if (force_hide) { + const desiredCount = Math.max(existingRow ? existingRow.seen_count : 0, SEARCH_POST_HIDE_THRESHOLD); + const urlsToUpdate = Array.from(new Set(normalizedUrls)); + + for (const candidate of urlsToUpdate) { + const row = selectSearchSeenStmt.get(candidate); + const candidateCount = row ? Math.max(row.seen_count, desiredCount) : desiredCount; + if (row) { + updateSearchSeenStmt.run(candidateCount, 1, candidate); + } else { + insertSearchSeenStmt.run(candidate, candidateCount, 1); + } + } + + return res.json({ seen_count: desiredCount, should_hide: true, manually_hidden: true }); + } + + if (skip_increment) { + if (!existingRow) { + return res.json({ seen_count: 0, should_hide: false, manually_hidden: false }); + } + const seenCount = existingRow.seen_count; + const shouldHide = seenCount >= SEARCH_POST_HIDE_THRESHOLD || existingManualHidden; + return res.json({ seen_count: seenCount, should_hide: shouldHide, manually_hidden: existingManualHidden }); + } + + let seenCount = existingRow ? existingRow.seen_count + 1 : 1; + const manualHidden = existingManualHidden; + + if (existingRow) { + updateSearchSeenStmt.run(seenCount, manualHidden ? 1 : 0, targetUrl); + } else { + insertSearchSeenStmt.run(targetUrl, seenCount, manualHidden ? 1 : 0); + } + + const shouldHide = seenCount >= SEARCH_POST_HIDE_THRESHOLD || manualHidden; + res.json({ seen_count: seenCount, should_hide: shouldHide, manually_hidden: manualHidden }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.delete('/api/search-posts', (req, res) => { + try { + db.prepare('DELETE FROM search_seen_posts').run(); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.get('/api/profile-state', (req, res) => { + try { + const scopeId = req.profileScope; + let profileNumber = getScopedProfileNumber(scopeId); + if (!profileNumber) { + profileNumber = 1; + setScopedProfileNumber(scopeId, profileNumber); + } + + res.json({ profile_number: profileNumber }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.post('/api/profile-state', (req, res) => { + try { + const { profile_number } = req.body; + if (typeof profile_number === 'undefined') { + return res.status(400).json({ error: 'profile_number is required' }); + } + + const parsed = parseInt(profile_number, 10); + if (Number.isNaN(parsed) || parsed < 1 || parsed > 5) { + return res.status(400).json({ error: 'profile_number must be between 1 and 5' }); + } + + const scopeId = req.profileScope; + const sanitized = sanitizeProfileNumber(parsed) || 1; + setScopedProfileNumber(scopeId, sanitized); + + res.json({ profile_number: sanitized }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.post('/api/posts/:postId/screenshot', (req, res) => { + try { + const { postId } = req.params; + const { imageData } = req.body; + + const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); + if (!post) { + return res.status(404).json({ error: 'Post not found' }); + } + + if (!imageData || typeof imageData !== 'string') { + return res.status(400).json({ error: 'imageData is required' }); + } + + const match = imageData.match(/^data:(image\/\w+);base64,(.+)$/); + if (!match) { + return res.status(400).json({ error: 'Invalid image data format' }); + } + + const mimeType = match[1]; + const base64 = match[2]; + const extension = mimeType === 'image/jpeg' ? 'jpg' : 'png'; + const buffer = Buffer.from(base64, 'base64'); + const fileName = `${postId}.${extension}`; + const filePath = path.join(screenshotDir, fileName); + + if (post.screenshot_path && post.screenshot_path !== fileName) { + const existingPath = path.join(screenshotDir, post.screenshot_path); + if (fs.existsSync(existingPath)) { + try { + fs.unlinkSync(existingPath); + } catch (error) { + console.warn('Failed to remove previous screenshot:', error.message); + } + } + } + + fs.writeFileSync(filePath, buffer); + db.prepare('UPDATE posts SET screenshot_path = ?, last_change = CURRENT_TIMESTAMP WHERE id = ?').run(fileName, postId); + + const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); + res.json(mapPostRow(updatedPost)); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.get('/api/posts/:postId/screenshot', (req, res) => { + try { + const { postId } = req.params; + const post = db.prepare('SELECT screenshot_path FROM posts WHERE id = ?').get(postId); + + // Placeholder path (mounted in Docker container) + const placeholderPath = path.join(__dirname, 'noScreenshot.png'); + + if (!post || !post.screenshot_path) { + // Return placeholder image + if (fs.existsSync(placeholderPath)) { + res.set('Cache-Control', 'public, max-age=86400'); + return res.sendFile(placeholderPath); + } + return res.status(404).json({ error: 'Screenshot not found' }); + } + + const filePath = path.join(screenshotDir, post.screenshot_path); + if (!fs.existsSync(filePath)) { + // Return placeholder image + if (fs.existsSync(placeholderPath)) { + res.set('Cache-Control', 'public, max-age=86400'); + return res.sendFile(placeholderPath); + } + return res.status(404).json({ error: 'Screenshot not found' }); + } + + res.set('Cache-Control', 'no-store'); + res.sendFile(filePath); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Create new post +app.post('/api/posts', (req, res) => { + try { + const { + url, + title, + target_count, + created_by_profile, + created_by_name, + profile_number, + deadline_at + } = req.body; + + const validatedTargetCount = validateTargetCount(typeof target_count === 'undefined' ? 1 : target_count); + + const normalizedUrl = normalizeFacebookPostUrl(url); + + if (!normalizedUrl) { + return res.status(400).json({ error: 'URL must be a valid Facebook link' }); + } + + if (!validatedTargetCount) { + return res.status(400).json({ error: 'target_count must be between 1 and 5' }); + } + + const id = uuidv4(); + + let creatorProfile = sanitizeProfileNumber(created_by_profile); + if (!creatorProfile) { + creatorProfile = sanitizeProfileNumber(profile_number) || null; + } + + let normalizedDeadline = null; + if (typeof deadline_at !== 'undefined' && deadline_at !== null && String(deadline_at).trim() !== '') { + normalizedDeadline = normalizeDeadline(deadline_at); + if (!normalizedDeadline) { + return res.status(400).json({ error: 'deadline_at must be a valid date string' }); + } + } + + const creatorDisplayName = normalizeCreatorName(created_by_name); + + const stmt = db.prepare(` + INSERT INTO posts (id, url, title, target_count, checked_count, screenshot_path, created_by_profile, created_by_name, deadline_at, last_change) + VALUES (?, ?, ?, ?, 0, NULL, ?, ?, ?, CURRENT_TIMESTAMP) + `); + stmt.run(id, normalizedUrl, title || '', validatedTargetCount, creatorProfile, creatorDisplayName, normalizedDeadline); + + const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(id); + + removeSearchSeenEntries([normalizedUrl]); + + res.json(mapPostRow(post)); + } catch (error) { + if (error.message.includes('UNIQUE constraint failed')) { + res.status(409).json({ error: 'Post with this URL already exists' }); + } else { + res.status(500).json({ error: error.message }); + } + } +}); + +app.put('/api/posts/:postId', (req, res) => { + try { + const { postId } = req.params; + const { target_count, title, created_by_profile, created_by_name, deadline_at, url } = req.body || {}; + + const updates = []; + const params = []; + let normalizedUrlForCleanup = null; + + if (typeof target_count !== 'undefined') { + const validatedTargetCount = validateTargetCount(target_count); + if (!validatedTargetCount) { + return res.status(400).json({ error: 'target_count must be between 1 and 5' }); + } + updates.push('target_count = ?'); + params.push(validatedTargetCount); + } + + if (typeof title !== 'undefined') { + updates.push('title = ?'); + params.push(title || ''); + } + + if (typeof created_by_profile !== 'undefined') { + const sanitized = sanitizeProfileNumber(created_by_profile); + if (created_by_profile !== null && typeof created_by_profile !== 'undefined' && !sanitized) { + return res.status(400).json({ error: 'created_by_profile must be between 1 and 5 or null' }); + } + updates.push('created_by_profile = ?'); + params.push(sanitized || null); + } + + if (typeof created_by_name !== 'undefined') { + const normalizedName = normalizeCreatorName(created_by_name); + updates.push('created_by_name = ?'); + params.push(normalizedName); + } + + if (typeof deadline_at !== 'undefined') { + let normalizedDeadline = null; + const rawDeadline = deadline_at; + if (rawDeadline !== null && String(rawDeadline).trim() !== '') { + normalizedDeadline = normalizeDeadline(rawDeadline); + if (!normalizedDeadline) { + return res.status(400).json({ error: 'deadline_at must be a valid date string' }); + } + } + updates.push('deadline_at = ?'); + params.push(normalizedDeadline); + } + + if (typeof url !== 'undefined') { + const normalizedUrl = normalizeFacebookPostUrl(url); + if (!normalizedUrl) { + return res.status(400).json({ error: 'url must be a valid Facebook link' }); + } + updates.push('url = ?'); + params.push(normalizedUrl); + normalizedUrlForCleanup = normalizedUrl; + } + + if (!updates.length) { + return res.status(400).json({ error: 'No valid fields to update' }); + } + + updates.push('last_change = CURRENT_TIMESTAMP'); + params.push(postId); + + const stmt = db.prepare(`UPDATE posts SET ${updates.join(', ')} WHERE id = ?`); + let result; + try { + result = stmt.run(...params); + } catch (error) { + if (error && error.code === 'SQLITE_CONSTRAINT_UNIQUE') { + return res.status(409).json({ error: 'Post with this URL already exists' }); + } + throw error; + } + + if (result.changes === 0) { + return res.status(404).json({ error: 'Post not found' }); + } + + recalcCheckedCount(postId); + + const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); + + if (normalizedUrlForCleanup) { + removeSearchSeenEntries([normalizedUrlForCleanup]); + } + + res.json(mapPostRow(updatedPost)); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Check a post for a profile +app.post('/api/posts/:postId/check', (req, res) => { + try { + const { postId } = req.params; + const { profile_number } = req.body; + + const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); + if (!post) { + return res.status(404).json({ error: 'Post not found' }); + } + + // Check if deadline has passed + if (post.deadline_at) { + const deadline = new Date(post.deadline_at); + if (new Date() > deadline) { + return res.status(400).json({ error: 'Deadline ist abgelaufen' }); + } + } + + const requiredProfiles = getRequiredProfiles(post.target_count); + let didChange = false; + if (post.target_count !== requiredProfiles.length) { + db.prepare('UPDATE posts SET target_count = ?, last_change = CURRENT_TIMESTAMP WHERE id = ?').run(requiredProfiles.length, postId); + post.target_count = requiredProfiles.length; + didChange = true; + } + + let profileValue = sanitizeProfileNumber(profile_number); + if (!profileValue) { + const storedProfile = getScopedProfileNumber(req.profileScope); + profileValue = storedProfile || requiredProfiles[0]; + } + + if (!requiredProfiles.includes(profileValue)) { + return res.status(409).json({ error: 'Dieses Profil ist für diesen Beitrag nicht erforderlich.' }); + } + + const existingCheck = db.prepare( + 'SELECT id FROM checks WHERE post_id = ? AND profile_number = ?' + ).get(postId, profileValue); + + if (existingCheck) { + const existingPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); + return res.json(mapPostRow(existingPost)); + } + + // Allow creator to check immediately, regardless of profile number + const isCreator = post.created_by_profile === profileValue; + + if (requiredProfiles.length > 0 && !isCreator) { + const prerequisiteProfiles = requiredProfiles.slice(0, requiredProfiles.indexOf(profileValue)); + if (prerequisiteProfiles.length) { + const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(postId); + const completedSet = new Set( + completedRows + .map(row => sanitizeProfileNumber(row.profile_number)) + .filter(Boolean) + ); + + const missingPrerequisites = prerequisiteProfiles.filter(num => !completedSet.has(num)); + if (missingPrerequisites.length) { + return res.status(409).json({ + error: 'Vorherige Profile müssen zuerst bestätigen.', + missing_profiles: missingPrerequisites + }); + } + } + } + + const insertStmt = db.prepare('INSERT INTO checks (post_id, profile_number) VALUES (?, ?)'); + insertStmt.run(postId, profileValue); + didChange = true; + recalcCheckedCount(postId); + if (didChange) { + touchPost(postId); + } + + const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); + + res.json(mapPostRow(updatedPost)); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Check by URL (for web interface auto-check) +app.post('/api/check-by-url', (req, res) => { + try { + const { url, profile_number } = req.body; + + if (!url) { + return res.status(400).json({ error: 'URL is required' }); + } + + const normalizedUrl = normalizeFacebookPostUrl(url); + if (!normalizedUrl) { + return res.status(400).json({ error: 'URL must be a valid Facebook link' }); + } + + const post = db.prepare('SELECT * FROM posts WHERE url = ?').get(normalizedUrl); + if (!post) { + return res.status(404).json({ error: 'Post not found' }); + } + + // Check if deadline has passed + if (post.deadline_at) { + const deadline = new Date(post.deadline_at); + if (new Date() > deadline) { + return res.status(400).json({ error: 'Deadline ist abgelaufen' }); + } + } + + const requiredProfiles = getRequiredProfiles(post.target_count); + let didChange = false; + if (post.target_count !== requiredProfiles.length) { + db.prepare('UPDATE posts SET target_count = ?, last_change = CURRENT_TIMESTAMP WHERE id = ?').run(requiredProfiles.length, post.id); + post.target_count = requiredProfiles.length; + didChange = true; + } + + let profileValue = sanitizeProfileNumber(profile_number); + if (!profileValue) { + const storedProfile = getScopedProfileNumber(req.profileScope); + profileValue = storedProfile || requiredProfiles[0]; + } + + if (!requiredProfiles.includes(profileValue)) { + return res.status(409).json({ error: 'Dieses Profil ist für diesen Beitrag nicht erforderlich.' }); + } + + const existingCheck = db.prepare( + 'SELECT id FROM checks WHERE post_id = ? AND profile_number = ?' + ).get(post.id, profileValue); + + if (existingCheck) { + const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(post.id); + return res.json(mapPostRow(updatedPost)); + } + + // Allow creator to check immediately, regardless of profile number + const isCreator = post.created_by_profile === profileValue; + + if (requiredProfiles.length > 0 && !isCreator) { + const prerequisiteProfiles = requiredProfiles.slice(0, requiredProfiles.indexOf(profileValue)); + if (prerequisiteProfiles.length) { + const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(post.id); + const completedSet = new Set( + completedRows + .map(row => sanitizeProfileNumber(row.profile_number)) + .filter(Boolean) + ); + + const missingPrerequisites = prerequisiteProfiles.filter(num => !completedSet.has(num)); + if (missingPrerequisites.length) { + return res.status(409).json({ + error: 'Vorherige Profile müssen zuerst bestätigen.', + missing_profiles: missingPrerequisites + }); + } + } + } + + db.prepare('INSERT INTO checks (post_id, profile_number) VALUES (?, ?)').run(post.id, profileValue); + didChange = true; + recalcCheckedCount(post.id); + if (didChange) { + touchPost(post.id); + } + + const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(post.id); + + res.json(mapPostRow(updatedPost)); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.post('/api/posts/:postId/profile-status', (req, res) => { + try { + const { postId } = req.params; + const { profile_number, status } = req.body || {}; + + const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); + if (!post) { + return res.status(404).json({ error: 'Post not found' }); + } + + // Check if deadline has passed (only for setting to 'done') + if (status === 'done' && post.deadline_at) { + const deadline = new Date(post.deadline_at); + if (new Date() > deadline) { + return res.status(400).json({ error: 'Deadline ist abgelaufen' }); + } + } + + const requiredProfiles = getRequiredProfiles(post.target_count); + let didChange = false; + if (post.target_count !== requiredProfiles.length) { + db.prepare('UPDATE posts SET target_count = ?, last_change = CURRENT_TIMESTAMP WHERE id = ?').run(requiredProfiles.length, post.id); + post.target_count = requiredProfiles.length; + didChange = true; + } + + const profileValue = sanitizeProfileNumber(profile_number); + if (!profileValue) { + return res.status(400).json({ error: 'Valid profile_number required' }); + } + + if (!requiredProfiles.includes(profileValue)) { + return res.status(409).json({ error: 'Dieses Profil ist für diesen Beitrag nicht erforderlich.' }); + } + + const normalizedStatus = status === 'done' ? 'done' : 'pending'; + + if (normalizedStatus === 'done') { + // Allow creator to check immediately, regardless of profile number + const isCreator = post.created_by_profile === profileValue; + + if (!isCreator) { + const prerequisiteProfiles = requiredProfiles.slice(0, requiredProfiles.indexOf(profileValue)); + if (prerequisiteProfiles.length) { + const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(postId); + const completedSet = new Set( + completedRows + .map(row => sanitizeProfileNumber(row.profile_number)) + .filter(Boolean) + ); + + const missingPrerequisites = prerequisiteProfiles.filter(num => !completedSet.has(num)); + if (missingPrerequisites.length) { + return res.status(409).json({ + error: 'Vorherige Profile müssen zuerst bestätigen.', + missing_profiles: missingPrerequisites + }); + } + } + } + + const existingCheck = db.prepare( + 'SELECT id FROM checks WHERE post_id = ? AND profile_number = ?' + ).get(postId, profileValue); + + if (!existingCheck) { + db.prepare('INSERT INTO checks (post_id, profile_number) VALUES (?, ?)').run(postId, profileValue); + didChange = true; + } + } else { + db.prepare('DELETE FROM checks WHERE post_id = ? AND profile_number = ?').run(postId, profileValue); + didChange = true; + } + + recalcCheckedCount(postId); + if (didChange) { + touchPost(postId); + } + + const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); + res.json(mapPostRow(updatedPost)); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Update post URL or success status +app.patch('/api/posts/:postId', (req, res) => { + try { + const { postId } = req.params; + const { url, is_successful } = req.body; + + // Check if post exists + const post = db.prepare('SELECT id FROM posts WHERE id = ?').get(postId); + if (!post) { + return res.status(404).json({ error: 'Post not found' }); + } + + if (url !== undefined) { + const normalizedUrl = normalizeFacebookPostUrl(url); + if (!normalizedUrl) { + return res.status(400).json({ error: 'Invalid Facebook URL' }); + } + + // Check for URL conflicts + const conflict = db.prepare('SELECT id FROM posts WHERE url = ? AND id != ?').get(normalizedUrl, postId); + if (conflict) { + return res.status(409).json({ error: 'URL already used by another post' }); + } + + // Update URL + db.prepare('UPDATE posts SET url = ? WHERE id = ?').run(normalizedUrl, postId); + removeSearchSeenEntries([normalizedUrl]); + return res.json({ success: true, url: normalizedUrl }); + } + + if (is_successful !== undefined) { + const successValue = is_successful ? 1 : 0; + db.prepare('UPDATE posts SET is_successful = ? WHERE id = ?').run(successValue, postId); + + const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); + return res.json(mapPostRow(updatedPost)); + } + + return res.status(400).json({ error: 'No valid update parameter provided' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Delete post +app.delete('/api/posts/:postId', (req, res) => { + try { + const { postId } = req.params; + + const post = db.prepare('SELECT screenshot_path FROM posts WHERE id = ?').get(postId); + + db.prepare('DELETE FROM checks WHERE post_id = ?').run(postId); + const result = db.prepare('DELETE FROM posts WHERE id = ?').run(postId); + + if (result.changes === 0) { + return res.status(404).json({ error: 'Post not found' }); + } + + if (post && post.screenshot_path) { + const filePath = path.join(screenshotDir, post.screenshot_path); + if (fs.existsSync(filePath)) { + try { + fs.unlinkSync(filePath); + } catch (error) { + console.warn('Failed to remove screenshot:', error.message); + } + } + } + + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// AI Credentials endpoints +app.get('/api/ai-credentials', (req, res) => { + try { + const credentials = db.prepare('SELECT id, name, provider, model, base_url, is_active, priority, created_at, updated_at FROM ai_credentials ORDER BY priority ASC, id ASC').all(); + res.json(credentials); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.post('/api/ai-credentials', (req, res) => { + try { + const { name, provider, api_key, model, base_url } = req.body; + + const trimmedName = typeof name === 'string' ? name.trim() : ''; + const trimmedProvider = typeof provider === 'string' ? provider.trim() : ''; + const trimmedApiKey = typeof api_key === 'string' ? api_key.trim() : ''; + const trimmedModel = typeof model === 'string' ? model.trim() : ''; + const rawBaseUrl = typeof base_url === 'string' ? base_url.trim() : ''; + const normalizedBaseUrl = (trimmedProvider === 'openai' && rawBaseUrl) + ? rawBaseUrl.replace(/\/+$/, '') + : ''; + + if (!trimmedName || !trimmedProvider) { + return res.status(400).json({ error: 'Name und Provider sind erforderlich' }); + } + + if (normalizedBaseUrl && !/^https?:\/\//i.test(normalizedBaseUrl)) { + return res.status(400).json({ error: 'Basis-URL muss mit http:// oder https:// beginnen' }); + } + + let finalApiKey = trimmedApiKey; + if (!finalApiKey) { + if (trimmedProvider === 'openai' && normalizedBaseUrl) { + finalApiKey = ''; + } else { + return res.status(400).json({ error: 'API-Schlüssel wird benötigt' }); + } + } + + const result = db.prepare(` + INSERT INTO ai_credentials (name, provider, api_key, model, base_url) + VALUES (?, ?, ?, ?, ?) + `).run( + trimmedName, + trimmedProvider, + finalApiKey, + trimmedModel || null, + normalizedBaseUrl || null + ); + + const credential = db.prepare('SELECT id, name, provider, model, base_url, is_active, created_at, updated_at FROM ai_credentials WHERE id = ?').get(result.lastInsertRowid); + res.json(credential); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.put('/api/ai-credentials/:id', (req, res) => { + try { + const { id } = req.params; + const { name, provider, api_key, model, base_url } = req.body; + + const credentialId = parseInt(id, 10); + const existing = db.prepare('SELECT * FROM ai_credentials WHERE id = ?').get(credentialId); + + if (!existing) { + return res.status(404).json({ error: 'Credential nicht gefunden' }); + } + + const trimmedName = typeof name === 'string' ? name.trim() : existing.name; + const trimmedProvider = typeof provider === 'string' ? provider.trim() : existing.provider; + if (!trimmedName || !trimmedProvider) { + return res.status(400).json({ error: 'Name und Provider sind erforderlich' }); + } + const trimmedModel = typeof model === 'string' ? model.trim() : existing.model || ''; + const rawBaseUrl = typeof base_url === 'string' ? base_url.trim() : (existing.base_url || ''); + const normalizedBaseUrl = (trimmedProvider === 'openai' && rawBaseUrl) + ? rawBaseUrl.replace(/\/+$/, '') + : ''; + + let apiKeyProvided = api_key !== undefined; + let trimmedApiKey = typeof api_key === 'string' ? api_key.trim() : ''; + + if (!apiKeyProvided) { + trimmedApiKey = existing.api_key; + } else if (!trimmedApiKey && !(trimmedProvider === 'openai' && normalizedBaseUrl)) { + return res.status(400).json({ error: 'API-Schlüssel wird benötigt' }); + } + + if (normalizedBaseUrl && !/^https?:\/\//i.test(normalizedBaseUrl)) { + return res.status(400).json({ error: 'Basis-URL muss mit http:// oder https:// beginnen' }); + } + + if (trimmedProvider === 'openai' && !trimmedApiKey && !normalizedBaseUrl) { + return res.status(400).json({ error: 'Für OpenAI wird ein API-Schlüssel benötigt, wenn keine Basis-URL angegeben ist' }); + } + + db.prepare(` + UPDATE ai_credentials + SET name = ?, provider = ?, api_key = ?, model = ?, base_url = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run( + trimmedName, + trimmedProvider, + trimmedApiKey, + trimmedModel || null, + normalizedBaseUrl || null, + credentialId + ); + + const credential = db.prepare('SELECT id, name, provider, model, base_url, is_active, created_at, updated_at FROM ai_credentials WHERE id = ?').get(credentialId); + res.json(credential); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.patch('/api/ai-credentials/:id', (req, res) => { + try { + const { id } = req.params; + const { is_active } = req.body; + + if (is_active === undefined) { + return res.status(400).json({ error: 'is_active is required' }); + } + + db.prepare(` + UPDATE ai_credentials + SET is_active = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run(is_active, id); + + const credential = db.prepare('SELECT id, name, provider, model, base_url, is_active, priority, created_at, updated_at FROM ai_credentials WHERE id = ?').get(id); + res.json(credential); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.post('/api/ai-credentials/reorder', (req, res) => { + try { + const { order } = req.body; // Array of IDs in new order + + if (!Array.isArray(order)) { + return res.status(400).json({ error: 'order must be an array' }); + } + + // Update priorities based on order + order.forEach((id, index) => { + db.prepare('UPDATE ai_credentials SET priority = ? WHERE id = ?').run(index, id); + }); + + const credentials = db.prepare('SELECT id, name, provider, model, base_url, is_active, priority, created_at, updated_at FROM ai_credentials ORDER BY priority ASC, id ASC').all(); + res.json(credentials); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.delete('/api/ai-credentials/:id', (req, res) => { + try { + const { id } = req.params; + + // Check if this credential is active + const settings = db.prepare('SELECT active_credential_id FROM ai_settings WHERE id = 1').get(); + if (settings && settings.active_credential_id === parseInt(id)) { + return res.status(400).json({ error: 'Cannot delete active credential' }); + } + + db.prepare('DELETE FROM ai_credentials WHERE id = ?').run(id); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// AI Settings endpoints +app.get('/api/ai-settings', (req, res) => { + try { + let settings = db.prepare('SELECT * FROM ai_settings WHERE id = 1').get(); + + if (!settings) { + settings = { + id: 1, + active_credential_id: null, + prompt_prefix: 'Schreibe einen freundlichen, authentischen Kommentar auf Deutsch zu folgendem Facebook-Post. Der Kommentar soll natürlich wirken und maximal 2-3 Sätze lang sein:\n\n', + enabled: 0, + updated_at: null + }; + } + + // Get active credential if set + let activeCredential = null; + if (settings.active_credential_id) { + activeCredential = db.prepare('SELECT id, name, provider, model FROM ai_credentials WHERE id = ?').get(settings.active_credential_id); + } + + res.json({ + ...settings, + active_credential: activeCredential + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.put('/api/ai-settings', (req, res) => { + try { + const { active_credential_id, prompt_prefix, enabled } = req.body; + + const existing = db.prepare('SELECT * FROM ai_settings WHERE id = 1').get(); + + if (existing) { + db.prepare(` + UPDATE ai_settings + SET active_credential_id = ?, prompt_prefix = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = 1 + `).run(active_credential_id || null, prompt_prefix, enabled ? 1 : 0); + } else { + db.prepare(` + INSERT INTO ai_settings (id, active_credential_id, prompt_prefix, enabled, updated_at) + VALUES (1, ?, ?, ?, CURRENT_TIMESTAMP) + `).run(active_credential_id || null, prompt_prefix, enabled ? 1 : 0); + } + + const updated = db.prepare('SELECT * FROM ai_settings WHERE id = 1').get(); + + let activeCredential = null; + if (updated.active_credential_id) { + activeCredential = db.prepare('SELECT id, name, provider, model FROM ai_credentials WHERE id = ?').get(updated.active_credential_id); + } + + res.json({ + ...updated, + active_credential: activeCredential + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +function sanitizeAIComment(text) { + if (!text) { + return ''; + } + + return text + .replace(/[\s\S]*?<\/think>/gi, '') + .replace(/\s{2,}/g, ' ') + .trim(); +} + +async function tryGenerateComment(credential, promptPrefix, postText) { + const provider = credential.provider; + const apiKey = credential.api_key; + const model = credential.model; + + let comment = ''; + + if (provider === 'gemini') { + // Gemini API + const modelName = model || 'gemini-2.0-flash-exp'; + const prompt = promptPrefix + postText; + + const response = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/${modelName}:generateContent?key=${apiKey}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contents: [{ + parts: [{ text: prompt }] + }] + }) + } + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(`Gemini API error: ${errorData.error?.message || response.statusText}`); + } + + const data = await response.json(); + comment = data.candidates?.[0]?.content?.parts?.[0]?.text || ''; + + } else if (provider === 'openai') { + // OpenAI/ChatGPT API + const modelName = model || 'gpt-3.5-turbo'; + const prompt = promptPrefix + postText; + + const baseUrl = (credential.base_url || 'https://api.openai.com/v1').trim().replace(/\/+$/, ''); + const endpoint = baseUrl.endsWith('/chat/completions') ? baseUrl : `${baseUrl}/chat/completions`; + + const headers = { + 'Content-Type': 'application/json' + }; + if (apiKey) { + headers['Authorization'] = `Bearer ${apiKey}`; + } + + const response = await fetch(endpoint, { + method: 'POST', + headers, + body: JSON.stringify({ + model: modelName, + messages: [{ role: 'user', content: prompt }], + max_tokens: 150 + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + const message = errorData.error?.message || response.statusText; + throw new Error(`OpenAI API error: ${message}`); + } + + const data = await response.json(); + comment = data.choices?.[0]?.message?.content || ''; + + } else if (provider === 'claude') { + // Anthropic Claude API + const modelName = model || 'claude-3-5-haiku-20241022'; + const prompt = promptPrefix + postText; + + const response = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01' + }, + body: JSON.stringify({ + model: modelName, + max_tokens: 150, + messages: [{ role: 'user', content: prompt }] + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(`Claude API error: ${errorData.error?.message || response.statusText}`); + } + + const data = await response.json(); + comment = data.content?.[0]?.text || ''; + + } else { + throw new Error(`Unsupported AI provider: ${provider}`); + } + + return sanitizeAIComment(comment); +} + +app.post('/api/ai/generate-comment', async (req, res) => { + try { + const { postText, profileNumber, preferredCredentialId } = req.body; + + if (!postText) { + return res.status(400).json({ error: 'postText is required' }); + } + + const settings = db.prepare('SELECT * FROM ai_settings WHERE id = 1').get(); + + if (!settings || !settings.enabled) { + return res.status(400).json({ error: 'AI comment generation is not enabled' }); + } + + // Get all active credentials, ordered by priority + const credentials = db.prepare('SELECT * FROM ai_credentials WHERE is_active = 1 ORDER BY priority ASC, id ASC').all(); + + if (!credentials || credentials.length === 0) { + return res.status(400).json({ error: 'No active AI credentials available' }); + } + + let orderedCredentials = credentials; + if (typeof preferredCredentialId !== 'undefined' && preferredCredentialId !== null) { + const parsedPreferredId = Number(preferredCredentialId); + if (!Number.isNaN(parsedPreferredId)) { + const idx = credentials.findIndex(credential => credential.id === parsedPreferredId); + if (idx > 0) { + const preferred = credentials[idx]; + orderedCredentials = [preferred, ...credentials.slice(0, idx), ...credentials.slice(idx + 1)]; + } + } + } + + let promptPrefix = settings.prompt_prefix || ''; + + // Get friend names for the profile if available + if (profileNumber) { + const friends = db.prepare('SELECT friend_names FROM profile_friends WHERE profile_number = ?').get(profileNumber); + if (friends && friends.friend_names) { + promptPrefix = promptPrefix.replace('{FREUNDE}', friends.friend_names); + } else { + promptPrefix = promptPrefix.replace('{FREUNDE}', ''); + } + } else { + promptPrefix = promptPrefix.replace('{FREUNDE}', ''); + } + + // Try each active credential until one succeeds + let lastError = null; + for (const credential of orderedCredentials) { + try { + console.log(`Trying credential: ${credential.name} (ID: ${credential.id})`); + const comment = await tryGenerateComment(credential, promptPrefix, postText); + console.log(`Success with credential: ${credential.name}`); + return res.json({ comment, usedCredential: credential.name }); + } catch (error) { + console.error(`Failed with credential ${credential.name}:`, error.message); + lastError = error; + // Continue to next credential + } + } + + // If we get here, all credentials failed + throw lastError || new Error('All AI credentials failed'); + + } catch (error) { + console.error('AI comment generation error:', error); + res.status(500).json({ error: error.message }); + } +}); + +// ============================================================================ +// PROFILE FRIENDS API +// ============================================================================ + +// Get friends for a profile +app.get('/api/profile-friends/:profileNumber', (req, res) => { + try { + const profileNumber = parseInt(req.params.profileNumber); + const friends = db.prepare('SELECT * FROM profile_friends WHERE profile_number = ?').get(profileNumber); + + if (!friends) { + return res.json({ profile_number: profileNumber, friend_names: '' }); + } + + res.json(friends); + } catch (error) { + console.error('Error fetching profile friends:', error); + res.status(500).json({ error: error.message }); + } +}); + +// Update friends for a profile +app.put('/api/profile-friends/:profileNumber', (req, res) => { + try { + const profileNumber = parseInt(req.params.profileNumber); + const { friend_names } = req.body; + + if (friend_names === undefined) { + return res.status(400).json({ error: 'friend_names is required' }); + } + + const existing = db.prepare('SELECT * FROM profile_friends WHERE profile_number = ?').get(profileNumber); + + if (existing) { + db.prepare('UPDATE profile_friends SET friend_names = ?, updated_at = CURRENT_TIMESTAMP WHERE profile_number = ?') + .run(friend_names, profileNumber); + } else { + db.prepare('INSERT INTO profile_friends (profile_number, friend_names) VALUES (?, ?)') + .run(profileNumber, friend_names); + } + + const updated = db.prepare('SELECT * FROM profile_friends WHERE profile_number = ?').get(profileNumber); + res.json(updated); + } catch (error) { + console.error('Error updating profile friends:', error); + res.status(500).json({ error: error.message }); + } +}); + +// Health check +app.get('/health', (req, res) => { + res.json({ status: 'ok' }); +}); + +app.listen(PORT, '0.0.0.0', () => { + console.log(`Server running on port ${PORT}`); +}); diff --git a/data/_data/screenshots/0068dcfa-18cf-4aa2-aa43-24eb3a890391.jpg b/data/_data/screenshots/0068dcfa-18cf-4aa2-aa43-24eb3a890391.jpg new file mode 100644 index 0000000..d72fb65 Binary files /dev/null and b/data/_data/screenshots/0068dcfa-18cf-4aa2-aa43-24eb3a890391.jpg differ diff --git a/data/_data/screenshots/00b8b2a1-b1c2-4a1b-9b5d-5a7c532fb161.jpg b/data/_data/screenshots/00b8b2a1-b1c2-4a1b-9b5d-5a7c532fb161.jpg new file mode 100644 index 0000000..9ec58ef Binary files /dev/null and b/data/_data/screenshots/00b8b2a1-b1c2-4a1b-9b5d-5a7c532fb161.jpg differ diff --git a/data/_data/screenshots/025ebdc2-969f-4de4-a374-cfca28575636.jpg b/data/_data/screenshots/025ebdc2-969f-4de4-a374-cfca28575636.jpg new file mode 100644 index 0000000..02ceaf5 Binary files /dev/null and b/data/_data/screenshots/025ebdc2-969f-4de4-a374-cfca28575636.jpg differ diff --git a/data/_data/screenshots/0969792f-b150-4635-adae-09bbdc595011.jpg b/data/_data/screenshots/0969792f-b150-4635-adae-09bbdc595011.jpg new file mode 100644 index 0000000..d236f28 Binary files /dev/null and b/data/_data/screenshots/0969792f-b150-4635-adae-09bbdc595011.jpg differ diff --git a/data/_data/screenshots/0a017b13-6794-4133-96d2-147fd28d6ccf.jpg b/data/_data/screenshots/0a017b13-6794-4133-96d2-147fd28d6ccf.jpg new file mode 100644 index 0000000..47e198d Binary files /dev/null and b/data/_data/screenshots/0a017b13-6794-4133-96d2-147fd28d6ccf.jpg differ diff --git a/data/_data/screenshots/0f6e6fd4-790f-4627-995a-7ca3fd6d0ce1.jpg b/data/_data/screenshots/0f6e6fd4-790f-4627-995a-7ca3fd6d0ce1.jpg new file mode 100644 index 0000000..3767461 Binary files /dev/null and b/data/_data/screenshots/0f6e6fd4-790f-4627-995a-7ca3fd6d0ce1.jpg differ diff --git a/data/_data/screenshots/11069847-41c3-42a6-9d5a-cf0d4ae65ece.jpg b/data/_data/screenshots/11069847-41c3-42a6-9d5a-cf0d4ae65ece.jpg new file mode 100644 index 0000000..c1da749 Binary files /dev/null and b/data/_data/screenshots/11069847-41c3-42a6-9d5a-cf0d4ae65ece.jpg differ diff --git a/data/_data/screenshots/286db7e5-0d36-4c1c-a290-73b193470ca5.jpg b/data/_data/screenshots/286db7e5-0d36-4c1c-a290-73b193470ca5.jpg new file mode 100644 index 0000000..c3cf47c Binary files /dev/null and b/data/_data/screenshots/286db7e5-0d36-4c1c-a290-73b193470ca5.jpg differ diff --git a/data/_data/screenshots/338b3c24-ddaa-4666-acf9-05e5848e6ac4.jpg b/data/_data/screenshots/338b3c24-ddaa-4666-acf9-05e5848e6ac4.jpg new file mode 100644 index 0000000..4583d5a Binary files /dev/null and b/data/_data/screenshots/338b3c24-ddaa-4666-acf9-05e5848e6ac4.jpg differ diff --git a/data/_data/screenshots/347edaa2-ead7-49fc-8594-8db7b799e751.jpg b/data/_data/screenshots/347edaa2-ead7-49fc-8594-8db7b799e751.jpg new file mode 100644 index 0000000..db2b175 Binary files /dev/null and b/data/_data/screenshots/347edaa2-ead7-49fc-8594-8db7b799e751.jpg differ diff --git a/data/_data/screenshots/37827b3a-2b7a-41d6-9bab-234789c34545.jpg b/data/_data/screenshots/37827b3a-2b7a-41d6-9bab-234789c34545.jpg new file mode 100644 index 0000000..2e357d6 Binary files /dev/null and b/data/_data/screenshots/37827b3a-2b7a-41d6-9bab-234789c34545.jpg differ diff --git a/data/_data/screenshots/46eb7edd-536c-4bca-bae4-f96b8c6fecde.jpg b/data/_data/screenshots/46eb7edd-536c-4bca-bae4-f96b8c6fecde.jpg new file mode 100644 index 0000000..66b0ca9 Binary files /dev/null and b/data/_data/screenshots/46eb7edd-536c-4bca-bae4-f96b8c6fecde.jpg differ diff --git a/data/_data/screenshots/48564db4-d1bd-4ec9-b327-551c303388a4.jpg b/data/_data/screenshots/48564db4-d1bd-4ec9-b327-551c303388a4.jpg new file mode 100644 index 0000000..8f9f06e Binary files /dev/null and b/data/_data/screenshots/48564db4-d1bd-4ec9-b327-551c303388a4.jpg differ diff --git a/data/_data/screenshots/48f9c844-24b4-48a1-bb44-d45e54a8f7b1.jpg b/data/_data/screenshots/48f9c844-24b4-48a1-bb44-d45e54a8f7b1.jpg new file mode 100644 index 0000000..3bad484 Binary files /dev/null and b/data/_data/screenshots/48f9c844-24b4-48a1-bb44-d45e54a8f7b1.jpg differ diff --git a/data/_data/screenshots/49a2b60d-b14a-43d0-a02c-84f61c0914f2.jpg b/data/_data/screenshots/49a2b60d-b14a-43d0-a02c-84f61c0914f2.jpg new file mode 100644 index 0000000..1407a20 Binary files /dev/null and b/data/_data/screenshots/49a2b60d-b14a-43d0-a02c-84f61c0914f2.jpg differ diff --git a/data/_data/screenshots/4b4464a1-a38f-4454-bb98-0e696aed37bd.jpg b/data/_data/screenshots/4b4464a1-a38f-4454-bb98-0e696aed37bd.jpg new file mode 100644 index 0000000..40587d8 Binary files /dev/null and b/data/_data/screenshots/4b4464a1-a38f-4454-bb98-0e696aed37bd.jpg differ diff --git a/data/_data/screenshots/4de302e1-04e1-4292-8612-3f0503ec83ee.jpg b/data/_data/screenshots/4de302e1-04e1-4292-8612-3f0503ec83ee.jpg new file mode 100644 index 0000000..bd9cfeb Binary files /dev/null and b/data/_data/screenshots/4de302e1-04e1-4292-8612-3f0503ec83ee.jpg differ diff --git a/data/_data/screenshots/54b56007-6927-483e-ba06-a19c69e0ce06.jpg b/data/_data/screenshots/54b56007-6927-483e-ba06-a19c69e0ce06.jpg new file mode 100644 index 0000000..7d14f91 Binary files /dev/null and b/data/_data/screenshots/54b56007-6927-483e-ba06-a19c69e0ce06.jpg differ diff --git a/data/_data/screenshots/59c49a02-867f-435a-bf3b-13ca40a1e22a.jpg b/data/_data/screenshots/59c49a02-867f-435a-bf3b-13ca40a1e22a.jpg new file mode 100644 index 0000000..35786cb Binary files /dev/null and b/data/_data/screenshots/59c49a02-867f-435a-bf3b-13ca40a1e22a.jpg differ diff --git a/data/_data/screenshots/5bdc951b-8d12-4922-9055-98cbaddd62bc.jpg b/data/_data/screenshots/5bdc951b-8d12-4922-9055-98cbaddd62bc.jpg new file mode 100644 index 0000000..408595f Binary files /dev/null and b/data/_data/screenshots/5bdc951b-8d12-4922-9055-98cbaddd62bc.jpg differ diff --git a/data/_data/screenshots/5f691344-58b0-40a0-bb20-0ff11349d622.jpg b/data/_data/screenshots/5f691344-58b0-40a0-bb20-0ff11349d622.jpg new file mode 100644 index 0000000..6b305b7 Binary files /dev/null and b/data/_data/screenshots/5f691344-58b0-40a0-bb20-0ff11349d622.jpg differ diff --git a/data/_data/screenshots/6026f6bf-d7e5-47c4-8a3e-e16d5576bdc6.jpg b/data/_data/screenshots/6026f6bf-d7e5-47c4-8a3e-e16d5576bdc6.jpg new file mode 100644 index 0000000..c584c37 Binary files /dev/null and b/data/_data/screenshots/6026f6bf-d7e5-47c4-8a3e-e16d5576bdc6.jpg differ diff --git a/data/_data/screenshots/60ec7a10-1e50-4a6f-96e8-d865abb2c3e7.jpg b/data/_data/screenshots/60ec7a10-1e50-4a6f-96e8-d865abb2c3e7.jpg new file mode 100644 index 0000000..decad47 Binary files /dev/null and b/data/_data/screenshots/60ec7a10-1e50-4a6f-96e8-d865abb2c3e7.jpg differ diff --git a/data/_data/screenshots/661a7efd-db35-4963-8e86-da0f087c47e4.jpg b/data/_data/screenshots/661a7efd-db35-4963-8e86-da0f087c47e4.jpg new file mode 100644 index 0000000..660852c Binary files /dev/null and b/data/_data/screenshots/661a7efd-db35-4963-8e86-da0f087c47e4.jpg differ diff --git a/data/_data/screenshots/6656a743-1689-4492-a7f2-44fd5e2db051.jpg b/data/_data/screenshots/6656a743-1689-4492-a7f2-44fd5e2db051.jpg new file mode 100644 index 0000000..a6a9664 Binary files /dev/null and b/data/_data/screenshots/6656a743-1689-4492-a7f2-44fd5e2db051.jpg differ diff --git a/data/_data/screenshots/70158d79-a86d-4a21-824e-18a9ecccd7ed.jpg b/data/_data/screenshots/70158d79-a86d-4a21-824e-18a9ecccd7ed.jpg new file mode 100644 index 0000000..cfc799d Binary files /dev/null and b/data/_data/screenshots/70158d79-a86d-4a21-824e-18a9ecccd7ed.jpg differ diff --git a/data/_data/screenshots/753957f5-9508-474f-8055-99d7b0592279.jpg b/data/_data/screenshots/753957f5-9508-474f-8055-99d7b0592279.jpg new file mode 100644 index 0000000..7c8da33 Binary files /dev/null and b/data/_data/screenshots/753957f5-9508-474f-8055-99d7b0592279.jpg differ diff --git a/data/_data/screenshots/771ae55e-66e6-491e-9915-379cc543c6e1.jpg b/data/_data/screenshots/771ae55e-66e6-491e-9915-379cc543c6e1.jpg new file mode 100644 index 0000000..df484d2 Binary files /dev/null and b/data/_data/screenshots/771ae55e-66e6-491e-9915-379cc543c6e1.jpg differ diff --git a/data/_data/screenshots/77a234b7-6bd4-4e81-abae-76c93739b64b.jpg b/data/_data/screenshots/77a234b7-6bd4-4e81-abae-76c93739b64b.jpg new file mode 100644 index 0000000..593441c Binary files /dev/null and b/data/_data/screenshots/77a234b7-6bd4-4e81-abae-76c93739b64b.jpg differ diff --git a/data/_data/screenshots/7db61ad4-8e1f-46c2-bf52-266e946d3925.jpg b/data/_data/screenshots/7db61ad4-8e1f-46c2-bf52-266e946d3925.jpg new file mode 100644 index 0000000..a741b40 Binary files /dev/null and b/data/_data/screenshots/7db61ad4-8e1f-46c2-bf52-266e946d3925.jpg differ diff --git a/data/_data/screenshots/82643673-8a80-44a0-9de1-a67435306099.jpg b/data/_data/screenshots/82643673-8a80-44a0-9de1-a67435306099.jpg new file mode 100644 index 0000000..09fdc23 Binary files /dev/null and b/data/_data/screenshots/82643673-8a80-44a0-9de1-a67435306099.jpg differ diff --git a/data/_data/screenshots/88ca9a64-4415-4bc5-8ca0-f24ab0443806.jpg b/data/_data/screenshots/88ca9a64-4415-4bc5-8ca0-f24ab0443806.jpg new file mode 100644 index 0000000..860dace Binary files /dev/null and b/data/_data/screenshots/88ca9a64-4415-4bc5-8ca0-f24ab0443806.jpg differ diff --git a/data/_data/screenshots/8b9413ce-f2d3-4acb-a76b-1adb99068471.jpg b/data/_data/screenshots/8b9413ce-f2d3-4acb-a76b-1adb99068471.jpg new file mode 100644 index 0000000..7d9c099 Binary files /dev/null and b/data/_data/screenshots/8b9413ce-f2d3-4acb-a76b-1adb99068471.jpg differ diff --git a/data/_data/screenshots/9b71c2cb-072c-4b2d-8831-98aba98e6ed7.jpg b/data/_data/screenshots/9b71c2cb-072c-4b2d-8831-98aba98e6ed7.jpg new file mode 100644 index 0000000..5a9fc67 Binary files /dev/null and b/data/_data/screenshots/9b71c2cb-072c-4b2d-8831-98aba98e6ed7.jpg differ diff --git a/data/_data/screenshots/a5266ed6-7e7f-4275-a406-ebd1aaec93a2.jpg b/data/_data/screenshots/a5266ed6-7e7f-4275-a406-ebd1aaec93a2.jpg new file mode 100644 index 0000000..c13f3eb Binary files /dev/null and b/data/_data/screenshots/a5266ed6-7e7f-4275-a406-ebd1aaec93a2.jpg differ diff --git a/data/_data/screenshots/a6177dbe-5946-4ddc-b611-bd8e0dbdc732.jpg b/data/_data/screenshots/a6177dbe-5946-4ddc-b611-bd8e0dbdc732.jpg new file mode 100644 index 0000000..63031ac Binary files /dev/null and b/data/_data/screenshots/a6177dbe-5946-4ddc-b611-bd8e0dbdc732.jpg differ diff --git a/data/_data/screenshots/a6197b86-3162-4375-886d-53dfd79a403d.jpg b/data/_data/screenshots/a6197b86-3162-4375-886d-53dfd79a403d.jpg new file mode 100644 index 0000000..224f6a2 Binary files /dev/null and b/data/_data/screenshots/a6197b86-3162-4375-886d-53dfd79a403d.jpg differ diff --git a/data/_data/screenshots/aa1ff7c6-1e4c-4a33-bd92-c661d50bff9e.jpg b/data/_data/screenshots/aa1ff7c6-1e4c-4a33-bd92-c661d50bff9e.jpg new file mode 100644 index 0000000..e411382 Binary files /dev/null and b/data/_data/screenshots/aa1ff7c6-1e4c-4a33-bd92-c661d50bff9e.jpg differ diff --git a/data/_data/screenshots/ab3296ae-94f4-4884-b873-df0ece5ff4c8.jpg b/data/_data/screenshots/ab3296ae-94f4-4884-b873-df0ece5ff4c8.jpg new file mode 100644 index 0000000..c023451 Binary files /dev/null and b/data/_data/screenshots/ab3296ae-94f4-4884-b873-df0ece5ff4c8.jpg differ diff --git a/data/_data/screenshots/b1aaf381-927a-453c-9881-76a1cfd21a5b.jpg b/data/_data/screenshots/b1aaf381-927a-453c-9881-76a1cfd21a5b.jpg new file mode 100644 index 0000000..6d11c4b Binary files /dev/null and b/data/_data/screenshots/b1aaf381-927a-453c-9881-76a1cfd21a5b.jpg differ diff --git a/data/_data/screenshots/b1b4bc40-baac-4bac-91e5-79979ca4de53.jpg b/data/_data/screenshots/b1b4bc40-baac-4bac-91e5-79979ca4de53.jpg new file mode 100644 index 0000000..7bf9552 Binary files /dev/null and b/data/_data/screenshots/b1b4bc40-baac-4bac-91e5-79979ca4de53.jpg differ diff --git a/data/_data/screenshots/b62db094-4ed5-4dd3-b3c2-81c23aaea92c.jpg b/data/_data/screenshots/b62db094-4ed5-4dd3-b3c2-81c23aaea92c.jpg new file mode 100644 index 0000000..efb2090 Binary files /dev/null and b/data/_data/screenshots/b62db094-4ed5-4dd3-b3c2-81c23aaea92c.jpg differ diff --git a/data/_data/screenshots/b7b3765f-e6ed-436a-9f9b-3bb529f026ee.jpg b/data/_data/screenshots/b7b3765f-e6ed-436a-9f9b-3bb529f026ee.jpg new file mode 100644 index 0000000..def2d1a Binary files /dev/null and b/data/_data/screenshots/b7b3765f-e6ed-436a-9f9b-3bb529f026ee.jpg differ diff --git a/data/_data/screenshots/ba29024d-545f-4384-8b4a-ca7f41ea90f4.jpg b/data/_data/screenshots/ba29024d-545f-4384-8b4a-ca7f41ea90f4.jpg new file mode 100644 index 0000000..140d5ed Binary files /dev/null and b/data/_data/screenshots/ba29024d-545f-4384-8b4a-ca7f41ea90f4.jpg differ diff --git a/data/_data/screenshots/bcef9016-b3f0-474e-a8b1-f6bf9e11557c.jpg b/data/_data/screenshots/bcef9016-b3f0-474e-a8b1-f6bf9e11557c.jpg new file mode 100644 index 0000000..b9bd194 Binary files /dev/null and b/data/_data/screenshots/bcef9016-b3f0-474e-a8b1-f6bf9e11557c.jpg differ diff --git a/data/_data/screenshots/beb8be22-5a4b-4b64-aae1-4d3e008ffd58.jpg b/data/_data/screenshots/beb8be22-5a4b-4b64-aae1-4d3e008ffd58.jpg new file mode 100644 index 0000000..7217920 Binary files /dev/null and b/data/_data/screenshots/beb8be22-5a4b-4b64-aae1-4d3e008ffd58.jpg differ diff --git a/data/_data/screenshots/c4b71f22-754a-4567-b7e3-f5293d15922b.jpg b/data/_data/screenshots/c4b71f22-754a-4567-b7e3-f5293d15922b.jpg new file mode 100644 index 0000000..54ad8c2 Binary files /dev/null and b/data/_data/screenshots/c4b71f22-754a-4567-b7e3-f5293d15922b.jpg differ diff --git a/data/_data/screenshots/cf921190-f429-4293-9580-fb4499ef6099.jpg b/data/_data/screenshots/cf921190-f429-4293-9580-fb4499ef6099.jpg new file mode 100644 index 0000000..93debcc Binary files /dev/null and b/data/_data/screenshots/cf921190-f429-4293-9580-fb4499ef6099.jpg differ diff --git a/data/_data/screenshots/d97b86b0-cc5e-41bc-9471-d9be757a720d.jpg b/data/_data/screenshots/d97b86b0-cc5e-41bc-9471-d9be757a720d.jpg new file mode 100644 index 0000000..68bfa32 Binary files /dev/null and b/data/_data/screenshots/d97b86b0-cc5e-41bc-9471-d9be757a720d.jpg differ diff --git a/data/_data/screenshots/d9a3e791-e135-4d63-a376-637137f17187.jpg b/data/_data/screenshots/d9a3e791-e135-4d63-a376-637137f17187.jpg new file mode 100644 index 0000000..d3caa5f Binary files /dev/null and b/data/_data/screenshots/d9a3e791-e135-4d63-a376-637137f17187.jpg differ diff --git a/data/_data/screenshots/dc70e5e0-57db-4605-bb99-e85bd8b862dd.jpg b/data/_data/screenshots/dc70e5e0-57db-4605-bb99-e85bd8b862dd.jpg new file mode 100644 index 0000000..9e6ae95 Binary files /dev/null and b/data/_data/screenshots/dc70e5e0-57db-4605-bb99-e85bd8b862dd.jpg differ diff --git a/data/_data/screenshots/ded93d20-b3c6-4d1f-b1e1-6d96044b7257.jpg b/data/_data/screenshots/ded93d20-b3c6-4d1f-b1e1-6d96044b7257.jpg new file mode 100644 index 0000000..4c5ad1a Binary files /dev/null and b/data/_data/screenshots/ded93d20-b3c6-4d1f-b1e1-6d96044b7257.jpg differ diff --git a/data/_data/screenshots/e5f5a36c-3fd4-48a2-b742-9c231ded1098.jpg b/data/_data/screenshots/e5f5a36c-3fd4-48a2-b742-9c231ded1098.jpg new file mode 100644 index 0000000..53d640d Binary files /dev/null and b/data/_data/screenshots/e5f5a36c-3fd4-48a2-b742-9c231ded1098.jpg differ diff --git a/data/_data/screenshots/e5f60072-18a7-4303-a718-9f6beb6fb5b7.jpg b/data/_data/screenshots/e5f60072-18a7-4303-a718-9f6beb6fb5b7.jpg new file mode 100644 index 0000000..8be0f1a Binary files /dev/null and b/data/_data/screenshots/e5f60072-18a7-4303-a718-9f6beb6fb5b7.jpg differ diff --git a/data/_data/screenshots/eac0eb15-cea2-45b0-a00b-ffe68d8644d0.jpg b/data/_data/screenshots/eac0eb15-cea2-45b0-a00b-ffe68d8644d0.jpg new file mode 100644 index 0000000..1aca981 Binary files /dev/null and b/data/_data/screenshots/eac0eb15-cea2-45b0-a00b-ffe68d8644d0.jpg differ diff --git a/data/_data/screenshots/f2c8fdae-bb7f-4073-a06c-d9f6a03414b2.jpg b/data/_data/screenshots/f2c8fdae-bb7f-4073-a06c-d9f6a03414b2.jpg new file mode 100644 index 0000000..8804a2a Binary files /dev/null and b/data/_data/screenshots/f2c8fdae-bb7f-4073-a06c-d9f6a03414b2.jpg differ diff --git a/data/_data/screenshots/f9375c46-1653-49e3-b0c5-a5d52afe0673.jpg b/data/_data/screenshots/f9375c46-1653-49e3-b0c5-a5d52afe0673.jpg new file mode 100644 index 0000000..78735d0 Binary files /dev/null and b/data/_data/screenshots/f9375c46-1653-49e3-b0c5-a5d52afe0673.jpg differ diff --git a/data/_data/tracker.db b/data/_data/tracker.db new file mode 100644 index 0000000..d019f46 Binary files /dev/null and b/data/_data/tracker.db differ diff --git a/data/tracker.db b/data/tracker.db new file mode 100644 index 0000000..4e3a6b9 Binary files /dev/null and b/data/tracker.db differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..04e9c2a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +version: '3.8' + +services: + backend: + build: ./backend + container_name: fb-tracker-backend + ports: + - "3001:3000" + volumes: + - ./backend/server.js:/app/server.js:ro + - db-data:/app/data + environment: + - NODE_ENV=production + - PORT=3000 + labels: + - com.centurylinklabs.watchtower.enable=false + restart: unless-stopped + + web: + build: ./web + container_name: fb-tracker-web + ports: + - "8081:80" + depends_on: + - backend + restart: unless-stopped + +volumes: + db-data: \ No newline at end of file diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..b5b66c9 --- /dev/null +++ b/extension/background.js @@ -0,0 +1,48 @@ +// Background script for service worker +// Currently minimal, can be extended for additional functionality + +chrome.runtime.onInstalled.addListener(() => { + console.log('Facebook Post Tracker extension installed'); + + // Set default profile if not set + chrome.storage.sync.get(['profileNumber'], (result) => { + if (!result.profileNumber) { + chrome.storage.sync.set({ profileNumber: 1 }); + } + }); + + // Create context menu for manual post parsing + chrome.contextMenus.create({ + id: 'fb-tracker-reparse', + title: 'FB Tracker: Post neu parsen', + contexts: ['all'], + documentUrlPatterns: ['*://*.facebook.com/*'] + }); +}); + +chrome.contextMenus.onClicked.addListener((info, tab) => { + if (info.menuItemId === 'fb-tracker-reparse') { + chrome.tabs.sendMessage(tab.id, { + type: 'reparsePost', + x: info.pageX || 0, + y: info.pageY || 0 + }); + } +}); + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message && message.type === 'captureScreenshot') { + const windowId = sender && sender.tab ? sender.tab.windowId : chrome.windows.WINDOW_ID_CURRENT; + + chrome.tabs.captureVisibleTab(windowId, { format: 'jpeg', quality: 80 }, (imageData) => { + if (chrome.runtime.lastError) { + sendResponse({ error: chrome.runtime.lastError.message }); + return; + } + + sendResponse({ imageData }); + }); + return true; + } + return false; +}); diff --git a/extension/config.js b/extension/config.js new file mode 100644 index 0000000..a9f1b91 --- /dev/null +++ b/extension/config.js @@ -0,0 +1,3 @@ +// API Configuration +// Passe die URL an deine Deployment-Domain an +const API_BASE_URL = 'https://fb.srv.medeba-media.de'; diff --git a/extension/content.css b/extension/content.css new file mode 100644 index 0000000..111c016 --- /dev/null +++ b/extension/content.css @@ -0,0 +1,64 @@ +.fb-tracker-ui { + margin: 8px 0; + padding: 6px 12px; + background: #f0f2f5; + border-radius: 6px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +.fb-tracker-status, +.fb-tracker-add { + display: flex; + align-items: center; + gap: 6px; +} + +.fb-tracker-icon { + font-size: 16px; +} + +.fb-tracker-text { + flex: 1; + font-size: 13px; + color: #050505; + font-weight: 500; +} + +.fb-tracker-status.complete .fb-tracker-text { + color: #059669; +} + +.fb-tracker-count { + padding: 4px 6px; + border: 1px solid #ccd0d5; + border-radius: 4px; + font-size: 13px; + background: white; + cursor: pointer; +} + +.fb-tracker-add-btn, +.fb-tracker-check-btn { + padding: 4px 12px; + background: #1877f2; + color: white; + border: none; + border-radius: 4px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} + +.fb-tracker-add-btn:hover, +.fb-tracker-check-btn:hover { + background: #166fe5; +} + +.fb-tracker-check-btn { + background: #059669; +} + +.fb-tracker-check-btn:hover { + background: #047857; +} \ No newline at end of file diff --git a/extension/content.js b/extension/content.js new file mode 100644 index 0000000..6de4ed2 --- /dev/null +++ b/extension/content.js @@ -0,0 +1,3608 @@ +// Facebook Post Tracker Extension +// Uses API_BASE_URL from config.js + +const EXTENSION_VERSION = '1.1.0'; +const PROCESSED_ATTR = 'data-fb-tracker-processed'; +const PENDING_ATTR = 'data-fb-tracker-pending'; +const DIALOG_ROOT_SELECTOR = '[role="dialog"], [data-pagelet*="Modal"], [data-pagelet="StoriesRecentStoriesFeedSection"]'; +const API_URL = `${API_BASE_URL}/api`; +const MAX_SELECTION_LENGTH = 5000; +const postSelectionCache = new WeakMap(); +const LAST_SELECTION_MAX_AGE = 5000; +let selectionCacheTimeout = null; +let lastGlobalSelection = { text: '', timestamp: 0 }; +const processedPostUrls = new Map(); +const SEARCH_RESULTS_PATH = '/search/top'; +const FEED_HOME_PATHS = ['/', '/home.php']; +const sessionSearchRecordedUrls = new Set(); +const sessionSearchInfoCache = new Map(); + +const AI_CREDENTIAL_CACHE_TTL = 60 * 1000; // 1 minute cache +const aiCredentialCache = { + data: null, + timestamp: 0, + pending: null +}; + +console.log(`[FB Tracker v${EXTENSION_VERSION}] Extension loaded, API URL:`, API_URL); + +function backendFetch(url, options = {}) { + const config = { + ...options, + credentials: 'include' + }; + + if (options && options.headers) { + config.headers = { ...options.headers }; + } + + return fetch(url, config); +} + +async function fetchActiveAICredentials(forceRefresh = false) { + const now = Date.now(); + if (!forceRefresh && aiCredentialCache.data && (now - aiCredentialCache.timestamp < AI_CREDENTIAL_CACHE_TTL)) { + return aiCredentialCache.data; + } + + if (aiCredentialCache.pending) { + try { + return await aiCredentialCache.pending; + } catch (error) { + // fall through to retry below + } + } + + aiCredentialCache.pending = (async () => { + const response = await backendFetch(`${API_URL}/ai-credentials`); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || 'AI-Provider konnten nicht geladen werden'); + } + + const credentials = await response.json(); + let active = Array.isArray(credentials) + ? credentials.filter(entry => entry && Number(entry.is_active) === 1) + : []; + + if (active.length === 0 && Array.isArray(credentials)) { + active = credentials.slice(); + } + + aiCredentialCache.data = active; + aiCredentialCache.timestamp = Date.now(); + return active; + })(); + + try { + return await aiCredentialCache.pending; + } finally { + aiCredentialCache.pending = null; + } +} + +function formatAICredentialLabel(credential) { + if (!credential || typeof credential !== 'object') { + return 'Unbekannte AI'; + } + + const name = (credential.name || '').trim(); + const provider = (credential.provider || '').trim(); + const model = (credential.model || '').trim(); + + if (name) { + if (provider && model) { + return `${name} · ${provider}/${model}`; + } + if (provider) { + return `${name} · ${provider}`; + } + if (model) { + return `${name} · ${model}`; + } + return name; + } + + if (provider && model) { + return `${provider}/${model}`; + } + if (model) { + return model; + } + if (provider) { + return provider; + } + + return 'AI Provider'; +} + +document.addEventListener('selectionchange', () => { + if (selectionCacheTimeout) { + clearTimeout(selectionCacheTimeout); + } + selectionCacheTimeout = setTimeout(() => { + cacheCurrentSelection(); + selectionCacheTimeout = null; + }, 50); +}); + +// Profile state helpers +async function fetchBackendProfileNumber() { + try { + const response = await backendFetch(`${API_URL}/profile-state`); + if (!response.ok) { + return null; + } + const data = await response.json(); + if (data && data.profile_number) { + return data.profile_number; + } + } catch (error) { + console.warn('[FB Tracker] Failed to fetch profile state from backend:', error); + } + return null; +} + +function extractAuthorName(postElement) { + if (!postElement) { + return null; + } + + const selectors = [ + 'h2 strong a[role="link"]', + 'h2 span a[role="link"]', + 'a[role="link"][tabindex="0"] strong', + 'a[role="link"][tabindex="0"]' + ]; + + for (const selector of selectors) { + const node = postElement.querySelector(selector); + if (node && node.textContent) { + const text = node.textContent.trim(); + if (text) { + return text; + } + } + } + + const ariaLabelNode = postElement.querySelector('[aria-label]'); + if (ariaLabelNode) { + const ariaLabel = ariaLabelNode.getAttribute('aria-label'); + if (ariaLabel && ariaLabel.trim()) { + return ariaLabel.trim(); + } + } + + return null; +} + +function storeProfileNumberLocally(profileNumber) { + chrome.storage.sync.set({ profileNumber }); +} + +async function getProfileNumber() { + const backendProfile = await fetchBackendProfileNumber(); + if (backendProfile) { + storeProfileNumberLocally(backendProfile); + console.log('[FB Tracker] Profile number (backend):', backendProfile); + return backendProfile; + } + + return new Promise((resolve) => { + chrome.storage.sync.get(['profileNumber'], (result) => { + const profile = result.profileNumber || 1; + console.log('[FB Tracker] Profile number (local):', profile); + resolve(profile); + }); + }); +} + +// Extract post URL from post element +function cleanPostUrl(rawUrl) { + if (!rawUrl) { + return ''; + } + + const cftIndex = rawUrl.indexOf('__cft__'); + let trimmed = cftIndex !== -1 ? rawUrl.slice(0, cftIndex) : rawUrl; + trimmed = trimmed.replace(/[?&]$/, ''); + return trimmed; +} + +function toAbsoluteFacebookUrl(rawUrl) { + if (!rawUrl) { + return null; + } + + const cleaned = cleanPostUrl(rawUrl); + let url; + + try { + url = new URL(cleaned); + } catch (error) { + try { + url = new URL(cleaned, window.location.origin); + } catch (innerError) { + return null; + } + } + + const host = url.hostname.toLowerCase(); + if (!host.endsWith('facebook.com')) { + return null; + } + + return url; +} + +function isValidFacebookPostUrl(url) { + if (!url) { + return false; + } + + const path = url.pathname.toLowerCase(); + const searchParams = url.searchParams; + + const postPathPatterns = [ + '/posts/', + '/permalink/', + '/photos/', + '/videos/', + '/reel/', + '/watch/' + ]; + + if (postPathPatterns.some(pattern => path.includes(pattern))) { + return true; + } + + if (path.startsWith('/photo') && searchParams.has('fbid')) { + return true; + } + + if (path.startsWith('/photo.php') && searchParams.has('fbid')) { + return true; + } + + if (path === '/permalink.php' && searchParams.has('story_fbid')) { + return true; + } + + if (path.startsWith('/groups/') && searchParams.has('multi_permalinks')) { + return true; + } + + // /watch/ URLs with video ID parameter + if (path.startsWith('/watch') && searchParams.has('v')) { + return true; + } + + return false; +} + +function formatFacebookPostUrl(url) { + if (!url) { + return ''; + } + + let search = url.search; + if (search.endsWith('?') || search.endsWith('&')) { + search = search.slice(0, -1); + } + + return `${url.origin}${url.pathname}${search}`; +} + +function extractPostUrlCandidate(rawUrl) { + const absoluteUrl = toAbsoluteFacebookUrl(rawUrl); + if (!absoluteUrl || !isValidFacebookPostUrl(absoluteUrl)) { + return ''; + } + + const formatted = formatFacebookPostUrl(absoluteUrl); + return normalizeFacebookPostUrl(formatted); +} + +function getPostUrl(postElement, postNum = '?') { + console.log('[FB Tracker] Post #' + postNum + ' - Extracting URL from:', postElement); + + const allCandidates = []; + + // Strategy 1: Look for attribution links inside post + const attributionLinks = postElement.querySelectorAll('a[attributionsrc*="/privacy_sandbox/comet/"][href]'); + for (const link of attributionLinks) { + const candidate = extractPostUrlCandidate(link.href); + if (candidate && !allCandidates.includes(candidate)) { + allCandidates.push(candidate); + } + } + + // Strategy 2: Look in parent elements (timestamp might be outside container) + let current = postElement; + for (let i = 0; i < 5 && current; i++) { + const parentLinks = current.querySelectorAll('a[attributionsrc*="/privacy_sandbox/comet/"][href]'); + for (const link of parentLinks) { + const candidate = extractPostUrlCandidate(link.href); + if (candidate && !allCandidates.includes(candidate)) { + allCandidates.push(candidate); + } + } + current = current.parentElement; + } + + // Strategy 3: Check all links in post + const links = postElement.querySelectorAll('a[href]'); + for (const link of links) { + const candidate = extractPostUrlCandidate(link.href); + if (candidate && !allCandidates.includes(candidate)) { + allCandidates.push(candidate); + } + } + + // Prefer main post links over photo/video links + const mainPostLink = allCandidates.find(url => + url.includes('/posts/') || url.includes('/permalink/') || url.includes('/permalink.php') + ); + + if (mainPostLink) { + console.log('[FB Tracker] Post #' + postNum + ' - Found main post URL:', mainPostLink, postElement); + return { url: mainPostLink, allCandidates }; + } + + // Fallback to first candidate + if (allCandidates.length > 0) { + console.log('[FB Tracker] Post #' + postNum + ' - Using first candidate URL:', allCandidates[0], postElement); + return { url: allCandidates[0], allCandidates }; + } + + const fallbackCandidate = extractPostUrlCandidate(window.location.href); + if (fallbackCandidate) { + console.log('[FB Tracker] Post #' + postNum + ' - Using fallback URL:', fallbackCandidate, postElement); + return { url: fallbackCandidate, allCandidates: [fallbackCandidate] }; + } + + console.log('[FB Tracker] Post #' + postNum + ' - No valid URL found for:', postElement); + return { url: '', allCandidates: [] }; +} + +// Check if post is already tracked (checks all URL candidates to avoid duplicates) +async function checkPostStatus(postUrl, allUrlCandidates = []) { + try { + const normalizedUrl = normalizeFacebookPostUrl(postUrl); + if (!normalizedUrl) { + console.warn('[FB Tracker] Überspringe Statusabfrage, URL ungültig:', postUrl); + return null; + } + + // Build list of URLs to check (primary + all candidates) + const urlsToCheck = [normalizedUrl]; + + console.log('[FB Tracker] Received candidates to check:', allUrlCandidates); + + for (const candidate of allUrlCandidates) { + const normalized = normalizeFacebookPostUrl(candidate); + if (normalized && !urlsToCheck.includes(normalized)) { + urlsToCheck.push(normalized); + } + } + + console.log('[FB Tracker] Checking post status for URLs:', urlsToCheck); + + let foundPost = null; + let foundUrl = null; + + // Check each URL + for (const url of urlsToCheck) { + const response = await backendFetch(`${API_URL}/posts/by-url?url=${encodeURIComponent(url)}`); + + if (response.ok) { + const data = await response.json(); + if (data && data.id) { + console.log('[FB Tracker] Post found with URL:', url, data); + foundPost = data; + foundUrl = url; + break; + } else { + console.log('[FB Tracker] URL not found in backend:', url); + } + } else { + console.log('[FB Tracker] Backend error for URL:', url, response.status); + } + } + + // If post found and we have a better main post URL, update it + if (foundPost && foundUrl !== normalizedUrl) { + const isMainPostUrl = normalizedUrl.includes('/posts/') || normalizedUrl.includes('/permalink/'); + const isPhotoUrl = foundUrl.includes('/photo'); + + if (isMainPostUrl && isPhotoUrl) { + console.log('[FB Tracker] Updating post URL from photo link to main post link:', foundUrl, '->', normalizedUrl); + await updatePostUrl(foundPost.id, normalizedUrl); + foundPost.url = normalizedUrl; // Update local copy + } + } + + if (foundPost) { + return foundPost; + } + + console.log('[FB Tracker] Post not tracked yet (checked', urlsToCheck.length, 'URLs)'); + return null; + } catch (error) { + console.error('[FB Tracker] Error checking post status:', error); + return null; + } +} + +async function recordSearchResultPost(primaryUrl, allUrlCandidates = [], options = {}) { + try { + if (!primaryUrl) { + return null; + } + + const { skipIncrement = false, forceHide = false } = options || {}; + + const payload = { + url: primaryUrl, + candidates: Array.isArray(allUrlCandidates) ? allUrlCandidates : [], + skip_increment: !!skipIncrement, + force_hide: !!forceHide + }; + + const response = await backendFetch(`${API_URL}/search-posts`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + console.warn('[FB Tracker] Failed to record search post occurrence:', response.status); + return null; + } + + const data = await response.json(); + return data; + } catch (error) { + console.error('[FB Tracker] Error recording search result post:', error); + return null; + } +} + +// Update post URL +async function updatePostUrl(postId, newUrl) { + try { + const response = await backendFetch(`${API_URL}/posts/${postId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ url: newUrl }) + }); + + if (response.ok) { + console.log('[FB Tracker] Post URL updated successfully'); + return true; + } else { + console.error('[FB Tracker] Failed to update post URL:', response.status); + return false; + } + } catch (error) { + console.error('[FB Tracker] Error updating post URL:', error); + return false; + } +} + +// Add post to tracking +async function markPostChecked(postId, profileNumber) { + try { + console.log('[FB Tracker] Marking post as checked:', postId, 'Profile:', profileNumber); + const response = await backendFetch(`${API_URL}/posts/${postId}/check`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ profile_number: profileNumber }) + }); + + if (response.ok) { + const data = await response.json(); + console.log('[FB Tracker] Post marked as checked:', data); + return data; + } + + if (response.status === 409) { + console.log('[FB Tracker] Post already checked by this profile'); + return null; + } + + console.error('[FB Tracker] Failed to mark post as checked:', response.status); + return null; + } catch (error) { + console.error('[FB Tracker] Error marking post as checked:', error); + return null; + } +} + +async function addPostToTracking(postUrl, targetCount, profileNumber, options = {}) { + try { + console.log('[FB Tracker] Adding post:', postUrl, 'Target:', targetCount, 'Profile:', profileNumber); + + let createdByName = null; + if (options && options.postElement) { + createdByName = extractAuthorName(options.postElement) || null; + } + + let deadlineIso = null; + if (options && typeof options.deadline === 'string' && options.deadline.trim()) { + const parsedDeadline = new Date(options.deadline.trim()); + if (!Number.isNaN(parsedDeadline.getTime())) { + deadlineIso = parsedDeadline.toISOString(); + } + } + + const normalizedUrl = normalizeFacebookPostUrl(postUrl); + if (!normalizedUrl) { + console.error('[FB Tracker] Post URL konnte nicht normalisiert werden:', postUrl); + return null; + } + + const payload = { + url: normalizedUrl, + target_count: targetCount, + profile_number: profileNumber, + created_by_profile: profileNumber + }; + + if (createdByName) { + payload.created_by_name = createdByName; + } + + if (deadlineIso) { + payload.deadline_at = deadlineIso; + } + + const response = await backendFetch(`${API_URL}/posts`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload) + }); + + if (response.ok) { + const data = await response.json(); + console.log('[FB Tracker] Post added successfully:', data); + + if (data && data.id) { + const checkedData = await markPostChecked(data.id, profileNumber); + await captureAndUploadScreenshot(data.id, options.postElement || null); + if (checkedData) { + return checkedData; + } + } + + return data; + } else { + console.error('[FB Tracker] Failed to add post:', response.status); + return null; + } + } catch (error) { + console.error('[FB Tracker] Error adding post:', error); + return null; + } +} + +function normalizeButtonLabel(button) { + const aria = button.getAttribute('aria-label'); + if (aria) { + return aria.trim().toLowerCase(); + } + + const title = button.getAttribute('title'); + if (title) { + return title.trim().toLowerCase(); + } + + return (button.textContent || '').trim().toLowerCase(); +} + +const LIKE_LABEL_KEYWORDS = ['gefällt mir', 'like', 'mag ich']; +const COMMENT_LABEL_KEYWORDS = ['kommentieren', 'comment', 'kommentar', 'antwort hinterlassen']; +const SHARE_LABEL_KEYWORDS = ['teilen', 'share', 'senden', 'posten', 'profil posten']; +const REPLY_LABEL_KEYWORDS = ['antworten', 'reply']; + +const LIKE_ROLE_KEYWORDS = ['gefällt mir', 'like']; +const COMMENT_ROLE_KEYWORDS = ['comment']; +const SHARE_ROLE_KEYWORDS = ['share']; +const REPLY_ROLE_KEYWORDS = ['reply']; + +function matchesKeyword(label, keywords) { + return keywords.some((keyword) => label.includes(keyword)); +} + +function isPostLikedByCurrentUser(likeButton, postElement) { + const candidates = []; + if (likeButton) { + candidates.push(likeButton); + } + if (postElement) { + postElement.querySelectorAll('[data-ad-rendering-role*="gefällt" i], [aria-label*="gefällt" i]').forEach((node) => { + if (node && !candidates.includes(node)) { + candidates.push(node); + } + }); + } + + for (const candidate of candidates) { + if (!candidate) { + continue; + } + + const styleTarget = candidate.matches('[data-ad-rendering-role*="gefällt" i]') + ? candidate + : candidate.querySelector && candidate.querySelector('[data-ad-rendering-role*="gefällt" i]'); + + if (styleTarget) { + const inlineStyle = (styleTarget.getAttribute('style') || '').trim(); + if (inlineStyle && /(#0?866ff|reaction-like)/i.test(inlineStyle)) { + return true; + } + + try { + const computed = window.getComputedStyle(styleTarget); + if (computed && computed.color) { + const color = computed.color.toLowerCase(); + if (color.includes('rgb(8, 102, 255)') || color.includes('rgba(8, 102, 255')) { + return true; + } + } + } catch (error) { + console.debug('[FB Tracker] Unable to inspect computed style for like button:', error); + } + } + + const ariaPressed = candidate.getAttribute && candidate.getAttribute('aria-pressed'); + if (ariaPressed && ariaPressed.toLowerCase() === 'true') { + return true; + } + + const ariaLabel = (candidate.getAttribute && candidate.getAttribute('aria-label')) || ''; + const buttonText = candidate.textContent || ''; + const combined = `${ariaLabel} ${buttonText}`.toLowerCase(); + + const likedIndicators = ['gefällt dir', 'gefällt dir nicht mehr', 'unlike', 'remove like', 'nicht mehr gefällt']; + if (likedIndicators.some(indicator => combined.includes(indicator))) { + return true; + } + } + + return false; +} + +function hidePostElement(postElement) { + if (!postElement) { + return; + } + + const postContainer = ensurePrimaryPostElement(postElement); + if (postContainer && postContainer.closest(DIALOG_ROOT_SELECTOR)) { + console.log('[FB Tracker] Skipping hide for dialog/modal context'); + return; + } + if (postContainer && !isMainPost(postContainer, null)) { + console.log('[FB Tracker] Skipping hide for comment container'); + return; + } + const removalSelectors = [ + '[role="listitem"][aria-posinset]', + 'div[aria-posinset]', + 'div[data-ad-comet-feed-verbose-tracking]', + 'div[data-ad-preview]', + 'article[role="article"]', + 'article' + ]; + + let elementToRemove = null; + for (const selector of removalSelectors) { + const candidate = postElement.closest(selector); + if (candidate && candidate !== document.body && candidate !== document.documentElement) { + elementToRemove = candidate; + break; + } + } + + if (!elementToRemove) { + elementToRemove = postElement; + } + + let removalRoot = elementToRemove; + let parent = removalRoot.parentElement; + + while (parent && parent !== document.body && parent !== document.documentElement) { + if (parent.childElementCount > 1) { + break; + } + + if (parent.matches('[role="feed"], [role="main"], [role="region"], [data-pagelet], [data-testid="SEARCH_RESULT_CONTAINER"], #ssrb_feed_start')) { + break; + } + + removalRoot = parent; + parent = parent.parentElement; + } + + removalRoot.setAttribute('data-fb-tracker-hidden', '1'); + + const removalParent = removalRoot.parentElement; + if (removalParent) { + removalParent.removeChild(removalRoot); + + let current = removalParent; + while (current && current !== document.body && current !== document.documentElement) { + if (current.childElementCount > 0) { + break; + } + const nextParent = current.parentElement; + if (!nextParent) { + break; + } + if (nextParent === document.body || nextParent === document.documentElement) { + current.remove(); + break; + } + if (nextParent.childElementCount > 1) { + current.remove(); + break; + } + current.remove(); + current = nextParent; + } + } else { + removalRoot.style.display = 'none'; + } +} + +function collectButtonMeta(button) { + const textParts = []; + const roleParts = []; + + const label = normalizeButtonLabel(button); + if (label) { + textParts.push(label); + } + + const collectRole = (value) => { + if (!value) { + return; + } + const lower = value.toLowerCase(); + roleParts.push(lower); + + const tokens = lower.split(/[_\-\s]+/); + tokens.forEach((token) => { + if (token) { + roleParts.push(token); + } + }); + }; + + collectRole(button.getAttribute('data-ad-rendering-role')); + + const descendantRoles = button.querySelectorAll('[data-ad-rendering-role]'); + descendantRoles.forEach((el) => { + collectRole(el.getAttribute('data-ad-rendering-role')); + if (el.textContent) { + textParts.push(normalizeButtonLabel(el)); + } + }); + + return { + text: textParts.join(' '), + roles: roleParts.join(' ') + }; +} + +function buttonClassification(button) { + const meta = collectButtonMeta(button); + const text = meta.text; + const roles = meta.roles; + const combined = `${text} ${roles}`; + return { + isLike: matchesKeyword(combined, LIKE_LABEL_KEYWORDS) || matchesKeyword(roles, LIKE_ROLE_KEYWORDS), + isComment: matchesKeyword(combined, COMMENT_LABEL_KEYWORDS) || matchesKeyword(roles, COMMENT_ROLE_KEYWORDS), + isShare: matchesKeyword(combined, SHARE_LABEL_KEYWORDS) || matchesKeyword(roles, SHARE_ROLE_KEYWORDS), + isReply: matchesKeyword(combined, REPLY_LABEL_KEYWORDS) || matchesKeyword(roles, REPLY_ROLE_KEYWORDS) + }; +} + +function findShareButtonForArticle(article) { + const direct = article.querySelector('[data-ad-rendering-role="share_button"]'); + if (direct) { + return direct; + } + + const shareButtons = document.querySelectorAll('[data-ad-rendering-role="share_button"]'); + for (const button of shareButtons) { + const owningArticle = button.closest('[role="article"]'); + if (owningArticle === article) { + return button; + } + } + + return null; +} + +function hasInteractionButtons(container) { + if (!container) { + return false; + } + + const buttons = container.querySelectorAll('[role="button"], button'); + if (!buttons.length) { + return false; + } + + let hasLike = false; + let hasComment = false; + let hasShare = false; + let hasReply = false; + + for (const button of buttons) { + const info = buttonClassification(button); + if (info.isLike) { + hasLike = true; + } + if (info.isComment) { + hasComment = true; + } + if (info.isShare) { + hasShare = true; + } + if (info.isReply) { + hasReply = true; + } + } + + if (hasLike || hasComment || hasShare) { + console.log('[FB Tracker] Container analysis', { + tag: container.tagName, + classes: container.className, + hasLike, + hasComment, + hasShare, + hasReply, + buttonCount: buttons.length + }); + } + + const interactionCount = [hasLike, hasComment, hasShare].filter(Boolean).length; + if (interactionCount === 0) { + return false; + } + + if (hasShare) { + return true; + } + + if (interactionCount >= 2) { + return true; + } + + return !hasReply; +} + +function findButtonBar(postElement) { + const shareAnchor = findShareButtonForArticle(postElement); + if (shareAnchor) { + let container = shareAnchor.closest('[role="button"]') || shareAnchor.closest('button'); + for (let i = 0; i < 4 && container; i++) { + if (hasInteractionButtons(container)) { + console.log('[FB Tracker] Found button bar via share anchor'); + return container; + } + container = container.parentElement; + } + } + + // Gather all accessible buttons inside the article + const buttons = Array.from(postElement.querySelectorAll('[role="button"], button')); + const interactionButtons = buttons.filter((button) => { + const info = buttonClassification(button); + return info.isLike || info.isComment || info.isShare; + }); + + const anchorElements = [ + postElement.querySelector('[data-ad-rendering-role="share_button"]'), + postElement.querySelector('[data-ad-rendering-role="comment_button"]'), + postElement.querySelector('[data-ad-rendering-role*="gefällt"]') + ].filter(Boolean); + + const candidates = new Set(interactionButtons); + anchorElements.forEach((el) => { + const buttonContainer = el.closest('[role="button"]') || el.closest('button'); + if (buttonContainer) { + candidates.add(buttonContainer); + } + }); + + const seen = new Set(); + + for (const button of candidates) { + const info = buttonClassification(button); + console.log('[FB Tracker] Candidate button', { + label: normalizeButtonLabel(button), + hasLike: info.isLike, + hasComment: info.isComment, + hasShare: info.isShare, + hasReply: info.isReply, + classes: button.className + }); + let current = button; + while (current && current !== postElement && current !== document.body) { + if (seen.has(current)) { + current = current.parentElement; + continue; + } + + seen.add(current); + + const hasBar = hasInteractionButtons(current); + if (hasBar) { + console.log('[FB Tracker] Found button bar'); + return current; + } + + current = current.parentElement; + } + } + + let sibling = postElement.nextElementSibling; + for (let i = 0; i < 6 && sibling; i++) { + if (!seen.has(sibling) && hasInteractionButtons(sibling)) { + console.log('[FB Tracker] Found button bar via next sibling'); + return sibling; + } + sibling = sibling.nextElementSibling; + } + + sibling = postElement.previousElementSibling; + for (let i = 0; i < 3 && sibling; i++) { + if (!seen.has(sibling) && hasInteractionButtons(sibling)) { + console.log('[FB Tracker] Found button bar via previous sibling'); + return sibling; + } + sibling = sibling.previousElementSibling; + } + + let parent = postElement.parentElement; + for (let depth = 0; depth < 4 && parent; depth++) { + if (!seen.has(parent) && hasInteractionButtons(parent)) { + console.log('[FB Tracker] Found button bar via parent'); + return parent; + } + + let next = parent.nextElementSibling; + for (let i = 0; i < 4 && next; i++) { + if (!seen.has(next) && hasInteractionButtons(next)) { + console.log('[FB Tracker] Found button bar via parent sibling'); + return next; + } + next = next.nextElementSibling; + } + + let prev = parent.previousElementSibling; + for (let i = 0; i < 3 && prev; i++) { + if (!seen.has(prev) && hasInteractionButtons(prev)) { + console.log('[FB Tracker] Found button bar via parent previous sibling'); + return prev; + } + prev = prev.previousElementSibling; + } + + parent = parent.parentElement; + } + + console.log('[FB Tracker] Button bar not found'); + return null; +} + +function findPostContainers() { + const containers = []; + const seen = new Set(); + + const candidateSelectors = [ + 'div[role="dialog"] article', + 'div[role="dialog"] div[aria-posinset]', + '[data-pagelet*="FeedUnit"] article', + 'div[role="main"] article', + '[data-visualcompletion="ignore-dynamic"] article', + 'div[aria-posinset]', + 'article[role="article"]', + 'article', + 'div[data-pagelet*="Reel"]', + 'div[data-pagelet*="WatchFeed"]', + 'div[data-pagelet*="QPE_PublisherStory"]' + ]; + + const candidateElements = document.querySelectorAll(candidateSelectors.join(', ')); + + candidateElements.forEach((element) => { + const container = ensurePrimaryPostElement(element); + if (!container) { + return; + } + + if (seen.has(container)) { + return; + } + + const buttonBar = findButtonBar(container); + if (!isMainPost(container, buttonBar)) { + return; + } + + const likeButton = findLikeButtonWithin(container); + + seen.add(container); + if (likeButton) { + containers.push({ container, likeButton, buttonBar: buttonBar || null }); + } + }); + + return containers; +} + +function findLikeButtonWithin(container) { + if (!container) { + return null; + } + + const selectors = [ + '[data-ad-rendering-role="gefällt"]', + '[data-ad-rendering-role="gefällt mir_button"]', + '[data-ad-rendering-role*="gefällt" i]', + '[aria-label*="gefällt" i]', + '[aria-label*="like" i]', + 'div[role="button"][aria-pressed]' + ]; + + for (const selector of selectors) { + const button = container.querySelector(selector); + if (button) { + return button; + } + } + + return container.querySelector('div[role="button"]'); +} + +function findLikeButtonWithin(container) { + if (!container) { + return null; + } + + const selectors = [ + '[data-ad-rendering-role="gefällt"]', + '[data-ad-rendering-role="gefällt mir_button"]', + '[data-ad-rendering-role*="gefällt" i]', + '[aria-label*="gefällt" i]', + '[aria-label*="like" i]', + '[aria-pressed="true"]', + 'div[role="button"]' + ]; + + for (const selector of selectors) { + const button = container.querySelector(selector); + if (button) { + return button; + } + } + + return null; +} + +function captureScreenshot(screenshotRect) { + return new Promise((resolve) => { + chrome.runtime.sendMessage({ type: 'captureScreenshot', screenshotRect }, (response) => { + if (chrome.runtime.lastError) { + console.warn('[FB Tracker] Screenshot capture failed:', chrome.runtime.lastError.message); + resolve(null); + return; + } + + if (!response || response.error) { + if (response && response.error) { + console.warn('[FB Tracker] Screenshot capture reported error:', response.error); + } + resolve(null); + return; + } + + if (!response.imageData) { + console.warn('[FB Tracker] Screenshot capture returned no data'); + resolve(null); + return; + } + + (async () => { + try { + let result = response.imageData; + if (screenshotRect) { + const cropped = await cropScreenshot(result, screenshotRect); + if (cropped) { + result = cropped; + } + } + + resolve(result); + } catch (error) { + console.warn('[FB Tracker] Screenshot processing failed:', error); + resolve(response.imageData); + } + })(); + }); + }); +} + +async function uploadScreenshot(postId, imageData) { + try { + const response = await backendFetch(`${API_URL}/posts/${postId}/screenshot`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ imageData }) + }); + + if (!response.ok) { + console.warn('[FB Tracker] Screenshot upload failed:', response.status); + } + } catch (error) { + console.error('[FB Tracker] Screenshot upload error:', error); + } +} + +async function captureAndUploadScreenshot(postId, postElement) { + const imageData = await captureElementScreenshot(postElement); + if (!imageData) { + return; + } + + const optimized = await maybeDownscaleScreenshot(imageData); + await uploadScreenshot(postId, optimized); +} + +async function captureElementScreenshot(element) { + if (!element) { + return await captureScreenshot(); + } + + const horizontalMargin = 32; + const verticalMargin = 96; + const maxSegments = 12; + const delayBetweenScrolls = 200; + const originalScrollX = window.scrollX; + const originalScrollY = window.scrollY; + const devicePixelRatio = window.devicePixelRatio || 1; + const stickyOffset = getStickyHeaderHeight(); + + const segments = []; + + const elementRect = element.getBoundingClientRect(); + const elementTop = elementRect.top + window.scrollY; + const elementBottom = elementRect.bottom + window.scrollY; + const documentHeight = document.documentElement.scrollHeight; + const startY = Math.max(0, elementTop - verticalMargin - stickyOffset); + const endY = Math.min(documentHeight, elementBottom + verticalMargin); + const baseDocTop = Math.max(0, elementTop - verticalMargin); + + try { + let iteration = 0; + let targetScroll = startY; + + while (iteration < maxSegments) { + iteration += 1; + window.scrollTo({ top: targetScroll, left: window.scrollX, behavior: 'auto' }); + await delay(delayBetweenScrolls); + + const rect = element.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + + const captureTop = Math.max(0, rect.top - verticalMargin - stickyOffset); + const captureBottom = Math.min(viewportHeight, rect.bottom + verticalMargin); + const captureHeight = captureBottom - captureTop; + + if (captureHeight <= 0) { + break; + } + + const captureRect = { + left: rect.left - horizontalMargin, + top: captureTop, + width: rect.width + horizontalMargin * 2, + height: captureHeight, + devicePixelRatio + }; + + const segmentData = await captureScreenshot(captureRect); + if (!segmentData) { + break; + } + + const docTop = Math.max(0, window.scrollY + captureTop); + const docBottom = docTop + captureHeight; + + segments.push({ data: segmentData, docTop, docBottom }); + + const reachedBottom = docBottom >= endY - 4; + if (reachedBottom) { + break; + } + + const nextScroll = docBottom - Math.max(0, (viewportHeight - stickyOffset) * 0.5); + const maxScroll = Math.max(0, endY - viewportHeight); + targetScroll = Math.min(nextScroll, maxScroll); + + if (targetScroll <= window.scrollY + 1) { + targetScroll = window.scrollY + Math.max(160, viewportHeight * 0.6); + } + + if (targetScroll <= window.scrollY + 1 || targetScroll >= endY) { + break; + } + } + } finally { + window.scrollTo({ top: originalScrollY, left: originalScrollX, behavior: 'auto' }); + } + + if (!segments.length) { + return await captureScreenshot(); + } + + const stitched = await stitchScreenshotSegments(segments, devicePixelRatio, baseDocTop); + return stitched; +} + +async function stitchScreenshotSegments(segments, devicePixelRatio, baseDocTop) { + const images = []; + let maxDocBottom = baseDocTop; + + for (const segment of segments) { + const img = await loadImage(segment.data); + if (!img) { + continue; + } + images.push({ img, docTop: segment.docTop, docBottom: segment.docBottom }); + if (segment.docBottom > maxDocBottom) { + maxDocBottom = segment.docBottom; + } + } + + if (!images.length) { + return null; + } + + const width = images.reduce((max, item) => Math.max(max, item.img.width), 0); + const totalHeightPx = Math.max(1, Math.round((maxDocBottom - baseDocTop) * devicePixelRatio)); + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = totalHeightPx; + + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, width, totalHeightPx); + + for (const { img, docTop } of images) { + const offsetY = Math.round(Math.max(0, docTop - baseDocTop) * devicePixelRatio); + ctx.drawImage(img, 0, offsetY); + } + + return canvas.toDataURL('image/jpeg', 0.85); +} + +async function cropScreenshot(imageData, rect) { + if (!rect) { + return imageData; + } + + try { + const image = await loadImage(imageData); + if (!image) { + return imageData; + } + + const ratio = rect.devicePixelRatio || window.devicePixelRatio || 1; + + const rawLeft = (rect.left || 0) * ratio; + const rawTop = (rect.top || 0) * ratio; + const rawWidth = (rect.width || image.width) * ratio; + const rawHeight = (rect.height || image.height) * ratio; + + const rawRight = rawLeft + rawWidth; + const rawBottom = rawTop + rawHeight; + + const left = Math.max(0, Math.floor(rawLeft)); + const top = Math.max(0, Math.floor(rawTop)); + const right = Math.min(image.width, Math.ceil(rawRight)); + const bottom = Math.min(image.height, Math.ceil(rawBottom)); + + const width = Math.max(1, right - left); + const height = Math.max(1, bottom - top); + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d'); + ctx.drawImage(image, left, top, width, height, 0, 0, width, height); + + return canvas.toDataURL('image/jpeg', 0.85); + } catch (error) { + console.warn('[FB Tracker] Failed to crop screenshot:', error); + return imageData; + } +} + +async function maybeDownscaleScreenshot(imageData) { + try { + const maxWidth = 1600; + + const current = await loadImage(imageData); + if (!current) { + return imageData; + } + + if (current.width <= maxWidth) { + return imageData; + } + + const scale = maxWidth / current.width; + const canvas = document.createElement('canvas'); + canvas.width = Math.round(current.width * scale); + canvas.height = Math.round(current.height * scale); + + const ctx = canvas.getContext('2d'); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(current, 0, 0, canvas.width, canvas.height); + + return canvas.toDataURL('image/jpeg', 0.8); + } catch (error) { + console.warn('[FB Tracker] Failed to downscale screenshot:', error); + return imageData; + } +} + +function getStickyHeaderHeight() { + try { + const banner = document.querySelector('[role="banner"], header[role="banner"]'); + if (!banner) { + return 0; + } + + const rect = banner.getBoundingClientRect(); + if (!rect || !rect.height) { + return 0; + } + + return Math.min(rect.height, 160); + } catch (error) { + console.warn('[FB Tracker] Failed to determine sticky header height:', error); + return 0; + } +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function loadImage(dataUrl) { + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => resolve(null); + img.src = dataUrl; + }); +} + +function toDateTimeLocalString(date) { + const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000); + return local.toISOString().slice(0, 16); +} + +function getNextDayDefaultDeadlineValue() { + const tomorrow = new Date(); + tomorrow.setHours(0, 0, 0, 0); + tomorrow.setDate(tomorrow.getDate() + 1); + return toDateTimeLocalString(tomorrow); +} + +function extractDeadlineFromPostText(postElement) { + if (!postElement) { + return null; + } + + // Get all text content from the post + const textNodes = []; + const walker = document.createTreeWalker( + postElement, + NodeFilter.SHOW_TEXT, + null, + false + ); + + let node; + while (node = walker.nextNode()) { + if (node.textContent.trim()) { + textNodes.push(node.textContent.trim()); + } + } + + const fullText = textNodes.join(' '); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const monthNames = { + 'januar': 1, 'jan': 1, + 'februar': 2, 'feb': 2, + 'märz': 3, 'mär': 3, 'maerz': 3, + 'april': 4, 'apr': 4, + 'mai': 5, + 'juni': 6, 'jun': 6, + 'juli': 7, 'jul': 7, + 'august': 8, 'aug': 8, + 'september': 9, 'sep': 9, 'sept': 9, + 'oktober': 10, 'okt': 10, + 'november': 11, 'nov': 11, + 'dezember': 12, 'dez': 12 + }; + + // German date patterns + const patterns = [ + // DD.MM.YYYY or DD.MM.YY + /\b(\d{1,2})\.(\d{1,2})\.(\d{2,4})\b/g, + // DD.MM (without year) + /\b(\d{1,2})\.(\d{1,2})\.\s*(?!\d)/g + ]; + + const foundDates = []; + + for (const pattern of patterns) { + let match; + while ((match = pattern.exec(fullText)) !== null) { + const day = parseInt(match[1], 10); + const month = parseInt(match[2], 10); + let year = match[3] ? parseInt(match[3], 10) : today.getFullYear(); + + // Handle 2-digit years + if (year < 100) { + year += 2000; + } + + // Validate date + if (month >= 1 && month <= 12 && day >= 1 && day <= 31) { + const date = new Date(year, month - 1, day, 0, 0, 0, 0); + + // Check if date is valid (e.g., not 31.02.) + if (date.getMonth() === month - 1 && date.getDate() === day) { + // Only add if date is in the future + if (date > today) { + foundDates.push(date); + } + } + } + } + } + + // Pattern for "12. Oktober" or "12 Oktober" + const monthPattern = /\b(\d{1,2})\.?\s+(januar|jan|februar|feb|märz|mär|maerz|april|apr|mai|juni|jun|juli|jul|august|aug|september|sep|sept|oktober|okt|november|nov|dezember|dez)\b/gi; + let monthMatch; + while ((monthMatch = monthPattern.exec(fullText)) !== null) { + const day = parseInt(monthMatch[1], 10); + const monthStr = monthMatch[2].toLowerCase(); + const month = monthNames[monthStr]; + const year = today.getFullYear(); + + if (month && day >= 1 && day <= 31) { + const date = new Date(year, month - 1, day, 0, 0, 0, 0); + + // Check if date is valid + if (date.getMonth() === month - 1 && date.getDate() === day) { + // If date has passed this year, assume next year + if (date <= today) { + date.setFullYear(year + 1); + } + foundDates.push(date); + } + } + } + + // Return the earliest future date + if (foundDates.length > 0) { + foundDates.sort((a, b) => a - b); + return toDateTimeLocalString(foundDates[0]); + } + + return null; +} + +function normalizeFacebookPostUrl(rawValue) { + if (typeof rawValue !== 'string') { + return ''; + } + + let value = rawValue.trim(); + if (!value) { + return ''; + } + + const trackingIndex = value.indexOf('__cft__'); + if (trackingIndex !== -1) { + value = value.slice(0, trackingIndex); + } + + value = value.replace(/[?&]$/, ''); + + let parsed; + try { + parsed = new URL(value); + } catch (error) { + try { + parsed = new URL(value, 'https://www.facebook.com'); + } catch (fallbackError) { + return ''; + } + } + + if (!parsed.hostname.toLowerCase().endsWith('facebook.com')) { + return ''; + } + + const cleanedParams = new URLSearchParams(); + parsed.searchParams.forEach((paramValue, paramKey) => { + const lowerKey = paramKey.toLowerCase(); + if ( + lowerKey.startsWith('__cft__') + || lowerKey.startsWith('__tn__') + || lowerKey.startsWith('__eep__') + || lowerKey.startsWith('mibextid') + || lowerKey === 'set' + || lowerKey === 'comment_id' + || lowerKey === 'hoisted_section_header_type' + ) { + return; + } + cleanedParams.append(paramKey, paramValue); + }); + + const multiPermalinkId = cleanedParams.get('multi_permalinks'); + if (multiPermalinkId) { + cleanedParams.delete('multi_permalinks'); + + const groupMatch = parsed.pathname.match(/^\/groups\/([A-Za-z0-9\.\-_]+)/); + if (groupMatch && multiPermalinkId.match(/^[0-9]+$/)) { + parsed.pathname = `/groups/${groupMatch[1]}/posts/${multiPermalinkId}`; + } else if (groupMatch) { + parsed.pathname = `/groups/${groupMatch[1]}/permalink/${multiPermalinkId}`; + } + } + + const search = cleanedParams.toString(); + const formatted = `${parsed.origin}${parsed.pathname}${search ? `?${search}` : ''}`; + return formatted.replace(/[?&]$/, ''); +} + +// Create the tracking UI +async function createTrackerUI(postElement, buttonBar, postNum = '?', options = {}) { + // Normalize to top-level post container if nested element passed + postElement = ensurePrimaryPostElement(postElement); + + const existingUI = postElement.querySelector('.fb-tracker-ui'); + if (existingUI) { + console.log('[FB Tracker] Post #' + postNum + ' - UI already exists on normalized element'); + return; + } + + // Mark immediately to prevent duplicate creation during async operations + if (postElement.getAttribute(PROCESSED_ATTR) === '1') { + console.log('[FB Tracker] Post #' + postNum + ' - Already processed:', postElement); + return; + } + postElement.setAttribute(PROCESSED_ATTR, '1'); + + const postUrlData = getPostUrl(postElement, postNum); + if (!postUrlData.url) { + console.log('[FB Tracker] Post #' + postNum + ' - No URL found:', postElement); + postElement.removeAttribute(PROCESSED_ATTR); + return; + } + + console.log('[FB Tracker] Post #' + postNum + ' - Creating tracker UI for:', postUrlData.url, postElement); + + const encodedUrl = encodeURIComponent(postUrlData.url); + + const existingEntry = processedPostUrls.get(encodedUrl); + if (existingEntry && existingEntry.element && existingEntry.element !== postElement) { + if (document.body.contains(existingEntry.element)) { + existingEntry.element.removeAttribute(PROCESSED_ATTR); + } else { + processedPostUrls.delete(encodedUrl); + } + const otherUI = document.querySelector(`.fb-tracker-ui[data-post-url="${encodedUrl}"]`); + if (otherUI) { + otherUI.remove(); + } + } + + const { likeButton: sourceLikeButton = null, isSearchResult = false, isDialogContext = false } = options; + const currentPath = window.location.pathname || '/'; + const isFeedHome = FEED_HOME_PATHS.includes(currentPath); + const likedByCurrentUser = isPostLikedByCurrentUser(sourceLikeButton, postElement); + + // Create UI container + const container = document.createElement('div'); + container.className = 'fb-tracker-ui'; + container.id = 'fb-tracker-ui-post-' + postNum; + container.setAttribute('data-post-num', postNum); + container.setAttribute('data-post-url', encodedUrl); + container.style.cssText = ` + padding: 6px 12px; + background-color: #f0f2f5; + border-top: 1px solid #e4e6eb; + display: flex; + flex-wrap: wrap; + flex-direction: row; + align-items: center; + gap: 8px; + row-gap: 6px; + width: 100%; + box-sizing: border-box; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 13px; + `; + + // Check current status (check all URL candidates to avoid duplicates) + const profileNumber = await getProfileNumber(); + const postData = await checkPostStatus(postUrlData.url, postUrlData.allCandidates); + const isTracked = !!postData; + + let searchTrackingInfo = null; + if (isSearchResult) { + const cacheKey = encodedUrl; + const alreadyRecorded = sessionSearchRecordedUrls.has(cacheKey); + const latestInfo = await recordSearchResultPost(postUrlData.url, postUrlData.allCandidates, { + skipIncrement: alreadyRecorded + }); + + if (!alreadyRecorded && latestInfo) { + sessionSearchRecordedUrls.add(cacheKey); + } + + if (latestInfo) { + sessionSearchInfoCache.set(cacheKey, latestInfo); + searchTrackingInfo = latestInfo; + } else if (sessionSearchInfoCache.has(cacheKey)) { + searchTrackingInfo = sessionSearchInfoCache.get(cacheKey); + } else { + searchTrackingInfo = latestInfo; + } + } + + let manualHideInfo = null; + if (!isSearchResult && isFeedHome) { + try { + manualHideInfo = await recordSearchResultPost(postUrlData.url, postUrlData.allCandidates, { + skipIncrement: true + }); + } catch (error) { + console.debug('[FB Tracker] Manual hide lookup failed:', error); + } + } + + if (isSearchResult && (isTracked || likedByCurrentUser)) { + if (isDialogContext) { + console.log('[FB Tracker] Post #' + postNum + ' - Would hide on search results (tracked or liked) but skipping in dialog context'); + } else { + console.log('[FB Tracker] Post #' + postNum + ' - Hidden on search results (tracked or liked)'); + hidePostElement(postElement); + processedPostUrls.set(encodedUrl, { + element: postElement, + createdAt: Date.now(), + hidden: true, + searchSeenCount: searchTrackingInfo && typeof searchTrackingInfo.seen_count === 'number' + ? searchTrackingInfo.seen_count + : null + }); + return; + } + } + + if (searchTrackingInfo && searchTrackingInfo.should_hide && !isTracked && !likedByCurrentUser) { + if (isDialogContext) { + console.log('[FB Tracker] Post #' + postNum + ' - Would hide on search results after repeated sightings but skipping in dialog context:', searchTrackingInfo); + } else { + console.log('[FB Tracker] Post #' + postNum + ' - Hidden on search results after repeated sightings:', searchTrackingInfo); + hidePostElement(postElement); + processedPostUrls.set(encodedUrl, { + element: postElement, + createdAt: Date.now(), + hidden: true, + searchSeenCount: typeof searchTrackingInfo.seen_count === 'number' ? searchTrackingInfo.seen_count : null + }); + return; + } + } + + if (!isSearchResult && manualHideInfo && (manualHideInfo.manually_hidden || manualHideInfo.should_hide)) { + if (isDialogContext) { + console.log('[FB Tracker] Post #' + postNum + ' - Would hide on feed (manually hidden) but skipping in dialog context:', manualHideInfo); + } else { + console.log('[FB Tracker] Post #' + postNum + ' - Hidden on feed (manually hidden):', manualHideInfo); + hidePostElement(postElement); + processedPostUrls.set(encodedUrl, { + element: postElement, + createdAt: Date.now(), + hidden: true, + searchSeenCount: typeof manualHideInfo.seen_count === 'number' ? manualHideInfo.seen_count : null + }); + return; + } + } + + if (postData) { + const checkedCount = postData.checked_count ?? (Array.isArray(postData.checks) ? postData.checks.length : 0); + const statusText = `${checkedCount}/${postData.target_count}`; + const completed = checkedCount >= postData.target_count; + const lastCheck = Array.isArray(postData.checks) && postData.checks.length + ? new Date(postData.checks[postData.checks.length - 1].checked_at).toLocaleString('de-DE', { timeZone: 'Europe/Berlin' }) + : null; + + // Check if deadline has passed + const isExpired = postData.deadline_at ? new Date(postData.deadline_at) < new Date() : false; + const expiredText = isExpired ? ' ⚠️ ABGELAUFEN' : ''; + + // Check if current profile can check this post + const canCurrentProfileCheck = postData.next_required_profile === profileNumber; + const isCurrentProfileDone = Array.isArray(postData.checks) && postData.checks.some(check => check.profile_number === profileNumber); + + if (isFeedHome && isCurrentProfileDone) { + if (isDialogContext) { + console.log('[FB Tracker] Post #' + postNum + ' - Would hide on feed (already checked by current profile) but skipping in dialog context'); + } else { + console.log('[FB Tracker] Post #' + postNum + ' - Hidden on feed (already checked by current profile)'); + hidePostElement(postElement); + processedPostUrls.set(encodedUrl, { + element: postElement, + createdAt: Date.now(), + hidden: true, + searchSeenCount: manualHideInfo && typeof manualHideInfo.seen_count === 'number' ? manualHideInfo.seen_count : null + }); + return; + } + } + + let statusHtml = ` +
+ Tracker: ${statusText}${completed ? ' ✓' : ''}${expiredText} +
+ ${lastCheck ? `
Letzte: ${lastCheck}
` : ''} + `; + + // Add check button if current profile can check and not expired + if (canCurrentProfileCheck && !isExpired && !completed) { + statusHtml += ` + + `; + } else if (isCurrentProfileDone) { + statusHtml += ` +
+ ✓ Von dir bestätigt +
+ `; + } + + container.innerHTML = statusHtml; + + // Add AI button + await addAICommentButton(container, postElement); + + // Add event listener for check button + const checkBtn = container.querySelector('.fb-tracker-check-btn'); + if (checkBtn) { + checkBtn.addEventListener('click', async () => { + checkBtn.disabled = true; + checkBtn.textContent = 'Wird bestätigt...'; + + const result = await markPostChecked(postData.id, profileNumber); + + if (result) { + const newCheckedCount = result.checked_count ?? checkedCount + 1; + const newStatusText = `${newCheckedCount}/${postData.target_count}`; + const newCompleted = newCheckedCount >= postData.target_count; + const newLastCheck = new Date().toLocaleString('de-DE', { timeZone: 'Europe/Berlin' }); + + container.innerHTML = ` +
+ Tracker: ${newStatusText}${newCompleted ? ' ✓' : ''} +
+
Letzte: ${newLastCheck}
+
+ ✓ Von dir bestätigt +
+ `; + + // Re-add AI button after update + await addAICommentButton(container, postElement); + } else { + checkBtn.disabled = false; + checkBtn.textContent = 'Fehler - Erneut versuchen'; + checkBtn.style.backgroundColor = '#e74c3c'; + } + }); + } + + console.log('[FB Tracker] Showing status:', statusText); + } else { + // Post not tracked - show add UI + const selectId = `tracker-select-${Date.now()}`; + const deadlineId = `tracker-deadline-${Date.now()}`; + container.innerHTML = ` + + + + + + `; + + // Add click handler for the button + const addButton = container.querySelector('.fb-tracker-add-btn'); + const selectElement = container.querySelector(`#${selectId}`); + const deadlineInput = container.querySelector(`#${deadlineId}`); + selectElement.value = '2'; + + if (deadlineInput) { + // Try to extract deadline from post text first + const extractedDeadline = extractDeadlineFromPostText(postElement); + deadlineInput.value = extractedDeadline || getNextDayDefaultDeadlineValue(); + } + + addButton.addEventListener('click', async () => { + const targetCount = parseInt(selectElement.value, 10); + console.log('[FB Tracker] Add button clicked, target:', targetCount); + + addButton.disabled = true; + addButton.textContent = 'Wird hinzugefügt...'; + + postElement.scrollIntoView({ behavior: 'auto', block: 'start', inline: 'nearest' }); + await delay(220); + + const deadlineValue = deadlineInput ? deadlineInput.value : ''; + + const result = await addPostToTracking(postUrlData.url, targetCount, profileNumber, { + postElement, + deadline: deadlineValue + }); + + if (result) { + const checks = Array.isArray(result.checks) ? result.checks : []; + const checkedCount = typeof result.checked_count === 'number' ? result.checked_count : checks.length; + const targetTotal = result.target_count || targetCount; + const statusText = `${checkedCount}/${targetTotal}`; + const completed = checkedCount >= targetTotal; + const lastCheck = checks.length ? new Date(checks[checks.length - 1].checked_at).toLocaleString('de-DE', { timeZone: 'Europe/Berlin' }) : null; + + let statusHtml = ` +
+ Tracker: ${statusText}${completed ? ' ✓' : ''} +
+ `; + + if (lastCheck) { + statusHtml += ` +
+ Letzte: ${lastCheck} +
+ `; + } + + container.innerHTML = statusHtml; + if (deadlineInput) { + deadlineInput.value = ''; + } + + await addAICommentButton(container, postElement); + } else { + // Error + addButton.disabled = false; + addButton.textContent = 'Fehler - Erneut versuchen'; + addButton.style.backgroundColor = '#e74c3c'; + if (deadlineInput) { + deadlineInput.value = getNextDayDefaultDeadlineValue(); + } + } + }); + + console.log('[FB Tracker] Post #' + postNum + ' - UI created for new post'); + + // Add AI button for new posts + await addAICommentButton(container, postElement); + } + + if (isSearchResult) { + const info = document.createElement('button'); + info.type = 'button'; + info.className = 'fb-tracker-search-info'; + info.title = 'Beitrag künftig in den Suchergebnissen ausblenden'; + info.setAttribute('aria-label', 'Beitrag künftig in den Suchergebnissen ausblenden'); + const countText = searchTrackingInfo && typeof searchTrackingInfo.seen_count === 'number' + ? searchTrackingInfo.seen_count + : (isTracked ? 'gespeichert' : 'n/v'); + + const iconSpan = document.createElement('span'); + iconSpan.setAttribute('aria-hidden', 'true'); + iconSpan.style.fontSize = '15px'; + const countSpan = document.createElement('span'); + countSpan.textContent = countText; + + info.appendChild(iconSpan); + info.appendChild(countSpan); + + info.style.cssText = ` + color: #1d2129; + font-weight: 600; + border-radius: 999px; + padding: 4px 12px; + background: rgba(255, 255, 255, 0.98); + border: 1px solid rgba(29, 33, 41, 0.18); + display: inline-flex; + align-items: center; + gap: 6px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); + transition: transform 150ms ease, box-shadow 150ms ease, opacity 120ms ease; + cursor: pointer; + outline: none; + `; + + const OPEN_EYES_ICON = '😳'; + const CLOSED_EYES_ICON = '😌'; + + const setIconOpen = () => { + iconSpan.textContent = OPEN_EYES_ICON; + }; + + const setIconClosed = () => { + iconSpan.textContent = CLOSED_EYES_ICON; + }; + + setIconOpen(); + + const resetHover = () => { + setIconOpen(); + info.style.transform = 'scale(1)'; + info.style.boxShadow = '0 1px 2px rgba(0, 0, 0, 0.08)'; + }; + + info.addEventListener('mouseenter', () => { + info.style.transform = 'translateY(-1px) scale(1.05)'; + info.style.boxShadow = '0 6px 14px rgba(0, 0, 0, 0.16)'; + setIconClosed(); + }); + info.addEventListener('mouseleave', () => { + resetHover(); + }); + info.addEventListener('focus', () => { + info.style.transform = 'translateY(-1px) scale(1.05)'; + info.style.boxShadow = '0 6px 14px rgba(24, 119, 242, 0.35)'; + setIconClosed(); + }); + info.addEventListener('blur', () => { + resetHover(); + }); + info.addEventListener('mousedown', () => { + info.style.transform = 'translateY(0) scale(0.96)'; + info.style.boxShadow = '0 3px 8px rgba(0, 0, 0, 0.18)'; + }); + info.addEventListener('mouseup', () => { + info.style.transform = 'translateY(-1px) scale(1.03)'; + info.style.boxShadow = '0 6px 14px rgba(0, 0, 0, 0.16)'; + }); + + const handleManualHide = async (event) => { + event.preventDefault(); + event.stopPropagation(); + + if (postElement.getAttribute('data-fb-tracker-hidden') === '1') { + return; + } + + info.disabled = true; + info.style.cursor = 'progress'; + info.style.opacity = '0.75'; + + let hideResult = null; + + try { + hideResult = await recordSearchResultPost(postUrlData.url, postUrlData.allCandidates, { + forceHide: true + }); + } catch (error) { + console.error('[FB Tracker] Failed to hide search result manually:', error); + } + + if (!hideResult) { + info.disabled = false; + info.style.cursor = 'pointer'; + info.style.opacity = '1'; + resetHover(); + return; + } + + if (isSearchResult) { + const cacheKeyForHide = encodedUrl; + sessionSearchRecordedUrls.add(cacheKeyForHide); + sessionSearchInfoCache.set(cacheKeyForHide, hideResult); + } + + setIconClosed(); + + if (isDialogContext) { + console.log('[FB Tracker] Post #' + postNum + ' - Manual hide skipped in dialog context'); + } else { + hidePostElement(postElement); + + const seenCountValue = typeof hideResult.seen_count === 'number' ? hideResult.seen_count : null; + processedPostUrls.set(encodedUrl, { + element: postElement, + createdAt: Date.now(), + hidden: true, + searchSeenCount: seenCountValue + }); + } + }; + + info.addEventListener('click', handleManualHide); + resetHover(); + + container.insertBefore(info, container.firstChild); + } + + // Insert UI - try multiple strategies to find stable insertion point + let inserted = false; + + // Strategy 1: After button bar's parent (more stable) + if (buttonBar && buttonBar.parentElement && buttonBar.parentElement.parentElement) { + const grandParent = buttonBar.parentElement.parentElement; + grandParent.insertBefore(container, buttonBar.parentElement.nextSibling); + console.log('[FB Tracker] Post #' + postNum + ' - UI inserted after button bar parent. ID: #' + container.id); + inserted = true; + } + // Strategy 2: After button bar directly + else if (buttonBar && buttonBar.parentElement) { + buttonBar.parentElement.insertBefore(container, buttonBar.nextSibling); + console.log('[FB Tracker] Post #' + postNum + ' - UI inserted after button bar. ID: #' + container.id); + inserted = true; + } + // Strategy 3: Append to post element + else { + postElement.appendChild(container); + console.log('[FB Tracker] Post #' + postNum + ' - UI inserted into article (fallback). ID: #' + container.id); + inserted = true; + } + + if (inserted) { + processedPostUrls.set(encodedUrl, { + element: postElement, + createdAt: Date.now(), + searchSeenCount: searchTrackingInfo && typeof searchTrackingInfo.seen_count === 'number' + ? searchTrackingInfo.seen_count + : null, + hidden: false + }); + } + + // Monitor if the UI gets removed and re-insert it + if (inserted) { + const observer = new MutationObserver((mutations) => { + if (!document.getElementById(container.id)) { + console.log('[FB Tracker] Post #' + postNum + ' - UI was removed, re-inserting...'); + observer.disconnect(); + // Try to re-insert + if (buttonBar && buttonBar.parentElement && buttonBar.parentElement.parentElement) { + buttonBar.parentElement.parentElement.insertBefore(container, buttonBar.parentElement.nextSibling); + } else if (postElement.parentElement) { + postElement.parentElement.appendChild(container); + } + } + }); + + // Observe the parent for changes + const observeTarget = container.parentElement || postElement; + observer.observe(observeTarget, { childList: true, subtree: false }); + + // Stop observing after 5 seconds + setTimeout(() => observer.disconnect(), 5000); + } +} + +// Check if article is a main post (not a comment) +function isMainPost(article, buttonBar) { + if (!article || article === document.body) { + return false; + } + + const roleDescription = (article.getAttribute('aria-roledescription') || '').toLowerCase(); + if (roleDescription && (roleDescription.includes('kommentar') || roleDescription.includes('comment'))) { + return false; + } + + if (article.matches('[data-testid*="comment" i], [data-scope="comment"]')) { + return false; + } + + if (article.closest('[data-testid*="comment" i]')) { + return false; + } + + if (buttonBar) { + const shareIndicator = buttonBar.querySelector('[data-ad-rendering-role="share_button"]'); + if (shareIndicator) { + return true; + } + } + + // Comments are usually nested inside other articles or comment sections + // Check if this article is inside another article (likely a comment) + let parent = article.parentElement; + while (parent && parent !== document.body) { + if (parent.getAttribute('role') === 'article') { + const parentRoleDescription = (parent.getAttribute('aria-roledescription') || '').toLowerCase(); + if (parentRoleDescription.includes('kommentar') || parentRoleDescription.includes('comment')) { + return false; + } + // This article is inside another article - it's a comment + return false; + } + parent = parent.parentElement; + } + + // Additional check: Main posts usually have a link with /posts/ or /permalink/ + const postLinks = article.querySelectorAll('a[href*="/posts/"], a[href*="/permalink/"], a[href*="/photo"], a[href*="/videos/"], a[href*="/reel/"]'); + if (postLinks.length === 0) { + if (article.querySelector('[data-testid*="comment" i]')) { + return false; + } + if (article.querySelector('[data-ad-rendering-role="share_button"]')) { + return true; + } + const nextSibling = article.nextElementSibling; + if (nextSibling && nextSibling.querySelector('[data-ad-rendering-role="share_button"]')) { + const nextButtons = nextSibling.querySelectorAll('[role="button"]'); + if (nextButtons.length > 30) { + return true; + } + } + // No post-type links found - likely a comment + return false; + } + + return true; +} + +// Global post counter for unique IDs +let globalPostCounter = 0; + +// Find all Facebook posts on the page +function findPosts() { + console.log('[FB Tracker] Scanning for posts...'); + + const postContainers = findPostContainers(); + const seenFeedContainers = new Set(); + const seenDialogContainers = new Set(); + + console.log('[FB Tracker] Found', postContainers.length, 'candidate containers'); + + let processed = 0; + const isSearchResultsPage = window.location.pathname.startsWith(SEARCH_RESULTS_PATH); + + for (const { container: originalContainer, likeButton, buttonBar: precomputedButtonBar } of postContainers) { + let container = ensurePrimaryPostElement(originalContainer); + const dialogRoot = container.closest(DIALOG_ROOT_SELECTOR); + const isInDialog = !!dialogRoot; + const seenSet = isInDialog ? seenDialogContainers : seenFeedContainers; + + if (seenSet.has(container)) { + continue; + } + + const isPending = container.getAttribute(PENDING_ATTR) === '1'; + if (isPending) { + seenSet.add(container); + continue; + } + + const existingTracker = container.querySelector('.fb-tracker-ui'); + const alreadyProcessed = container.getAttribute(PROCESSED_ATTR) === '1'; + const trackerDialogRoot = existingTracker ? existingTracker.closest(DIALOG_ROOT_SELECTOR) : null; + const trackerInSameDialog = Boolean(existingTracker && existingTracker.isConnected && trackerDialogRoot === dialogRoot); + + if (isInDialog) { + if (trackerInSameDialog) { + seenSet.add(container); + continue; + } + + if (existingTracker && existingTracker.isConnected) { + existingTracker.remove(); + } + + if (alreadyProcessed) { + container.removeAttribute(PROCESSED_ATTR); + } + } else { + if (alreadyProcessed || (existingTracker && existingTracker.isConnected)) { + seenSet.add(container); + continue; + } + } + + const buttonBar = precomputedButtonBar || findButtonBar(container); + if (!buttonBar) { + console.log('[FB Tracker] Proceeding without button bar, will fallback to post container insertion'); + } + + if (!isMainPost(container, buttonBar)) { + console.log('[FB Tracker] Skipping non-main post container'); + continue; + } + + seenSet.add(container); + + processed++; + globalPostCounter++; + const postNum = globalPostCounter; + console.log('[FB Tracker] Post #' + postNum + ' - Adding tracker:', container); + + container.setAttribute(PENDING_ATTR, '1'); + createTrackerUI(container, buttonBar, postNum, { + likeButton, + isSearchResult: isSearchResultsPage, + isDialogContext: isInDialog + }).catch((error) => { + console.error('[FB Tracker] Post #' + postNum + ' - Failed to create UI:', error, container); + }).finally(() => { + container.removeAttribute(PENDING_ATTR); + }); + } + + console.log('[FB Tracker] Total processed posts this scan:', processed, '| Global count:', globalPostCounter); +} + +// Initialize +console.log('[FB Tracker] Initializing...'); + +// Run multiple times to catch loading posts +setTimeout(findPosts, 2000); +setTimeout(findPosts, 4000); +setTimeout(findPosts, 6000); + +// Debounced scan function +let scanTimeout = null; +function scheduleScan() { + if (scanTimeout) { + clearTimeout(scanTimeout); + } + scanTimeout = setTimeout(() => { + console.log('[FB Tracker] Scheduled scan triggered'); + findPosts(); + }, 1000); +} + +// Watch for new posts being added to the page +const observer = new MutationObserver((mutations) => { + scheduleScan(); +}); + +// Start observing +observer.observe(document.body, { + childList: true, + subtree: true +}); + +// Trigger scan on scroll (for infinite scroll) +let scrollTimeout = null; +window.addEventListener('scroll', () => { + if (scrollTimeout) { + clearTimeout(scrollTimeout); + } + scrollTimeout = setTimeout(() => { + console.log('[FB Tracker] Scroll detected, scanning...'); + findPosts(); + }, 1000); +}); + +// Use IntersectionObserver to detect when posts become visible +const visibilityObserver = new IntersectionObserver((entries) => { + let needsScan = false; + + entries.forEach(entry => { + if (entry.isIntersecting) { + const postContainer = entry.target; + + // Check if already processed + if (postContainer.getAttribute(PROCESSED_ATTR) !== '1') { + console.log('[FB Tracker] Post became visible and not yet processed:', postContainer); + needsScan = true; + } + } + }); + + if (needsScan) { + // Delay scan slightly to let Facebook finish loading the post content + setTimeout(() => { + console.log('[FB Tracker] Scanning newly visible posts...'); + findPosts(); + }, 500); + } +}, { + root: null, + rootMargin: '50px', + threshold: 0.1 +}); + +// Watch for new posts and observe them +const postObserver = new MutationObserver((mutations) => { + // Find all posts and observe them for visibility + const posts = document.querySelectorAll('div[aria-posinset]'); + posts.forEach(post => { + if (!post.dataset.trackerObserved) { + post.dataset.trackerObserved = 'true'; + visibilityObserver.observe(post); + } + }); + + scheduleScan(); +}); + +// Start observing +postObserver.observe(document.body, { + childList: true, + subtree: true +}); + +// Initial observation of existing posts +const initialPosts = document.querySelectorAll('div[aria-posinset]'); +initialPosts.forEach(post => { + post.dataset.trackerObserved = 'true'; + visibilityObserver.observe(post); +}); + +console.log('[FB Tracker] Observer with IntersectionObserver started'); + +// Store the element where context menu was opened +let contextMenuTarget = null; +document.addEventListener('contextmenu', (event) => { + contextMenuTarget = event.target; + console.log('[FB Tracker] Context menu opened on:', contextMenuTarget); +}, true); + +// Listen for manual reparse command +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message && message.type === 'reparsePost') { + console.log('[FB Tracker] Manual reparse triggered'); + + // Use the stored context menu target, fallback to elementFromPoint + let clickedElement = contextMenuTarget; + if (!clickedElement && message.x !== undefined && message.y !== undefined) { + clickedElement = document.elementFromPoint(message.x, message.y); + } + + if (!clickedElement) { + console.log('[FB Tracker] No element found'); + sendResponse({ success: false }); + return true; + } + + console.log('[FB Tracker] Searching for post container starting from:', clickedElement); + + // Find the post container (aria-posinset) + let postContainer = clickedElement.closest('div[aria-posinset]'); + + if (!postContainer) { + console.log('[FB Tracker] No post container found for clicked element:', clickedElement); + sendResponse({ success: false }); + return true; + } + + console.log('[FB Tracker] Found post container:', postContainer); + + const normalizedContainer = ensurePrimaryPostElement(postContainer); + if (normalizedContainer && normalizedContainer !== postContainer) { + console.log('[FB Tracker] Normalized post container to:', normalizedContainer); + postContainer = normalizedContainer; + } + + // Remove processed attribute and existing UI + postContainer.removeAttribute(PROCESSED_ATTR); + const existingUI = postContainer.querySelector('.fb-tracker-ui'); + if (existingUI) { + existingUI.remove(); + console.log('[FB Tracker] Removed existing UI'); + } + + // Find button bar and create UI + let buttonBar = findButtonBar(postContainer); + if (!buttonBar) { + let fallback = postContainer.parentElement; + while (!buttonBar && fallback && fallback !== document.body) { + buttonBar = findButtonBar(fallback); + fallback = fallback.parentElement; + } + } + + if (!buttonBar) { + console.log('[FB Tracker] No button bar found for this post, proceeding with fallback'); + } + + globalPostCounter++; + const postNum = globalPostCounter; + console.log('[FB Tracker] Reparsing post as #' + postNum); + + createTrackerUI(postContainer, buttonBar, postNum).then(() => { + sendResponse({ success: true }); + }).catch((error) => { + console.error('[FB Tracker] Failed to reparse:', error); + sendResponse({ success: false }); + }); + + return true; + } +}); + +// ============================================================================ +// AI COMMENT GENERATION +// ============================================================================ + +/** + * Show a toast notification + */ +function showToast(message, type = 'info') { + const toast = document.createElement('div'); + toast.style.cssText = ` + position: fixed; + bottom: 24px; + right: 24px; + background: ${type === 'error' ? '#e74c3c' : type === 'success' ? '#42b72a' : '#1877f2'}; + color: white; + padding: 12px 20px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 14px; + z-index: 999999; + max-width: 350px; + animation: slideIn 0.3s ease-out; + `; + toast.textContent = message; + + // Add animation keyframes + if (!document.getElementById('fb-tracker-toast-styles')) { + const style = document.createElement('style'); + style.id = 'fb-tracker-toast-styles'; + style.textContent = ` + @keyframes slideIn { + from { + transform: translateX(400px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } + } + @keyframes slideOut { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(400px); + opacity: 0; + } + } + `; + document.head.appendChild(style); + } + + document.body.appendChild(toast); + + setTimeout(() => { + toast.style.animation = 'slideOut 0.3s ease-out'; + setTimeout(() => toast.remove(), 300); + }, 3000); +} + +/** + * Extract post text from a Facebook post element + */ +function normalizeSelectionText(text) { + if (!text) { + return ''; + } + const trimmed = text.trim(); + if (!trimmed) { + return ''; + } + return trimmed.length > MAX_SELECTION_LENGTH + ? trimmed.substring(0, MAX_SELECTION_LENGTH) + : trimmed; +} + +function ensurePrimaryPostElement(element) { + if (!element) { + return element; + } + + const selectors = [ + 'div[role="dialog"] article', + 'div[role="dialog"] div[aria-posinset]', + '[data-pagelet*="FeedUnit"] article', + 'div[role="main"] article', + '[data-visualcompletion="ignore-dynamic"] article', + 'div[aria-posinset]', + 'article[role="article"]', + 'article' + ]; + + let current = element; + while (current && current !== document.body && current !== document.documentElement) { + for (const selector of selectors) { + if (current.matches && current.matches(selector)) { + return current; + } + } + current = current.parentElement; + } + + return element; +} + +function cacheSelectionForPost(postElement) { + try { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return; + } + + const text = normalizeSelectionText(selection.toString()); + if (text) { + postSelectionCache.set(postElement, { + text, + timestamp: Date.now() + }); + lastGlobalSelection = { text, timestamp: Date.now() }; + } + } catch (error) { + console.warn('[FB Tracker] Failed to cache selection text:', error); + } +} + +function cacheCurrentSelection() { + try { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return; + } + + const text = normalizeSelectionText(selection.toString()); + if (text) { + lastGlobalSelection = { + text, + timestamp: Date.now() + }; + } + } catch (error) { + console.debug('[FB Tracker] Unable to cache current selection:', error); + } +} + +function getSelectedTextFromPost(postElement) { + try { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + throw new Error('No active selection'); + } + + const text = normalizeSelectionText(selection.toString()); + if (text) { + postSelectionCache.set(postElement, { text, timestamp: Date.now() }); + lastGlobalSelection = { text, timestamp: Date.now() }; + return text; + } + throw new Error('Empty selection'); + } catch (error) { + if (error && error.message) { + console.debug('[FB Tracker] Selection fallback:', error.message); + } + const cached = postSelectionCache.get(postElement); + if (cached && Date.now() - cached.timestamp < LAST_SELECTION_MAX_AGE) { + return cached.text; + } + if (lastGlobalSelection.text && Date.now() - lastGlobalSelection.timestamp < LAST_SELECTION_MAX_AGE) { + return lastGlobalSelection.text; + } + return ''; + } +} + +function extractPostText(postElement) { + if (!postElement) { + return ''; + } + + // Try to find the post content div + const contentSelectors = [ + '[data-ad-preview="message"]', + '[data-ad-comet-preview="message"]', + 'div[dir="auto"][style*="text-align"]', + 'div[data-ad-comet-preview] > div > div > span', + '.x193iq5w.xeuugli' // Common Facebook text class + ]; + + const uiTextPattern = /(Gefällt mir|Kommentieren|Teilen|Like|Comment|Share)/gi; + const timePattern = /\d+\s*(Std\.|Min\.|Tag|hour|minute|day)/gi; + const sponsoredPattern = /(Gesponsert|Sponsored)/gi; + + const cleanCandidate = (text) => { + if (!text) { + return ''; + } + + const cleaned = text + .replace(uiTextPattern, ' ') + .replace(timePattern, ' ') + .replace(sponsoredPattern, ' ') + .replace(/\s+/g, ' ') + .trim(); + + if (!cleaned) { + return ''; + } + + // Ignore very short snippets that are likely button labels + if (cleaned.length < 5 && cleaned.split(/\s+/).length < 2) { + return ''; + } + + return cleaned; + }; + + const candidates = []; + + for (const selector of contentSelectors) { + const elements = postElement.querySelectorAll(selector); + for (const element of elements) { + const candidate = cleanCandidate(element.innerText || element.textContent || ''); + if (candidate) { + candidates.push(candidate); + } + } + if (candidates.length) { + break; + } + } + + let textContent = ''; + + if (candidates.length) { + textContent = candidates.reduce((longest, current) => ( + current.length > longest.length ? current : longest + ), ''); + } + + // Fallback: Get all text but filter out common UI elements + if (!textContent) { + const allText = postElement.innerText || postElement.textContent || ''; + textContent = cleanCandidate(allText); + } + + return textContent.substring(0, 2000); // Limit length +} + +/** + * Find and click the comment button to open comment field + */ +function findAndClickCommentButton(postElement) { + if (!postElement) { + return false; + } + + // Look for comment button with various selectors + const commentButtonSelectors = [ + '[data-ad-rendering-role="comment_button"]', + '[aria-label*="Kommentieren"]', + '[aria-label*="Comment"]' + ]; + + for (const selector of commentButtonSelectors) { + const button = postElement.querySelector(selector); + if (button) { + console.log('[FB Tracker] Found comment button, clicking it'); + button.click(); + return true; + } + } + + // Try in parent elements + let parent = postElement; + for (let i = 0; i < 3; i++) { + parent = parent.parentElement; + if (!parent) break; + + for (const selector of commentButtonSelectors) { + const button = parent.querySelector(selector); + if (button) { + console.log('[FB Tracker] Found comment button in parent, clicking it'); + button.click(); + return true; + } + } + } + + return false; +} + +/** + * Find comment input field on current page + */ +function findCommentInput(postElement) { + if (!postElement) { + return null; + } + + // Try multiple selectors for comment input + const selectors = [ + 'div[contenteditable="true"][role="textbox"]', + 'div[aria-label*="Kommentar"][contenteditable="true"]', + 'div[aria-label*="comment"][contenteditable="true"]', + 'div[aria-label*="Write a comment"][contenteditable="true"]' + ]; + + for (const selector of selectors) { + const input = postElement.querySelector(selector); + if (input) { + return input; + } + } + + // Search in parent containers + let parent = postElement; + for (let i = 0; i < 3; i++) { + parent = parent.parentElement; + if (!parent) break; + + for (const selector of selectors) { + const input = parent.querySelector(selector); + if (input) { + return input; + } + } + } + + return null; +} + +function isElementVisible(element) { + if (!element || !element.isConnected) { + return false; + } + + if (typeof element.offsetParent !== 'undefined' && element.offsetParent !== null) { + return true; + } + + const rects = element.getClientRects(); + return rects && rects.length > 0; +} + +function isCancellationError(error) { + if (!error) { + return false; + } + + if (error.name === 'AICancelled' || error.name === 'AbortError') { + return true; + } + + if (typeof error.message === 'string') { + const normalized = error.message.toLowerCase(); + if (normalized === 'ai_cancelled' || normalized === 'abgebrochen') { + return true; + } + } + + return false; +} + +async function waitForCommentInput(postElement, options = {}) { + const { + encodedPostUrl = null, + timeout = 6000, + interval = 200, + context = null + } = options; + + const deadline = Date.now() + Math.max(timeout, 0); + let attempts = 0; + + const findByEncodedUrl = () => { + if (context && context.cancelled) { + return null; + } + + if (!encodedPostUrl) { + return null; + } + + const trackers = document.querySelectorAll(`.fb-tracker-ui[data-post-url="${encodedPostUrl}"]`); + for (const tracker of trackers) { + if (!tracker.isConnected) { + continue; + } + + const trackerContainer = tracker.closest('div[aria-posinset], article[role="article"], article'); + if (trackerContainer) { + const input = findCommentInput(trackerContainer); + if (isElementVisible(input)) { + return input; + } + } + + const dialogRoot = tracker.closest(DIALOG_ROOT_SELECTOR); + if (dialogRoot) { + const dialogInput = dialogRoot.querySelector('div[contenteditable="true"][role="textbox"]'); + if (isElementVisible(dialogInput)) { + return dialogInput; + } + } + } + + return null; + }; + + while (Date.now() <= deadline) { + if (context && context.cancelled) { + return null; + } + + attempts++; + + let input = findCommentInput(postElement); + if (isElementVisible(input)) { + if (attempts > 1) { + console.log('[FB Tracker] Comment input located after', attempts, 'attempts (post context)'); + } + return input; + } + + input = findByEncodedUrl(); + if (input) { + if (attempts > 1) { + console.log('[FB Tracker] Comment input located after', attempts, 'attempts (encoded URL context)'); + } + return input; + } + + const dialogRootFromPost = postElement ? postElement.closest(DIALOG_ROOT_SELECTOR) : null; + if (dialogRootFromPost) { + const dialogInput = dialogRootFromPost.querySelector('div[contenteditable="true"][role="textbox"]'); + if (isElementVisible(dialogInput)) { + if (attempts > 1) { + console.log('[FB Tracker] Comment input located after', attempts, 'attempts (dialog context)'); + } + return dialogInput; + } + } + + const fallbackDialog = document.querySelector(DIALOG_ROOT_SELECTOR); + if (fallbackDialog && fallbackDialog !== dialogRootFromPost) { + const dialogInput = fallbackDialog.querySelector('div[contenteditable="true"][role="textbox"]'); + if (isElementVisible(dialogInput)) { + if (attempts > 1) { + console.log('[FB Tracker] Comment input located after', attempts, 'attempts (fallback dialog)'); + } + return dialogInput; + } + } + + const globalInputs = Array.from(document.querySelectorAll('div[contenteditable="true"][role="textbox"]')).filter(isElementVisible); + if (globalInputs.length > 0) { + const lastInput = globalInputs[globalInputs.length - 1]; + if (lastInput) { + if (attempts > 1) { + console.log('[FB Tracker] Comment input located after', attempts, 'attempts (global fallback)'); + } + return lastInput; + } + } + + await delay(interval); + } + + console.log('[FB Tracker] Comment input wait timed out after', timeout, 'ms'); + return null; +} + +/** + * Set text in comment input field + */ +async function setCommentText(inputElement, text, options = {}) { + const { context = null } = options; + + const ensureNotCancelled = () => { + if (context && context.cancelled) { + const cancelError = new Error('AI_CANCELLED'); + cancelError.name = 'AICancelled'; + throw cancelError; + } + }; + + if (!inputElement || !text) { + return false; + } + + try { + ensureNotCancelled(); + console.log('[FB Tracker] Setting comment text:', text.substring(0, 50) + '...'); + console.log('[FB Tracker] Input element:', inputElement); + + // Focus and click to ensure field is active + inputElement.focus(); + inputElement.click(); + + // Small delay to ensure field is ready + await delay(50); + ensureNotCancelled(); + + // Clear existing content + inputElement.textContent = ''; + + // Method 1: Try execCommand first (best for Facebook) + const range = document.createRange(); + const selection = window.getSelection(); + range.selectNodeContents(inputElement); + selection.removeAllRanges(); + selection.addRange(range); + + const execSuccess = document.execCommand('insertText', false, text); + console.log('[FB Tracker] execCommand result:', execSuccess); + + // Wait a bit and check if it worked + await delay(100); + ensureNotCancelled(); + + let currentContent = inputElement.textContent || inputElement.innerText || ''; + console.log('[FB Tracker] Content after execCommand:', currentContent); + + // If execCommand didn't work, use direct method + if (!currentContent || currentContent.trim().length === 0) { + console.log('[FB Tracker] execCommand failed, using direct method'); + inputElement.textContent = text; + currentContent = text; + } + + // Trigger input events + inputElement.dispatchEvent(new InputEvent('input', { bubbles: true, cancelable: true })); + inputElement.dispatchEvent(new Event('change', { bubbles: true })); + + // Final verification + ensureNotCancelled(); + const finalContent = inputElement.textContent || inputElement.innerText || ''; + console.log('[FB Tracker] Final content:', finalContent.substring(0, 50)); + + return finalContent.length > 0; + } catch (error) { + console.error('[FB Tracker] Failed to set comment text:', error); + return false; + } +} + +/** + * Generate AI comment for a post + */ +async function generateAIComment(postText, profileNumber, options = {}) { + const { signal = null, preferredCredentialId = null } = options; + try { + const payload = { + postText, + profileNumber + }; + + if (typeof preferredCredentialId === 'number') { + payload.preferredCredentialId = preferredCredentialId; + } + + const response = await backendFetch(`${API_URL}/ai/generate-comment`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to generate comment'); + } + + const data = await response.json(); + return data.comment; + + } catch (error) { + console.error('[FB Tracker] AI comment generation failed:', error); + throw error; + } +} + +/** + * Check if AI is enabled + */ +async function isAIEnabled() { + try { + const response = await backendFetch(`${API_URL}/ai-settings`); + if (!response.ok) { + return false; + } + const settings = await response.json(); + return settings && settings.enabled === 1; + } catch (error) { + console.warn('[FB Tracker] Failed to check AI settings:', error); + return false; + } +} + +/** + * Add AI comment button to tracker UI + */ +async function addAICommentButton(container, postElement) { + const aiEnabled = await isAIEnabled(); + if (!aiEnabled) { + return; + } + + const encodedPostUrl = container && container.getAttribute('data-post-url') + ? container.getAttribute('data-post-url') + : null; + + const wrapper = document.createElement('div'); + wrapper.className = 'fb-tracker-ai-wrapper'; + wrapper.style.cssText = ` + margin-left: auto; + position: relative; + display: inline-flex; + align-items: stretch; + border-radius: 6px; + overflow: visible; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); + `; + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'fb-tracker-btn fb-tracker-btn-ai'; + button.textContent = '✨ AI'; + button.title = 'Generiere automatisch einen passenden Kommentar'; + button.style.cssText = ` + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + padding: 6px 12px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + flex: 1 1 auto; + border-radius: 6px 0 0 6px; + transition: all 0.2s ease; + `; + + const dropdownButton = document.createElement('button'); + dropdownButton.type = 'button'; + dropdownButton.className = 'fb-tracker-btn fb-tracker-btn-ai-dropdown'; + dropdownButton.textContent = '▾'; + dropdownButton.title = 'AI auswählen'; + dropdownButton.setAttribute('aria-label', 'AI auswählen'); + dropdownButton.setAttribute('aria-haspopup', 'menu'); + dropdownButton.setAttribute('aria-expanded', 'false'); + dropdownButton.style.cssText = ` + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + width: 34px; + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border-left: 1px solid rgba(255, 255, 255, 0.35); + border-radius: 0 6px 6px 0; + transition: all 0.2s ease; + `; + + const dropdown = document.createElement('div'); + dropdown.className = 'fb-tracker-ai-dropdown'; + dropdown.style.cssText = ` + display: none; + position: absolute; + right: 0; + top: calc(100% + 6px); + min-width: 220px; + background: #ffffff; + border-radius: 8px; + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18); + z-index: 2147483647; + padding: 6px 0; + `; + + wrapper.appendChild(button); + wrapper.appendChild(dropdownButton); + wrapper.appendChild(dropdown); + container.appendChild(wrapper); + + button.addEventListener('mouseenter', () => { + if ((button.dataset.aiState || 'idle') === 'idle') { + button.style.transform = 'translateY(-2px)'; + button.style.boxShadow = '0 4px 12px rgba(102, 126, 234, 0.4)'; + dropdownButton.style.transform = 'translateY(-2px)'; + dropdownButton.style.boxShadow = '0 4px 12px rgba(102, 126, 234, 0.4)'; + } + }); + + button.addEventListener('mouseleave', () => { + button.style.transform = 'translateY(0)'; + button.style.boxShadow = 'none'; + dropdownButton.style.transform = 'translateY(0)'; + dropdownButton.style.boxShadow = 'none'; + }); + + dropdownButton.addEventListener('mouseenter', () => { + if ((button.dataset.aiState || 'idle') === 'idle') { + button.style.transform = 'translateY(-2px)'; + button.style.boxShadow = '0 4px 12px rgba(102, 126, 234, 0.4)'; + dropdownButton.style.transform = 'translateY(-2px)'; + dropdownButton.style.boxShadow = '0 4px 12px rgba(102, 126, 234, 0.4)'; + } + }); + + dropdownButton.addEventListener('mouseleave', () => { + if (!wrapper.classList.contains('fb-tracker-ai-wrapper--open')) { + button.style.transform = 'translateY(0)'; + button.style.boxShadow = 'none'; + dropdownButton.style.transform = 'translateY(0)'; + dropdownButton.style.boxShadow = 'none'; + } + }); + + button.addEventListener('pointerdown', () => { + cacheSelectionForPost(postElement); + }); + + button.dataset.aiState = 'idle'; + button.dataset.aiOriginalText = button.textContent; + + let dropdownOpen = false; + + const closeDropdown = () => { + if (!dropdownOpen) { + return; + } + dropdown.style.display = 'none'; + dropdownOpen = false; + dropdownButton.setAttribute('aria-expanded', 'false'); + dropdownButton.textContent = '▾'; + wrapper.classList.remove('fb-tracker-ai-wrapper--open'); + document.removeEventListener('click', handleOutsideClick, true); + document.removeEventListener('keydown', handleKeydown, true); + button.style.transform = 'translateY(0)'; + dropdownButton.style.transform = 'translateY(0)'; + button.style.boxShadow = 'none'; + dropdownButton.style.boxShadow = 'none'; + }; + + const handleOutsideClick = (event) => { + if (!wrapper.contains(event.target)) { + closeDropdown(); + } + }; + + const handleKeydown = (event) => { + if (event.key === 'Escape') { + closeDropdown(); + } + }; + + const renderDropdownItems = async () => { + dropdown.innerHTML = ''; + const loading = document.createElement('div'); + loading.textContent = 'Lade AI-Auswahl...'; + loading.style.cssText = 'padding: 8px 14px; font-size: 13px; color: #555;'; + dropdown.appendChild(loading); + + try { + const credentials = await fetchActiveAICredentials(); + dropdown.innerHTML = ''; + + if (!credentials || credentials.length === 0) { + const empty = document.createElement('div'); + empty.textContent = 'Keine aktiven AI-Anbieter gefunden'; + empty.style.cssText = 'padding: 8px 14px; font-size: 13px; color: #888;'; + dropdown.appendChild(empty); + return; + } + + credentials.forEach((credential) => { + const option = document.createElement('button'); + option.type = 'button'; + option.className = 'fb-tracker-ai-option'; + option.style.cssText = ` + width: 100%; + padding: 8px 14px; + background: transparent; + border: none; + text-align: left; + cursor: pointer; + font-size: 13px; + display: flex; + flex-direction: column; + gap: 2px; + `; + + option.addEventListener('mouseenter', () => { + option.style.background = '#f0f2f5'; + }); + + option.addEventListener('mouseleave', () => { + option.style.background = 'transparent'; + }); + + const label = document.createElement('span'); + label.textContent = formatAICredentialLabel(credential); + label.style.cssText = 'font-weight: 600; color: #1d2129;'; + + const metaParts = []; + if (credential.provider) { + metaParts.push(`Provider: ${credential.provider}`); + } + if (credential.model) { + metaParts.push(`Modell: ${credential.model}`); + } + + if (metaParts.length > 0) { + const meta = document.createElement('span'); + meta.textContent = metaParts.join(' · '); + meta.style.cssText = 'font-size: 12px; color: #65676b;'; + option.appendChild(label); + option.appendChild(meta); + } else { + option.appendChild(label); + } + + option.addEventListener('click', () => { + closeDropdown(); + if ((button.dataset.aiState || 'idle') === 'idle') { + cacheSelectionForPost(postElement); + startAIFlow(credential.id); + } + }); + + dropdown.appendChild(option); + }); + } catch (error) { + dropdown.innerHTML = ''; + const errorItem = document.createElement('div'); + errorItem.textContent = error.message || 'AI-Anbieter konnten nicht geladen werden'; + errorItem.style.cssText = 'padding: 8px 14px; font-size: 13px; color: #c0392b; white-space: normal;'; + dropdown.appendChild(errorItem); + } + }; + + const toggleDropdown = async () => { + if ((button.dataset.aiState || 'idle') !== 'idle') { + return; + } + + if (dropdownOpen) { + closeDropdown(); + return; + } + + dropdownOpen = true; + wrapper.classList.add('fb-tracker-ai-wrapper--open'); + dropdownButton.textContent = '▴'; + dropdown.style.display = 'block'; + dropdownButton.setAttribute('aria-expanded', 'true'); + document.addEventListener('click', handleOutsideClick, true); + document.addEventListener('keydown', handleKeydown, true); + + await renderDropdownItems(); + }; + + dropdownButton.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + toggleDropdown(); + }); + + const startAIFlow = async (preferredCredentialId = null) => { + closeDropdown(); + + const originalText = button.dataset.aiOriginalText || '✨ AI'; + const currentState = button.dataset.aiState || 'idle'; + + if (currentState === 'processing') { + const runningContext = button._aiContext; + if (runningContext && !runningContext.cancelled) { + runningContext.cancel(); + button.dataset.aiState = 'cancelling'; + button.textContent = '✋ Abbruch...'; + button.setAttribute('aria-busy', 'true'); + button.style.cursor = 'progress'; + dropdownButton.style.cursor = 'progress'; + button.classList.remove('fb-tracker-btn-ai--processing'); + button.classList.add('fb-tracker-btn-ai--cancelling'); + } + return; + } + + if (currentState === 'cancelling') { + return; + } + + const aiContext = { + cancelled: false, + abortController: new AbortController(), + cancel() { + if (!this.cancelled) { + this.cancelled = true; + this.abortController.abort(); + } + } + }; + + const throwIfCancelled = () => { + if (aiContext.cancelled) { + const cancelError = new Error('AI_CANCELLED'); + cancelError.name = 'AICancelled'; + throw cancelError; + } + }; + + const updateProcessingText = (text) => { + if (button.dataset.aiState === 'processing' && !aiContext.cancelled) { + button.textContent = text; + } + }; + + const restoreIdle = (text, revertDelay = 0) => { + button.dataset.aiState = 'idle'; + button._aiContext = null; + button.removeAttribute('aria-busy'); + button.classList.remove('fb-tracker-btn-ai--processing', 'fb-tracker-btn-ai--cancelling'); + button.style.cursor = 'pointer'; + dropdownButton.disabled = false; + dropdownButton.style.opacity = '1'; + dropdownButton.style.cursor = 'pointer'; + dropdownButton.textContent = '▾'; + dropdownButton.setAttribute('aria-busy', 'false'); + button.textContent = text; + + if (revertDelay > 0) { + setTimeout(() => { + if ((button.dataset.aiState || 'idle') === 'idle' && !button._aiContext) { + button.textContent = originalText; + } + }, revertDelay); + } + }; + + button._aiContext = aiContext; + button.dataset.aiState = 'processing'; + button.setAttribute('aria-busy', 'true'); + button.classList.add('fb-tracker-btn-ai--processing'); + button.classList.remove('fb-tracker-btn-ai--cancelling'); + button.style.cursor = 'progress'; + dropdownButton.disabled = true; + dropdownButton.style.opacity = '0.5'; + dropdownButton.style.cursor = 'not-allowed'; + dropdownButton.setAttribute('aria-busy', 'true'); + button.textContent = '⏳ Generiere...'; + + try { + let postText = ''; + const cachedSelection = postSelectionCache.get(postElement); + if (cachedSelection && Date.now() - cachedSelection.timestamp < LAST_SELECTION_MAX_AGE) { + console.log('[FB Tracker] Using cached selection text'); + postText = cachedSelection.text; + } + + throwIfCancelled(); + + if (!postText) { + postText = getSelectedTextFromPost(postElement); + if (postText) { + console.log('[FB Tracker] Using active selection text'); + } + } + + if (!postText) { + const latestCached = postSelectionCache.get(postElement); + if (latestCached && Date.now() - latestCached.timestamp < LAST_SELECTION_MAX_AGE) { + console.log('[FB Tracker] Using latest cached selection after check'); + postText = latestCached.text; + } + } + + if (!postText) { + postText = extractPostText(postElement); + if (postText) { + console.log('[FB Tracker] Fallback to DOM extraction'); + } + } + + if (!postText) { + throw new Error('Konnte Post-Text nicht extrahieren'); + } + + postSelectionCache.delete(postElement); + + throwIfCancelled(); + + console.log('[FB Tracker] Generating AI comment for:', postText.substring(0, 100)); + + const profileNumber = await getProfileNumber(); + + throwIfCancelled(); + + const comment = await generateAIComment(postText, profileNumber, { + signal: aiContext.abortController.signal, + preferredCredentialId + }); + + throwIfCancelled(); + + console.log('[FB Tracker] Generated comment:', comment); + + let commentInput = findCommentInput(postElement); + let waitedForInput = false; + + if (!commentInput) { + console.log('[FB Tracker] Comment input not found, trying to click comment button'); + const buttonClicked = findAndClickCommentButton(postElement); + + updateProcessingText(buttonClicked ? '⏳ Öffne Kommentare...' : '⏳ Suche Kommentarfeld...'); + + waitedForInput = true; + commentInput = await waitForCommentInput(postElement, { + encodedPostUrl, + timeout: buttonClicked ? 8000 : 5000, + interval: 250, + context: aiContext + }); + } + + if (!commentInput && !waitedForInput) { + updateProcessingText('⏳ Suche Kommentarfeld...'); + waitedForInput = true; + commentInput = await waitForCommentInput(postElement, { + encodedPostUrl, + timeout: 4000, + interval: 200, + context: aiContext + }); + } + + throwIfCancelled(); + + if (!commentInput) { + throwIfCancelled(); + await navigator.clipboard.writeText(comment); + throwIfCancelled(); + showToast('📋 Kommentarfeld nicht gefunden - In Zwischenablage kopiert', 'info'); + restoreIdle('📋 Kopiert', 2000); + return; + } + + if (waitedForInput) { + updateProcessingText('⏳ Füge Kommentar ein...'); + } + + const success = await setCommentText(commentInput, comment, { context: aiContext }); + + throwIfCancelled(); + + if (success) { + showToast('✓ Kommentar wurde eingefügt', 'success'); + restoreIdle('✓ Eingefügt', 2000); + } else { + await navigator.clipboard.writeText(comment); + throwIfCancelled(); + showToast('📋 Einfügen fehlgeschlagen - In Zwischenablage kopiert', 'info'); + restoreIdle('📋 Kopiert', 2000); + } + } catch (error) { + const cancelled = aiContext.cancelled || isCancellationError(error); + if (cancelled) { + console.log('[FB Tracker] AI comment operation cancelled'); + restoreIdle('✋ Abgebrochen', 1500); + showToast('⏹️ Vorgang abgebrochen', 'info'); + return; + } + + console.error('[FB Tracker] AI comment error:', error); + showToast(`❌ ${error.message}`, 'error'); + restoreIdle(originalText); + } + }; + + button.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + startAIFlow(); + }); + + container.appendChild(wrapper); +} + +// Expose function globally so it can be called from createTrackerUI +window.addAICommentButton = addAICommentButton; diff --git a/extension/icons/icon-128.png b/extension/icons/icon-128.png new file mode 100644 index 0000000..95319c6 Binary files /dev/null and b/extension/icons/icon-128.png differ diff --git a/extension/icons/icon-16.png b/extension/icons/icon-16.png new file mode 100644 index 0000000..291745d Binary files /dev/null and b/extension/icons/icon-16.png differ diff --git a/extension/icons/icon-32.png b/extension/icons/icon-32.png new file mode 100644 index 0000000..ab686c8 Binary files /dev/null and b/extension/icons/icon-32.png differ diff --git a/extension/icons/icon-48.png b/extension/icons/icon-48.png new file mode 100644 index 0000000..3beec5d Binary files /dev/null and b/extension/icons/icon-48.png differ diff --git a/extension/icons/icon-512.png b/extension/icons/icon-512.png new file mode 100644 index 0000000..a84c3d5 Binary files /dev/null and b/extension/icons/icon-512.png differ diff --git a/extension/icons/icon.svg b/extension/icons/icon.svg new file mode 100644 index 0000000..50588b3 --- /dev/null +++ b/extension/icons/icon.svg @@ -0,0 +1,4 @@ + + + 📋 + \ No newline at end of file diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..9b2c5fe --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,47 @@ +{ + "manifest_version": 3, + "name": "Facebook Post Tracker", + "version": "1.1.0", + "description": "Track Facebook posts across multiple profiles", + "permissions": [ + "storage", + "activeTab", + "tabs", + "contextMenus" + ], + "host_permissions": [ + "", + "https://www.facebook.com/*", + "https://facebook.com/*", + "http://localhost:3001/*", + "https://fb.srv.medeba-media.de/*" + ], + "icons": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + }, + "action": { + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png" + } + }, + "content_scripts": [ + { + "matches": [ + "https://www.facebook.com/*", + "https://facebook.com/*" + ], + "js": ["config.js", "content.js"], + "css": ["content.css"], + "run_at": "document_idle" + } + ], + "background": { + "service_worker": "background.js" + } +} diff --git a/extension/popup.html b/extension/popup.html new file mode 100644 index 0000000..f60596e --- /dev/null +++ b/extension/popup.html @@ -0,0 +1,119 @@ + + + + + Facebook Post Tracker + + + +

📋 Facebook Post Tracker

+ +
+ + +
Wähle das Browser-Profil aus, mit dem du aktuell arbeitest.
+
+ +
+ +
+ + +
+ + + + + \ No newline at end of file diff --git a/extension/popup.js b/extension/popup.js new file mode 100644 index 0000000..7c7d109 --- /dev/null +++ b/extension/popup.js @@ -0,0 +1,99 @@ +const profileSelect = document.getElementById('profileSelect'); +const statusEl = document.getElementById('status'); + +function apiFetch(url, options = {}) { + const config = { + ...options, + credentials: 'include' + }; + + if (options && options.headers) { + config.headers = { ...options.headers }; + } + + return fetch(url, config); +} + +async function fetchProfileState() { + try { + const response = await apiFetch(`${API_BASE_URL}/api/profile-state`); + if (!response.ok) { + return null; + } + + const data = await response.json(); + if (data && typeof data.profile_number !== 'undefined') { + const parsed = parseInt(data.profile_number, 10); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + return null; + } catch (error) { + console.warn('Profile state fetch failed:', error); + return null; + } +} + +async function updateProfileState(profileNumber) { + try { + const response = await apiFetch(`${API_BASE_URL}/api/profile-state`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ profile_number: profileNumber }) + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + } catch (error) { + console.error('Failed to update profile state:', error); + } +} + +function updateStatus(message, saved = false) { + statusEl.textContent = message; + statusEl.className = saved ? 'status saved' : 'status'; +} + +async function initProfileSelect() { + const backendProfile = await fetchProfileState(); + if (backendProfile) { + profileSelect.value = String(backendProfile); + chrome.storage.sync.set({ profileNumber: backendProfile }); + updateStatus(`Profil ${backendProfile} ausgewählt`); + return; + } + + chrome.storage.sync.get(['profileNumber'], (result) => { + const profileNumber = result.profileNumber || 1; + profileSelect.value = String(profileNumber); + updateStatus(`Profil ${profileNumber} ausgewählt (lokal)`); + }); +} + +initProfileSelect(); + +function reloadFacebookTabs() { + chrome.tabs.query({ url: ['https://www.facebook.com/*', 'https://facebook.com/*'] }, (tabs) => { + tabs.forEach(tab => { + chrome.tabs.reload(tab.id); + }); + }); +} + +document.getElementById('saveBtn').addEventListener('click', async () => { + const profileNumber = parseInt(profileSelect.value, 10); + + chrome.storage.sync.set({ profileNumber }, async () => { + updateStatus(`Profil ${profileNumber} gespeichert!`, true); + await updateProfileState(profileNumber); + reloadFacebookTabs(); + }); +}); + +document.getElementById('webInterfaceBtn').addEventListener('click', () => { + chrome.tabs.create({ url: API_BASE_URL }); +}); diff --git a/fb_buttonbar.txt b/fb_buttonbar.txt new file mode 100644 index 0000000..1f296f0 --- /dev/null +++ b/fb_buttonbar.txt @@ -0,0 +1 @@ +
Gefällt mir
Kommentieren
Teilen
\ No newline at end of file diff --git a/fb_feed.txt b/fb_feed.txt new file mode 100644 index 0000000..d6df2c4 --- /dev/null +++ b/fb_feed.txt @@ -0,0 +1,446 @@ +Facebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 

Startseite

Beitrag erstellen

Was machst du gerade, Mounsieur?
Live-Video
Foto/Video
Gefühl/Aktivität

Feed-Beiträge

Alle Reaktionen:
21
1 Kommentar
1 Mal geteilt
Gefällt mir
Kommentieren
Teilen
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fb_nottracked.txt b/fb_nottracked.txt new file mode 100644 index 0000000..a072040 --- /dev/null +++ b/fb_nottracked.txt @@ -0,0 +1,2 @@ +
Gefällt mir
Kommentieren
Teilen
\ No newline at end of file diff --git a/fb_post.txt b/fb_post.txt new file mode 100644 index 0000000..b95905c --- /dev/null +++ b/fb_post.txt @@ -0,0 +1 @@ +
Heute kommt auf den Tisch: "Mini Kartoffelklöße mit Pfifferling-Spinat-Rahm". 🥔 Ein leckeres und schnell gemachtes Mittagessen, das satt macht! 🍽️
D...
Mehr anzeigen
Alle Reaktionen:
2.043
62 Kommentare
76 Mal geteilt
Gefällt mir
Kommentieren
Teilen
Weitere Kommentare ansehen
Bettina Jöpen
Wo bekommt man die kleinen knuffigen Pfifferlinge im Lidl. Ist TK Ware?
4
Lidl in Deutschland
Bestätigtes Konto
Hallo Bettina, danke für deine Anfrage. Bei den Pfifferlingen handelt es sich um Aktionsware, die es immer nur in einem begrenzten Zeitraum gibt. Unsere beliebtesten Aktionsartikel versuchen wir aber immer wieder anzubieten. 😉 Leider können wir noch ke...
Mehr anzeigen
9
Weitere Antworten ansehen
\ No newline at end of file diff --git a/fb_postLinkVariante1.txt b/fb_postLinkVariante1.txt new file mode 100644 index 0000000..dfd9b1c --- /dev/null +++ b/fb_postLinkVariante1.txt @@ -0,0 +1 @@ +ptosernSdo00179m63f9t53ic12.fd7lf59a183l4 tS03hmt74a4fg5531h \ No newline at end of file diff --git a/fb_postLinkVariante2.txt b/fb_postLinkVariante2.txt new file mode 100644 index 0000000..bf92bf3 --- /dev/null +++ b/fb_postLinkVariante2.txt @@ -0,0 +1 @@ +ptosernSdo0017ememrbt51iS12331mlf:.a183l4etu032m 7tapfg 5 1h \ No newline at end of file diff --git a/noScreenshot.png b/noScreenshot.png new file mode 100644 index 0000000..b5d5573 Binary files /dev/null and b/noScreenshot.png differ diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..d9d248c --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,16 @@ +FROM nginx:alpine + +COPY index.html /usr/share/nginx/html/ +COPY dashboard.html /usr/share/nginx/html/ +COPY settings.html /usr/share/nginx/html/ +COPY style.css /usr/share/nginx/html/ +COPY dashboard.css /usr/share/nginx/html/ +COPY settings.css /usr/share/nginx/html/ +COPY app.js /usr/share/nginx/html/ +COPY dashboard.js /usr/share/nginx/html/ +COPY settings.js /usr/share/nginx/html/ +COPY assets /usr/share/nginx/html/assets/ + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..003be28 --- /dev/null +++ b/web/app.js @@ -0,0 +1,2869 @@ +const API_URL = 'https://fb.srv.medeba-media.de/api'; + +// Check if we should redirect to dashboard +(function checkViewRouting() { + const params = new URLSearchParams(window.location.search); + const view = params.get('view'); + if (view === 'dashboard') { + // Remove view parameter and keep other params + params.delete('view'); + const remainingParams = params.toString(); + window.location.href = 'dashboard.html' + (remainingParams ? '?' + remainingParams : ''); + } +})(); + +let currentProfile = 1; +let currentTab = 'pending'; +let posts = []; +let profilePollTimer = null; + +const MAX_PROFILES = 5; +const PROFILE_NAMES = { + 1: 'Profil 1', + 2: 'Profil 2', + 3: 'Profil 3', + 4: 'Profil 4', + 5: 'Profil 5' +}; + +function apiFetch(url, options = {}) { + const config = { + ...options, + credentials: 'include' + }; + + if (options && options.headers) { + config.headers = { ...options.headers }; + } + + return fetch(url, config); +} + +const screenshotModal = document.getElementById('screenshotModal'); +const screenshotModalContent = document.getElementById('screenshotModalContent'); +const screenshotModalImage = document.getElementById('screenshotModalImage'); +const screenshotModalClose = document.getElementById('screenshotModalClose'); +const screenshotModalBackdrop = document.getElementById('screenshotModalBackdrop'); +let screenshotModalLastFocus = null; +let screenshotModalPreviousOverflow = ''; +let screenshotModalZoomed = false; + +const manualPostForm = document.getElementById('manualPostForm'); +const manualPostUrlInput = document.getElementById('manualPostUrl'); +const manualPostTitleInput = document.getElementById('manualPostTitle'); +const manualPostTargetSelect = document.getElementById('manualPostTarget'); +const manualPostCreatorInput = document.getElementById('manualPostCreatorName'); +const manualPostDeadlineInput = document.getElementById('manualPostDeadline'); +const manualPostResetButton = document.getElementById('manualPostReset'); +const manualPostMessage = document.getElementById('manualPostMessage'); +const manualPostSubmitButton = document.getElementById('manualPostSubmitBtn'); +const manualPostModal = document.getElementById('manualPostModal'); +const manualPostModalContent = document.getElementById('manualPostModalContent'); +const manualPostModalBackdrop = document.getElementById('manualPostModalBackdrop'); +const manualPostModalClose = document.getElementById('manualPostModalClose'); +const manualPostModalTitle = document.getElementById('manualPostModalTitle'); +const openManualPostModalBtn = document.getElementById('openManualPostModalBtn'); + +const autoRefreshToggle = document.getElementById('autoRefreshToggle'); +const autoRefreshIntervalSelect = document.getElementById('autoRefreshInterval'); +const sortModeSelect = document.getElementById('sortMode'); +const sortDirectionToggle = document.getElementById('sortDirectionToggle'); + +const REFRESH_SETTINGS_KEY = 'trackerRefreshSettings'; +const SORT_SETTINGS_KEY = 'trackerSortSettings'; +const SORT_SETTINGS_LEGACY_KEY = 'trackerSortMode'; +const FACEBOOK_TRACKING_PARAMS = ['__cft__', '__tn__', '__eep__', 'mibextid']; +const VALID_SORT_MODES = new Set(['created', 'deadline', 'smart', 'lastCheck', 'lastChange']); +const DEFAULT_SORT_SETTINGS = { mode: 'created', direction: 'desc' }; + +let autoRefreshTimer = null; +let autoRefreshSettings = { + enabled: true, + interval: 30000 +}; +let sortMode = DEFAULT_SORT_SETTINGS.mode; +let sortDirection = DEFAULT_SORT_SETTINGS.direction; +let isFetchingPosts = false; +let manualPostMode = 'create'; +let manualPostEditingId = null; +let manualPostModalLastFocus = null; +let manualPostModalPreviousOverflow = ''; +let activeDeadlinePicker = null; + +const INITIAL_POST_LIMIT = 10; +const POST_LOAD_INCREMENT = 10; +const tabVisibleCounts = { + pending: INITIAL_POST_LIMIT, + expired: INITIAL_POST_LIMIT, + all: INITIAL_POST_LIMIT +}; +const tabFilteredCounts = { + pending: 0, + expired: 0, + all: 0 +}; +let loadMoreObserver = null; +let observedLoadMoreElement = null; + +function getProfileName(profileNumber) { + if (!profileNumber) { + return ''; + } + return PROFILE_NAMES[profileNumber] || `Profil ${profileNumber}`; +} + +function formatDateTime(value) { + if (!value) { + return ''; + } + + try { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) { + return ''; + } + return date.toLocaleString('de-DE', { timeZone: 'Europe/Berlin' }); + } catch (error) { + console.warn('Ungültiges Datum:', error); + return ''; + } +} + +function formatDeadline(value) { + if (!value) { + return 'Keine Deadline'; + } + + try { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) { + return 'Ungültige Deadline'; + } + return date.toLocaleString('de-DE', { timeZone: 'Europe/Berlin' }); + } catch (error) { + console.warn('Ungültige Deadline:', error); + return 'Ungültige Deadline'; + } +} + +function formatUrlForDisplay(url) { + if (!url) { + return ''; + } + + try { + const parsed = new URL(url); + const pathname = parsed.pathname === '/' ? '' : parsed.pathname; + const search = parsed.search || ''; + return `${parsed.hostname}${pathname}${search}`; + } catch (error) { + return url; + } +} + +function toTimestamp(value, fallback = null) { + if (!value) { + return fallback; + } + + const timestamp = value instanceof Date ? value.getTime() : new Date(value).getTime(); + if (Number.isNaN(timestamp)) { + return fallback; + } + + return timestamp; +} + +function getSortTabKey(tab = currentTab) { + if (tab === 'expired') { + return 'expired'; + } + if (tab === 'all') { + return 'all'; + } + return 'pending'; +} + +function normalizeSortMode(value) { + if (VALID_SORT_MODES.has(value)) { + return value; + } + return DEFAULT_SORT_SETTINGS.mode; +} + +function normalizeSortDirection(value) { + return value === 'asc' ? 'asc' : DEFAULT_SORT_SETTINGS.direction; +} + +function getSortStorage() { + try { + const raw = localStorage.getItem(SORT_SETTINGS_KEY) || localStorage.getItem(SORT_SETTINGS_LEGACY_KEY); + if (!raw) { + return {}; + } + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed; + } + } catch (error) { + console.warn('Konnte Sortierdaten nicht parsen:', error); + } + return {}; +} + +function persistSortStorage(storage) { + try { + localStorage.setItem(SORT_SETTINGS_KEY, JSON.stringify(storage)); + if (SORT_SETTINGS_KEY !== SORT_SETTINGS_LEGACY_KEY) { + localStorage.removeItem(SORT_SETTINGS_LEGACY_KEY); + } + } catch (error) { + console.warn('Konnte Sortierdaten nicht speichern:', error); + } +} + +function getSortSettingsPageKey() { + try { + const path = window.location.pathname; + if (typeof path === 'string' && path) { + return path; + } + } catch (error) { + // ignore and fall back + } + return 'default'; +} + +function updateSortDirectionToggleUI() { + if (!sortDirectionToggle) { + return; + } + + const isAsc = sortDirection === 'asc'; + sortDirectionToggle.setAttribute('aria-pressed', isAsc ? 'true' : 'false'); + sortDirectionToggle.setAttribute('aria-label', isAsc ? 'Aufsteigend' : 'Absteigend'); + sortDirectionToggle.title = isAsc ? 'Aufsteigend' : 'Absteigend'; + sortDirectionToggle.dataset.direction = sortDirection; + + const icon = sortDirectionToggle.querySelector('.sort-direction-toggle__icon'); + if (icon) { + icon.textContent = isAsc ? '▲' : '▼'; + } else { + sortDirectionToggle.textContent = isAsc ? '▲' : '▼'; + } +} + +function normalizeRequiredProfiles(post) { + if (Array.isArray(post.required_profiles) && post.required_profiles.length) { + return post.required_profiles + .map((value) => { + const parsed = parseInt(value, 10); + if (Number.isNaN(parsed)) { + return null; + } + return Math.min(MAX_PROFILES, Math.max(1, parsed)); + }) + .filter(Boolean); + } + + const parsedTarget = parseInt(post.target_count, 10); + const count = Number.isNaN(parsedTarget) + ? 1 + : Math.min(MAX_PROFILES, Math.max(1, parsedTarget)); + + return Array.from({ length: count }, (_, index) => index + 1); +} + +function updateTabButtons() { + document.querySelectorAll('.tab-btn').forEach((button) => { + if (!button.dataset.tab) { + return; + } + button.classList.toggle('active', button.dataset.tab === currentTab); + }); +} + +function updateTabInUrl() { + const url = new URL(window.location.href); + if (currentTab === 'pending') { + url.searchParams.set('tab', 'pending'); + } else if (currentTab === 'expired') { + url.searchParams.set('tab', 'expired'); + } else { + url.searchParams.set('tab', 'all'); + } + window.history.replaceState({}, document.title, `${url.pathname}?${url.searchParams.toString()}${url.hash}`); +} + +function getTabKey(tab = currentTab) { + if (tab === 'expired') { + return 'expired'; + } + if (tab === 'all') { + return 'all'; + } + return 'pending'; +} + +function getVisibleCount(tab = currentTab) { + const key = getTabKey(tab); + const value = tabVisibleCounts[key]; + const normalized = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : INITIAL_POST_LIMIT; + return Math.max(INITIAL_POST_LIMIT, normalized); +} + +function setVisibleCount(tab, count) { + const key = getTabKey(tab); + const normalized = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : INITIAL_POST_LIMIT; + tabVisibleCounts[key] = Math.max(INITIAL_POST_LIMIT, normalized); +} + +function resetVisibleCount(tab = currentTab) { + setVisibleCount(tab, INITIAL_POST_LIMIT); +} + +function updateFilteredCount(tab, count) { + const key = getTabKey(tab); + tabFilteredCounts[key] = Math.max(0, count || 0); +} + +function cleanupLoadMoreObserver() { + if (loadMoreObserver && observedLoadMoreElement) { + loadMoreObserver.unobserve(observedLoadMoreElement); + observedLoadMoreElement = null; + } +} + +function getTabDisplayLabel(tab = currentTab) { + if (tab === 'expired') { + return 'Abgelaufen/Abgeschlossen'; + } + if (tab === 'all') { + return 'Alle Beiträge'; + } + return 'Offene Beiträge'; +} + +function buildPostsSummary({ + tab, + visibleCount, + filteredCount, + tabTotalCount, + totalCountAll, + searchActive +}) { + const label = getTabDisplayLabel(tab); + const segments = []; + segments.push(`Angezeigt: ${visibleCount} von ${filteredCount}`); + + if (searchActive) { + segments.push(`Treffer: ${filteredCount} von ${tabTotalCount}`); + } + + segments.push(`Tab gesamt: ${tabTotalCount}`); + segments.push(`Alle Beiträge: ${totalCountAll}`); + + return ` +
+ ${label} + ${segments.join('·')} +
+ `; +} + +function ensureLoadMoreObserver() { + if (loadMoreObserver) { + return loadMoreObserver; + } + + loadMoreObserver = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) { + return; + } + + const element = entry.target; + const tab = element && element.dataset ? element.dataset.tab : currentTab; + + if (loadMoreObserver) { + loadMoreObserver.unobserve(element); + } + + loadMorePosts(tab, { triggeredByScroll: true }); + }); + }, { + root: null, + rootMargin: '200px 0px', + threshold: 0.1 + }); + + return loadMoreObserver; +} + +function observeLoadMoreElement(element, tab) { + if (!element) { + return; + } + const observer = ensureLoadMoreObserver(); + observedLoadMoreElement = element; + element.dataset.tab = getTabKey(tab); + observer.observe(element); +} + +function loadMorePosts(tab = currentTab, { triggeredByScroll = false } = {}) { + const key = getTabKey(tab); + const total = tabFilteredCounts[key] || 0; + const currentLimit = getVisibleCount(tab); + + if (currentLimit >= total) { + return; + } + + const newLimit = Math.min(total, currentLimit + POST_LOAD_INCREMENT); + setVisibleCount(tab, newLimit); + + if (tab === currentTab) { + renderPosts(); + } +} + +function setTab(tab, { updateUrl = true } = {}) { + if (tab === 'all') { + currentTab = 'all'; + } else if (tab === 'expired') { + currentTab = 'expired'; + } else { + currentTab = 'pending'; + } + updateTabButtons(); + loadSortMode({ fromTabChange: true }); + if (updateUrl) { + updateTabInUrl(); + } + renderPosts(); +} + +function initializeTabFromUrl() { + try { + const params = new URLSearchParams(window.location.search); + const tabParam = params.get('tab'); + if (tabParam === 'all' || tabParam === 'pending' || tabParam === 'expired') { + currentTab = tabParam; + } + } catch (error) { + console.warn('Konnte Tab-Parameter nicht auslesen:', error); + } + + updateTabButtons(); + updateTabInUrl(); +} + +function normalizeDeadlineInput(value) { + if (!value) { + return null; + } + + try { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return null; + } + return date.toISOString(); + } catch (error) { + console.warn('Ungültige Deadline-Eingabe:', error); + return null; + } +} + +function toDateTimeLocalValue(value) { + if (!value) { + return ''; + } + + try { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return ''; + } + const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000); + return local.toISOString().slice(0, 16); + } catch (error) { + console.warn('Kann Deadline nicht für Eingabe formatieren:', error); + return ''; + } +} + +function normalizeFacebookPostUrl(rawValue) { + if (typeof rawValue !== 'string') { + return null; + } + + let value = rawValue.trim(); + if (!value) { + return null; + } + + const trackingIndex = value.indexOf('__cft__'); + if (trackingIndex !== -1) { + value = value.slice(0, trackingIndex); + } + + value = value.replace(/[?&]$/, ''); + + let parsed; + try { + parsed = new URL(value); + } catch (error) { + try { + parsed = new URL(value, window.location.origin); + } catch (innerError) { + try { + parsed = new URL(value, 'https://www.facebook.com'); + } catch (fallbackError) { + return null; + } + } + } + + if (!parsed.hostname.toLowerCase().endsWith('facebook.com')) { + return null; + } + + const cleanedParams = new URLSearchParams(); + parsed.searchParams.forEach((paramValue, paramKey) => { + const lowerKey = paramKey.toLowerCase(); + if (FACEBOOK_TRACKING_PARAMS.some((trackingKey) => lowerKey.startsWith(trackingKey))) { + return; + } + cleanedParams.append(paramKey, paramValue); + }); + + const search = cleanedParams.toString(); + const formatted = `${parsed.origin}${parsed.pathname}${search ? `?${search}` : ''}`; + return formatted.replace(/[?&]$/, ''); +} + +function getDeadlinePartsFromValue(value) { + const localValue = toDateTimeLocalValue(value); + + if (!localValue) { + return { + date: '', + time: '' + }; + } + + const [datePart, timePart = ''] = localValue.split('T'); + return { + date: datePart, + time: timePart + }; +} + +function getDefaultDeadlineParts() { + const tomorrow = new Date(); + tomorrow.setHours(0, 0, 0, 0); + tomorrow.setDate(tomorrow.getDate() + 1); + + const localIso = new Date(tomorrow.getTime() - tomorrow.getTimezoneOffset() * 60000) + .toISOString() + .slice(0, 16); + + const [datePart, timePart = '00:00'] = localIso.split('T'); + + return { + date: datePart, + time: timePart || '00:00' + }; +} + +function getDefaultDeadlineInputValue() { + const { date, time } = getDefaultDeadlineParts(); + if (!date) { + return ''; + } + return `${date}T${time || '00:00'}`; +} + +function positionDeadlinePicker(picker, triggerElement) { + if (!picker || !triggerElement) { + return; + } + + const rect = triggerElement.getBoundingClientRect(); + const pickerRect = picker.getBoundingClientRect(); + + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const safeMargin = 16; + const offset = 8; + + let left = rect.left; + let top = rect.bottom + offset; + + if (left + pickerRect.width + safeMargin > viewportWidth) { + left = viewportWidth - pickerRect.width - safeMargin; + } + + if (left < safeMargin) { + left = safeMargin; + } + + if (top + pickerRect.height + safeMargin > viewportHeight) { + top = rect.top - pickerRect.height - offset; + if (top < safeMargin) { + top = viewportHeight - pickerRect.height - safeMargin; + } + } + + if (top < safeMargin) { + top = safeMargin; + } + + picker.style.left = `${Math.round(left)}px`; + picker.style.top = `${Math.round(top)}px`; +} + +function closeActiveDeadlinePicker() { + if (!activeDeadlinePicker) { + return; + } + + const current = activeDeadlinePicker; + activeDeadlinePicker = null; + + try { + if (typeof current.destroy === 'function') { + current.destroy(); + return; + } + + const { input } = current; + if (input) { + input.removeEventListener('change', current.onChange); + input.removeEventListener('blur', current.onBlur); + input.remove(); + } + } catch (error) { + console.warn('Konnte Deadline-Picker nicht schließen:', error); + } +} + +function openNativeDeadlinePicker(post, triggerElement) { + if (!post || !triggerElement) { + return; + } + + closeActiveDeadlinePicker(); + + const picker = document.createElement('div'); + picker.className = 'deadline-picker'; + picker.setAttribute('role', 'dialog'); + picker.setAttribute('aria-modal', 'true'); + picker.setAttribute('tabindex', '-1'); + + picker.innerHTML = ` +
+ Deadline anpassen + +
+
+
+ +
+
+ +
+
Uhrzeit optional – Standard ist 00:00 Uhr.
+
+
+ + +
+
+ `; + + const form = picker.querySelector('.deadline-picker__form'); + const dateInput = picker.querySelector('.deadline-picker__date'); + const timeInput = picker.querySelector('.deadline-picker__time'); + const errorEl = picker.querySelector('.deadline-picker__error'); + const cancelButton = picker.querySelector('.deadline-picker__cancel'); + const closeButton = picker.querySelector('.deadline-picker__close'); + const saveButton = picker.querySelector('.deadline-picker__save'); + + const originalSaveLabel = saveButton ? saveButton.textContent : ''; + const initialValues = { date: '', time: '' }; + + const applyInitialValues = () => { + const existing = getDeadlinePartsFromValue(post.deadline_at); + const defaults = existing.date ? existing : getDefaultDeadlineParts(); + + initialValues.date = defaults.date; + initialValues.time = defaults.time || ''; + + if (dateInput) { + dateInput.value = defaults.date; + } + if (timeInput) { + timeInput.value = defaults.time; + } + }; + + applyInitialValues(); + + document.body.appendChild(picker); + positionDeadlinePicker(picker, triggerElement); + + const showError = (message = '') => { + if (!errorEl) { + return; + } + errorEl.textContent = message; + errorEl.classList.toggle('is-visible', Boolean(message)); + }; + + let isSaving = false; + + const normalizeParts = (datePart, timePart) => { + if (!datePart) { + return ''; + } + return `${datePart}T${(timePart || '00:00')}`; + }; + + const attemptAutoSave = () => { + if (isSaving || !form || !dateInput) { + return false; + } + + const currentDate = dateInput.value; + if (!currentDate) { + return false; + } + + const currentTime = timeInput ? timeInput.value : ''; + const currentNormalized = normalizeParts(currentDate, currentTime); + const initialNormalized = normalizeParts(initialValues.date, initialValues.time); + + if (currentNormalized === initialNormalized) { + return false; + } + + if (typeof form.requestSubmit === 'function') { + form.requestSubmit(); + } else { + form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true })); + } + + return true; + }; + + const handleSubmit = async (event) => { + event.preventDefault(); + if (isSaving || !dateInput || !saveButton) { + return; + } + + showError(''); + + const dateValue = dateInput.value; + if (!dateValue) { + showError('Bitte ein Datum auswählen.'); + dateInput.focus(); + return; + } + + const timeValue = (timeInput && timeInput.value) ? timeInput.value : '00:00'; + const combined = `${dateValue}T${timeValue || '00:00'}`; + const normalized = normalizeDeadlineInput(combined); + + if (!normalized) { + showError('Ungültige Eingabe.'); + return; + } + + try { + isSaving = true; + saveButton.disabled = true; + saveButton.textContent = 'Speichert…'; + const success = await saveDeadline(post.id, normalized); + if (success) { + closeActiveDeadlinePicker(); + return; + } + showError('Deadline wurde nicht gespeichert.'); + } catch (error) { + console.warn('Deadline konnte nicht gespeichert werden:', error); + showError('Konnte Deadline nicht speichern.'); + } finally { + isSaving = false; + saveButton.disabled = false; + saveButton.textContent = originalSaveLabel; + } + }; + + form?.addEventListener('submit', handleSubmit); + + const cleanupListeners = []; + + const pickerState = { + destroy: () => { + while (cleanupListeners.length) { + const clean = cleanupListeners.pop(); + try { + clean(); + } catch (error) { + console.warn('Konnte Listener nicht entfernen:', error); + } + } + picker.remove(); + } + }; + + const registerListener = (target, type, listener, options) => { + if (!target) { + return; + } + target.addEventListener(type, listener, options); + cleanupListeners.push(() => target.removeEventListener(type, listener, options)); + }; + + const closePicker = () => { + if (activeDeadlinePicker) { + closeActiveDeadlinePicker(); + return; + } + + pickerState.destroy(); + }; + + registerListener(cancelButton, 'click', (event) => { + event.preventDefault(); + closePicker(); + }); + + registerListener(closeButton, 'click', (event) => { + event.preventDefault(); + closePicker(); + }); + + const handleOutsidePointer = (event) => { + const targetElement = event.target instanceof Element ? event.target : null; + const isClearButton = targetElement?.closest('.post-deadline__clear'); + + if (!picker.contains(event.target) && !triggerElement.contains(event.target)) { + if (!isClearButton && attemptAutoSave()) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + closePicker(); + } + }; + + const handleKeydown = (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + closePicker(); + } + }; + + const reposition = () => { + positionDeadlinePicker(picker, triggerElement); + }; + + registerListener(document, 'pointerdown', handleOutsidePointer, true); + registerListener(document, 'keydown', handleKeydown); + registerListener(window, 'resize', reposition); + registerListener(window, 'scroll', reposition, true); + + activeDeadlinePicker = pickerState; + + requestAnimationFrame(() => { + positionDeadlinePicker(picker, triggerElement); + requestAnimationFrame(() => { + dateInput?.focus({ preventScroll: true }); + }); + }); +} + +async function clearDeadline(postId) { + closeActiveDeadlinePicker(); + await saveDeadline(postId, null); +} + + +function normalizeChecks(checks) { + if (!Array.isArray(checks)) { + return []; + } + + return checks + .map((check) => { + if (!check) { + return null; + } + + const parsed = parseInt(check.profile_number, 10); + if (Number.isNaN(parsed)) { + return null; + } + + const profileNumber = Math.min(MAX_PROFILES, Math.max(1, parsed)); + + return { + ...check, + profile_number: profileNumber, + profile_name: check.profile_name || getProfileName(profileNumber), + checked_at: check.checked_at || null + }; + }) + .filter(Boolean) + .sort((a, b) => { + const aTime = a.checked_at ? new Date(a.checked_at).getTime() : 0; + const bTime = b.checked_at ? new Date(b.checked_at).getTime() : 0; + if (aTime === bTime) { + return a.profile_number - b.profile_number; + } + return aTime - bTime; + }); +} + +function calculateUrgencyScore(postItem) { + const post = postItem.post; + const status = postItem.status; + + // Remaining participations needed + const remaining = status.targetCount - status.checkedCount; + if (remaining <= 0) return 999999; // Completed posts go to bottom + + const now = Date.now(); + const createdAt = post.created_at ? new Date(post.created_at).getTime() : now; + + // Time until deadline (in hours) + let hoursUntilDeadline = Infinity; + let totalDuration = Infinity; + if (post.deadline_at) { + const deadline = new Date(post.deadline_at).getTime(); + hoursUntilDeadline = Math.max(0, (deadline - now) / (1000 * 60 * 60)); + totalDuration = Math.max(1, (deadline - createdAt) / (1000 * 60 * 60)); // Total hours from creation to deadline + } + + // Time since last participation (in hours) + let hoursSinceLastCheck = Infinity; + if (status.lastCheckedAt) { + const lastCheck = new Date(status.lastCheckedAt).getTime(); + hoursSinceLastCheck = (now - lastCheck) / (1000 * 60 * 60); + } + + // Calculate ideal pace: how often should participations happen? + let idealIntervalHours = Infinity; + let behindSchedule = false; + + if (totalDuration < Infinity && status.targetCount > 0) { + // Ideal interval between participations + idealIntervalHours = totalDuration / status.targetCount; + + // Expected participations by now (based on time elapsed) + const hoursElapsed = (now - createdAt) / (1000 * 60 * 60); + const expectedChecks = Math.floor(hoursElapsed / idealIntervalHours); + + // Are we behind schedule? + behindSchedule = status.checkedCount < expectedChecks; + } + + // Calculate urgency score (lower = more urgent) + let score = 0; + + // For posts with deadline: calculate when the next participation should ideally happen + if (hoursUntilDeadline < Infinity && remaining > 0) { + // How many hours do we have per remaining participation? + const hoursPerParticipation = hoursUntilDeadline / remaining; + + // When should the next participation ideally happen? + let hoursUntilNextIdeal = hoursPerParticipation; + + // If we have a last check, calculate from there + if (hoursSinceLastCheck < Infinity) { + hoursUntilNextIdeal = Math.max(0, hoursPerParticipation - hoursSinceLastCheck); + } + + // Score based on how soon the next participation is due + // Posts that are overdue or due soon get higher priority + if (hoursUntilNextIdeal <= 0) { + // Overdue! High priority + score = Math.abs(hoursUntilNextIdeal) * -10; // Negative score = very high priority + } else if (hoursUntilNextIdeal <= 24) { + // Due within 24h + score = hoursUntilNextIdeal * 10; // 0-240 + } else if (hoursUntilNextIdeal <= 72) { + // Due within 3 days + score = 240 + (hoursUntilNextIdeal - 24) * 20; // 240-1200 + } else { + // Due later + score = 1200 + (hoursUntilNextIdeal - 72) * 50; // 1200+ + } + + // Emergency boost: if deadline is very close, override everything + if (hoursUntilDeadline <= 24 && remaining > 0) { + score = Math.min(score, hoursUntilDeadline * 5); // Max 120 points + } else if (hoursUntilDeadline <= 48 && remaining > 1) { + score = Math.min(score, 120 + (hoursUntilDeadline - 24) * 10); + } + } else if (hoursUntilDeadline < Infinity && remaining === 0) { + // Completed with deadline + score = 100000; + } else { + // No deadline - use simpler heuristic + score = 50000; + + // Prioritize posts that haven't been checked in a while + if (hoursSinceLastCheck < Infinity) { + if (hoursSinceLastCheck < 24) { + score += 50; // Recently checked = lower priority + } else if (hoursSinceLastCheck > 72) { + score -= 100; // Long time since check = higher priority + } + } + } + + return score; +} + +function comparePostItems(a, b) { + const postA = a.post; + const postB = b.post; + const createdA = toTimestamp(postA.created_at, 0); + const createdB = toTimestamp(postB.created_at, 0); + + let comparison = 0; + + if (sortMode === 'deadline') { + const deadlineA = toTimestamp(postA.deadline_at, Infinity); + const deadlineB = toTimestamp(postB.deadline_at, Infinity); + + if (deadlineA !== deadlineB) { + comparison = deadlineA - deadlineB; + } + } else if (sortMode === 'smart') { + // Smart sorting based on urgency + const scoreA = calculateUrgencyScore(a); + const scoreB = calculateUrgencyScore(b); + + if (scoreA !== scoreB) { + comparison = scoreB - scoreA; // Higher score = lower priority (inverted for intuitive direction) + } + } else if (sortMode === 'lastChange') { + const changeA = Number.isFinite(a.status.lastChangeTimestamp) + ? a.status.lastChangeTimestamp + : toTimestamp(postA.created_at, 0); + const changeB = Number.isFinite(b.status.lastChangeTimestamp) + ? b.status.lastChangeTimestamp + : toTimestamp(postB.created_at, 0); + + if (changeA !== changeB) { + comparison = changeA - changeB; + } + } else if (sortMode === 'lastCheck') { + const lastA = Number.isFinite(a.status.lastCheckedTimestamp) + ? a.status.lastCheckedTimestamp + : Number.NEGATIVE_INFINITY; + const lastB = Number.isFinite(b.status.lastCheckedTimestamp) + ? b.status.lastCheckedTimestamp + : Number.NEGATIVE_INFINITY; + + if (lastA !== lastB) { + comparison = lastA - lastB; + } + } else if (createdA !== createdB) { + comparison = createdA - createdB; + } + + if (comparison === 0 && createdA !== createdB) { + comparison = createdA - createdB; + } + + if (comparison === 0) { + const idA = String(postA.id || ''); + const idB = String(postB.id || ''); + if (idA !== idB) { + comparison = idA < idB ? -1 : 1; + } + } + + if (comparison === 0) { + return 0; + } + + const multiplier = sortDirection === 'asc' ? 1 : -1; + return comparison * multiplier; +} + +function displayManualPostMessage(message, type = 'success') { + if (!manualPostMessage) { + return; + } + + manualPostMessage.textContent = message; + manualPostMessage.classList.remove('error', 'success'); + manualPostMessage.classList.add(type); +} + +function clearManualPostMessage() { + if (!manualPostMessage) { + return; + } + manualPostMessage.textContent = ''; + manualPostMessage.classList.remove('error', 'success'); +} + +function openManualPostModal({ mode = 'create', post = null, focus = null } = {}) { + if (!manualPostModal || !manualPostForm) { + return; + } + + manualPostModalLastFocus = document.activeElement && typeof document.activeElement.blur === 'function' + ? document.activeElement + : null; + + resetManualPostForm({ keepMessages: false }); + + manualPostMode = mode === 'edit' ? 'edit' : 'create'; + manualPostEditingId = manualPostMode === 'edit' && post ? post.id : null; + + const desiredFocus = typeof focus === 'string' ? focus : (manualPostMode === 'edit' ? 'title' : 'url'); + + if (manualPostModalTitle) { + manualPostModalTitle.textContent = manualPostMode === 'edit' + ? 'Beitrag bearbeiten' + : 'Beitrag hinzufügen'; + } + + if (manualPostSubmitButton) { + manualPostSubmitButton.textContent = manualPostMode === 'edit' + ? 'Aktualisieren' + : 'Speichern'; + } + + if (manualPostMode === 'edit' && post) { + populateManualPostForm(post); + } + + manualPostModal.removeAttribute('hidden'); + manualPostModal.classList.add('open'); + + manualPostModalPreviousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + requestAnimationFrame(() => { + if (manualPostModalContent) { + manualPostModalContent.focus(); + } + + if (desiredFocus === 'deadline' && manualPostDeadlineInput) { + manualPostDeadlineInput.focus(); + manualPostDeadlineInput.select?.(); + return; + } + + if (desiredFocus === 'title' && manualPostTitleInput) { + manualPostTitleInput.focus(); + manualPostTitleInput.select(); + return; + } + + if (desiredFocus === 'url' && manualPostUrlInput) { + manualPostUrlInput.focus(); + manualPostUrlInput.select?.(); + return; + } + + if (manualPostMode === 'edit' && manualPostTitleInput) { + manualPostTitleInput.focus(); + manualPostTitleInput.select(); + } else if (manualPostUrlInput) { + manualPostUrlInput.focus(); + } + }); +} + +function closeManualPostModal() { + if (!manualPostModal) { + return; + } + + manualPostModal.classList.remove('open'); + manualPostModal.setAttribute('hidden', ''); + resetManualPostForm(); + + document.body.style.overflow = manualPostModalPreviousOverflow; + + if (manualPostModalLastFocus && typeof manualPostModalLastFocus.focus === 'function') { + manualPostModalLastFocus.focus(); + } +} + +function loadAutoRefreshSettings() { + try { + const stored = localStorage.getItem(REFRESH_SETTINGS_KEY); + if (stored) { + const parsed = JSON.parse(stored); + if (typeof parsed === 'object' && parsed) { + if (typeof parsed.enabled === 'boolean') { + autoRefreshSettings.enabled = parsed.enabled; + } + if (typeof parsed.interval === 'number' && parsed.interval >= 5000) { + autoRefreshSettings.interval = parsed.interval; + } + } + } + } catch (error) { + console.warn('Konnte Refresh-Einstellungen nicht laden:', error); + } + + if (autoRefreshToggle) { + autoRefreshToggle.checked = autoRefreshSettings.enabled; + } + if (autoRefreshIntervalSelect) { + autoRefreshIntervalSelect.value = String(autoRefreshSettings.interval); + autoRefreshIntervalSelect.disabled = !autoRefreshSettings.enabled; + } +} + +function saveAutoRefreshSettings() { + try { + localStorage.setItem(REFRESH_SETTINGS_KEY, JSON.stringify(autoRefreshSettings)); + } catch (error) { + console.warn('Konnte Refresh-Einstellungen nicht speichern:', error); + } +} + +function applyAutoRefreshSettings() { + if (autoRefreshTimer) { + clearInterval(autoRefreshTimer); + autoRefreshTimer = null; + } + + if (!autoRefreshSettings.enabled) { + return; + } + + autoRefreshTimer = setInterval(() => { + if (document.hidden) { + return; + } + fetchPosts({ showLoader: false }); + }, autoRefreshSettings.interval); +} + +function loadSortMode({ fromTabChange = false } = {}) { + const pageKey = getSortSettingsPageKey(); + const tabKey = getSortTabKey(); + const storage = getSortStorage(); + const pageSettings = storage[pageKey] && typeof storage[pageKey] === 'object' ? storage[pageKey] : {}; + + const legacyCandidate = (pageSettings.mode || pageSettings.direction) + ? { mode: pageSettings.mode, direction: pageSettings.direction } + : null; + + const candidates = [pageSettings[tabKey], legacyCandidate, pageSettings.default, storage.default]; + let applied = null; + + for (const candidate of candidates) { + if (candidate && typeof candidate === 'object') { + applied = candidate; + break; + } + } + + if (applied) { + sortMode = normalizeSortMode(applied.mode); + sortDirection = normalizeSortDirection(applied.direction); + } else { + sortMode = normalizeSortMode(sortMode); + sortDirection = normalizeSortDirection(sortDirection); + } + + if (sortModeSelect) { + sortModeSelect.value = sortMode; + } + updateSortDirectionToggleUI(); + + if (!fromTabChange) { + // Ensure structure exists for initial load + saveSortMode(); + } +} + +function saveSortMode() { + const pageKey = getSortSettingsPageKey(); + const tabKey = getSortTabKey(); + const storage = getSortStorage(); + + if (!storage[pageKey] || typeof storage[pageKey] !== 'object') { + storage[pageKey] = {}; + } + + delete storage[pageKey].mode; + delete storage[pageKey].direction; + + storage[pageKey][tabKey] = { + mode: normalizeSortMode(sortMode), + direction: normalizeSortDirection(sortDirection) + }; + + if (!storage[pageKey].default) { + storage[pageKey].default = { ...DEFAULT_SORT_SETTINGS }; + } + + if (!storage.default) { + storage.default = { ...DEFAULT_SORT_SETTINGS }; + } + + persistSortStorage(storage); +} + +function computePostStatus(post, profileNumber = currentProfile) { + const requiredProfiles = normalizeRequiredProfiles(post); + const checks = normalizeChecks(post.checks); + + let lastCheckedAt = null; + let lastCheckedTimestamp = null; + + for (const check of checks) { + if (!check || !check.checked_at) { + continue; + } + const timestamp = new Date(check.checked_at).getTime(); + if (Number.isNaN(timestamp)) { + continue; + } + if (lastCheckedTimestamp === null || timestamp > lastCheckedTimestamp) { + lastCheckedTimestamp = timestamp; + lastCheckedAt = check.checked_at; + } + } + + const lastChangeTimestamp = toTimestamp(post.last_change, toTimestamp(post.created_at, 0)); + const lastChangeAt = post.last_change || post.created_at || null; + + // Check if post is expired (deadline passed) + const isExpired = post.deadline_at ? new Date(post.deadline_at) < new Date() : false; + + const backendStatuses = Array.isArray(post.profile_statuses) ? post.profile_statuses : []; + let profileStatuses = backendStatuses + .map((status) => { + if (!status) { + return null; + } + const parsed = parseInt(status.profile_number, 10); + if (Number.isNaN(parsed)) { + return null; + } + const profileNumberValue = Math.min(MAX_PROFILES, Math.max(1, parsed)); + let normalizedStatus = status.status; + if (normalizedStatus !== 'done' && normalizedStatus !== 'available') { + normalizedStatus = 'locked'; + } + const check = checks.find((item) => item.profile_number === profileNumberValue) || null; + return { + profile_number: profileNumberValue, + profile_name: status.profile_name || getProfileName(profileNumberValue), + status: normalizedStatus, + checked_at: status.checked_at || (check ? check.checked_at : null) || null + }; + }) + .filter(Boolean); + + if (profileStatuses.length !== requiredProfiles.length) { + const checksByProfile = new Map(checks.map((check) => [check.profile_number, check])); + const completedSet = new Set(checks.map((check) => check.profile_number)); + + profileStatuses = requiredProfiles.map((value, index) => { + const prerequisites = requiredProfiles.slice(0, index); + const prerequisitesMet = prerequisites.every((profile) => completedSet.has(profile)); + const check = checksByProfile.get(value) || null; + + return { + profile_number: value, + profile_name: getProfileName(value), + status: check ? 'done' : (prerequisitesMet ? 'available' : 'locked'), + checked_at: check ? check.checked_at : null + }; + }); + } else { + const checksByProfile = new Map(checks.map((check) => [check.profile_number, check])); + profileStatuses = requiredProfiles.map((value) => { + const status = profileStatuses.find((item) => item.profile_number === value); + if (!status) { + const check = checksByProfile.get(value) || null; + return { + profile_number: value, + profile_name: getProfileName(value), + status: check ? 'done' : 'locked', + checked_at: check ? check.checked_at : null + }; + } + + if (status.status === 'done') { + const check = checksByProfile.get(value) || null; + return { + ...status, + checked_at: status.checked_at || (check ? check.checked_at : null) || null + }; + } + + if (status.status === 'available') { + return { + ...status, + checked_at: status.checked_at || null + }; + } + + return { + ...status, + status: 'locked', + checked_at: status.checked_at || null + }; + }); + } + + const completedProfilesSet = new Set( + profileStatuses + .filter((status) => status.status === 'done') + .map((status) => status.profile_number) + ); + + const checkedCount = profileStatuses.filter((status) => status.status === 'done').length; + const targetCount = profileStatuses.length; + const isComplete = profileStatuses.every((status) => status.status === 'done'); + const nextRequiredProfile = profileStatuses.find((status) => status.status === 'available') || null; + const isCurrentProfileRequired = requiredProfiles.includes(profileNumber); + const isCurrentProfileDone = profileStatuses.some((status) => status.profile_number === profileNumber && status.status === 'done'); + const canCurrentProfileCheck = profileStatuses.some((status) => status.profile_number === profileNumber && status.status === 'available'); + + const waitingForStatuses = profileStatuses.filter((status) => status.profile_number < profileNumber && status.status !== 'done'); + const waitingForProfiles = waitingForStatuses.map((status) => status.profile_number); + const waitingForNames = waitingForStatuses.map((status) => status.profile_name); + + return { + requiredProfiles, + profileStatuses, + checks, + lastCheckedAt, + lastCheckedTimestamp, + lastChangeAt, + lastChangeTimestamp, + completedProfilesSet, + checkedCount, + targetCount, + isComplete, + isCurrentProfileRequired, + isCurrentProfileDone, + canCurrentProfileCheck, + waitingForProfiles, + waitingForNames, + nextRequiredProfile, + profileNumber, + isExpired + }; +} + +function applyScreenshotModalSize() { + if (!screenshotModalContent || !screenshotModalImage) { + return; + } + + if (screenshotModalZoomed) { + return; + } + + if (!screenshotModalImage.src) { + return; + } + + requestAnimationFrame(() => { + const padding = 48; + const viewportWidth = Math.max(320, window.innerWidth * 0.95); + const viewportHeight = Math.max(280, window.innerHeight * 0.92); + const naturalWidth = screenshotModalImage.naturalWidth || screenshotModalImage.width || 0; + const naturalHeight = screenshotModalImage.naturalHeight || screenshotModalImage.height || 0; + + const targetWidth = Math.min(Math.max(320, naturalWidth + padding), viewportWidth); + const targetHeight = Math.min(Math.max(260, naturalHeight + padding), viewportHeight); + + screenshotModalContent.style.width = `${targetWidth}px`; + screenshotModalContent.style.height = `${targetHeight}px`; + }); +} + +async function fetchProfileState() { + try { + const response = await apiFetch(`${API_URL}/profile-state`); + if (!response.ok) { + return null; + } + const data = await response.json(); + if (data && typeof data.profile_number !== 'undefined') { + const parsed = parseInt(data.profile_number, 10); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + return null; + } catch (error) { + console.warn('Profilstatus konnte nicht geladen werden:', error); + return null; + } +} + +async function pushProfileState(profileNumber) { + try { + await apiFetch(`${API_URL}/profile-state`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ profile_number: profileNumber }) + }); + } catch (error) { + console.error('Profilstatus konnte nicht gespeichert werden:', error); + } +} + +function applyProfileNumber(profileNumber, { fromBackend = false } = {}) { + if (!profileNumber) { + return; + } + + document.getElementById('profileSelect').value = String(profileNumber); + + if (currentProfile === profileNumber) { + if (!fromBackend) { + pushProfileState(profileNumber); + } + return; + } + + currentProfile = profileNumber; + localStorage.setItem('profileNumber', currentProfile); + + if (!fromBackend) { + pushProfileState(currentProfile); + } + + resetVisibleCount(); + renderPosts(); +} + +// Load profile from localStorage +function loadProfile() { + fetchProfileState().then((backendProfile) => { + if (backendProfile) { + applyProfileNumber(backendProfile, { fromBackend: true }); + } else { + const saved = localStorage.getItem('profileNumber'); + if (saved) { + applyProfileNumber(parseInt(saved, 10) || 1, { fromBackend: true }); + } else { + applyProfileNumber(1, { fromBackend: true }); + } + } + }); +} + +// Save profile to localStorage +function saveProfile(profileNumber) { + applyProfileNumber(profileNumber); +} + +function startProfilePolling() { + if (profilePollTimer) { + clearInterval(profilePollTimer); + } + + profilePollTimer = setInterval(async () => { + const backendProfile = await fetchProfileState(); + if (backendProfile && backendProfile !== currentProfile) { + applyProfileNumber(backendProfile, { fromBackend: true }); + } + }, 5000); +} + +// Profile selector change handler +document.getElementById('profileSelect').addEventListener('change', (e) => { + saveProfile(parseInt(e.target.value, 10)); +}); + +// Tab switching +document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', () => { + setTab(btn.dataset.tab, { updateUrl: true }); + }); +}); + +if (manualPostForm) { + manualPostForm.addEventListener('submit', handleManualPostSubmit); +} + +if (manualPostResetButton) { + manualPostResetButton.addEventListener('click', () => { + if (manualPostMode === 'edit' && manualPostEditingId) { + const post = posts.find((item) => item.id === manualPostEditingId); + if (post) { + populateManualPostForm(post); + } + } else { + resetManualPostForm(); + if (manualPostUrlInput) { + manualPostUrlInput.focus(); + } + } + }); +} + +if (autoRefreshToggle) { + autoRefreshToggle.addEventListener('change', () => { + autoRefreshSettings.enabled = !!autoRefreshToggle.checked; + if (autoRefreshIntervalSelect) { + autoRefreshIntervalSelect.disabled = !autoRefreshSettings.enabled; + } + saveAutoRefreshSettings(); + applyAutoRefreshSettings(); + if (autoRefreshSettings.enabled) { + fetchPosts({ showLoader: false }); + } + }); +} + +if (autoRefreshIntervalSelect) { + autoRefreshIntervalSelect.addEventListener('change', () => { + const value = parseInt(autoRefreshIntervalSelect.value, 10); + if (!Number.isNaN(value) && value >= 5000) { + autoRefreshSettings.interval = value; + saveAutoRefreshSettings(); + applyAutoRefreshSettings(); + } + }); +} + +const manualRefreshBtn = document.getElementById('manualRefreshBtn'); +if (manualRefreshBtn) { + manualRefreshBtn.addEventListener('click', () => { + fetchPosts(); + }); +} + +const searchInput = document.getElementById('searchInput'); +if (searchInput) { + searchInput.addEventListener('input', () => { + resetVisibleCount(); + renderPosts(); + }); +} + +if (sortModeSelect) { + sortModeSelect.addEventListener('change', () => { + const value = sortModeSelect.value; + sortMode = VALID_SORT_MODES.has(value) ? value : DEFAULT_SORT_SETTINGS.mode; + saveSortMode(); + resetVisibleCount(); + renderPosts(); + }); +} + +if (sortDirectionToggle) { + sortDirectionToggle.addEventListener('click', () => { + sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'; + updateSortDirectionToggleUI(); + saveSortMode(); + resetVisibleCount(); + renderPosts(); + }); +} + +if (openManualPostModalBtn) { + openManualPostModalBtn.addEventListener('click', () => { + openManualPostModal({ mode: 'create' }); + }); +} + +if (manualPostModalClose) { + manualPostModalClose.addEventListener('click', closeManualPostModal); +} + +if (manualPostModalBackdrop) { + manualPostModalBackdrop.addEventListener('click', closeManualPostModal); +} + +// Fetch all posts +async function fetchPosts({ showLoader = true } = {}) { + if (isFetchingPosts) { + return; + } + + isFetchingPosts = true; + + try { + if (showLoader) { + showLoading(); + } + + const response = await apiFetch(`${API_URL}/posts`); + + if (!response.ok) { + throw new Error('Failed to fetch posts'); + } + + const data = await response.json(); + posts = Array.isArray(data) ? data : []; + await normalizeLoadedPostUrls(); + renderPosts(); + } catch (error) { + if (showLoader) { + showError('Fehler beim Laden der Beiträge. Stelle sicher, dass das Backend läuft.'); + } + console.error('Error fetching posts:', error); + } finally { + if (showLoader) { + hideLoading(); + } + isFetchingPosts = false; + } +} + +async function normalizeLoadedPostUrls() { + if (!Array.isArray(posts) || !posts.length) { + return false; + } + + const candidates = posts + .map((post) => { + if (!post || !post.id || !post.url) { + return null; + } + const cleaned = normalizeFacebookPostUrl(post.url); + if (!cleaned || cleaned === post.url) { + return null; + } + return { id: post.id, cleaned }; + }) + .filter(Boolean); + + if (!candidates.length) { + return false; + } + + let changed = false; + + for (const candidate of candidates) { + try { + const response = await apiFetch(`${API_URL}/posts/${candidate.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: candidate.cleaned }) + }); + + if (!response.ok) { + console.warn(`Konnte URL für Beitrag ${candidate.id} nicht normalisieren.`); + continue; + } + + const updatedPost = await response.json(); + posts = posts.map((item) => (item.id === candidate.id ? updatedPost : item)); + changed = true; + } catch (error) { + console.warn(`Fehler beim Normalisieren der URL für Beitrag ${candidate.id}:`, error); + } + } + + return changed; +} + +// Render posts +function renderPosts() { + hideLoading(); + hideError(); + + const container = document.getElementById('postsContainer'); + if (!container) { + return; + } + + closeActiveDeadlinePicker(); + updateTabButtons(); + cleanupLoadMoreObserver(); + + const postItems = posts.map((post) => ({ + post, + status: computePostStatus(post) + })); + + const sortedItems = [...postItems].sort(comparePostItems); + + let filteredItems = sortedItems; + + if (currentTab === 'pending') { + filteredItems = sortedItems.filter((item) => !item.status.isExpired && item.status.canCurrentProfileCheck && !item.status.isComplete); + } else if (currentTab === 'expired') { + filteredItems = sortedItems.filter((item) => item.status.isExpired || item.status.isComplete); + } else if (currentTab === 'all') { + filteredItems = sortedItems.filter((item) => !item.status.isExpired && !item.status.isComplete); + } + + const tabTotalCount = filteredItems.length; + + const searchInput = document.getElementById('searchInput'); + const searchValue = searchInput && typeof searchInput.value === 'string' ? searchInput.value.trim() : ''; + const searchActive = Boolean(searchValue); + + if (searchActive) { + const searchTerm = searchValue.toLowerCase(); + filteredItems = filteredItems.filter((item) => { + const post = item.post; + return ( + (post.title && post.title.toLowerCase().includes(searchTerm)) || + (post.url && post.url.toLowerCase().includes(searchTerm)) || + (post.created_by_name && post.created_by_name.toLowerCase().includes(searchTerm)) || + (post.id && post.id.toLowerCase().includes(searchTerm)) + ); + }); + } + + updateFilteredCount(currentTab, filteredItems.length); + + const visibleCount = Math.min(filteredItems.length, getVisibleCount(currentTab)); + const visibleItems = filteredItems.slice(0, visibleCount); + + const summaryHtml = buildPostsSummary({ + tab: currentTab, + visibleCount, + filteredCount: filteredItems.length, + tabTotalCount, + totalCountAll: posts.length, + searchActive + }); + + if (filteredItems.length === 0) { + let emptyMessage = 'Noch keine Beiträge erfasst.'; + let emptyIcon = '🎉'; + if (currentTab === 'pending') { + emptyMessage = 'Keine offenen Beiträge!'; + } else if (currentTab === 'expired') { + emptyMessage = 'Keine abgelaufenen oder abgeschlossenen Beiträge.'; + } + + if (searchActive) { + emptyMessage = 'Keine Beiträge gefunden.'; + emptyIcon = '🔍'; + } + + container.innerHTML = `${summaryHtml} +
+
${emptyIcon}
+
+ ${emptyMessage} +
+
+ `; + return; + } + + container.innerHTML = `${summaryHtml}${visibleItems + .map(({ post, status }, index) => createPostCard(post, status, { + index, + globalIndex: index + 1, + totalFiltered: filteredItems.length, + totalOverall: posts.length, + tabTotalCount, + searchActive + })) + .join('')}`; + + visibleItems.forEach(({ post, status }) => attachPostEventHandlers(post, status)); + + if (visibleCount < filteredItems.length) { + const loadMoreContainer = document.createElement('div'); + loadMoreContainer.className = 'posts-load-more'; + + const loadMoreButton = document.createElement('button'); + loadMoreButton.type = 'button'; + loadMoreButton.className = 'btn btn-secondary posts-load-more__btn'; + loadMoreButton.textContent = 'Weitere Beiträge laden'; + loadMoreButton.addEventListener('click', () => { + loadMoreButton.disabled = true; + loadMoreButton.textContent = 'Lade...'; + loadMorePosts(currentTab, { triggeredByScroll: false }); + }); + + loadMoreContainer.appendChild(loadMoreButton); + container.appendChild(loadMoreContainer); + + observeLoadMoreElement(loadMoreContainer, currentTab); + } +} + +function attachPostEventHandlers(post, status) { + const card = document.getElementById(`post-${post.id}`); + if (!card) { + return; + } + + const openBtn = card.querySelector('.btn-open'); + if (openBtn) { + openBtn.addEventListener('click', () => openPost(post.id)); + } + + const deleteBtn = card.querySelector('.btn-delete'); + if (deleteBtn) { + deleteBtn.addEventListener('click', () => deletePost(post.id)); + } + + const screenshotEl = card.querySelector('.post-screenshot'); + if (screenshotEl && screenshotEl.dataset.screenshot) { + const url = screenshotEl.dataset.screenshot; + const openHandler = () => openScreenshotModal(url); + screenshotEl.addEventListener('click', openHandler); + screenshotEl.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + openScreenshotModal(url); + } + }); + } + + const toggleButtons = card.querySelectorAll('.profile-line__toggle'); + toggleButtons.forEach((button) => { + button.addEventListener('click', () => { + const profileNumber = parseInt(button.dataset.profile, 10); + const currentStatus = button.dataset.status || 'pending'; + toggleProfileStatus(post.id, profileNumber, currentStatus); + }); + }); + + const editPostBtn = card.querySelector('.btn-edit-post'); + if (editPostBtn) { + editPostBtn.addEventListener('click', () => { + openManualPostModal({ mode: 'edit', post }); + }); + } + + const deadlineButton = card.querySelector('.post-deadline__calendar'); + if (deadlineButton) { + deadlineButton.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + openNativeDeadlinePicker(post, deadlineButton); + }); + } + + const clearDeadlineButton = card.querySelector('.post-deadline__clear'); + if (clearDeadlineButton) { + clearDeadlineButton.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + clearDeadline(post.id); + }); + } + + const successCheckbox = card.querySelector('.success-checkbox-input'); + if (successCheckbox) { + successCheckbox.addEventListener('change', async () => { + await toggleSuccessStatus(post.id, successCheckbox.checked); + }); + } + + const targetSelect = card.querySelector('.post-target__select'); + if (targetSelect) { + targetSelect.dataset.originalValue = String(status.targetCount); + targetSelect.addEventListener('change', () => { + const value = parseInt(targetSelect.value, 10); + if (targetSelect.dataset.originalValue && String(value) === targetSelect.dataset.originalValue) { + targetSelect.blur(); + return; + } + updateTargetInline(post.id, value, targetSelect); + targetSelect.blur(); + }); + } +} + +function openScreenshotModal(url) { + if (!screenshotModal || !url) { + return; + } + + screenshotModalLastFocus = document.activeElement; + screenshotModalImage.src = url; + resetScreenshotZoom(); + screenshotModalPreviousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + screenshotModal.removeAttribute('hidden'); + screenshotModal.classList.add('open'); + + if (screenshotModalClose) { + screenshotModalClose.focus(); + } + + if (screenshotModalImage.complete) { + applyScreenshotModalSize(); + } else { + const handleLoad = () => { + applyScreenshotModalSize(); + }; + screenshotModalImage.addEventListener('load', handleLoad, { once: true }); + } +} + +function closeScreenshotModal() { + if (!screenshotModal) { + return; + } + + if (!screenshotModal.classList.contains('open')) { + return; + } + + resetScreenshotZoom(); + screenshotModal.classList.remove('open'); + screenshotModal.setAttribute('hidden', ''); + screenshotModalImage.src = ''; + document.body.style.overflow = screenshotModalPreviousOverflow; + + if (screenshotModalLastFocus && typeof screenshotModalLastFocus.focus === 'function') { + screenshotModalLastFocus.focus(); + } +} + +function resetScreenshotZoom() { + screenshotModalZoomed = false; + if (screenshotModalContent) { + screenshotModalContent.classList.remove('zoomed'); + screenshotModalContent.style.width = ''; + screenshotModalContent.style.height = ''; + screenshotModalContent.scrollTo({ top: 0, left: 0, behavior: 'auto' }); + } + if (screenshotModalImage) { + screenshotModalImage.classList.remove('zoomed'); + } + applyScreenshotModalSize(); +} + +function toggleScreenshotZoom() { + if (!screenshotModalContent || !screenshotModalImage) { + return; + } + + screenshotModalZoomed = !screenshotModalZoomed; + screenshotModalContent.classList.toggle('zoomed', screenshotModalZoomed); + screenshotModalImage.classList.toggle('zoomed', screenshotModalZoomed); + + if (screenshotModalZoomed) { + screenshotModalContent.style.width = 'min(95vw, 1300px)'; + screenshotModalContent.style.height = '92vh'; + screenshotModalContent.scrollTo({ top: 0, left: 0, behavior: 'auto' }); + } else { + screenshotModalContent.style.width = ''; + screenshotModalContent.style.height = ''; + applyScreenshotModalSize(); + } +} + +// Create post card HTML +function createPostCard(post, status, meta = {}) { + const createdDate = formatDateTime(post.created_at) || '—'; + const lastChangeDate = formatDateTime(post.last_change || post.created_at) || '—'; + + const resolvedScreenshotPath = post.screenshot_path + ? (post.screenshot_path.startsWith('http') + ? post.screenshot_path + : `${API_URL.replace(/\/api$/, '')}${post.screenshot_path.startsWith('/') ? '' : '/'}${post.screenshot_path}`) + : `${API_URL}/posts/${post.id}/screenshot`; + + const screenshotHtml = ` +
+ Screenshot zum Beitrag +
+ `; + + const displayIndex = typeof meta.globalIndex === 'number' ? meta.globalIndex : typeof meta.index === 'number' ? meta.index + 1 : null; + const totalFiltered = typeof meta.totalFiltered === 'number' ? meta.totalFiltered : posts.length; + const totalOverall = typeof meta.totalOverall === 'number' ? meta.totalOverall : posts.length; + const tabTotalCount = typeof meta.tabTotalCount === 'number' ? meta.tabTotalCount : posts.length; + const searchActive = !!meta.searchActive; + + const counterBadge = displayIndex !== null + ? ` + + ` + : ''; + + const profileRowsHtml = status.profileStatuses.map((profileStatus) => { + const classes = ['profile-line', `profile-line--${profileStatus.status}`]; + const isCurrentProfile = parseInt(profileStatus.profile_number, 10) === status.profileNumber; + if (isCurrentProfile) { + classes.push('profile-line--current'); + } + let label = 'Wartet'; + if (profileStatus.status === 'done') { + const doneDate = formatDateTime(profileStatus.checked_at); + label = doneDate ? `Erledigt (${doneDate})` : 'Erledigt'; + } else if (profileStatus.status === 'available') { + label = 'Bereit'; + } + + const toggleLabel = profileStatus.status === 'done' + ? 'Als offen markieren' + : 'Als erledigt markieren'; + + // Disable toggle button if post is expired + const toggleDisabled = status.isExpired ? 'disabled' : ''; + + const badgeHtml = isCurrentProfile ? 'Dein Profil' : ''; + + return ` +
+ ${escapeHtml(profileStatus.profile_name)}${badgeHtml} + ${escapeHtml(label)} +
+ +
+
+ `; + }).join(''); + + const infoMessages = []; + if (status.isExpired) { + infoMessages.push('Deadline ist abgelaufen.'); + } + if (!status.isCurrentProfileRequired) { + infoMessages.push('Dieses Profil muss den Beitrag nicht bestätigen.'); + } else if (status.isCurrentProfileDone) { + infoMessages.push('Für dein Profil erledigt.'); + } else if (status.waitingForNames.length) { + infoMessages.push(`Wartet auf: ${status.waitingForNames.join(', ')}`); + } + + const infoHtml = infoMessages.length + ? ` +
+ ${infoMessages.map((message) => ` +
+ ${escapeHtml(message)} +
+ `).join('')} +
+ ` + : ''; + + const directLinkHtml = post.url + ? ` +
+ Direktlink: + + ${escapeHtml(formatUrlForDisplay(post.url))} + +
+ ` + : ''; + + const openButtonHtml = (status.canCurrentProfileCheck && !status.isExpired) + ? ` + + ` + : ''; + + const bodyClasses = ['post-body']; + if (resolvedScreenshotPath) { + bodyClasses.push('post-body--with-screenshot'); + } + + let creatorName = typeof post.created_by_name === 'string' && post.created_by_name.trim() + ? post.created_by_name.trim() + : null; + + // Remove ", Story ansehen" suffix if present + if (creatorName && creatorName.endsWith(', Story ansehen')) { + creatorName = creatorName.slice(0, -16).trim(); + } + const creatorDisplay = creatorName || 'Unbekannt'; + + const titleText = (post.title && post.title.trim()) ? post.title.trim() : creatorDisplay; + + const deadlineText = formatDeadline(post.deadline_at); + const hasDeadline = Boolean(post.deadline_at); + const isOverdue = hasDeadline && (new Date(post.deadline_at).getTime() < Date.now()); + const deadlineClasses = ['post-deadline']; + + let deadlineStyle = ''; + if (hasDeadline) { + deadlineClasses.push('has-deadline'); + + // Calculate color based on time until deadline (smooth gradient) + const now = Date.now(); + const deadlineTime = new Date(post.deadline_at).getTime(); + const hoursUntilDeadline = (deadlineTime - now) / (1000 * 60 * 60); + + let color; + if (hoursUntilDeadline < 0) { + // Overdue - dark red + color = '#dc2626'; + } else { + // Smooth gradient from red (0h) to default gray (168h/7 days) + const maxHours = 168; // 7 days + const ratio = Math.min(hoursUntilDeadline / maxHours, 1); + + // Color stops: red -> default gray (#4b5563) + // Red: rgb(220, 38, 38) + // Gray: rgb(75, 85, 99) + + const r = Math.round(220 - (220 - 75) * ratio); + const g = Math.round(38 + (85 - 38) * ratio); + const b = Math.round(38 + (99 - 38) * ratio); + color = `rgb(${r}, ${g}, ${b})`; + } + + deadlineStyle = `style="color: ${color};"`; + } + if (isOverdue) { + deadlineClasses.push('overdue'); + } + + return ` +
+
+ ${counterBadge} +
+
${escapeHtml(titleText)}
+ +
+
+
+ Benötigte Profile: + +
+
+ ${status.checkedCount}/${status.targetCount} ${status.isComplete ? '✓' : ''} +
+
+
+ +
+
+ ${screenshotHtml} +
+ +
+ + ${directLinkHtml} + +
+ Deadline: ${escapeHtml(deadlineText)} + + ${hasDeadline ? ` + + ` : ''} +
+ +
+ ${profileRowsHtml} +
+ + ${infoHtml} + +
+ ${openButtonHtml} + + ${post.url ? ` + + Direkt öffnen + + ` : ''} + +
+
+
+
+ `; +} + +// Open post and auto-check +async function openPost(postId) { + const post = posts.find((item) => item.id === postId); + if (!post) { + alert('Beitrag konnte nicht gefunden werden.'); + return; + } + + if (!post.url) { + alert('Für diesen Beitrag ist kein Direktlink vorhanden.'); + return; + } + + const status = computePostStatus(post); + + if (!status.isCurrentProfileRequired) { + alert('Dieses Profil muss den Beitrag nicht bestätigen.'); + return; + } + + if (status.isCurrentProfileDone) { + window.open(post.url, '_blank'); + return; + } + + if (!status.canCurrentProfileCheck) { + if (status.waitingForNames.length) { + alert(`Wartet auf: ${status.waitingForNames.join(', ')}`); + } else { + alert('Der Beitrag kann aktuell nicht abgehakt werden.'); + } + return; + } + + try { + const response = await apiFetch(`${API_URL}/check-by-url`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: post.url, + profile_number: currentProfile + }) + }); + + if (!response.ok) { + if (response.status === 409) { + const data = await response.json().catch(() => null); + if (data && data.error) { + alert(data.error); + return; + } + } + throw new Error('Failed to check post'); + } + + window.open(post.url, '_blank'); + + await fetchPosts({ showLoader: false }); + } catch (error) { + alert('Fehler beim Abhaken des Beitrags'); + console.error('Error checking post:', error); + } +} + +async function toggleSuccessStatus(postId, isSuccessful) { + try { + const response = await apiFetch(`${API_URL}/posts/${postId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_successful: isSuccessful }) + }); + + if (!response.ok) { + alert('Status konnte nicht geändert werden.'); + return; + } + + const updatedPost = await response.json(); + posts = posts.map((item) => (item.id === postId ? updatedPost : item)); + renderPosts(); + } catch (error) { + console.error('Error updating success status:', error); + alert('Status konnte nicht geändert werden.'); + } +} + +async function toggleProfileStatus(postId, profileNumber, currentStatus) { + if (!profileNumber) { + return; + } + + const desiredStatus = currentStatus === 'done' ? 'pending' : 'done'; + + try { + const response = await apiFetch(`${API_URL}/posts/${postId}/profile-status`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + profile_number: profileNumber, + status: desiredStatus + }) + }); + + if (!response.ok) { + const data = await response.json().catch(() => null); + const message = data && data.error ? data.error : 'Status konnte nicht geändert werden.'; + alert(message); + return; + } + + const updatedPost = await response.json(); + posts = posts.map((item) => (item.id === postId ? updatedPost : item)); + renderPosts(); + } catch (error) { + console.error('Error updating profile status:', error); + alert('Profilstatus konnte nicht geändert werden.'); + } +} + +function populateManualPostForm(post) { + if (!manualPostForm || !post) { + return; + } + + if (manualPostUrlInput) { + const normalizedUrl = typeof post.url === 'string' ? normalizeFacebookPostUrl(post.url) : null; + manualPostUrlInput.value = normalizedUrl || post.url || ''; + manualPostUrlInput.disabled = true; + manualPostUrlInput.readOnly = true; + } + + if (manualPostTitleInput) { + manualPostTitleInput.value = post.title || ''; + } + + if (manualPostTargetSelect) { + const targetValue = parseInt(post.target_count, 10); + manualPostTargetSelect.value = Number.isNaN(targetValue) ? '1' : String(targetValue); + } + + if (manualPostCreatorInput) { + manualPostCreatorInput.value = post.created_by_name || ''; + } + + if (manualPostDeadlineInput) { + const existingValue = toDateTimeLocalValue(post.deadline_at); + manualPostDeadlineInput.value = existingValue || getDefaultDeadlineInputValue(); + } + + clearManualPostMessage(); +} + +function resetManualPostForm({ keepMessages = false } = {}) { + if (!manualPostForm) { + return; + } + + manualPostMode = 'create'; + manualPostEditingId = null; + + manualPostForm.reset(); + + if (manualPostTargetSelect) { + manualPostTargetSelect.value = '1'; + } + + if (manualPostUrlInput) { + manualPostUrlInput.disabled = false; + manualPostUrlInput.readOnly = false; + manualPostUrlInput.value = ''; + } + + if (manualPostTitleInput) { + manualPostTitleInput.value = ''; + } + + if (manualPostCreatorInput) { + manualPostCreatorInput.value = ''; + } + + if (manualPostDeadlineInput) { + manualPostDeadlineInput.value = getDefaultDeadlineInputValue(); + } + + if (manualPostModalTitle) { + manualPostModalTitle.textContent = 'Beitrag hinzufügen'; + } + + if (manualPostSubmitButton) { + manualPostSubmitButton.textContent = 'Speichern'; + } + + if (!keepMessages) { + clearManualPostMessage(); + } +} + +async function saveDeadline(postId, deadlineIso) { + try { + const response = await apiFetch(`${API_URL}/posts/${postId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deadline_at: deadlineIso }) + }); + + if (!response.ok) { + const data = await response.json().catch(() => null); + const message = data && data.error ? data.error : 'Deadline konnte nicht gespeichert werden.'; + alert(message); + return false; + } + + const updatedPost = await response.json(); + posts = posts.map((item) => (item.id === postId ? updatedPost : item)); + renderPosts(); + return true; + } catch (error) { + console.error('Error updating deadline:', error); + alert('Deadline konnte nicht gespeichert werden.'); + return false; + } +} + +async function updateTargetInline(postId, value, selectElement) { + if (!selectElement) { + return; + } + + if (Number.isNaN(value) || value < 1 || value > MAX_PROFILES) { + renderPosts(); + return; + } + + try { + const response = await apiFetch(`${API_URL}/posts/${postId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target_count: value }) + }); + + if (!response.ok) { + const data = await response.json().catch(() => null); + const message = data && data.error ? data.error : 'Anzahl konnte nicht gespeichert werden.'; + alert(message); + if (selectElement.dataset.originalValue) { + selectElement.value = selectElement.dataset.originalValue; + } + renderPosts(); + return; + } + + const updatedPost = await response.json(); + posts = posts.map((item) => (item.id === postId ? updatedPost : item)); + renderPosts(); + } catch (error) { + console.error('Error updating target count:', error); + alert('Anzahl konnte nicht gespeichert werden.'); + if (selectElement.dataset.originalValue) { + selectElement.value = selectElement.dataset.originalValue; + } + renderPosts(); + } +} + +async function handleManualPostSubmit(event) { + event.preventDefault(); + + if (!manualPostForm) { + return; + } + + clearManualPostMessage(); + + const urlValue = manualPostUrlInput ? manualPostUrlInput.value.trim() : ''; + if (!urlValue) { + displayManualPostMessage('Bitte gib einen Direktlink an.', 'error'); + if (manualPostUrlInput) { + manualPostUrlInput.focus(); + } + return; + } + + const targetValue = manualPostTargetSelect ? manualPostTargetSelect.value : '1'; + const parsedTarget = parseInt(targetValue, 10); + if (Number.isNaN(parsedTarget) || parsedTarget < 1 || parsedTarget > MAX_PROFILES) { + displayManualPostMessage('Die Anzahl der benötigten Profile muss zwischen 1 und 5 liegen.', 'error'); + return; + } + + const creatorValue = manualPostCreatorInput ? manualPostCreatorInput.value.trim() : ''; + const deadlineValue = manualPostDeadlineInput ? manualPostDeadlineInput.value : ''; + const titleValue = manualPostTitleInput ? manualPostTitleInput.value.trim() : ''; + + const cleanedUrl = normalizeFacebookPostUrl(urlValue); + if (!cleanedUrl) { + displayManualPostMessage('Bitte gib einen gültigen Facebook-Link an.', 'error'); + if (manualPostUrlInput) { + manualPostUrlInput.focus(); + manualPostUrlInput.select?.(); + } + return; + } + + const payload = { + url: cleanedUrl, + target_count: parsedTarget + }; + + if (titleValue) { + payload.title = titleValue; + } + + if (creatorValue) { + payload.created_by_name = creatorValue; + } + + const normalizedDeadline = normalizeDeadlineInput(deadlineValue); + if (normalizedDeadline) { + payload.deadline_at = normalizedDeadline; + } + + const submitButtons = manualPostForm.querySelectorAll('button, input[type="submit"]'); + submitButtons.forEach((btn) => { + btn.disabled = true; + }); + + try { + if (manualPostMode === 'edit' && manualPostEditingId) { + const updatePayload = { + target_count: parsedTarget, + title: titleValue || '' + }; + + if (creatorValue || creatorValue === '') { + updatePayload.created_by_name = creatorValue || null; + } + + updatePayload.deadline_at = normalizedDeadline; + + const response = await apiFetch(`${API_URL}/posts/${manualPostEditingId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updatePayload) + }); + + if (!response.ok) { + const data = await response.json().catch(() => null); + const message = data && data.error ? data.error : 'Beitrag konnte nicht aktualisiert werden.'; + displayManualPostMessage(message, 'error'); + return; + } + + const updatedPost = await response.json(); + posts = posts.map((item) => (item.id === manualPostEditingId ? updatedPost : item)); + renderPosts(); + closeManualPostModal(); + } else { + const response = await apiFetch(`${API_URL}/posts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const data = await response.json().catch(() => null); + if (response.status === 409 && data && data.error) { + displayManualPostMessage(data.error, 'error'); + } else { + const message = data && data.error ? data.error : 'Beitrag konnte nicht erstellt werden.'; + displayManualPostMessage(message, 'error'); + } + return; + } + + const createdPost = await response.json(); + posts = [createdPost, ...posts.filter((item) => item.id !== createdPost.id)]; + renderPosts(); + displayManualPostMessage('Beitrag wurde erstellt.', 'success'); + resetManualPostForm({ keepMessages: true }); + if (manualPostUrlInput) { + manualPostUrlInput.focus(); + } + } + } catch (error) { + console.error('Error creating manual post:', error); + displayManualPostMessage('Beitrag konnte nicht erstellt werden.', 'error'); + } finally { + submitButtons.forEach((btn) => { + btn.disabled = false; + }); + } +} + +// Delete post +async function deletePost(postId) { + if (!confirm('Beitrag wirklich löschen?')) { + return; + } + + try { + const response = await apiFetch(`${API_URL}/posts/${postId}`, { + method: 'DELETE' + }); + + if (!response.ok) { + throw new Error('Failed to delete post'); + } + + await fetchPosts({ showLoader: false }); + } catch (error) { + alert('Fehler beim Löschen des Beitrags'); + console.error('Error deleting post:', error); + } +} + +// Utility functions +function showLoading() { + document.getElementById('loading').style.display = 'block'; + document.getElementById('postsContainer').style.display = 'none'; +} + +function hideLoading() { + document.getElementById('loading').style.display = 'none'; + document.getElementById('postsContainer').style.display = 'block'; +} + +function showError(message) { + const errorEl = document.getElementById('error'); + errorEl.textContent = message; + errorEl.style.display = 'block'; +} + +function hideError() { + document.getElementById('error').style.display = 'none'; +} + +function escapeHtml(unsafe) { + if (unsafe === null || unsafe === undefined) { + unsafe = ''; + } + + if (typeof unsafe !== 'string') { + unsafe = String(unsafe); + } + + return unsafe + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// Auto-check on page load if URL parameter is present +function checkAutoCheck() { + const urlParams = new URLSearchParams(window.location.search); + const autoCheckUrl = urlParams.get('check'); + + if (autoCheckUrl) { + // Try to check this URL automatically + apiFetch(`${API_URL}/check-by-url`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: decodeURIComponent(autoCheckUrl), + profile_number: currentProfile + }) + }).then(() => { + // Remove nur den check-Parameter aus der URL + try { + const url = new URL(window.location.href); + url.searchParams.delete('check'); + const paramsString = url.searchParams.toString(); + const newUrl = paramsString ? `${url.pathname}?${paramsString}${url.hash}` : `${url.pathname}${url.hash}`; + window.history.replaceState({}, document.title, newUrl); + } catch (error) { + console.warn('Konnte check-Parameter nicht entfernen:', error); + } + fetchPosts({ showLoader: false }); + }).catch(console.error); + } +} + +if (screenshotModalClose) { + screenshotModalClose.addEventListener('click', closeScreenshotModal); +} + +if (screenshotModalBackdrop) { + screenshotModalBackdrop.addEventListener('click', closeScreenshotModal); +} + +if (screenshotModalImage) { + screenshotModalImage.addEventListener('click', (event) => { + event.stopPropagation(); + toggleScreenshotZoom(); + }); + + screenshotModalImage.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + toggleScreenshotZoom(); + } + }); + + screenshotModalImage.setAttribute('tabindex', '0'); + screenshotModalImage.setAttribute('role', 'button'); + screenshotModalImage.setAttribute('aria-label', 'Screenshot vergrößern oder verkleinern'); +} + +document.addEventListener('keydown', (event) => { + if (event.key !== 'Escape') { + return; + } + + if (manualPostModal && manualPostModal.classList.contains('open')) { + event.preventDefault(); + closeManualPostModal(); + return; + } + + if (screenshotModal && screenshotModal.classList.contains('open')) { + if (screenshotModalZoomed) { + resetScreenshotZoom(); + return; + } + closeScreenshotModal(); + } +}); + +window.addEventListener('resize', () => { + if (screenshotModal && screenshotModal.classList.contains('open') && !screenshotModalZoomed) { + applyScreenshotModalSize(); + } +}); + +// Initialize +loadAutoRefreshSettings(); +initializeTabFromUrl(); +loadSortMode(); +resetManualPostForm(); +loadProfile(); +startProfilePolling(); +fetchPosts(); +checkAutoCheck(); +applyAutoRefreshSettings(); diff --git a/web/assets/app-icon-192.png b/web/assets/app-icon-192.png new file mode 100644 index 0000000..6db01b8 Binary files /dev/null and b/web/assets/app-icon-192.png differ diff --git a/web/assets/app-icon-512.png b/web/assets/app-icon-512.png new file mode 100644 index 0000000..a84c3d5 Binary files /dev/null and b/web/assets/app-icon-512.png differ diff --git a/web/assets/app-icon-64.png b/web/assets/app-icon-64.png new file mode 100644 index 0000000..de7c3cf Binary files /dev/null and b/web/assets/app-icon-64.png differ diff --git a/web/dashboard.css b/web/dashboard.css new file mode 100644 index 0000000..e942ab7 --- /dev/null +++ b/web/dashboard.css @@ -0,0 +1,804 @@ +/* ========================================== + DASHBOARD - UNIFIED DESIGN SYSTEM + ========================================== */ + +/* Color Palette */ +:root { + --color-primary: #1877f2; + --color-success: #42b983; + --color-warning: #f39c12; + --color-danger: #e74c3c; + --color-info: #3498db; + + --bg-card: #ffffff; + --bg-section: #f8f9fa; + --bg-hover: #f0f2f5; + + --text-primary: #1c1e21; + --text-secondary: #65676b; + --text-muted: #8a8d91; + + --border-light: #e4e6eb; + --border-medium: #d1d5db; + + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06); + --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.12); + + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + + --spacing-xs: 8px; + --spacing-sm: 12px; + --spacing-md: 16px; + --spacing-lg: 24px; + --spacing-xl: 32px; +} + +/* Dashboard Container */ +.dashboard-container { + display: flex; + flex-direction: column; + gap: var(--spacing-xl); + padding: var(--spacing-lg) 0; +} + +/* ========================================== + SECTIONS + ========================================== */ + +.dashboard-section { + background: var(--bg-section); + border-radius: var(--radius-lg); + padding: var(--spacing-lg); +} + +.section-title { + font-size: 22px; + font-weight: 700; + color: var(--text-primary); + margin: 0 0 var(--spacing-lg) 0; + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +/* ========================================== + OVERVIEW SECTION + ========================================== */ + +/* Stats Grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--spacing-md); + margin-bottom: var(--spacing-lg); +} + +.stat-card { + background: var(--bg-card); + border-radius: var(--radius-md); + padding: var(--spacing-md) var(--spacing-lg); + box-shadow: var(--shadow-sm); + display: flex; + align-items: center; + gap: var(--spacing-md); + transition: all 0.2s ease; + border-left: 4px solid transparent; +} + +.stat-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.stat-card--primary { border-left-color: var(--color-primary); } +.stat-card--success { border-left-color: var(--color-success); } +.stat-card--warning { border-left-color: var(--color-warning); } +.stat-card--danger { border-left-color: var(--color-danger); } +.stat-card--info { border-left-color: var(--color-info); } + +.stat-card__icon { + font-size: 32px; + line-height: 1; + opacity: 0.9; +} + +.stat-card__content { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +} + +.stat-card__label { + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.stat-card__value { + font-size: 28px; + font-weight: 700; + color: var(--text-primary); + line-height: 1; +} + +/* Metrics Grid */ +.metrics-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: var(--spacing-md); +} + +.metric-card { + background: var(--bg-card); + border-radius: var(--radius-md); + padding: var(--spacing-lg); + box-shadow: var(--shadow-sm); + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + transition: all 0.2s ease; +} + +.metric-card:hover { + box-shadow: var(--shadow-md); +} + +.metric-card__label { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.metric-card__value { + font-size: 32px; + font-weight: 700; + color: var(--text-primary); + line-height: 1.1; +} + +.metric-card__change { + font-size: 12px; + font-weight: 600; + padding: 4px 8px; + border-radius: var(--radius-sm); + display: inline-block; + align-self: flex-start; +} + +.metric-card__change--up { + color: var(--color-success); + background: rgba(66, 185, 131, 0.1); +} + +.metric-card__change--down { + color: var(--color-danger); + background: rgba(231, 76, 60, 0.1); +} + +.metric-card__change--neutral { + color: var(--text-muted); + background: var(--bg-hover); +} + +.metric-card__subtext { + font-size: 12px; + color: var(--text-muted); + font-weight: 500; +} + +/* ========================================== + ANALYTICS SECTION + ========================================== */ + +.charts-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); + gap: var(--spacing-lg); + margin-bottom: var(--spacing-lg); +} + +.charts-row:last-child { + margin-bottom: 0; +} + +.chart-card { + background: var(--bg-card); + border-radius: var(--radius-md); + padding: var(--spacing-lg); + box-shadow: var(--shadow-sm); + transition: all 0.2s ease; +} + +.chart-card--full { + grid-column: 1 / -1; +} + +.chart-card:hover { + box-shadow: var(--shadow-md); +} + +.chart-card__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-md); + flex-wrap: wrap; + gap: var(--spacing-sm); +} + +.chart-card__title { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + margin: 0; +} + +.chart-card__subtitle { + font-size: 12px; + color: var(--text-muted); + font-weight: 500; +} + +.chart-card__body { + min-height: 200px; + position: relative; +} + +/* Bar Chart (for profile chart) */ +.bar-chart { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.bar-chart-item { + display: grid; + grid-template-columns: 80px 1fr 60px; + gap: var(--spacing-sm); + align-items: center; + padding: var(--spacing-xs); + border-radius: var(--radius-sm); + transition: background 0.2s ease; +} + +.bar-chart-item:hover { + background: var(--bg-hover); +} + +.bar-chart-item__label { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.bar-chart-item__bar-container { + background: var(--border-light); + border-radius: var(--radius-sm); + height: 32px; + overflow: hidden; + position: relative; +} + +.bar-chart-item__bar { + background: linear-gradient(135deg, var(--color-primary) 0%, #4a9eff 100%); + height: 100%; + display: flex; + align-items: center; + justify-content: flex-end; + padding-right: var(--spacing-xs); + font-size: 12px; + font-weight: 700; + color: white; + border-radius: var(--radius-sm); + transition: width 0.4s ease; + min-width: 2px; +} + +.bar-chart-item__value { + font-size: 14px; + font-weight: 700; + color: var(--text-primary); + text-align: right; +} + +/* ========================================== + PERFORMANCE COMPARISONS SECTION + ========================================== */ + +.comparison-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: var(--spacing-lg); + margin-bottom: var(--spacing-lg); +} + +.comparison-card { + background: var(--bg-card); + border-radius: var(--radius-md); + padding: var(--spacing-lg); + box-shadow: var(--shadow-sm); + transition: all 0.2s ease; +} + +.comparison-card:hover { + box-shadow: var(--shadow-md); +} + +.comparison-card__title { + font-size: 15px; + font-weight: 700; + color: var(--text-primary); + margin: 0 0 var(--spacing-md) 0; + padding-bottom: var(--spacing-sm); + border-bottom: 2px solid var(--border-light); +} + +.comparison-card__content { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.comparison-item { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.comparison-item__header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.comparison-item__label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.comparison-item__value { + font-size: 24px; + font-weight: 700; + color: var(--text-primary); +} + +.comparison-item__bar { + background: var(--border-light); + border-radius: 100px; + height: 8px; + overflow: hidden; +} + +.comparison-item__bar-fill { + height: 100%; + border-radius: 100px; + transition: width 0.4s ease; +} + +.comparison-item__bar-fill--current { + background: linear-gradient(90deg, var(--color-primary), #4a9eff); +} + +.comparison-item__bar-fill--success { + background: linear-gradient(90deg, var(--color-success), #5dd39e); +} + +.comparison-item__subtext { + font-size: 11px; + color: var(--text-muted); + font-weight: 500; +} + +.comparison-item__change { + font-size: 11px; + font-weight: 700; + padding: 3px 8px; + border-radius: var(--radius-sm); +} + +.comparison-item__change--up { + color: var(--color-success); + background: rgba(66, 185, 131, 0.12); +} + +.comparison-item__change--down { + color: var(--color-danger); + background: rgba(231, 76, 60, 0.12); +} + +.comparison-item__change--neutral { + color: var(--text-muted); + background: var(--bg-hover); +} + +.comparison-divider { + height: 1px; + background: var(--border-light); + margin: var(--spacing-xs) 0; +} + +/* Success Comparison Grid */ +.success-comparison-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: var(--spacing-lg); +} + +.success-comparison-card { + background: linear-gradient(135deg, #f5f7fa 0%, #ffffff 100%); + border-radius: var(--radius-md); + padding: var(--spacing-lg); + box-shadow: var(--shadow-sm); + border: 1px solid var(--border-light); + transition: all 0.2s ease; +} + +.success-comparison-card:hover { + box-shadow: var(--shadow-md); + border-color: var(--color-success); +} + +.success-comparison-card__title { + font-size: 15px; + font-weight: 700; + color: var(--text-primary); + margin: 0 0 var(--spacing-md) 0; + padding-bottom: var(--spacing-sm); + border-bottom: 2px solid var(--color-success); +} + +.success-comparison-card__content { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +/* ========================================== + DETAILS SECTION + ========================================== */ + +.details-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); + gap: var(--spacing-lg); +} + +.detail-card { + background: var(--bg-card); + border-radius: var(--radius-md); + padding: var(--spacing-lg); + box-shadow: var(--shadow-sm); + transition: all 0.2s ease; +} + +.detail-card--full { + grid-column: 1 / -1; +} + +.detail-card:hover { + box-shadow: var(--shadow-md); +} + +.detail-card__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-md); + padding-bottom: var(--spacing-sm); + border-bottom: 2px solid var(--border-light); +} + +.detail-card__title { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + margin: 0; +} + +.detail-card__badge { + background: var(--color-primary); + color: white; + font-size: 12px; + font-weight: 700; + padding: 4px 10px; + border-radius: 100px; +} + +.detail-card__body { + max-height: 400px; + overflow-y: auto; +} + +/* Performers List */ +.performers-list { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.performer-item { + display: flex; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-sm); + background: var(--bg-hover); + border-radius: var(--radius-sm); + transition: all 0.2s ease; +} + +.performer-item:hover { + background: var(--border-light); + transform: translateX(4px); +} + +.performer-item--gold { + background: linear-gradient(135deg, #ffd700 20%, #fff9e6 100%); + border: 2px solid #ffd700; +} + +.performer-item--silver { + background: linear-gradient(135deg, #c0c0c0 20%, #f5f5f5 100%); + border: 2px solid #c0c0c0; +} + +.performer-item--bronze { + background: linear-gradient(135deg, #cd7f32 20%, #fff5e6 100%); + border: 2px solid #cd7f32; +} + +.performer-item__rank { + font-size: 16px; + font-weight: 700; + color: var(--text-secondary); + min-width: 24px; + text-align: center; +} + +.performer-item__avatar { + font-size: 24px; + line-height: 1; +} + +.performer-item__content { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; +} + +.performer-item__name { + font-size: 14px; + font-weight: 700; + color: var(--text-primary); +} + +.performer-item__stats { + font-size: 12px; + color: var(--text-secondary); + font-weight: 500; +} + +.performer-item__badge { + background: var(--color-primary); + color: white; + font-size: 12px; + font-weight: 700; + padding: 4px 12px; + border-radius: 100px; +} + +/* Deadline List */ +.deadline-list { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.deadline-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-sm); + background: var(--bg-hover); + border-radius: var(--radius-sm); + border-left: 4px solid var(--color-info); + transition: all 0.2s ease; +} + +.deadline-item:hover { + background: var(--border-light); +} + +.deadline-item--warning { + border-left-color: var(--color-warning); + background: rgba(243, 156, 18, 0.05); +} + +.deadline-item--danger { + border-left-color: var(--color-danger); + background: rgba(231, 76, 60, 0.05); +} + +.deadline-item__content { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +} + +.deadline-item__title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.deadline-item__progress { + font-size: 11px; + color: var(--text-secondary); + font-weight: 500; +} + +.deadline-item__time { + font-size: 12px; + font-weight: 700; + color: var(--text-secondary); + padding: 4px 10px; + background: white; + border-radius: var(--radius-sm); +} + +/* Activity List */ +.activity-list { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.activity-item { + display: flex; + align-items: flex-start; + gap: var(--spacing-sm); + padding: var(--spacing-sm); + background: var(--bg-hover); + border-radius: var(--radius-sm); + transition: all 0.2s ease; +} + +.activity-item:hover { + background: var(--border-light); +} + +.activity-item__icon { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-success); + color: white; + border-radius: 50%; + font-size: 14px; + font-weight: 700; + flex-shrink: 0; +} + +.activity-item__content { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; +} + +.activity-item__text { + font-size: 13px; + color: var(--text-primary); + line-height: 1.4; +} + +.activity-item__profile { + font-weight: 700; + color: var(--color-primary); +} + +.activity-item__time { + font-size: 11px; + color: var(--text-muted); + font-weight: 500; +} + +/* ========================================== + EMPTY STATES + ========================================== */ + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--spacing-xl); + text-align: center; + color: var(--text-muted); +} + +.empty-state-icon { + font-size: 48px; + margin-bottom: var(--spacing-sm); + opacity: 0.5; +} + +.empty-state-text { + font-size: 14px; + font-weight: 500; +} + +/* ========================================== + RESPONSIVE DESIGN + ========================================== */ + +@media (max-width: 1024px) { + .charts-row { + grid-template-columns: 1fr; + } + + .chart-card--full { + grid-column: auto; + } +} + +@media (max-width: 768px) { + .dashboard-section { + padding: var(--spacing-md); + } + + .stats-grid, + .metrics-grid, + .comparison-grid, + .success-comparison-grid, + .details-grid { + grid-template-columns: 1fr; + } + + .detail-card--full { + grid-column: auto; + } + + .stat-card__value, + .metric-card__value { + font-size: 24px; + } +} + +/* ========================================== + ANIMATIONS + ========================================== */ + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.dashboard-section { + animation: fadeInUp 0.4s ease forwards; +} + +.dashboard-section:nth-child(1) { animation-delay: 0s; } +.dashboard-section:nth-child(2) { animation-delay: 0.1s; } +.dashboard-section:nth-child(3) { animation-delay: 0.2s; } +.dashboard-section:nth-child(4) { animation-delay: 0.3s; } diff --git a/web/dashboard.html b/web/dashboard.html new file mode 100644 index 0000000..cfb786d --- /dev/null +++ b/web/dashboard.html @@ -0,0 +1,274 @@ + + + + + + Dashboard - Facebook Post Tracker + + + + + + + +
+
+
+

📊 Dashboard

+ Zurück zu Beiträgen +
+
+
+ + +
+
+ + +
+ +
+
+ +
Lade Statistiken...
+ + + +
+ + + + diff --git a/web/dashboard.js b/web/dashboard.js new file mode 100644 index 0000000..b1b31d8 --- /dev/null +++ b/web/dashboard.js @@ -0,0 +1,1581 @@ +const API_URL = 'https://fb.srv.medeba-media.de/api'; + +// Check if we should redirect to posts view +(function checkViewRouting() { + const params = new URLSearchParams(window.location.search); + const view = params.get('view'); + if (view === 'posts') { + // Remove view parameter and keep other params + params.delete('view'); + const remainingParams = params.toString(); + window.location.href = 'index.html' + (remainingParams ? '?' + remainingParams : ''); + } +})(); + +let posts = []; +let filteredPosts = []; +let currentTimeFilter = 'week'; +let currentProfileFilter = 'all'; + +const DAY_IN_MS = 24 * 60 * 60 * 1000; +const HOUR_IN_MS = 60 * 60 * 1000; + +function startOfDay(date) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +function addDays(date, days) { + const result = new Date(date.getTime()); + result.setDate(result.getDate() + days); + return result; +} + +function addMonths(date, months) { + return new Date(date.getFullYear(), date.getMonth() + months, 1); +} + +function getPostDisplayTitle(post) { + if (post.title && post.title.trim()) { + return post.title.trim(); + } + if (post.created_by_name && post.created_by_name.trim()) { + let creatorName = post.created_by_name.trim(); + // Remove ", Story ansehen" suffix if present + if (creatorName.endsWith(', Story ansehen')) { + creatorName = creatorName.slice(0, -16).trim(); + } + return creatorName; + } + return 'Unbekannt'; +} + +function matchesProfileFilter(post) { + if (currentProfileFilter === 'all') { + return true; + } + + const profileNum = parseInt(currentProfileFilter, 10); + if (Number.isNaN(profileNum)) { + return true; + } + + if (post.created_by_profile === profileNum) { + return true; + } + + if (Array.isArray(post.required_profiles)) { + const requiredMatch = post.required_profiles.some((value) => parseInt(value, 10) === profileNum); + if (requiredMatch) { + return true; + } + } + + if (Array.isArray(post.checks)) { + const checkMatch = post.checks.some(check => check && parseInt(check.profile_number, 10) === profileNum); + if (checkMatch) { + return true; + } + } + + return false; +} + +function getFilterStartDate(filter, now = new Date()) { + switch (filter) { + case 'today': + return startOfDay(now); + case 'week': + return addDays(startOfDay(now), -7); + case 'month': + return new Date(now.getFullYear(), now.getMonth(), 1); + case 'year': + return new Date(now.getFullYear(), 0, 1); + case 'all': + default: + return null; + } +} + +function getComparisonConfig() { + const now = new Date(); + const todayStart = startOfDay(now); + + switch (currentTimeFilter) { + case 'today': { + const currentStart = todayStart; + const currentEnd = addDays(currentStart, 1); + const previousStart = addDays(currentStart, -1); + const previousEnd = currentStart; + return { + granularity: 'hour', + bucketCount: 24, + currentStart, + currentEnd, + previousStart, + previousEnd, + currentLabel: 'Heute', + previousLabel: 'Gestern', + rangeDescription: '24-Stunden-Vergleich' + }; + } + case 'week': { + const currentEnd = addDays(todayStart, 1); + const currentStart = addDays(currentEnd, -7); + const previousEnd = currentStart; + const previousStart = addDays(previousEnd, -7); + return { + granularity: 'day', + bucketCount: 7, + currentStart, + currentEnd, + previousStart, + previousEnd, + currentLabel: 'Aktuelle 7 Tage', + previousLabel: 'Vorherige 7 Tage', + rangeDescription: '7-Tage-Vergleich' + }; + } + case 'month': { + const currentEnd = addDays(todayStart, 1); + const currentStart = addDays(currentEnd, -30); + const previousEnd = currentStart; + const previousStart = addDays(previousEnd, -30); + return { + granularity: 'day', + bucketCount: 30, + currentStart, + currentEnd, + previousStart, + previousEnd, + currentLabel: 'Aktuelle 30 Tage', + previousLabel: 'Vorherige 30 Tage', + rangeDescription: '30-Tage-Rollierend' + }; + } + case 'year': { + const currentEnd = addMonths(new Date(now.getFullYear(), now.getMonth(), 1), 1); + const currentStart = addMonths(currentEnd, -12); + const previousEnd = currentStart; + const previousStart = addMonths(previousEnd, -12); + return { + granularity: 'month', + bucketCount: 12, + currentStart, + currentEnd, + previousStart, + previousEnd, + currentLabel: 'Aktuelle 12 Monate', + previousLabel: 'Vorherige 12 Monate', + rangeDescription: '12-Monats-Vergleich' + }; + } + case 'all': + default: { + const currentEnd = addDays(todayStart, 1); + const currentStart = addDays(currentEnd, -30); + const previousEnd = currentStart; + const previousStart = addDays(previousEnd, -30); + return { + granularity: 'day', + bucketCount: 30, + currentStart, + currentEnd, + previousStart, + previousEnd, + currentLabel: 'Aktuelle 30 Tage', + previousLabel: 'Vorherige 30 Tage', + rangeDescription: 'Rollierender Zeitraum' + }; + } + } +} + +function countChecksBetween(start, end, targetProfile = null) { + let sum = 0; + + posts.forEach(post => { + if (!matchesProfileFilter(post)) { + return; + } + + if (!Array.isArray(post.checks)) { + return; + } + + post.checks.forEach(check => { + if (!check || !check.checked_at) { + return; + } + + const profileNumber = parseInt(check.profile_number, 10); + if (targetProfile && profileNumber !== targetProfile) { + return; + } + + const timestamp = new Date(check.checked_at); + if (timestamp >= start && timestamp < end) { + sum += 1; + } + }); + }); + + return sum; +} + +function buildTrendSeries(start, bucketCount, granularity) { + const labels = []; + const values = []; + const formatterDay = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit' }); + const formatterMonth = new Intl.DateTimeFormat('de-DE', { month: 'short' }); + + for (let index = 0; index < bucketCount; index++) { + let bucketStart; + let bucketEnd; + let label; + + if (granularity === 'hour') { + bucketStart = new Date(start.getTime() + index * HOUR_IN_MS); + bucketEnd = new Date(bucketStart.getTime() + HOUR_IN_MS); + label = `${String(bucketStart.getHours()).padStart(2, '0')}h`; + } else if (granularity === 'month') { + bucketStart = addMonths(start, index); + bucketEnd = addMonths(start, index + 1); + label = formatterMonth.format(bucketStart); + } else { + bucketStart = addDays(start, index); + bucketEnd = addDays(bucketStart, 1); + label = formatterDay.format(bucketStart); + } + + labels.push(label); + values.push(countChecksBetween(bucketStart, bucketEnd)); + } + + return { labels, values }; +} + +function countChecksPerProfile(start, end) { + const counts = [0, 0, 0, 0, 0]; + const targetProfile = currentProfileFilter !== 'all' ? parseInt(currentProfileFilter, 10) : null; + + posts.forEach(post => { + if (!matchesProfileFilter(post)) { + return; + } + + if (!Array.isArray(post.checks)) { + return; + } + + post.checks.forEach(check => { + if (!check || !check.checked_at) { + return; + } + + const profileNumber = parseInt(check.profile_number, 10); + if (Number.isNaN(profileNumber) || profileNumber < 1 || profileNumber > 5) { + return; + } + + if (targetProfile && profileNumber !== targetProfile) { + return; + } + + const timestamp = new Date(check.checked_at); + if (timestamp >= start && timestamp < end) { + counts[profileNumber - 1] += 1; + } + }); + }); + + return counts; +} + +function formatDurationFromHours(hours) { + if (hours == null || Number.isNaN(hours)) { + return '-'; + } + + if (hours < 1) { + const minutes = Math.max(1, Math.round(hours * 60)); + return `${minutes} Min.`; + } + + if (hours < 48) { + return `${Math.round(hours)} Std.`; + } + + const days = Math.round(hours / 24); + return `${days} Tg.`; +} + +function apiFetch(url, options = {}) { + const config = { + ...options, + credentials: 'include' + }; + + if (options && options.headers) { + config.headers = { ...options.headers }; + } + + return fetch(url, config); +} + +function showLoading() { + const loading = document.getElementById('loading'); + if (loading) { + loading.style.display = 'block'; + } +} + +function hideLoading() { + const loading = document.getElementById('loading'); + if (loading) { + loading.style.display = 'none'; + } +} + +function showError(message) { + const error = document.getElementById('error'); + if (error) { + error.textContent = message; + error.style.display = 'block'; + } +} + +function hideError() { + const error = document.getElementById('error'); + if (error) { + error.style.display = 'none'; + } +} + +function applyFilters() { + const now = new Date(); + const timeStart = getFilterStartDate(currentTimeFilter, now); + + filteredPosts = posts.filter(post => { + // Time filter + if (timeStart && post.created_at) { + if (new Date(post.created_at) < timeStart) { + return false; + } + } + + return matchesProfileFilter(post); + }); + + renderDashboard(); +} + +async function fetchPosts() { + try { + showLoading(); + hideError(); + + const response = await apiFetch(`${API_URL}/posts`); + if (!response.ok) { + throw new Error('Konnte Beiträge nicht laden'); + } + + posts = await response.json(); + applyFilters(); + } catch (error) { + console.error('Fehler beim Laden der Beiträge:', error); + showError('Fehler beim Laden der Statistiken'); + } finally { + hideLoading(); + } +} + +function renderDashboard() { + const container = document.getElementById('dashboardContainer'); + if (container) { + container.style.display = 'flex'; + } + + // Section 1: Overview + calculateOverviewStats(); + renderKeyMetrics(); + + // Section 2: Analytics + renderTimelineChart(); + renderProfileChart(); + renderProgressChart(); + renderPeriodTrendChart(); + renderProfileComparisonChart(); + + // Section 3: Performance Comparisons + renderComparisons(); + renderSuccessAnalysis(); + + // Section 4: Details + renderTopPerformers(); + renderUpcomingDeadlines(); + renderRecentActivity(); +} + +function calculateOverviewStats() { + const now = new Date(); + + const completed = filteredPosts.filter(post => post.is_complete).length; + const expired = filteredPosts.filter(post => { + if (!post.deadline_at) return false; + return new Date(post.deadline_at) < now && !post.is_complete; + }).length; + const active = filteredPosts.filter(post => { + const isExpired = post.deadline_at ? new Date(post.deadline_at) < now : false; + return !post.is_complete && !isExpired; + }).length; + + const successful = filteredPosts.filter((post) => post.is_successful).length; + + document.getElementById('totalPosts').textContent = filteredPosts.length; + document.getElementById('completedPosts').textContent = completed; + document.getElementById('activePosts').textContent = active; + document.getElementById('expiredPosts').textContent = expired; + document.getElementById('successfulPosts').textContent = successful; +} + +function renderKeyMetrics() { + const now = new Date(); + const yesterday = new Date(now.getTime() - DAY_IN_MS); + + // Success Rate Metric + const successRateEl = document.getElementById('successRateMetric'); + const successRateChangeEl = document.getElementById('successRateChange'); + if (successRateEl) { + const totalWithDeadline = filteredPosts.filter(post => post.deadline_at).length; + if (totalWithDeadline > 0) { + const completedBeforeDeadline = filteredPosts.filter(post => { + if (!post.deadline_at || !post.is_complete) return false; + const deadline = new Date(post.deadline_at); + const lastCheck = Array.isArray(post.checks) && post.checks.length > 0 + ? new Date(post.checks[post.checks.length - 1].checked_at) + : null; + return lastCheck && lastCheck <= deadline; + }).length; + const rate = Math.round((completedBeforeDeadline / totalWithDeadline) * 100); + successRateEl.textContent = `${rate}%`; + } else { + successRateEl.textContent = '-'; + } + } + + // Average Completion Time + const avgTimeEl = document.getElementById('avgCompletionTime'); + if (avgTimeEl) { + const completedPosts = filteredPosts.filter(post => post.is_complete); + if (completedPosts.length > 0) { + let totalHours = 0; + let count = 0; + completedPosts.forEach(post => { + if (post.created_at && Array.isArray(post.checks) && post.checks.length > 0) { + const created = new Date(post.created_at); + const lastCheck = new Date(post.checks[post.checks.length - 1].checked_at); + const hours = (lastCheck - created) / HOUR_IN_MS; + totalHours += hours; + count++; + } + }); + if (count > 0) { + avgTimeEl.textContent = formatDurationFromHours(totalHours / count); + } else { + avgTimeEl.textContent = '-'; + } + } else { + avgTimeEl.textContent = '-'; + } + } + + // Checks Today + const checksTodayEl = document.getElementById('checksToday'); + if (checksTodayEl) { + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + let checksToday = 0; + filteredPosts.forEach(post => { + if (Array.isArray(post.checks)) { + post.checks.forEach(check => { + if (check.checked_at && new Date(check.checked_at) >= todayStart) { + checksToday++; + } + }); + } + }); + checksTodayEl.textContent = checksToday; + } + + // Deadline Risk + const deadlineRiskValueEl = document.getElementById('deadlineRiskValue'); + const deadlineRiskTextEl = document.getElementById('deadlineRiskText'); + if (deadlineRiskValueEl && deadlineRiskTextEl) { + const upcomingThreshold = new Date(now.getTime() + DAY_IN_MS); + const activeWithDeadline = filteredPosts.filter(post => !post.is_complete && post.deadline_at); + + if (activeWithDeadline.length === 0) { + deadlineRiskValueEl.textContent = '0'; + deadlineRiskTextEl.textContent = 'keine Risiken'; + } else { + let urgent = 0; + let overdue = 0; + + activeWithDeadline.forEach(post => { + const deadline = new Date(post.deadline_at); + if (deadline <= now) { + overdue += 1; + } else if (deadline <= upcomingThreshold) { + urgent += 1; + } + }); + + deadlineRiskValueEl.textContent = urgent + overdue; + if (overdue > 0) { + deadlineRiskTextEl.textContent = `${overdue} überfällig, ${urgent} dringend`; + } else if (urgent > 0) { + deadlineRiskTextEl.textContent = `${urgent} in 24h fällig`; + } else { + deadlineRiskTextEl.textContent = 'keine Risiken'; + } + } + } +} + +function renderProfileChart() { + const container = document.getElementById('profileChart'); + const subtitle = document.getElementById('profileChartSubtitle'); + if (!container) return; + + // Count checks per profile + const profileCounts = {}; + for (let i = 1; i <= 5; i++) { + profileCounts[i] = 0; + } + + filteredPosts.forEach(post => { + if (Array.isArray(post.checks)) { + post.checks.forEach(check => { + const profileNum = check.profile_number; + if (profileNum >= 1 && profileNum <= 5) { + profileCounts[profileNum]++; + } + }); + } + }); + + const maxCount = Math.max(...Object.values(profileCounts), 1); + const totalChecks = Object.values(profileCounts).reduce((sum, count) => sum + count, 0); + + if (subtitle) { + subtitle.textContent = `${totalChecks} Teilnahmen gesamt`; + } + + container.innerHTML = Object.entries(profileCounts) + .map(([profile, count]) => { + const percentage = (count / maxCount) * 100; + return ` +
+
Profil ${profile}
+
+
+ ${count > 0 ? count : ''} +
+
+
${count}
+
+ `; + }) + .join(''); + + // Add click handlers for drill-down + container.querySelectorAll('.bar-chart-item').forEach(item => { + item.addEventListener('click', () => { + const profile = item.dataset.profile; + currentProfileFilter = profile; + document.getElementById('profileFilter').value = profile; + applyFilters(); + }); + }); +} + +function renderProgressChart() { + const canvas = document.getElementById('progressChart'); + const subtitle = document.getElementById('progressChartSubtitle'); + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Calculate data + const completed = filteredPosts.filter(post => post.is_complete).length; + const now = new Date(); + const expired = filteredPosts.filter(post => { + if (!post.deadline_at) return false; + return new Date(post.deadline_at) < now && !post.is_complete; + }).length; + const active = filteredPosts.length - completed - expired; + + const data = [ + { label: 'Abgeschlossen', value: completed, color: '#38ef7d' }, + { label: 'Aktiv', value: active, color: '#f5576c' }, + { label: 'Abgelaufen', value: expired, color: '#fee140' } + ]; + + if (subtitle) { + const completionRate = filteredPosts.length > 0 + ? Math.round((completed / filteredPosts.length) * 100) + : 0; + subtitle.textContent = `${completionRate}% Erfolgsquote`; + } + + // Draw donut chart + const total = data.reduce((sum, item) => sum + item.value, 0); + + if (total === 0) { + ctx.fillStyle = '#65676b'; + ctx.font = '16px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('Keine Daten verfügbar', canvas.width / 2, canvas.height / 2); + return; + } + + const centerX = canvas.width / 2; + const centerY = canvas.height / 2; + const radius = Math.min(centerX, centerY) - 40; + const innerRadius = radius * 0.6; + + let currentAngle = -Math.PI / 2; + + data.forEach(item => { + const sliceAngle = (item.value / total) * 2 * Math.PI; + + // Draw slice + ctx.beginPath(); + ctx.arc(centerX, centerY, radius, currentAngle, currentAngle + sliceAngle); + ctx.arc(centerX, centerY, innerRadius, currentAngle + sliceAngle, currentAngle, true); + ctx.closePath(); + ctx.fillStyle = item.color; + ctx.fill(); + + currentAngle += sliceAngle; + }); + + // Draw center circle (white) + ctx.beginPath(); + ctx.arc(centerX, centerY, innerRadius, 0, 2 * Math.PI); + ctx.fillStyle = 'white'; + ctx.fill(); + + // Draw total in center + ctx.fillStyle = '#1c1e21'; + ctx.font = 'bold 32px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(total, centerX, centerY - 10); + + ctx.font = '14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + ctx.fillStyle = '#65676b'; + ctx.fillText('Beiträge', centerX, centerY + 15); + + // Draw legend + const legendY = canvas.height - 30; + let legendX = 40; + const legendSpacing = 120; + + data.forEach(item => { + // Color box + ctx.fillStyle = item.color; + ctx.fillRect(legendX, legendY, 12, 12); + + // Label + ctx.fillStyle = '#1c1e21'; + ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText(`${item.label} (${item.value})`, legendX + 18, legendY + 9); + + legendX += legendSpacing; + }); +} + +function renderPeriodTrendChart() { + const canvas = document.getElementById('periodTrendChart'); + const subtitle = document.getElementById('trendChartSubtitle'); + if (!canvas) { + return; + } + + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, canvas.width, canvas.height); + + const config = getComparisonConfig(); + const currentSeries = buildTrendSeries(config.currentStart, config.bucketCount, config.granularity); + const previousSeries = buildTrendSeries(config.previousStart, config.bucketCount, config.granularity); + + const currentTotal = currentSeries.values.reduce((sum, value) => sum + value, 0); + const previousTotal = previousSeries.values.reduce((sum, value) => sum + value, 0); + + if (subtitle) { + subtitle.textContent = `${config.rangeDescription} · ${config.currentLabel}: ${currentTotal} vs. ${config.previousLabel}: ${previousTotal}`; + } + + const maxValue = Math.max(1, ...currentSeries.values, ...previousSeries.values); + const padding = 48; + const chartWidth = canvas.width - padding * 2; + const chartHeight = canvas.height - padding * 2; + const stepCount = Math.max(config.bucketCount - 1, 1); + + // Background grid + ctx.strokeStyle = '#e5e7eb'; + ctx.lineWidth = 1; + const gridLines = 4; + ctx.beginPath(); + for (let i = 0; i <= gridLines; i++) { + const y = padding + (chartHeight / gridLines) * i; + ctx.moveTo(padding, y); + ctx.lineTo(padding + chartWidth, y); + } + ctx.stroke(); + + // Axes + ctx.strokeStyle = '#d1d5db'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(padding, padding); + ctx.lineTo(padding, padding + chartHeight); + ctx.lineTo(padding + chartWidth, padding + chartHeight); + ctx.stroke(); + + const labelInterval = Math.max(1, Math.round(config.bucketCount / 8)); + + // Y-axis labels + ctx.fillStyle = '#6b7280'; + ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + ctx.textAlign = 'right'; + for (let i = 0; i <= gridLines; i++) { + const value = Math.round((maxValue / gridLines) * (gridLines - i)); + const y = padding + (chartHeight / gridLines) * i; + ctx.fillText(String(value), padding - 8, y + 4); + } + + // X-axis labels + ctx.textAlign = 'center'; + currentSeries.labels.forEach((label, index) => { + if (index % labelInterval !== 0 && index !== currentSeries.labels.length - 1) { + return; + } + const x = padding + (chartWidth / stepCount) * index; + ctx.fillText(label, x, padding + chartHeight + 18); + }); + + function drawSeries(values, color) { + ctx.beginPath(); + values.forEach((value, index) => { + const x = padding + (chartWidth / stepCount) * index; + const heightRatio = value / maxValue; + const y = padding + chartHeight - heightRatio * chartHeight; + + if (index === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + }); + + ctx.strokeStyle = color; + ctx.lineWidth = 2.5; + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + ctx.stroke(); + + values.forEach((value, index) => { + const x = padding + (chartWidth / stepCount) * index; + const y = padding + chartHeight - (value / maxValue) * chartHeight; + + ctx.beginPath(); + ctx.arc(x, y, 3.5, 0, 2 * Math.PI); + ctx.fillStyle = color; + ctx.fill(); + }); + } + + drawSeries(previousSeries.values, '#9ca3af'); + drawSeries(currentSeries.values, '#2563eb'); + + // Legend + const legendY = padding - 22; + let legendX = padding; + const legendItems = [ + { label: config.currentLabel, color: '#2563eb' }, + { label: config.previousLabel, color: '#9ca3af' } + ]; + + ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + ctx.textAlign = 'left'; + + legendItems.forEach(item => { + ctx.fillStyle = item.color; + ctx.fillRect(legendX, legendY, 12, 12); + ctx.fillStyle = '#1f2937'; + ctx.fillText(item.label, legendX + 16, legendY + 10); + legendX += ctx.measureText(item.label).width + 48; + }); +} + +function renderProfileComparisonChart() { + const canvas = document.getElementById('profileComparisonChart'); + const subtitle = document.getElementById('profileComparisonSubtitle'); + if (!canvas) { + return; + } + + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, canvas.width, canvas.height); + + const config = getComparisonConfig(); + const currentCounts = countChecksPerProfile(config.currentStart, config.currentEnd); + const previousCounts = countChecksPerProfile(config.previousStart, config.previousEnd); + + const currentTotal = currentCounts.reduce((sum, value) => sum + value, 0); + const previousTotal = previousCounts.reduce((sum, value) => sum + value, 0); + + if (subtitle) { + subtitle.textContent = `${config.currentLabel}: ${currentTotal} · ${config.previousLabel}: ${previousTotal}`; + } + + const maxValue = Math.max(1, ...currentCounts, ...previousCounts); + const padding = 56; + const chartWidth = canvas.width - padding * 2; + const chartHeight = canvas.height - padding * 2; + const profileCount = currentCounts.length; + const groupWidth = chartWidth / profileCount; + const barWidth = groupWidth * 0.32; + const groupMargin = (groupWidth - barWidth * 2) / 3; + + // Axes + ctx.strokeStyle = '#d1d5db'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(padding, padding); + ctx.lineTo(padding, padding + chartHeight); + ctx.lineTo(padding + chartWidth, padding + chartHeight); + ctx.stroke(); + + // Horizontal grid lines + ctx.strokeStyle = '#e5e7eb'; + ctx.lineWidth = 1; + const gridLines = 4; + ctx.beginPath(); + for (let i = 1; i <= gridLines; i++) { + const y = padding + (chartHeight / gridLines) * i; + ctx.moveTo(padding, y); + ctx.lineTo(padding + chartWidth, y); + } + ctx.stroke(); + + // Y-axis labels + ctx.fillStyle = '#6b7280'; + ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + ctx.textAlign = 'right'; + for (let i = 0; i <= gridLines; i++) { + const value = Math.round((maxValue / gridLines) * i); + const y = padding + chartHeight - (chartHeight / gridLines) * i; + ctx.fillText(String(value), padding - 10, y + 4); + } + + // Bars and labels + ctx.textAlign = 'center'; + ctx.fillStyle = '#1f2937'; + ctx.font = '13px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + + for (let index = 0; index < profileCount; index++) { + const baseX = padding + index * groupWidth; + const currentValue = currentCounts[index]; + const previousValue = previousCounts[index]; + + const currentX = baseX + groupMargin; + const previousX = currentX + barWidth + groupMargin; + + const currentHeight = (currentValue / maxValue) * chartHeight; + const previousHeight = (previousValue / maxValue) * chartHeight; + + ctx.fillStyle = '#2563eb'; + ctx.fillRect( + currentX, + padding + chartHeight - currentHeight, + barWidth, + currentHeight + ); + + ctx.fillStyle = '#9ca3af'; + ctx.fillRect( + previousX, + padding + chartHeight - previousHeight, + barWidth, + previousHeight + ); + + // Value labels + ctx.fillStyle = '#111827'; + ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + ctx.fillText(String(currentValue), currentX + barWidth / 2, padding + chartHeight - currentHeight - 6); + ctx.fillText(String(previousValue), previousX + barWidth / 2, padding + chartHeight - previousHeight - 6); + + // X-axis label + ctx.font = '13px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + ctx.fillStyle = '#1f2937'; + ctx.fillText(`Profil ${index + 1}`, baseX + groupWidth / 2, padding + chartHeight + 20); + } + + // Legend + const legendY = padding - 22; + let legendX = padding; + const legendItems = [ + { label: config.currentLabel, color: '#2563eb' }, + { label: config.previousLabel, color: '#9ca3af' } + ]; + + ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + ctx.textAlign = 'left'; + + legendItems.forEach(item => { + ctx.fillStyle = item.color; + ctx.fillRect(legendX, legendY, 12, 12); + ctx.fillStyle = '#1f2937'; + ctx.fillText(item.label, legendX + 16, legendY + 10); + legendX += ctx.measureText(item.label).width + 48; + }); +} + +function renderTimelineChart() { + const canvas = document.getElementById('timelineChart'); + const subtitle = document.getElementById('timelineSubtitle'); + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Group checks by date + const checksByDate = {}; + filteredPosts.forEach(post => { + if (Array.isArray(post.checks)) { + post.checks.forEach(check => { + if (check.checked_at) { + const date = new Date(check.checked_at); + const dateKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + checksByDate[dateKey] = (checksByDate[dateKey] || 0) + 1; + } + }); + } + }); + + // Get date range + const dates = Object.keys(checksByDate).sort(); + if (dates.length === 0) { + ctx.fillStyle = '#65676b'; + ctx.font = '16px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('Keine Aktivitätsdaten verfügbar', canvas.width / 2, canvas.height / 2); + return; + } + + // Fill in missing dates + const startDate = new Date(dates[0]); + const endDate = new Date(dates[dates.length - 1]); + const allDates = []; + for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { + const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + allDates.push(dateKey); + if (!checksByDate[dateKey]) { + checksByDate[dateKey] = 0; + } + } + + const values = allDates.map(date => checksByDate[date]); + const maxValue = Math.max(...values, 1); + + if (subtitle) { + const totalChecks = values.reduce((sum, val) => sum + val, 0); + subtitle.textContent = `${totalChecks} Teilnahmen in ${allDates.length} Tagen`; + } + + // Chart dimensions + const padding = 40; + const chartWidth = canvas.width - 2 * padding; + const chartHeight = canvas.height - 2 * padding - 20; + const barWidth = chartWidth / allDates.length; + + // Draw bars + ctx.fillStyle = '#1877f2'; + allDates.forEach((date, index) => { + const value = checksByDate[date]; + const barHeight = (value / maxValue) * chartHeight; + const x = padding + index * barWidth; + const y = padding + chartHeight - barHeight; + + // Gradient + const gradient = ctx.createLinearGradient(x, y, x, padding + chartHeight); + gradient.addColorStop(0, '#667eea'); + gradient.addColorStop(1, '#764ba2'); + ctx.fillStyle = gradient; + + ctx.fillRect(x, y, Math.max(barWidth - 2, 1), barHeight); + }); + + // Draw axes + ctx.strokeStyle = '#e4e6eb'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(padding, padding); + ctx.lineTo(padding, padding + chartHeight); + ctx.lineTo(padding + chartWidth, padding + chartHeight); + ctx.stroke(); + + // Draw labels (every few dates) + ctx.fillStyle = '#65676b'; + ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + ctx.textAlign = 'center'; + + const labelInterval = Math.ceil(allDates.length / 10); + allDates.forEach((date, index) => { + if (index % labelInterval === 0 || index === allDates.length - 1) { + const x = padding + index * barWidth + barWidth / 2; + const parts = date.split('-'); + const label = `${parts[2]}.${parts[1]}`; + ctx.fillText(label, x, padding + chartHeight + 15); + } + }); + + // Draw max value label + ctx.textAlign = 'right'; + ctx.fillText(maxValue, padding - 5, padding + 5); + ctx.fillText('0', padding - 5, padding + chartHeight + 5); +} + +function renderTopPerformers() { + const container = document.getElementById('topPerformers'); + const countBadge = document.getElementById('performersCount'); + if (!container) return; + + // Calculate stats per profile + const profileStats = {}; + for (let i = 1; i <= 5; i++) { + profileStats[i] = { + profile: i, + checks: 0, + postsCreated: 0, + avgCompletionTime: 0 + }; + } + + filteredPosts.forEach(post => { + // Count checks + if (Array.isArray(post.checks)) { + post.checks.forEach(check => { + const profileNum = check.profile_number; + if (profileNum >= 1 && profileNum <= 5) { + profileStats[profileNum].checks++; + } + }); + } + + // Count created posts + if (post.created_by_profile >= 1 && post.created_by_profile <= 5) { + profileStats[post.created_by_profile].postsCreated++; + } + }); + + // Calculate score (checks * 2 + posts created) + const performers = Object.values(profileStats) + .map(stats => ({ + ...stats, + score: stats.checks * 2 + stats.postsCreated + })) + .filter(stats => stats.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 5); + + if (countBadge) { + countBadge.textContent = performers.length; + } + + if (performers.length === 0) { + container.innerHTML = ` +
+
🏆
+
Noch keine Aktivitäten
+
+ `; + return; + } + + container.innerHTML = performers.map((performer, index) => { + let rankClass = ''; + let medal = ''; + if (index === 0) { + rankClass = 'performer-item--gold'; + medal = '🥇'; + } else if (index === 1) { + rankClass = 'performer-item--silver'; + medal = '🥈'; + } else if (index === 2) { + rankClass = 'performer-item--bronze'; + medal = '🥉'; + } + + return ` +
+
${index + 1}
+
${medal || '👤'}
+
+
Profil ${performer.profile}
+
+ ${performer.checks} Teilnahmen · ${performer.postsCreated} Beiträge erstellt +
+
+
${performer.score} Punkte
+
+ `; + }).join(''); +} + +function renderRecentActivity() { + const container = document.getElementById('recentActivity'); + const countBadge = document.getElementById('activityCount'); + if (!container) return; + + // Collect all checks with post info + const activities = []; + filteredPosts.forEach(post => { + if (Array.isArray(post.checks)) { + post.checks.forEach(check => { + activities.push({ + type: 'check', + postTitle: getPostDisplayTitle(post), + profileNumber: check.profile_number, + timestamp: check.checked_at, + postId: post.id + }); + }); + } + }); + + // Sort by timestamp (newest first) + activities.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); + + // Take top 10 + const recentActivities = activities.slice(0, 10); + + if (countBadge) { + countBadge.textContent = recentActivities.length; + } + + if (recentActivities.length === 0) { + container.innerHTML = ` +
+
📭
+
Noch keine Aktivitäten
+
+ `; + return; + } + + container.innerHTML = recentActivities.map(activity => { + const date = new Date(activity.timestamp); + const timeAgo = formatTimeAgo(date); + + return ` +
+
+
+
+ Profil ${activity.profileNumber} + hat "${activity.postTitle}" bestätigt +
+
${timeAgo}
+
+
+ `; + }).join(''); +} + +function renderUpcomingDeadlines() { + const container = document.getElementById('upcomingDeadlines'); + const countBadge = document.getElementById('deadlinesCount'); + if (!container) return; + + const now = new Date(); + + // Get posts with deadlines that haven't been completed + const postsWithDeadlines = filteredPosts + .filter(post => post.deadline_at && !post.is_complete) + .map(post => { + const deadline = new Date(post.deadline_at); + const hoursUntil = (deadline - now) / (1000 * 60 * 60); + return { + ...post, + deadline, + hoursUntil + }; + }) + .filter(post => post.hoursUntil > 0) // Only future deadlines + .sort((a, b) => a.deadline - b.deadline) + .slice(0, 8); + + if (countBadge) { + countBadge.textContent = postsWithDeadlines.length; + } + + if (postsWithDeadlines.length === 0) { + container.innerHTML = ` +
+
🎉
+
Keine anstehenden Deadlines
+
+ `; + return; + } + + container.innerHTML = postsWithDeadlines.map(post => { + const timeUntil = formatTimeUntil(post.deadline); + let urgencyClass = ''; + + if (post.hoursUntil <= 24) { + urgencyClass = 'deadline-item--danger'; + } else if (post.hoursUntil <= 72) { + urgencyClass = 'deadline-item--warning'; + } + + return ` +
+
+
${getPostDisplayTitle(post)}
+
${post.checked_count}/${post.target_count} Bestätigungen
+
+
${timeUntil}
+
+ `; + }).join(''); +} + +// Removed - metrics now handled in renderKeyMetrics() + +function formatTimeAgo(date) { + const now = new Date(); + const seconds = Math.floor((now - date) / 1000); + + if (seconds < 60) return 'gerade eben'; + if (seconds < 3600) return `vor ${Math.floor(seconds / 60)} Min.`; + if (seconds < 86400) return `vor ${Math.floor(seconds / 3600)} Std.`; + if (seconds < 604800) return `vor ${Math.floor(seconds / 86400)} Tagen`; + + return date.toLocaleDateString('de-DE'); +} + +function formatTimeUntil(date) { + const now = new Date(); + const hours = Math.floor((date - now) / (1000 * 60 * 60)); + + if (hours < 1) { + const minutes = Math.floor((date - now) / (1000 * 60)); + return `in ${minutes} Min.`; + } + if (hours < 24) return `in ${hours} Std.`; + + const days = Math.floor(hours / 24); + return `in ${days} ${days === 1 ? 'Tag' : 'Tagen'}`; +} + +function renderSuccessAnalysis() { + renderWeeklySuccessComparison(); + renderMonthlySuccessComparison(); + renderYearlySuccessComparison(); +} + +function renderWeeklySuccessComparison() { + const container = document.getElementById('weeklySuccessComparison'); + if (!container) return; + + const now = new Date(); + const thisWeekStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const lastWeekStart = new Date(thisWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000); + + const thisWeekData = calculateSuccessStats(posts, thisWeekStart, now); + const lastWeekData = calculateSuccessStats(posts, lastWeekStart, thisWeekStart); + + container.innerHTML = renderSuccessComparisonContent(thisWeekData, lastWeekData, 'Diese Woche', 'Letzte Woche'); +} + +function renderMonthlySuccessComparison() { + const container = document.getElementById('monthlySuccessComparison'); + if (!container) return; + + const now = new Date(); + const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); + const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1); + const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59); + + const thisMonthData = calculateSuccessStats(posts, thisMonthStart, now); + const lastMonthData = calculateSuccessStats(posts, lastMonthStart, lastMonthEnd); + + container.innerHTML = renderSuccessComparisonContent(thisMonthData, lastMonthData, 'Dieser Monat', 'Letzter Monat'); +} + +function renderYearlySuccessComparison() { + const container = document.getElementById('yearlySuccessComparison'); + if (!container) return; + + const now = new Date(); + const thisYearStart = new Date(now.getFullYear(), 0, 1); + const lastYearStart = new Date(now.getFullYear() - 1, 0, 1); + const lastYearEnd = new Date(now.getFullYear() - 1, 11, 31, 23, 59, 59); + + const thisYearData = calculateSuccessStats(posts, thisYearStart, now); + const lastYearData = calculateSuccessStats(posts, lastYearStart, lastYearEnd); + + container.innerHTML = renderSuccessComparisonContent(thisYearData, lastYearData, 'Dieses Jahr', 'Letztes Jahr'); +} + +function calculateSuccessStats(allPosts, startDate, endDate) { + const periodPosts = allPosts.filter(post => { + if (!post.created_at) return false; + const postDate = new Date(post.created_at); + return postDate >= startDate && postDate < endDate; + }); + + const successful = periodPosts.filter(post => post.is_successful).length; + const completed = periodPosts.filter(post => post.is_complete).length; + const total = periodPosts.length; + + return { + successful, + completed, + total, + successRate: total > 0 ? Math.round((successful / total) * 100) : 0, + completionRate: total > 0 ? Math.round((completed / total) * 100) : 0 + }; +} + +function renderSuccessComparisonContent(currentData, previousData, currentLabel, previousLabel) { + const successChange = calculateChange(currentData.successful, previousData.successful); + const rateChange = calculateChange(currentData.successRate, previousData.successRate); + + const maxSuccessful = Math.max(currentData.successful, previousData.successful, 1); + const maxTotal = Math.max(currentData.total, previousData.total, 1); + + return ` +
+
+
Erfolgreich markiert
+ ${renderChangeIndicator(successChange)} +
+
${currentData.successful}
+
+
+
+
${previousLabel}: ${previousData.successful}
+
+ +
+ +
+
+
Erfolgsrate
+ ${renderChangeIndicator(rateChange)} +
+
${currentData.successRate}%
+
+
+
+
${currentData.successful} von ${currentData.total} Beiträgen
+
+ +
+ +
+
+
Abschlussrate
+
+
${currentData.completionRate}%
+
+
+
+
${currentData.completed} von ${currentData.total} abgeschlossen
+
+ `; +} + +function renderComparisons() { + renderWeeklyComparison(); + renderMonthlyComparison(); + renderYearlyComparison(); +} + +function renderWeeklyComparison() { + const container = document.getElementById('weeklyComparison'); + if (!container) return; + + const now = new Date(); + const thisWeekStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const lastWeekStart = new Date(thisWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000); + + const thisWeekData = calculatePeriodStats(posts, thisWeekStart, now); + const lastWeekData = calculatePeriodStats(posts, lastWeekStart, thisWeekStart); + + container.innerHTML = renderComparisonContent(thisWeekData, lastWeekData, 'Diese Woche', 'Letzte Woche'); +} + +function renderMonthlyComparison() { + const container = document.getElementById('monthlyComparison'); + if (!container) return; + + const now = new Date(); + const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); + const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1); + const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59); + + const thisMonthData = calculatePeriodStats(posts, thisMonthStart, now); + const lastMonthData = calculatePeriodStats(posts, lastMonthStart, lastMonthEnd); + + container.innerHTML = renderComparisonContent(thisMonthData, lastMonthData, 'Dieser Monat', 'Letzter Monat'); +} + +function renderYearlyComparison() { + const container = document.getElementById('yearlyComparison'); + if (!container) return; + + const now = new Date(); + const thisYearStart = new Date(now.getFullYear(), 0, 1); + const lastYearStart = new Date(now.getFullYear() - 1, 0, 1); + const lastYearEnd = new Date(now.getFullYear() - 1, 11, 31, 23, 59, 59); + + const thisYearData = calculatePeriodStats(posts, thisYearStart, now); + const lastYearData = calculatePeriodStats(posts, lastYearStart, lastYearEnd); + + container.innerHTML = renderComparisonContent(thisYearData, lastYearData, 'Dieses Jahr', 'Letztes Jahr'); +} + +function calculatePeriodStats(allPosts, startDate, endDate) { + const periodPosts = allPosts.filter(post => { + if (!post.created_at) return false; + const postDate = new Date(post.created_at); + return postDate >= startDate && postDate < endDate; + }); + + let totalChecks = 0; + let completedPosts = 0; + + periodPosts.forEach(post => { + if (post.is_complete) { + completedPosts++; + } + if (Array.isArray(post.checks)) { + post.checks.forEach(check => { + if (check.checked_at) { + const checkDate = new Date(check.checked_at); + if (checkDate >= startDate && checkDate < endDate) { + totalChecks++; + } + } + }); + } + }); + + return { + posts: periodPosts.length, + checks: totalChecks, + completed: completedPosts + }; +} + +function renderComparisonContent(currentData, previousData, currentLabel, previousLabel) { + const postsChange = calculateChange(currentData.posts, previousData.posts); + const checksChange = calculateChange(currentData.checks, previousData.checks); + const completedChange = calculateChange(currentData.completed, previousData.completed); + + const maxPosts = Math.max(currentData.posts, previousData.posts, 1); + const maxChecks = Math.max(currentData.checks, previousData.checks, 1); + const maxCompleted = Math.max(currentData.completed, previousData.completed, 1); + + return ` +
+
+
Neue Beiträge
+ ${renderChangeIndicator(postsChange)} +
+
${currentData.posts}
+
+
+
+
${previousLabel}: ${previousData.posts}
+
+ +
+ +
+
+
Teilnahmen
+ ${renderChangeIndicator(checksChange)} +
+
${currentData.checks}
+
+
+
+
${previousLabel}: ${previousData.checks}
+
+ +
+ +
+
+
Abgeschlossen
+ ${renderChangeIndicator(completedChange)} +
+
${currentData.completed}
+
+
+
+
${previousLabel}: ${previousData.completed}
+
+ `; +} + +function calculateChange(current, previous) { + if (previous === 0) { + return current > 0 ? 100 : 0; + } + return Math.round(((current - previous) / previous) * 100); +} + +function renderChangeIndicator(changePercent) { + if (changePercent > 0) { + return `
↑ ${changePercent}%
`; + } else if (changePercent < 0) { + return `
↓ ${Math.abs(changePercent)}%
`; + } else { + return `
→ 0%
`; + } +} + +// Event listeners +document.getElementById('timeFilter')?.addEventListener('change', (e) => { + currentTimeFilter = e.target.value; + applyFilters(); +}); + +document.getElementById('profileFilter')?.addEventListener('change', (e) => { + currentProfileFilter = e.target.value; + applyFilters(); +}); + +document.getElementById('refreshBtn')?.addEventListener('click', () => { + fetchPosts(); +}); + +// Initialize +fetchPosts(); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..039ab9c --- /dev/null +++ b/web/index.html @@ -0,0 +1,136 @@ + + + + + + Post Tracker - Web Interface + + + + + + +
+
+
+

📋 Post Tracker

+
+ Dashboard + ⚙️ Einstellungen + +
+
+
+
+ + +
+
+ + + +
+
+ +
+ + +
+
+
+
+ +
+
+ + + +
+
+ +
+
+ +
Lade Beiträge...
+ + +
+
+ + + + + + + + diff --git a/web/settings.css b/web/settings.css new file mode 100644 index 0000000..5831a3f --- /dev/null +++ b/web/settings.css @@ -0,0 +1,339 @@ +/* Settings Page Styles */ + +.settings-container { + max-width: 800px; + margin: 0 auto; + padding: 24px 0; +} + +.settings-section { + background: white; + border-radius: 12px; + padding: 32px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + margin-bottom: 24px; +} + +.section-title { + font-size: 24px; + font-weight: 700; + color: #1c1e21; + margin: 0 0 12px 0; +} + +.section-description { + font-size: 14px; + color: #65676b; + line-height: 1.6; + margin: 0 0 32px 0; +} + +/* Form Styles */ + +.form-group { + margin-bottom: 24px; +} + +.form-group:last-of-type { + margin-bottom: 0; +} + +.form-label { + display: block; + font-size: 14px; + font-weight: 600; + color: #1c1e21; + margin-bottom: 8px; +} + +.form-label input[type="checkbox"] { + margin-right: 8px; +} + +.form-checkbox { + width: 18px; + height: 18px; + cursor: pointer; + vertical-align: middle; +} + +.form-input, +.form-select, +.form-textarea { + width: 100%; + padding: 10px 14px; + font-size: 14px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + border: 1px solid #d1d5db; + border-radius: 8px; + background: white; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.form-input:focus, +.form-select:focus, +.form-textarea:focus { + outline: none; + border-color: #1877f2; + box-shadow: 0 0 0 3px rgba(24, 119, 242, 0.1); +} + +.form-input::placeholder, +.form-textarea::placeholder { + color: #8a8d91; +} + +.form-textarea { + resize: vertical; + min-height: 100px; +} + +.form-help { + font-size: 12px; + color: #65676b; + margin: 6px 0 0 0; + line-height: 1.4; +} + +.form-help a { + color: #1877f2; + text-decoration: none; +} + +.form-help a:hover { + text-decoration: underline; +} + +.form-actions { + display: flex; + gap: 12px; + margin-top: 32px; + padding-top: 24px; + border-top: 1px solid #e4e6eb; +} + +/* Credentials List */ + +.credentials-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 20px; +} + +.credential-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + background: white; + border: 1px solid #e4e6eb; + border-radius: 8px; + transition: all 0.2s ease; + cursor: move; +} + +.credential-item:hover { + border-color: #1877f2; + box-shadow: 0 2px 8px rgba(24, 119, 242, 0.1); +} + +.credential-item.drag-over { + border-color: #42b72a; + background: #f0fdf4; + box-shadow: 0 2px 8px rgba(66, 183, 42, 0.2); +} + +.credential-item__drag-handle { + font-size: 20px; + color: #65676b; + margin-right: 12px; + cursor: grab; + user-select: none; + line-height: 1; +} + +.credential-item__drag-handle:active { + cursor: grabbing; +} + +.credential-item__info { + flex: 1; +} + +.credential-item__name { + font-size: 15px; + font-weight: 600; + color: #1c1e21; + margin-bottom: 4px; +} + +.credential-item__provider { + font-size: 13px; + color: #65676b; +} + +.credential-item__actions { + display: flex; + gap: 8px; +} + +.btn-icon { + background: none; + border: none; + font-size: 18px; + cursor: pointer; + padding: 6px; + border-radius: 4px; + transition: background 0.2s ease; +} + +.btn-icon:hover { + background: #f0f2f5; +} + +.empty-state { + text-align: center; + padding: 40px 20px; + color: #65676b; + font-size: 14px; +} + +/* Messages */ + +.success { + background: #d4edda; + color: #155724; + padding: 14px 18px; + border-radius: 8px; + margin-bottom: 20px; + font-size: 14px; + font-weight: 500; + border: 1px solid #c3e6cb; +} + +/* Test Modal */ + +.modal { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +.modal__backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); +} + +.modal__content { + position: relative; + background: white; + border-radius: 12px; + padding: 32px; + max-width: 600px; + width: 90%; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + z-index: 1; +} + +.modal__close { + position: absolute; + top: 16px; + right: 16px; + width: 32px; + height: 32px; + border: none; + background: #f0f2f5; + color: #65676b; + font-size: 24px; + line-height: 1; + border-radius: 50%; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.modal__close:hover { + background: #e4e6eb; + color: #1c1e21; +} + +.modal__title { + font-size: 20px; + font-weight: 700; + color: #1c1e21; + margin: 0 0 24px 0; +} + +.modal__body { + display: flex; + flex-direction: column; + gap: 20px; +} + +.test-loading { + padding: 20px; + text-align: center; + color: #65676b; + font-size: 14px; + background: #f0f2f5; + border-radius: 8px; +} + +.test-result { + background: #f0f2f5; + border-radius: 8px; + padding: 20px; +} + +.test-result h3 { + font-size: 14px; + font-weight: 600; + color: #1c1e21; + margin: 0 0 12px 0; +} + +.test-comment { + background: white; + padding: 16px; + border-radius: 8px; + border: 1px solid #d1d5db; + font-size: 14px; + line-height: 1.6; + color: #1c1e21; + white-space: pre-wrap; +} + +.test-error { + background: #f8d7da; + color: #721c24; + padding: 14px 18px; + border-radius: 8px; + font-size: 14px; + border: 1px solid #f5c6cb; +} + +/* Responsive */ + +@media (max-width: 768px) { + .settings-section { + padding: 20px; + } + + .form-actions { + flex-direction: column; + } + + .modal__content { + padding: 24px; + } +} diff --git a/web/settings.html b/web/settings.html new file mode 100644 index 0000000..84404ec --- /dev/null +++ b/web/settings.html @@ -0,0 +1,202 @@ + + + + + + Einstellungen - Facebook Post Tracker + + + + + + + +
+
+
+

⚙️ Einstellungen

+ Zurück zu Beiträgen +
+
+ + + + + +
+ + +
+

AI-Anmeldedaten

+

+ Verwalte deine API-Schlüssel für verschiedene AI-Provider. Du kannst mehrere Credentials speichern und schnell zwischen ihnen wechseln. +

+ +
+ + +
+ + +
+

AI-Kommentar-Generator

+

+ Konfiguriere die automatische Generierung von Kommentaren durch KI. +

+ +
+
+ +
+ +
+ + +

+ Wähle welche API-Anmeldedaten verwendet werden sollen +

+
+ +
+ + +

+ Dieser Text wird vor dem eigentlichen Post-Text an die KI gesendet. Verwende {FREUNDE} als Platzhalter für Freundesnamen. +

+
+
+
+ + +
+

👥 Freundesnamen pro Profil

+

+ Gib für jedes Profil eine Liste von Freundesnamen an, die im Prompt verwendet werden können. +

+ +
+
+ + +
+
+ + +
+
+ +
+
+ + + + + + + + + + diff --git a/web/settings.js b/web/settings.js new file mode 100644 index 0000000..2063940 --- /dev/null +++ b/web/settings.js @@ -0,0 +1,656 @@ +const API_URL = 'https://fb.srv.medeba-media.de/api'; + +const PROVIDER_MODELS = { + gemini: [ + { value: '', label: 'Standard (gemini-2.0-flash-exp)' }, + { value: 'gemini-2.0-flash-exp', label: 'Gemini 2.0 Flash' }, + { value: 'gemini-1.5-flash', label: 'Gemini 1.5 Flash' }, + { value: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro' } + ], + claude: [ + { value: '', label: 'Standard (claude-3-5-haiku)' }, + { value: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku (schnell)' }, + { value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet (beste Qualität)' }, + { value: 'claude-3-opus-20240229', label: 'Claude 3 Opus' } + ], + openai: [ + { value: '', label: 'Standard (gpt-3.5-turbo)' }, + { value: 'gpt-3.5-turbo', label: 'GPT-3.5 Turbo (günstig)' }, + { value: 'gpt-4o-mini', label: 'GPT-4o Mini' }, + { value: 'gpt-4o', label: 'GPT-4o' }, + { value: 'gpt-4-turbo', label: 'GPT-4 Turbo' } + ] +}; + +const PROVIDER_INFO = { + gemini: { + name: 'Google Gemini', + apiKeyLink: 'https://aistudio.google.com/app/apikey', + apiKeyHelp: 'API-Schlüssel erforderlich. Erstelle ihn im Google AI Studio.' + }, + claude: { + name: 'Anthropic Claude', + apiKeyLink: 'https://console.anthropic.com/settings/keys', + apiKeyHelp: 'API-Schlüssel erforderlich. Erstelle ihn in der Anthropic Console.' + }, + openai: { + name: 'OpenAI', + apiKeyLink: 'https://platform.openai.com/api-keys', + apiKeyHelp: 'Für lokale OpenAI-kompatible Server (z.B. Ollama) kannst du den Schlüssel leer lassen.' + } +}; + +let credentials = []; +let currentSettings = null; + +function apiFetch(url, options = {}) { + return fetch(url, {...options, credentials: 'include'}); +} + +function showToast(message, type = 'info') { + const toast = document.createElement('div'); + toast.style.cssText = ` + position: fixed; + bottom: 24px; + right: 24px; + background: ${type === 'error' ? '#e74c3c' : type === 'success' ? '#42b72a' : '#1877f2'}; + color: white; + padding: 12px 20px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 14px; + z-index: 999999; + max-width: 350px; + animation: slideIn 0.3s ease-out; + `; + toast.textContent = message; + + if (!document.getElementById('settings-toast-styles')) { + const style = document.createElement('style'); + style.id = 'settings-toast-styles'; + style.textContent = ` + @keyframes slideIn { + from { + transform: translateX(400px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } + } + @keyframes slideOut { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(400px); + opacity: 0; + } + } + `; + document.head.appendChild(style); + } + + document.body.appendChild(toast); + + setTimeout(() => { + toast.style.animation = 'slideOut 0.3s ease-out'; + setTimeout(() => toast.remove(), 300); + }, 3000); +} + +function showError(msg) { showToast(msg, 'error'); } +function showSuccess(msg) { showToast(msg, 'success'); } + +async function loadCredentials() { + const res = await apiFetch(`${API_URL}/ai-credentials`); + if (!res.ok) throw new Error('Failed to load credentials'); + credentials = await res.json(); + renderCredentials(); + updateActiveCredentialSelect(); +} + +async function loadSettings() { + const res = await apiFetch(`${API_URL}/ai-settings`); + if (!res.ok) throw new Error('Failed to load settings'); + currentSettings = await res.json(); + document.getElementById('aiEnabled').checked = currentSettings.enabled === 1; + document.getElementById('activeCredential').value = currentSettings.active_credential_id || ''; + document.getElementById('aiPromptPrefix').value = currentSettings.prompt_prefix || + 'Schreibe einen freundlichen, authentischen Kommentar auf Deutsch zu folgendem Facebook-Post. Der Kommentar soll natürlich wirken und maximal 2-3 Sätze lang sein:\n\n'; +} + +function renderCredentials() { + const list = document.getElementById('credentialsList'); + if (!credentials.length) { + list.innerHTML = '

Noch keine Anmeldedaten gespeichert

'; + return; + } + list.innerHTML = credentials.map((c, index) => { + const providerName = escapeHtml(PROVIDER_INFO[c.provider]?.name || c.provider); + const modelLabel = c.model ? ` · ${escapeHtml(c.model)}` : ''; + const endpointLabel = c.base_url ? ` · ${escapeHtml(c.base_url)}` : ''; + + return ` +
+
⋮⋮
+
+ +
+
+ + +
+
+ `; + }).join(''); + + // Add drag and drop event listeners + setupDragAndDrop(); +} + +function updateActiveCredentialSelect() { + const select = document.getElementById('activeCredential'); + select.innerHTML = '' + + credentials.map(c => ``).join(''); + if (currentSettings?.active_credential_id) { + select.value = currentSettings.active_credential_id; + } +} + +function updateModelOptions(provider) { + const modelInput = document.getElementById('credentialModel'); + const modelList = document.getElementById('credentialModelOptions'); + const apiKeyInput = document.getElementById('credentialApiKey'); + const baseUrlGroup = document.getElementById('credentialBaseUrlGroup'); + const baseUrlHelp = document.getElementById('credentialBaseUrlHelp'); + const baseUrlInput = document.getElementById('credentialBaseUrl'); + const info = PROVIDER_INFO[provider]; + + const models = PROVIDER_MODELS[provider] || []; + if (modelList) { + modelList.innerHTML = models.map(m => ``).join(''); + } + + if (modelInput) { + const firstSuggestion = models.find(m => m.value)?.value; + modelInput.placeholder = firstSuggestion + ? `z.B. ${firstSuggestion}` + : 'Modell-ID (z.B. llama3.1)'; + } + + const help = document.getElementById('credentialApiKeyHelp'); + if (help) { + if (info) { + const parts = []; + if (info.apiKeyHelp) { + parts.push(info.apiKeyHelp); + } + if (info.apiKeyLink) { + parts.push(`API-Schlüssel erstellen`); + } + help.innerHTML = parts.join(' '); + } else { + help.textContent = ''; + } + } + + if (apiKeyInput) { + if (provider === 'openai') { + apiKeyInput.placeholder = 'sk-... oder leer für lokale Server'; + } else { + apiKeyInput.placeholder = 'API-Schlüssel'; + } + } + + if (baseUrlGroup && baseUrlHelp) { + if (provider === 'openai') { + baseUrlGroup.style.display = 'block'; + baseUrlHelp.textContent = 'Leer lassen für die offizielle OpenAI-API. Für lokale OpenAI/Ollama-Server gib die Basis-URL an, z.B. http://localhost:11434/v1'; + if (baseUrlInput) { + baseUrlInput.placeholder = 'https://api.openai.com/v1 oder http://localhost:11434/v1'; + } + } else { + baseUrlGroup.style.display = 'none'; + baseUrlHelp.textContent = ''; + if (baseUrlInput) { + baseUrlInput.placeholder = ''; + } + } + } + + const modelHelp = document.getElementById('credentialModelHelp'); + if (modelHelp) { + modelHelp.textContent = 'Trage die Modell-ID ein. Du kannst einen Vorschlag auswählen oder einen eigenen Wert eingeben.'; + } +} + +function openCredentialModal(credential = null) { + const modal = document.getElementById('credentialModal'); + const form = document.getElementById('credentialForm'); + const apiKeyInput = document.getElementById('credentialApiKey'); + const baseUrlInput = document.getElementById('credentialBaseUrl'); + + if (credential) { + document.getElementById('credentialModalTitle').textContent = 'Anmeldedaten bearbeiten'; + document.getElementById('credentialId').value = credential.id; + document.getElementById('credentialName').value = credential.name; + document.getElementById('credentialProvider').value = credential.provider; + updateModelOptions(credential.provider); + document.getElementById('credentialModel').value = credential.model || ''; + if (baseUrlInput) { + baseUrlInput.value = credential.base_url || ''; + } + if (apiKeyInput) { + apiKeyInput.value = ''; + apiKeyInput.placeholder = credential.provider === 'openai' + ? 'Leer lassen, um den bestehenden Schlüssel zu behalten' + : 'Leer lassen, um den bestehenden Schlüssel zu behalten'; + } + } else { + document.getElementById('credentialModalTitle').textContent = 'Anmeldedaten hinzufügen'; + form.reset(); + updateModelOptions('gemini'); + document.getElementById('credentialId').value = ''; + if (apiKeyInput) { + apiKeyInput.value = ''; + apiKeyInput.placeholder = 'API-Schlüssel'; + } + if (baseUrlInput) { + baseUrlInput.value = ''; + } + } + + modal.removeAttribute('hidden'); +} + +function closeCredentialModal() { + document.getElementById('credentialModal').setAttribute('hidden', ''); +} + +async function saveCredential(e) { + e.preventDefault(); + + try { + const id = document.getElementById('credentialId').value; + const name = document.getElementById('credentialName').value.trim(); + const provider = document.getElementById('credentialProvider').value; + const apiKey = document.getElementById('credentialApiKey').value.trim(); + const model = document.getElementById('credentialModel').value.trim(); + const baseUrlRaw = document.getElementById('credentialBaseUrl')?.value.trim() || ''; + + if (!name) { + throw new Error('Bitte einen Namen angeben'); + } + + const data = { + name, + provider, + model: model || null, + base_url: provider === 'openai' ? baseUrlRaw : '' + }; + + if (!id) { + if (!apiKey && !(provider === 'openai' && baseUrlRaw)) { + throw new Error('API-Schlüssel ist erforderlich (oder Basis-URL für lokale OpenAI-kompatible Server angeben)'); + } + data.api_key = apiKey; + } else if (apiKey) { + data.api_key = apiKey; + } + + const url = id ? `${API_URL}/ai-credentials/${id}` : `${API_URL}/ai-credentials`; + const method = id ? 'PUT' : 'POST'; + + const res = await apiFetch(url, { + method, + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(data) + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || 'Fehler beim Speichern der Anmeldedaten'); + } + + await loadCredentials(); + closeCredentialModal(); + showSuccess('✅ Anmeldedaten erfolgreich gespeichert'); + } catch (err) { + showError('❌ ' + err.message); + } +} + +async function editCredential(id) { + const cred = credentials.find(c => c.id === id); + if (!cred) { + showError('Anmeldedaten nicht gefunden'); + return; + } + + openCredentialModal(cred); +} + +async function toggleCredentialActive(id, isActive) { + try { + const res = await apiFetch(`${API_URL}/ai-credentials/${id}`, { + method: 'PATCH', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ is_active: isActive ? 1 : 0 }) + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || 'Fehler beim Aktualisieren'); + } + + await loadCredentials(); + showSuccess(`✅ Login ${isActive ? 'aktiviert' : 'deaktiviert'}`); + } catch (err) { + showError('❌ ' + err.message); + await loadCredentials(); // Reload to reset checkbox + } +} + +async function deleteCredential(id) { + if (!confirm('Wirklich löschen?')) return; + + const res = await apiFetch(`${API_URL}/ai-credentials/${id}`, {method: 'DELETE'}); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || 'Failed to delete'); + } + + await loadCredentials(); + showSuccess('Anmeldedaten gelöscht'); +} + +// Make function globally accessible +window.toggleCredentialActive = toggleCredentialActive; + +// ============================================================================ +// DRAG AND DROP +// ============================================================================ + +let draggedElement = null; + +function setupDragAndDrop() { + const items = document.querySelectorAll('.credential-item'); + + items.forEach(item => { + item.addEventListener('dragstart', handleDragStart); + item.addEventListener('dragover', handleDragOver); + item.addEventListener('drop', handleDrop); + item.addEventListener('dragend', handleDragEnd); + item.addEventListener('dragenter', handleDragEnter); + item.addEventListener('dragleave', handleDragLeave); + }); +} + +function handleDragStart(e) { + draggedElement = this; + this.style.opacity = '0.4'; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/html', this.innerHTML); +} + +function handleDragOver(e) { + if (e.preventDefault) { + e.preventDefault(); + } + e.dataTransfer.dropEffect = 'move'; + return false; +} + +function handleDragEnter(e) { + if (this !== draggedElement) { + this.classList.add('drag-over'); + } +} + +function handleDragLeave(e) { + this.classList.remove('drag-over'); +} + +function handleDrop(e) { + if (e.stopPropagation) { + e.stopPropagation(); + } + + if (draggedElement !== this) { + // Get the container + const container = this.parentNode; + const allItems = [...container.querySelectorAll('.credential-item')]; + + // Get indices + const draggedIndex = allItems.indexOf(draggedElement); + const targetIndex = allItems.indexOf(this); + + // Reorder in DOM + if (draggedIndex < targetIndex) { + this.parentNode.insertBefore(draggedElement, this.nextSibling); + } else { + this.parentNode.insertBefore(draggedElement, this); + } + + // Update backend + saveCredentialOrder(); + } + + this.classList.remove('drag-over'); + return false; +} + +function handleDragEnd(e) { + this.style.opacity = '1'; + + // Remove all drag-over classes + document.querySelectorAll('.credential-item').forEach(item => { + item.classList.remove('drag-over'); + }); +} + +async function saveCredentialOrder() { + try { + const items = document.querySelectorAll('.credential-item'); + const order = Array.from(items).map(item => parseInt(item.dataset.credentialId)); + + const res = await apiFetch(`${API_URL}/ai-credentials/reorder`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ order }) + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || 'Fehler beim Speichern der Reihenfolge'); + } + + credentials = await res.json(); + showSuccess('✅ Reihenfolge gespeichert'); + } catch (err) { + showError('❌ ' + err.message); + await loadCredentials(); // Reload to restore original order + } +} + +async function saveSettings(e) { + e.preventDefault(); + + try { + const data = { + enabled: document.getElementById('aiEnabled').checked, + active_credential_id: parseInt(document.getElementById('activeCredential').value) || null, + prompt_prefix: document.getElementById('aiPromptPrefix').value + }; + + const res = await apiFetch(`${API_URL}/ai-settings`, { + method: 'PUT', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(data) + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || 'Fehler beim Speichern der Einstellungen'); + } + + currentSettings = await res.json(); + showSuccess('✅ Einstellungen erfolgreich gespeichert'); + } catch (err) { + showError('❌ ' + err.message); + } +} + +async function testComment() { + const modal = document.getElementById('testModal'); + modal.removeAttribute('hidden'); + document.getElementById('testResult').style.display = 'none'; + document.getElementById('testError').style.display = 'none'; + + // Load last test data from localStorage + const lastTest = localStorage.getItem('lastTestComment'); + if (lastTest) { + try { + const data = JSON.parse(lastTest); + document.getElementById('testPostText').value = data.postText || ''; + document.getElementById('testProfileNumber').value = data.profileNumber || '1'; + } catch (e) { + console.error('Failed to load last test comment:', e); + } + } +} + +async function generateTest() { + const text = document.getElementById('testPostText').value; + const profileNumber = parseInt(document.getElementById('testProfileNumber').value); + + if (!text) return; + + document.getElementById('testLoading').style.display = 'block'; + document.getElementById('testResult').style.display = 'none'; + document.getElementById('testError').style.display = 'none'; + + try { + const res = await apiFetch(`${API_URL}/ai/generate-comment`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({postText: text, profileNumber}) + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || 'Failed'); + } + + const data = await res.json(); + document.getElementById('testComment').textContent = data.comment; + document.getElementById('testResult').style.display = 'block'; + + // Save test data to localStorage + localStorage.setItem('lastTestComment', JSON.stringify({ + postText: text, + profileNumber: profileNumber, + comment: data.comment, + timestamp: new Date().toISOString() + })); + } catch (err) { + document.getElementById('testError').textContent = err.message; + document.getElementById('testError').style.display = 'block'; + } finally { + document.getElementById('testLoading').style.display = 'none'; + } +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// ============================================================================ +// PROFILE FRIENDS +// ============================================================================ + +let profileFriends = {}; + +async function loadProfileFriends() { + const list = document.getElementById('profileFriendsList'); + list.innerHTML = ''; + + for (let i = 1; i <= 5; i++) { + const res = await apiFetch(`${API_URL}/profile-friends/${i}`); + const data = await res.json(); + profileFriends[i] = data.friend_names || ''; + + const div = document.createElement('div'); + div.className = 'form-group'; + div.innerHTML = ` + + +

Kommagetrennte Liste von Freundesnamen für Profil ${i}

+ `; + list.appendChild(div); + + document.getElementById(`friends${i}`).addEventListener('blur', async (e) => { + const newValue = e.target.value.trim(); + if (newValue !== profileFriends[i]) { + await saveFriends(i, newValue); + } + }); + } +} + +async function saveFriends(profileNumber, friendNames) { + try { + const res = await apiFetch(`${API_URL}/profile-friends/${profileNumber}`, { + method: 'PUT', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ friend_names: friendNames }) + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || 'Fehler beim Speichern'); + } + + profileFriends[profileNumber] = friendNames; + showSuccess(`✅ Freunde für Profil ${profileNumber} gespeichert`); + } catch (err) { + showError('❌ ' + err.message); + } +} + +// Event listeners +document.getElementById('addCredentialBtn').addEventListener('click', () => openCredentialModal()); +document.getElementById('credentialModalClose').addEventListener('click', closeCredentialModal); +document.getElementById('credentialCancelBtn').addEventListener('click', closeCredentialModal); +document.getElementById('credentialForm').addEventListener('submit', saveCredential); +document.getElementById('credentialProvider').addEventListener('change', e => updateModelOptions(e.target.value)); +document.getElementById('aiSettingsForm').addEventListener('submit', (e) => { + e.preventDefault(); + saveSettings(e); +}); +document.getElementById('saveAllBtn').addEventListener('click', (e) => { + e.preventDefault(); + saveSettings(e); +}); +document.getElementById('testBtn').addEventListener('click', testComment); +document.getElementById('testModalClose').addEventListener('click', () => document.getElementById('testModal').setAttribute('hidden', '')); +document.getElementById('generateTestComment').addEventListener('click', generateTest); + +// Initialize +Promise.all([loadCredentials(), loadSettings(), loadProfileFriends()]).catch(err => showError(err.message)); diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..0b2276f --- /dev/null +++ b/web/style.css @@ -0,0 +1,1225 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + background: #f0f2f5; + color: #050505; + line-height: 1.5; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +header { + background: white; + padding: 16px 18px; + border-radius: 10px; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08); + margin-bottom: 18px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.header-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.header-controls { + display: flex; + flex-wrap: wrap; + gap: 14px; + align-items: center; + justify-content: flex-end; +} + +.header-controls .control-group:last-child { + margin-left: auto; +} + +.control-group { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: #374151; +} + +.sort-controls { + display: flex; + align-items: center; + gap: 6px; +} + +.sort-direction-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 1px solid #e5e7eb; + border-radius: 4px; + background: transparent; + color: #6b7280; + cursor: pointer; + transition: all 0.15s ease; +} + +.sort-direction-toggle:hover { + background: #f3f4f6; + color: #374151; + border-color: #d1d5db; +} + +.sort-direction-toggle:focus { + outline: none; + border-color: #9ca3af; + box-shadow: 0 0 0 2px rgba(156, 163, 175, 0.15); +} + +.sort-direction-toggle[aria-pressed="true"] { + background: #f3f4f6; + color: #111827; + border-color: #d1d5db; +} + +.sort-direction-toggle__icon { + font-size: 11px; + line-height: 1; +} + +.refresh-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 4px; + background: transparent; + color: #6b7280; + cursor: pointer; + transition: all 0.15s ease; + font-size: 14px; + padding: 0; +} + +.refresh-btn:hover { + background: #f3f4f6; + color: #374151; +} + +.refresh-btn:focus { + outline: none; + background: #f3f4f6; + box-shadow: 0 0 0 2px rgba(156, 163, 175, 0.15); +} + +.refresh-btn:active { + transform: scale(0.95); +} + +.control-group label { + font-weight: 600; +} + +.control-group label.switch { + font-weight: 600; + gap: 6px; +} + +.search-input { + padding: 6px 12px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 13px; + min-width: 200px; +} + +.search-input:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1); +} + +.switch { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 600; + color: #1f2937; + cursor: pointer; +} + +.switch input { + width: 16px; + height: 16px; +} + +.control-select { + padding: 6px 10px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 13px; + background: white; + cursor: pointer; +} + +h1 { + font-size: 22px; + font-weight: 700; + margin: 0; +} + +.add-post-card { + background: white; + padding: 20px; + border-radius: 8px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + margin-bottom: 20px; +} + +.modal { + position: fixed; + inset: 0; + display: flex; + justify-content: center; + align-items: center; + padding: 24px; + z-index: 1100; +} + +.modal[hidden] { + display: none; +} + +.modal__backdrop { + position: absolute; + inset: 0; + background: rgba(15, 23, 42, 0.6); +} + +.modal__content { + position: relative; + z-index: 1; + width: min(640px, 92vw); + max-height: 92vh; + overflow: auto; + background: #fff; + border-radius: 12px; + padding: 28px 28px 24px; + box-shadow: 0 24px 60px rgba(15, 23, 42, 0.35); + display: flex; + flex-direction: column; + gap: 16px; +} + +.modal__content h2 { + font-size: 20px; + margin: 0; + color: #111827; +} + +.modal__close { + position: absolute; + top: 16px; + right: 16px; + width: 36px; + height: 36px; + border-radius: 50%; + border: none; + background: #111827; + color: #fff; + font-size: 22px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease; +} + +.modal__close:hover, +.modal__close:focus-visible { + background: #2563eb; +} + +.add-post-card h2 { + font-size: 18px; + margin-bottom: 16px; + color: #111827; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; +} + +.form-field { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; + color: #4b5563; +} + +.form-field input, +.form-field select { + padding: 10px 12px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 14px; + outline: none; + transition: border-color 0.2s ease; +} + +.form-field input:focus, +.form-field select:focus { + border-color: #2563eb; + box-shadow: 0 0 0 1px rgba(37, 99, 235, 0.15); +} + +.form-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 16px; +} + +.form-message { + margin-top: 12px; + font-size: 13px; + min-height: 18px; +} + +.form-message.error { + color: #dc2626; +} + +.form-message.success { + color: #065f46; +} + +.tabs-section { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + gap: 16px; +} + +.tabs { + display: flex; + gap: 8px; +} + +.search-container { + margin-left: auto; +} + +.tab-btn { + padding: 10px 20px; + background: white; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.tab-btn:hover { + background: #f8f9fa; +} + +.tab-btn.active { + background: #1877f2; + color: white; +} + +.loading, +.error { + text-align: center; + padding: 40px; + background: white; + border-radius: 8px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.error { + color: #dc2626; +} + +.posts-container { + display: flex; + flex-direction: column; + gap: 16px; +} + +.posts-summary { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 12px 16px; + background: white; + border-radius: 8px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); + font-size: 14px; + color: #1f2937; +} + +.posts-summary__label { + font-weight: 700; + color: #111827; + margin-right: 8px; +} + +.posts-summary__label::after { + content: ':'; + margin-left: 4px; + color: #6b7280; +} + +.posts-summary__item { + font-weight: 500; + color: #374151; +} + +.posts-summary__separator { + color: #9ca3af; + margin: 0 4px; +} + +.posts-load-more { + display: flex; + justify-content: center; + padding: 12px 0 32px; +} + +.posts-load-more__btn { + min-width: 220px; + font-weight: 600; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.post-card { + position: relative; + background: white; + border-radius: 8px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + padding: 20px; + transition: transform 0.2s, box-shadow 0.2s; + padding-left: 20px; +} + +.post-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +} + +.post-counter { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 26px; + height: 26px; + margin-right: 10px; + border-radius: 999px; + background: #f3f4f6; + color: #1f2937; + font-weight: 600; + font-size: 13px; +} + +.post-counter__value::before { + content: '#'; + margin-right: 2px; + color: #6b7280; +} + +.post-card.complete { + opacity: 0.7; + border-left: 4px solid #059669; +} + +.post-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 12px; + gap: 12px; +} + +.post-header-right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; +} + +.post-title-with-checkbox { + display: flex; + align-items: center; + gap: 16px; + flex: 1; +} + +.success-checkbox--header { + flex-shrink: 0; +} + +.post-title { + flex: 0 1 auto; + font-size: 16px; + color: #050505; + line-height: 1.4; +} + +.post-status { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: #f0f2f5; + border-radius: 6px; + font-size: 14px; + font-weight: 600; + white-space: nowrap; +} + +.post-status.complete { + background: #d1fae5; + color: #065f46; +} + + + +.post-body { + display: flex; + gap: 20px; + align-items: stretch; +} + +.post-body--with-screenshot { + align-items: flex-start; +} + +.post-screenshot-wrapper { + flex: 0 0 clamp(200px, 26vw, 320px); + max-width: clamp(200px, 26vw, 320px); + display: flex; +} + +.post-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 12px; +} + +.post-meta { + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: flex-start; +} + +.post-meta__line { + display: flex; + align-items: baseline; + gap: 6px; + font-size: 13px; + color: #4b5563; +} + +.post-meta__line--count { + font-weight: 600; + color: #1f2937; + width: 100%; +} + +.post-meta__label { + font-weight: 600; + color: #374151; +} + +.post-meta__value { + font-weight: 700; + color: #111827; +} + +.post-meta__stats { + font-size: 12px; + color: #6b7280; +} + +.post-info { + font-size: 13px; + color: #65676b; +} + +.post-last-change { + font-size: 13px; + color: #4b5563; +} + +.post-creator { + font-size: 13px; + color: #1f2937; + font-weight: 500; +} + +.post-target__select { + padding: 6px 10px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 13px; + cursor: pointer; + background: #fff; +} + +.post-deadline { + font-size: 13px; + color: #4b5563; +} +.post-deadline.has-deadline { + font-weight: 600; +} + +.post-deadline.overdue { + color: #dc2626; +} + +.post-deadline-row { + display: flex; + align-items: center; + gap: 12px; +} +.post-deadline__calendar, +.post-deadline__clear { + border: none; + background: none; + font-size: 16px; + line-height: 1; + cursor: pointer; + padding: 2px 4px; + color: #4b5563; +} + +.post-deadline__calendar:hover, +.post-deadline__calendar:focus, +.post-deadline__clear:hover, +.post-deadline__clear:focus { + color: #2563eb; +} + +.deadline-picker { + position: fixed; + width: 280px; + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.35); + background: #fff; + box-shadow: 0 18px 45px rgba(15, 23, 42, 0.18); + z-index: 1600; + animation: deadline-picker-enter 120ms ease-out; +} + +@keyframes deadline-picker-enter { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.deadline-picker__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.deadline-picker__title { + font-size: 14px; + font-weight: 600; + color: #1f2937; +} + +.deadline-picker__close { + border: none; + background: none; + padding: 2px 6px; + font-size: 20px; + line-height: 1; + color: #6b7280; + border-radius: 6px; + cursor: pointer; + transition: color 120ms ease, background-color 120ms ease; +} + +.deadline-picker__close:hover, +.deadline-picker__close:focus { + color: #1d4ed8; + background-color: rgba(37, 99, 235, 0.08); +} + +.deadline-picker__form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.deadline-picker__field label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 12px; + color: #4b5563; + font-weight: 500; +} + +.deadline-picker__field input { + border: 1px solid #d1d5db; + border-radius: 9px; + padding: 8px 10px; + font-size: 14px; + color: #111827; + background: #f9fafb; + transition: border-color 120ms ease, box-shadow 120ms ease, background-color 120ms ease; +} + +.deadline-picker__field input:focus { + outline: none; + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.2); + background: #fff; +} + +.deadline-picker__hint { + font-size: 12px; + color: #6b7280; +} + +.deadline-picker__error { + min-height: 18px; + font-size: 12px; + color: #dc2626; + visibility: hidden; +} + +.deadline-picker__error.is-visible { + visibility: visible; +} + +.deadline-picker__actions { + display: flex; + gap: 8px; +} + +.deadline-picker__actions .btn { + flex: 1; +} + +.post-deadline__clear { + font-size: 14px; +} + +.post-deadline-row input[type="datetime-local"] { + padding: 6px 10px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 13px; + cursor: pointer; +} + +.post-target { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: #1f2937; + font-weight: 500; +} + +.btn-inline { + background: none; + border: none; + color: #2563eb; + padding: 0; + font-size: 13px; + font-weight: 600; + cursor: pointer; + border-radius: 0; + display: inline; +} + +.btn-inline:hover, +.btn-inline:focus { + text-decoration: underline; +} + +.post-link { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + font-size: 13px; +} + +.post-link__label { + font-weight: 600; + color: #4b5563; +} + +.post-link__anchor { + color: #2563eb; + text-decoration: none; + word-break: break-all; +} + +.post-link__anchor:hover { + text-decoration: underline; +} + +.post-profiles { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f9fafb; +} + +.profile-line { + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 13px; + padding: 6px 8px; + border-radius: 6px; + align-items: center; +} + +.profile-line__name { + font-weight: 600; +} + +.profile-line__status { + font-weight: 500; +} + +.profile-line__actions { + display: flex; + gap: 6px; +} + +.profile-line__toggle { + background: none; + border: none; + color: #2563eb; + font-size: 12px; + font-weight: 600; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; +} + +.profile-line__toggle:hover, +.profile-line__toggle:focus { + text-decoration: underline; +} + +.profile-line--done { + background: #ecfdf5; + color: #047857; + border-left: 3px solid #10b981; +} + +.profile-line--available { + background: #eff6ff; + color: #1d4ed8; + border-left: 3px solid #3b82f6; +} + +.profile-line--locked { + background: #f3f4f6; + color: #6b7280; + border-left: 3px solid #9ca3af; +} + +.profile-line--current { + position: relative; + box-shadow: inset 0 0 0 2px rgba(96, 165, 250, 0.35); + background: linear-gradient(90deg, rgba(239, 246, 255, 0.95), rgba(221, 214, 254, 0.9)); +} + +.profile-line--current.profile-line--done { + color: #065f46; + border-left-color: #34d399; +} + +.profile-line--current.profile-line--available { + color: #1d4ed8; + border-left-color: #60a5fa; +} + +.profile-line--current.profile-line--locked { + color: #4b5563; + border-left-color: #818cf8; +} + +.profile-line__badge { + display: inline-flex; + align-items: center; + margin-left: 8px; + padding: 2px 8px; + border-radius: 999px; + background: linear-gradient(135deg, #60a5fa, #a855f7); + color: #ffffff; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.post-hints { + display: flex; + flex-direction: column; + gap: 6px; +} + +.post-hint { + font-size: 13px; + color: #92400e; + background: #fff7ed; + border-radius: 6px; + padding: 8px 10px; +} + +.post-hint--success { + color: #065f46; + background: #ecfdf5; +} + +.post-screenshot { + width: 100%; + border: 1px solid #d1d5db; + border-radius: 12px; + background: #f8fafc; + display: flex; + align-items: center; + justify-content: center; + height: clamp(180px, 24vw, 240px); + padding: 12px; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; + outline: none; + overflow: hidden; +} + +.post-screenshot:hover, +.post-screenshot:focus-visible { + transform: translateY(-2px); + box-shadow: 0 14px 32px rgba(15, 23, 42, 0.18); +} + +.post-screenshot img { + max-width: 100%; + max-height: 100%; + width: 100%; + height: auto; + object-fit: contain; + display: block; + border-radius: 8px; + box-shadow: 0 4px 18px rgba(15, 23, 42, 0.18); +} + +.check-badge { + padding: 4px 10px; + background: #e4e6eb; + border-radius: 6px; + font-size: 13px; + font-weight: 500; +} + +.check-badge:not(.checked) { + color: #4b5563; +} + +.check-badge.checked { + background: #d1fae5; + color: #065f46; +} + +.check-badge.current { + background: #dbeafe; + color: #1e40af; + border: 2px solid #3b82f6; +} + +.post-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: auto; + align-items: center; +} + +.success-checkbox { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 600; + color: #1f2937; + cursor: pointer; + user-select: none; +} + +.success-checkbox-input { + width: 16px; + height: 16px; + cursor: pointer; +} + +.btn { + padding: 10px 16px; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + text-decoration: none; + display: inline-block; + text-align: center; +} + +.btn-primary { + background: #1877f2; + color: white; +} + +.btn-primary:hover { + background: #166fe5; +} + +.btn-success { + background: #059669; + color: white; +} + +.btn-success:hover { + background: #047857; +} + +.btn-danger { + background: #dc2626; + color: white; +} + +.btn-danger:hover { + background: #b91c1c; +} + +.btn-secondary { + background: #e4e6eb; + color: #050505; +} + +.btn-secondary:hover { + background: #d8dadf; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.screenshot-modal { + position: fixed; + inset: 0; + display: flex; + justify-content: center; + align-items: center; + padding: 24px; + background: rgba(15, 23, 42, 0.7); + z-index: 1000; +} + +.screenshot-modal[hidden] { + display: none; +} + +.screenshot-modal__backdrop { + position: absolute; + inset: 0; +} + +.screenshot-modal__content { + position: relative; + width: min(95vw, 1300px); + max-height: 92vh; + padding: 24px; + border-radius: 18px; + background: rgba(15, 23, 42, 0.92); + display: flex; + justify-content: center; + align-items: center; + box-shadow: 0 30px 60px rgba(15, 23, 42, 0.45); + overflow: hidden; +} + +.screenshot-modal__content.zoomed { + justify-content: flex-start; + align-items: flex-start; +} + +.screenshot-modal__content img { + max-width: calc(95vw - 96px); + max-height: calc(92vh - 120px); + width: auto; + height: auto; + object-fit: contain; + border-radius: 12px; + box-shadow: 0 20px 44px rgba(8, 15, 35, 0.45); + cursor: zoom-in; +} + +.screenshot-modal__content.zoomed { + cursor: zoom-out; + overflow: auto; +} + +.screenshot-modal__content.zoomed img { + max-width: none; + max-height: none; + width: auto; + height: auto; + cursor: zoom-out; +} + +.screenshot-modal__close { + position: absolute; + top: 12px; + right: 12px; + width: 36px; + height: 36px; + border-radius: 999px; + border: none; + background: #111827; + color: #fff; + font-size: 22px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3); + transition: background 0.2s ease, transform 0.2s ease; +} + +.screenshot-modal__close:hover { + background: #2563eb; + transform: scale(1.05); +} + +.screenshot-modal__close:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 2px; +} + +.empty-state { + text-align: center; + padding: 60px 20px; + background: white; + border-radius: 8px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.empty-state-icon { + font-size: 64px; + margin-bottom: 16px; +} + +.empty-state-text { + font-size: 18px; + color: #65676b; +} + +@media (max-width: 768px) { + .container { + padding: 12px; + } + + h1 { + font-size: 20px; + } + + header { + padding: 14px; + } + + .post-card { + padding-left: 20px; + } + + .post-counter { + margin-bottom: 4px; + min-width: 24px; + height: 24px; + } + + .header-controls { + flex-direction: column; + align-items: flex-start; + } + + .form-actions { + flex-direction: column; + } + + .post-header { + flex-direction: column; + } + + .post-body { + flex-direction: column; + } + + .post-screenshot-wrapper { + max-width: 100%; + } + + .post-actions { + flex-direction: column; + } + + .btn { + width: 100%; + } +}