commit 80eb037b56557d5d28e30fa66208a60113fe2f82 Author: Meik Date: Tue Nov 11 10:36:31 2025 +0100 first commit diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..6a9fa4b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(mkdir:*)" + ], + "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/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c7549f3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM node:20-alpine +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci --only=production || npm i --only=production +COPY server.js ./ +COPY public ./public +EXPOSE 8080 +ENV PORT=8080 +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7b9f832 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# Docker Stack Stats UI (v5) + +Ein leichtgewichtiges Dashboard für die Übersicht über Docker-Stacks und deren Container-Ressourcen (CPU, RAM, Container). +Die Node.js-Serverkomponente wertet Docker-Statistiken aus, stellt sie über eine kleine REST-API bereit und liefert eine statische Weboberfläche aus `public/`. + +## Highlights +- Gruppierte Ansicht nach Stack/Compose-Projekt mit persistierbarem Toggle für Stapel-/Flat-Ansicht +- Sortierbare Spaltenköpfe und kompakteres UI für doppelt so viele Zeilen auf einen Blick +- System- und Container-RAM-Anzeige inklusive prozentualer Nutzung des Hosts +- Flat-View mit Spalten-filtern, Stack-Spalte und adaptiver Detailsicht + +## Projektstruktur +- `server.js`: Express-Server, Basis-Auth, Docker-Stats-Erfassung und API-Endpunkte +- `public/`: Gebündelte UI für das Dashboard +- `web/`: Alternative Version der Oberfläche (für Referenz oder eigenes Frontend) +- `docker-compose.yml` & `Dockerfile`: Containerisierung +- `extension/`: eigenständige Browser-Erweiterung (nicht Teil der Runtime, liegt hier als Referenz) + +## Voraussetzungen +- Docker Engine (Abfrage über Socket `/var/run/docker.sock`) +- Node.js 18+ (für lokale Entwicklung) + +## Lokale Entwicklung +1. Repository klonen und Abhängigkeiten installieren: + ```bash + npm install + ``` +2. Server starten (liefert UI unter http://localhost:8080): + ```bash + npm start + ``` +3. Alternativ mit einem Live-Proxy bzw. lokalen Backend an `server.js` binden und `public/` separat hosten. + +## Docker +1. Image bauen: + ```bash + docker build -t docker-stack-stats-ui . + ``` +2. Beispiel-Container starten (Docker-Socket mounten, optional Basic Auth): + ```bash + docker run -d --name stack-stats -p 8080:8080 \ + -v /var/run/docker.sock:/var/run/docker.sock:ro \ + -e BASIC_AUTH_USER=me -e BASIC_AUTH_PASS=secret \ + --restart unless-stopped \ + docker-stack-stats-ui + ``` +3. Oder mit Docker Compose: + ```bash + docker compose up -d + ``` + +## Konfiguration (Umgebungsvariablen) +- `PORT` (Default: `8080`) +- `BASIC_AUTH_USER` + `BASIC_AUTH_PASS`: Aktivieren Basic Auth; die Werte werden für den Zugriff auf das Dashboard geprüft. +- `MEM_SUBTRACT_CACHE` (`true`/`false`, Default `true`): Zieht Caches aus dem Statistik-Usage, um die sichtbare RAM-Nutzung auf aktiv verwendete Speicher zu begrenzen. + +## API-Endpunkte +- `GET /api/summary`: Gesamtdaten pro Stack inklusive CPU/MEM, Host-Systeminformationen. +- `GET /api/group/:name`: Detailinformationen zu einem Stack (Container-Liste, Summen). +- `GET /api/containers`: Liste aller Container (Name, Stack, CPU %, RAM MiB, Host-%). +- `GET /health`: Prüft Docker-Ping und Anzahl der Container; nützlich für Monitoring. + +Alle Antworten sind JSON; Fehler mit HTTP 500 liefern ein `error`-Feld. + +## Sicherheit & Betrieb +- Der Zugriff erfolgt über die statischen Dateien in `public/`, daher sollte der Container nur intern erreichbar gemacht oder Basic Auth aktiviert werden. +- Der Server benötigt nur Leserechte auf `/var/run/docker.sock`, daher ist das Mounten im `:ro`-Modus empfehlenswert. +- Neustart-policy (`unless-stopped`) sichert den Betrieb über Host-Reboots. 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/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..4b4ff02 --- /dev/null +++ b/backend/server.js @@ -0,0 +1,616 @@ +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 screenshotDir = path.join(__dirname, 'data', 'screenshots'); +if (!fs.existsSync(screenshotDir)) { + fs.mkdirSync(screenshotDir, { recursive: true }); +} + +// Middleware - Enhanced CORS for extension +app.use(cors({ + origin: '*', + methods: ['GET', 'POST', 'PUT', '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) => { + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + next(); +}); + +// Database setup +const dbPath = path.join(__dirname, 'data', 'tracker.db'); +const db = new Database(dbPath); + +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 getProfileName(profileNumber) { + return DEFAULT_PROFILE_NAMES[profileNumber] || `Profil ${profileNumber}`; +} + +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 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; + + db.prepare('UPDATE posts SET checked_count = ? WHERE id = ?').run(checkedCount, postId); + if (post.target_count !== requiredProfiles.length) { + db.prepare('UPDATE posts SET target_count = ? WHERE id = ?').run(requiredProfiles.length, postId); + } + + 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 + ); +`); + +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 + ); +`); + +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'); + +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}"`); + } + } +} + +const profileStateRow = db.prepare('SELECT profile_number FROM profile_state WHERE id = 1').get(); +if (!profileStateRow) { + db.prepare('INSERT INTO profile_state (id, profile_number) VALUES (1, 1)').run(); +} + +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; + + if (post.checked_count !== checkedCount) { + db.prepare('UPDATE posts SET checked_count = ? WHERE id = ?').run(checkedCount, post.id); + } + + if (post.target_count !== requiredProfiles.length) { + db.prepare('UPDATE posts SET target_count = ? WHERE id = ?').run(requiredProfiles.length, post.id); + } + + const nextRequired = statuses.find(status => status.status === 'available'); + + return { + ...post, + target_count: requiredProfiles.length, + checked_count: checkedCount, + checks: completedChecks, + is_complete: checkedCount >= requiredProfiles.length, + screenshot_path: screenshotPath, + required_profiles: requiredProfiles, + profile_statuses: statuses, + next_required_profile: nextRequired ? nextRequired.profile_number : 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 post = db.prepare('SELECT * FROM posts WHERE url = ?').get(url); + + if (!post) { + return res.json(null); + } + + res.json(mapPostRow(post)); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.get('/api/profile-state', (req, res) => { + try { + const row = db.prepare('SELECT profile_number FROM profile_state WHERE id = 1').get(); + res.json({ profile_number: row ? row.profile_number : 1 }); + } 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' }); + } + + db.prepare('UPDATE profile_state SET profile_number = ? WHERE id = 1').run(parsed); + + res.json({ profile_number: parsed }); + } 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 = ? 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); + + if (!post || !post.screenshot_path) { + return res.status(404).json({ error: 'Screenshot not found' }); + } + + const filePath = path.join(screenshotDir, post.screenshot_path); + if (!fs.existsSync(filePath)) { + 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 } = req.body; + + const validatedTargetCount = validateTargetCount(target_count); + + if (!url || !validatedTargetCount) { + return res.status(400).json({ error: 'URL and target_count are required (1-5)' }); + } + + const id = uuidv4(); + + const stmt = db.prepare('INSERT INTO posts (id, url, title, target_count, checked_count, screenshot_path) VALUES (?, ?, ?, ?, 0, NULL)'); + stmt.run(id, url, title || '', validatedTargetCount); + + const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(id); + + 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 } = req.body || {}; + + const updates = []; + const params = []; + + 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 (!updates.length) { + return res.status(400).json({ error: 'No valid fields to update' }); + } + + params.push(postId); + + const stmt = db.prepare(`UPDATE posts SET ${updates.join(', ')} WHERE id = ?`); + const result = stmt.run(...params); + + 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); + 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' }); + } + + const requiredProfiles = getRequiredProfiles(post.target_count); + if (post.target_count !== requiredProfiles.length) { + db.prepare('UPDATE posts SET target_count = ? WHERE id = ?').run(requiredProfiles.length, postId); + post.target_count = requiredProfiles.length; + } + + let profileValue = sanitizeProfileNumber(profile_number); + if (!profileValue) { + const stateRow = db.prepare('SELECT profile_number FROM profile_state WHERE id = 1').get(); + profileValue = sanitizeProfileNumber(stateRow ? stateRow.profile_number : null) || 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)); + } + + if (requiredProfiles.length > 0) { + 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); + recalcCheckedCount(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 post = db.prepare('SELECT * FROM posts WHERE url = ?').get(url); + if (!post) { + return res.status(404).json({ error: 'Post not found' }); + } + + const requiredProfiles = getRequiredProfiles(post.target_count); + if (post.target_count !== requiredProfiles.length) { + db.prepare('UPDATE posts SET target_count = ? WHERE id = ?').run(requiredProfiles.length, post.id); + post.target_count = requiredProfiles.length; + } + + let profileValue = sanitizeProfileNumber(profile_number); + if (!profileValue) { + const stateRow = db.prepare('SELECT profile_number FROM profile_state WHERE id = 1').get(); + profileValue = sanitizeProfileNumber(stateRow ? stateRow.profile_number : null) || 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)); + } + + if (requiredProfiles.length > 0) { + 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); + recalcCheckedCount(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 }); + } +}); + +// 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 }); + } +}); + +// 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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..71872c3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + stack-stats: + build: . + ports: + - "8080:8080" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + environment: + # Optional auth: + # - BASIC_AUTH_USER=me + # - BASIC_AUTH_PASS=secret + # Optional memory behavior: + # - MEM_SUBTRACT_CACHE=true + restart: unless-stopped diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..ed19932 --- /dev/null +++ b/extension/background.js @@ -0,0 +1,30 @@ +// 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 }); + } + }); +}); + +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..d7d7090 --- /dev/null +++ b/extension/content.css @@ -0,0 +1,64 @@ +.fb-tracker-ui { + margin: 12px 0; + padding: 10px; + background: #f0f2f5; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +.fb-tracker-status, +.fb-tracker-add { + display: flex; + align-items: center; + gap: 8px; +} + +.fb-tracker-icon { + font-size: 18px; +} + +.fb-tracker-text { + flex: 1; + font-size: 14px; + color: #050505; + font-weight: 500; +} + +.fb-tracker-status.complete .fb-tracker-text { + color: #059669; +} + +.fb-tracker-count { + padding: 6px 8px; + border: 1px solid #ccd0d5; + border-radius: 6px; + font-size: 14px; + background: white; + cursor: pointer; +} + +.fb-tracker-add-btn, +.fb-tracker-check-btn { + padding: 6px 16px; + background: #1877f2; + color: white; + border: none; + border-radius: 6px; + font-size: 14px; + 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..9cda4f5 --- /dev/null +++ b/extension/content.js @@ -0,0 +1,1128 @@ +// 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 API_URL = `${API_BASE_URL}/api`; + +console.log(`[FB Tracker v${EXTENSION_VERSION}] Extension loaded, API URL:`, API_URL); + +// Profile state helpers +async function fetchBackendProfileNumber() { + try { + const response = await fetch(`${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 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/' + ]; + + 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; + } + + 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 ''; + } + + return formatFacebookPostUrl(absoluteUrl); +} + +function getPostUrl(postElement) { + console.log('[FB Tracker] Extracting URL from post element'); + + const attributionLinks = postElement.querySelectorAll('a[attributionsrc*="/privacy_sandbox/comet/"][href]'); + for (const link of attributionLinks) { + const candidate = extractPostUrlCandidate(link.href); + if (candidate) { + console.log('[FB Tracker] Found post URL via attribution link:', candidate); + return candidate; + } + } + + const links = postElement.querySelectorAll('a[href]'); + for (const link of links) { + const candidate = extractPostUrlCandidate(link.href); + if (candidate) { + console.log('[FB Tracker] Found post URL via fallback patterns:', candidate); + return candidate; + } + } + + const fallbackCandidate = extractPostUrlCandidate(window.location.href); + if (fallbackCandidate) { + console.log('[FB Tracker] Using fallback URL:', fallbackCandidate); + return fallbackCandidate; + } + + console.log('[FB Tracker] No valid post URL found'); + return ''; +} + +// Check if post is already tracked +async function checkPostStatus(postUrl) { + try { + console.log('[FB Tracker] Checking post status for:', postUrl); + const response = await fetch(`${API_URL}/posts/by-url?url=${encodeURIComponent(postUrl)}`); + + if (response.ok) { + const data = await response.json(); + console.log('[FB Tracker] Post status:', data); + return data; + } + + console.log('[FB Tracker] Post not tracked yet'); + return null; + } catch (error) { + console.error('[FB Tracker] Error checking post status:', error); + return null; + } +} + +// Add post to tracking +async function markPostChecked(postId, profileNumber) { + try { + console.log('[FB Tracker] Marking post as checked:', postId, 'Profile:', profileNumber); + const response = await fetch(`${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); + + const response = await fetch(`${API_URL}/posts`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url: postUrl, + target_count: targetCount, + profile_number: profileNumber + }) + }); + + 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 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 + }); + } + + if (!hasLike || !hasComment) { + return false; + } + + if (hasShare) { + 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 likeButtons = document.querySelectorAll('[data-ad-rendering-role="gefällt mir_button"], [data-ad-rendering-role*="gefällt" i]'); + + likeButtons.forEach((likeButton) => { + const container = likeButton.closest('div[aria-posinset]'); + if (!container) { + return; + } + + if (container.getAttribute(PROCESSED_ATTR) === '1') { + return; + } + + if (container.querySelector('.fb-tracker-ui')) { + return; + } + + const commentButton = container.querySelector('[data-ad-rendering-role="comment_button"], [data-ad-rendering-role*="comment" i]'); + const shareButton = container.querySelector('[data-ad-rendering-role="share_button"], [data-ad-rendering-role*="share" i], [data-ad-rendering-role*="teilen" i]'); + + if (!commentButton || !shareButton) { + return; + } + + if (seen.has(container)) { + return; + } + + seen.add(container); + containers.push({ container, likeButton, commentButton, shareButton }); + }); + + return containers; +} + +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 fetch(`${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; + }); +} + +// Create the tracking UI +async function createTrackerUI(postElement, buttonBar) { + // Check if UI already exists + if (postElement.querySelector('.fb-tracker-ui')) { + console.log('[FB Tracker] UI already exists for this post'); + return; + } + + const postUrl = getPostUrl(postElement); + if (!postUrl) { + console.log('[FB Tracker] No URL found for post'); + return; + } + + console.log('[FB Tracker] Creating tracker UI for:', postUrl); + + // Create UI container + const container = document.createElement('div'); + container.className = 'fb-tracker-ui'; + container.style.cssText = ` + padding: 12px 16px; + background-color: #f0f2f5; + border-top: 1px solid #e4e6eb; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 14px; + `; + + // Check current status + const profileNumber = await getProfileNumber(); + const postData = await checkPostStatus(postUrl); + + 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') + : null; + + container.innerHTML = ` +
+ Tracker: ${statusText}${completed ? ' ✓ Ziel erreicht' : ''} +
+ ${lastCheck ? `
Letzte Bestätigung: ${lastCheck}
` : ''} + `; + + console.log('[FB Tracker] Showing status:', statusText); + } else { + // Post not tracked - show add UI + const selectId = `tracker-select-${Date.now()}`; + container.innerHTML = ` + + + + `; + + // Add click handler for the button + const addButton = container.querySelector('.fb-tracker-add-btn'); + const selectElement = container.querySelector('select'); + selectElement.value = '2'; + + 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 result = await addPostToTracking(postUrl, targetCount, profileNumber, { postElement }); + + 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') : null; + + let statusHtml = ` +
+ Tracker: ${statusText}${completed ? ' ✓ Ziel erreicht' : ' ✓ Erfolgreich hinzugefügt'} +
+ `; + + if (lastCheck) { + statusHtml += ` +
+ Letzte Bestätigung: ${lastCheck} +
+ `; + } + + container.innerHTML = statusHtml; + } else { + // Error + addButton.disabled = false; + addButton.textContent = 'Fehler - Erneut versuchen'; + addButton.style.backgroundColor = '#e74c3c'; + } + }); + + console.log('[FB Tracker] UI created for new post'); + } + + // Insert UI below the button bar if available, otherwise append at the end + if (buttonBar && buttonBar.parentElement) { + buttonBar.parentElement.insertBefore(container, buttonBar.nextSibling); + console.log('[FB Tracker] UI inserted after button bar'); + } else { + postElement.appendChild(container); + console.log('[FB Tracker] UI inserted into article (fallback)'); + } +} + +// Check if article is a main post (not a comment) +function isMainPost(article, buttonBar) { + 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') { + // 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-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; +} + +// Find all Facebook posts on the page +function findPosts() { + console.log('[FB Tracker] Scanning for posts...'); + + const postContainers = findPostContainers(); + console.log('[FB Tracker] Found', postContainers.length, 'candidate containers'); + + let processed = 0; + + for (const { container } of postContainers) { + if (container.getAttribute(PROCESSED_ATTR) === '1') { + continue; + } + + const buttonBar = findButtonBar(container); + if (!buttonBar) { + console.log('[FB Tracker] Skipping container without full button bar'); + continue; + } + + container.setAttribute(PROCESSED_ATTR, '1'); + processed++; + console.log('[FB Tracker] Adding tracker to post #' + processed); + createTrackerUI(container, buttonBar); + } + + console.log('[FB Tracker] Total processed posts:', processed); +} + +// 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); +}); + +console.log('[FB Tracker] Observer and scroll listener started'); 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..e3239e5 --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,35 @@ +{ + "manifest_version": 3, + "name": "Facebook Post Tracker", + "version": "1.1.0", + "description": "Track Facebook posts across multiple profiles", + "permissions": [ + "storage", + "activeTab", + "tabs" + ], + "host_permissions": [ + "", + "https://www.facebook.com/*", + "https://facebook.com/*", + "http://localhost:3001/*", + "https://fb.srv.medeba-media.de/*" + ], + "action": { + "default_popup": "popup.html" + }, + "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..aeb020d --- /dev/null +++ b/extension/popup.js @@ -0,0 +1,86 @@ +const profileSelect = document.getElementById('profileSelect'); +const statusEl = document.getElementById('status'); + +async function fetchProfileState() { + try { + const response = await fetch(`${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 fetch(`${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/package.json b/package.json new file mode 100644 index 0000000..ab98888 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "docker-stack-stats-ui", + "version": "5.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "dockerode": "^4.0.4", + "express": "^4.19.2" + } +} \ No newline at end of file diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..f81089f --- /dev/null +++ b/public/app.js @@ -0,0 +1,458 @@ + +// i18n +const I18N = { + en: { + title: "Docker Stack Stats", + autoRefresh: "Auto-refresh", + refresh: "Refresh", + stackProject: "Stack / Project", + containers: "Containers", + cpuSum: "CPU Sum", + memSum: "Mem Sum", + memPct: "Mem %", + container: "Container", + cpu: "CPU %", + memMiB: "Mem (MiB)", + memPctSys: "Mem %", + loading: "Loading…", + system: "System", + serverVersion: "Docker", + systemRam: "System RAM", + runningContainers: "Running", + cpuCores: "CPU Cores", + totalCpu: "Total CPU", + totalMem: "Total Mem (containers)", + totalMemPct: "Total Mem %", + groupByStack: "Group by stack", + healthError: "Error loading data: " + }, + de: { + title: "Docker-Stack-Statistiken", + autoRefresh: "Auto-Aktualisierung", + refresh: "Aktualisieren", + stackProject: "Stack / Projekt", + containers: "Container", + cpuSum: "CPU Summe", + memSum: "RAM Summe", + memPct: "RAM %", + container: "Container", + cpu: "CPU %", + memMiB: "RAM (MiB)", + memPctSys: "RAM %", + loading: "Lade…", + system: "System", + serverVersion: "Docker", + systemRam: "System‑RAM", + runningContainers: "Laufend", + cpuCores: "CPU‑Kerne", + totalCpu: "Gesamt‑CPU", + totalMem: "Container‑RAM gesamt", + totalMemPct: "Container‑RAM %", + groupByStack: "Nach Stack gruppieren", + healthError: "Fehler beim Laden der Daten: " + } +}; +let lang = localStorage.getItem('lang') || (navigator.language || 'en').slice(0,2); +if (!I18N[lang]) lang = 'en'; + +function t(key) { return (I18N[lang] && I18N[lang][key]) || I18N['en'][key] || key; } + +function updateSortIndicators() { + // Remove old indicators + document.querySelectorAll('.header-row .sort .sort-ind').forEach(s => s.remove()); + // Add new indicator to active key in BOTH headers (only one visible, but safe) + const addInd = (rootSel) => { + document.querySelectorAll(rootSel + ' .sort').forEach(btn => { + if (btn.dataset.sort === sort.key) { + const ind = document.createElement('span'); + ind.className = 'sort-ind'; + ind.textContent = sort.dir === 'asc' ? ' ▲' : ' ▼'; + btn.appendChild(ind); + } + }); + }; + addInd('#header-grouped'); + addInd('#header-flat'); +} + +function applyI18N() { + document.getElementById('title').textContent = t('title'); + document.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.getAttribute('data-i18n')); }); + const placeholders = { + 'search': lang === 'de' ? 'Suche (alle Spalten)' : 'Search (any column)', + 'f-stack': lang === 'de' ? 'filtern' : 'filter', + 'f-count': lang === 'de' ? '≥, ≤, = oder Text' : '≥, ≤, = or text', + 'f-cpu': lang === 'de' ? '≥, ≤, =' : '≥, ≤, =', + 'f-mem': lang === 'de' ? '≥, ≤, = (MiB)' : '≥, ≤, = (MiB)', + 'f-memPct': lang === 'de' ? '≥, ≤, =' : '≥, ≤, =', + 'f-container': lang === 'de' ? 'filtern' : 'filter', + 'f-stack-flat': lang === 'de' ? 'filtern' : 'filter', + 'f-cpu-flat': lang === 'de' ? '≥, ≤, =' : '≥, ≤, =', + 'f-mem-flat': lang === 'de' ? '≥, ≤, = (MiB)' : '≥, ≤, = (MiB)', + 'f-memPct-flat': lang === 'de' ? '≥, ≤, =' : '≥, ≤, =' + }; + for (const [id, ph] of Object.entries(placeholders)) { + const el = document.getElementById(id); + if (el) el.setAttribute('placeholder', ph); + } + const tg = document.querySelector('#toggleGroup .label'); + if (tg) tg.textContent = t('groupByStack'); + + // Re-apply indicator after texts may have changed + updateSortIndicators(); +} + +// number formatting +function nf(opts={}) { return new Intl.NumberFormat(lang, opts); } +function fmtPct(v) { return nf({minimumFractionDigits: 2, maximumFractionDigits: 2}).format(v) + ' %'; } +function fmtMiB(v) { return nf({minimumFractionDigits: 2, maximumFractionDigits: 2}).format(v) + ' MiB'; } + +async function fetchJSON(url) { const r = await fetch(url); if (!r.ok) throw new Error(await r.text()); return r.json(); } + +function el(tag, attrs = {}, ...children) { + const e = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === 'class') e.className = v; + else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.slice(2), v); + else e.setAttribute(k, v); + } + for (const c of children) if (c != null) e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); + return e; +} + +function parseNumericFilter(input) { + const s = input.trim(); + if (!s) return null; + const m = /^(>=|<=|=|>|<)?\s*([0-9]+(?:\.[0-9]+)?)$/.exec(s); + if (!m) return (x) => String(x).toLowerCase().includes(s.toLowerCase()); + const op = m[1] || '='; + const val = Number(m[2]); + return (x) => { + const n = Number(x); + if (op === '>=') return n >= val; + if (op === '<=') return n <= val; + if (op === '>') return n > val; + if (op === '<') return n < val; + return n === val; + }; +} + +let timer = null; +let current = { groups: [], total: { cpuSum: 0, memSumMiB: 0, memPctSys: 0 }, sys: {}, containers: [] }; +let sort = { key: 'group', dir: 'asc' }; +const openGroups = new Set(JSON.parse(localStorage.getItem('openGroups') || '[]')); +const rowMap = new Map(); // grouped rows +const contRowMap = new Map(); // flat rows +let groupByStack = (localStorage.getItem('groupByStack') ?? '1') === '1'; + +function applyGroupFilters(groups) { + const q = document.getElementById('search').value.trim().toLowerCase(); + const fStack = document.getElementById('f-stack').value.trim().toLowerCase(); + const countF = parseNumericFilter(document.getElementById('f-count').value); + const cpuF = parseNumericFilter(document.getElementById('f-cpu').value); + const memF = parseNumericFilter(document.getElementById('f-mem').value); + const memPctF= parseNumericFilter(document.getElementById('f-memPct').value); + + return groups.filter(g => { + const blob = `${g.group} ${g.count} ${g.cpuSum} ${g.memSumMiB} ${g.memPctSys}`.toLowerCase(); + if (q && !blob.includes(q)) return false; + if (fStack && !g.group.toLowerCase().includes(fStack)) return false; + if (countF && !countF(g.count)) return false; + if (cpuF && !cpuF(g.cpuSum)) return false; + if (memF && !memF(g.memSumMiB)) return false; + if (memPctF && !memPctF(g.memPctSys)) return false; + return true; + }); +} + +function applyContainerFilters(conts) { + const q = document.getElementById('search').value.trim().toLowerCase(); + const fName = document.getElementById('f-container').value.trim().toLowerCase(); + const fStack = document.getElementById('f-stack-flat').value.trim().toLowerCase(); + const cpuF = parseNumericFilter(document.getElementById('f-cpu-flat').value); + const memF = parseNumericFilter(document.getElementById('f-mem-flat').value); + const memPctF= parseNumericFilter(document.getElementById('f-memPct-flat').value); + + return conts.filter(c => { + const blob = `${c.name} ${c.group} ${c.cpu} ${c.memMiB} ${c.memPctSys}`.toLowerCase(); + if (q && !blob.includes(q)) return false; + if (fName && !c.name.toLowerCase().includes(fName)) return false; + if (fStack && !c.group.toLowerCase().includes(fStack)) return false; + if (cpuF && !cpuF(c.cpu)) return false; + if (memF && !memF(c.memMiB)) return false; + if (memPctF && !memPctF(c.memPctSys)) return false; + return true; + }); +} + +function applySort(list) { + const arr = list.slice(); + const k = sort.key, dir = sort.dir; + arr.sort((a, b) => { + let av = a[k], bv = b[k]; + if (typeof av === 'string') { av = av.toLowerCase(); bv = bv.toLowerCase(); } + if (av < bv) return dir === 'asc' ? -1 : 1; + if (av > bv) return dir === 'asc' ? 1 : -1; + return 0; + }); + return arr; +} + +function setSort(key) { + if (sort.key === key) sort.dir = (sort.dir === 'asc' ? 'desc' : 'asc'); + else { sort.key = key; sort.dir = 'asc'; } + updateSortIndicators(); + if (groupByStack) renderGrouped(); else renderFlat(); +} + +function renderHeaderClicks() { + document.querySelectorAll('.header-row .sort').forEach(btn => { + btn.addEventListener('click', () => setSort(btn.dataset.sort)); + }); + updateSortIndicators(); +} + +async function refreshData() { + const err = document.getElementById('error'); + err.classList.add('hidden'); + try { + const summary = await fetchJSON('/api/summary'); + current.total = summary.total; + current.groups = summary.groups; + current.sys = summary.sys || {}; + const cont = await fetchJSON('/api/containers'); + current.containers = cont.containers || []; + + renderSystemAndTotals(); + if (groupByStack) renderGrouped(); else renderFlat(); + + if (groupByStack) { + for (const g of openGroups) await refreshGroupDetail(g, true); + } + } catch (e) { + err.textContent = t('healthError') + e.message; + err.classList.remove('hidden'); + } +} + +function renderSystemAndTotals() { + const s = current.sys || {}; + const total = current.total || {}; + const box = document.getElementById('sys'); + box.classList.remove('hidden'); + box.innerHTML = ''; + const totalMemPct = (s.memTotalMiB > 0) ? (100 * (total.memSumMiB || 0) / s.memTotalMiB) : 0; + + box.appendChild(el('div', { class: 'row sys-row' }, + el('div', {}, el('strong', {}, t('system'))), + el('div', { class: 'col-num' }, `${t('serverVersion')}: ${s.serverVersion || '-'}`), + el('div', { class: 'col-num' }, `${t('cpuCores')}: ${s.ncpu || '-'}`), + el('div', { class: 'col-num' }, `${t('systemRam')}: ${fmtMiB(s.memTotalMiB || 0)}`) + )); + box.appendChild(el('div', { class: 'row sys-row' }, + el('div', {}, el('strong', {}, t('totalCpu'))), + el('div', { class: 'col-num' }, fmtPct(total.cpuSum || 0)), + el('div', {}, el('strong', {}, t('totalMem'))), + el('div', { class: 'col-num' }, `${fmtMiB(total.memSumMiB || 0)} · ${t('totalMemPct')}: ${fmtPct(totalMemPct)}`) + )); +} + +async function refreshGroupDetail(groupName, preserve=false) { + try { + const data = await fetchJSON(`/api/group/${encodeURIComponent(groupName)}`); + let details = rowMap.get(groupName)?.details; + if (!details) return; + const frag = document.createDocumentFragment(); + for (const c of data.containers) { + frag.appendChild(el('div', { class: 'row container-row grid-containers' }, + el('div', { class: 'container-name col-name' }, c.name), + el('div', { class: 'col-num spacer' }, ''), + el('div', { class: 'col-num' }, fmtPct(c.cpu)), + el('div', { class: 'col-num' }, fmtMiB(c.memMiB)), + el('div', { class: 'col-num' }, fmtPct(c.memPctSys)) + )); + } + details.innerHTML = ''; + details.appendChild(frag); + details.dataset.loaded = '1'; + } catch (e) { + let details = rowMap.get(groupName)?.details; + if (details) details.textContent = (t('healthError') + e.message); + } +} + +function ensureGroupRow(g) { + const root = document.getElementById('stacks'); + let entry = rowMap.get(g.group); + if (entry) return entry; + const row = el('div', { class: 'card stack' }); + const header = el('div', { class: 'row grid-stacks clickable' }); + const nameEl = el('div', { class: 'stack-name col-name', 'data-field': 'name' }, g.group); + const countEl= el('div', { class: 'col-num', 'data-field': 'count' }, String(g.count)); + const cpuEl = el('div', { class: 'col-num', 'data-field': 'cpu' }, fmtPct(g.cpuSum)); + const memEl = el('div', { class: 'col-num', 'data-field': 'mem' }, fmtMiB(g.memSumMiB)); + const pctEl = el('div', { class: 'col-num', 'data-field': 'pct' }, fmtPct(g.memPctSys)); + header.appendChild(nameEl); header.appendChild(countEl); header.appendChild(cpuEl); header.appendChild(memEl); header.appendChild(pctEl); + + const details = el('div', { class: 'details hidden', 'data-group': g.group }, el('div', { class: 'hint' }, t('loading'))); + + header.addEventListener('click', async () => { + const isOpen = !details.classList.contains('hidden'); + if (isOpen) { details.classList.add('hidden'); openGroups.delete(g.group); } + else { details.classList.remove('hidden'); openGroups.add(g.group); await refreshGroupDetail(g.group, true); } + localStorage.setItem('openGroups', JSON.stringify([...openGroups])); + }); + + row.appendChild(header); row.appendChild(details); + root.appendChild(row); + entry = { row, header, nameEl, countEl, cpuEl, memEl, pctEl, details }; + rowMap.set(g.group, entry); + return entry; +} + +function renderGrouped() { + document.getElementById('header-grouped').classList.remove('hidden'); + document.getElementById('stacks').classList.remove('hidden'); + document.getElementById('header-flat').classList.add('hidden'); + document.getElementById('containers').classList.add('hidden'); + + const filtered = applyGroupFilters(current.groups); + const sorted = applySort(filtered); + const root = document.getElementById('stacks'); + const existing = new Set(rowMap.keys()); + + for (const g of sorted) { + const entry = ensureGroupRow(g); + entry.nameEl.textContent = g.group; + entry.countEl.textContent = String(g.count); + entry.cpuEl.textContent = fmtPct(g.cpuSum); + entry.memEl.textContent = fmtMiB(g.memSumMiB); + entry.pctEl.textContent = fmtPct(g.memPctSys); + existing.delete(g.group); + root.appendChild(entry.row); + } + for (const gone of existing) { + const entry = rowMap.get(gone); + if (entry) entry.row.remove(); + rowMap.delete(gone); + openGroups.delete(gone); + } + localStorage.setItem('openGroups', JSON.stringify([...openGroups])); + updateSortIndicators(); +} + +function ensureContainerRow(key) { + const root = document.getElementById('containers'); + let entry = contRowMap.get(key); + if (entry) return entry; + const row = el('div', { class: 'card cont' }); + const header = el('div', { class: 'row grid-flat' }); + const nameEl = el('div', { class: 'col-name', 'data-field': 'name' }); + const groupEl= el('div', { class: 'col-name', 'data-field': 'group' }); + const cpuEl = el('div', { class: 'col-num', 'data-field': 'cpu' }); + const memEl = el('div', { class: 'col-num', 'data-field': 'mem' }); + const pctEl = el('div', { class: 'col-num', 'data-field': 'pct' }); + header.appendChild(nameEl); header.appendChild(groupEl); header.appendChild(cpuEl); header.appendChild(memEl); header.appendChild(pctEl); + row.appendChild(header); + root.appendChild(row); + entry = { row, nameEl, groupEl, cpuEl, memEl, pctEl }; + contRowMap.set(key, entry); + return entry; +} + +function renderFlat() { + document.getElementById('header-grouped').classList.add('hidden'); + document.getElementById('stacks').classList.add('hidden'); + document.getElementById('header-flat').classList.remove('hidden'); + document.getElementById('containers').classList.remove('hidden'); + + const filtered = applyContainerFilters(current.containers); + const sorted = applySort(filtered); + const root = document.getElementById('containers'); + const existing = new Set(contRowMap.keys()); + + for (const c of sorted) { + const key = c.id; + const entry = ensureContainerRow(key); + entry.nameEl.textContent = c.name; + entry.groupEl.textContent = c.group; + entry.cpuEl.textContent = fmtPct(c.cpu); + entry.memEl.textContent = fmtMiB(c.memMiB); + entry.pctEl.textContent = fmtPct(c.memPctSys); + existing.delete(key); + root.appendChild(entry.row); + } + for (const gone of existing) { + const entry = contRowMap.get(gone); + if (entry) entry.row.remove(); + contRowMap.delete(gone); + } + updateSortIndicators(); +} + +function setupAutoRefresh() { + const chk = document.getElementById('autorefresh'); + const intervalInput = document.getElementById('interval'); + function schedule() { + if (timer) { clearInterval(timer); timer = null; } + if (chk.checked) { + const sec = Math.max(2, Number(intervalInput.value) || 5); + timer = setInterval(refreshData, sec * 1000); + } + } + chk.addEventListener('change', () => { localStorage.setItem('autorefresh', chk.checked ? '1' : '0'); schedule(); }); + intervalInput.addEventListener('change', () => { localStorage.setItem('interval', intervalInput.value); schedule(); }); + const ar = localStorage.getItem('autorefresh'); const iv = localStorage.getItem('interval'); + if (ar !== null) chk.checked = ar === '1'; if (iv !== null) intervalInput.value = iv; schedule(); +} + +function setupTheme() { + const btn = document.getElementById('theme'); + const root = document.documentElement; + const saved = localStorage.getItem('theme') || 'dark'; + root.setAttribute('data-theme', saved); + btn.addEventListener('click', () => { + const cur = root.getAttribute('data-theme') || 'dark'; + const next = cur === 'dark' ? 'light' : 'dark'; + root.setAttribute('data-theme', next); + localStorage.setItem('theme', next); + }); +} + +function setupFilters() { + const ids = ['search','f-stack','f-count','f-cpu','f-mem','f-memPct','f-container','f-stack-flat','f-cpu-flat','f-mem-flat','f-memPct-flat','lang']; + for (const id of ids) { + const elmt = document.getElementById(id); + if (!elmt) continue; + if (id === 'lang') { + elmt.value = lang; + elmt.addEventListener('change', () => { lang = elmt.value; localStorage.setItem('lang', lang); applyI18N(); renderSystemAndTotals(); if (groupByStack) renderGrouped(); else renderFlat(); }); + } else { + elmt.addEventListener('input', () => { if (groupByStack) renderGrouped(); else renderFlat(); }); + } + } + renderHeaderClicks(); +} + +function setupGroupingToggle() { + const tgl = document.getElementById('toggleGroup'); + tgl.checked = groupByStack; + tgl.addEventListener('change', () => { + groupByStack = tgl.checked; + localStorage.setItem('groupByStack', groupByStack ? '1' : '0'); + if (groupByStack) { renderGrouped(); for (const g of openGroups) refreshGroupDetail(g, true); } + else { renderFlat(); } + updateSortIndicators(); + }); +} + +window.addEventListener('DOMContentLoaded', () => { + document.getElementById('refresh').addEventListener('click', refreshData); + setupGroupingToggle(); + setupAutoRefresh(); + setupTheme(); + setupFilters(); + applyI18N(); + updateSortIndicators(); + refreshData(); +}); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..f75d6d4 --- /dev/null +++ b/public/index.html @@ -0,0 +1,62 @@ + + + + + + Docker Stack Stats + + + +
+
+

Docker Stack Stats

+
+ + + + s + + + +
+
+ + + +
+ + +
+
+

+

+

+

+

+
+
+ + + + +
+ +
+ + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..3eba7a4 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,66 @@ +:root { + --bg: #0b1020; + --card: #111833; + --text: #ebefff; + --muted: #a7b0d0; + --accent: #8cb0ff; + --border: #243056; + + --grid-stacks: 1.4fr 90px 110px 120px 100px; + --grid-containers: 1.2fr 1fr 110px 120px 100px; + + --pad-card: 6px; + --pad-row: 4px; + --gap-row: 8px; + --radius: 12px; + --fs: 14px; +} +[data-theme="light"] { + --bg: #f7f8ff; + --card: #ffffff; + --text: #07102a; + --muted: #5a6a8a; + --accent: #345dff; + --border: #dfe6fb; +} + +* { box-sizing: border-box; } +body { margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial; background: var(--bg); color: var(--text); font-size: var(--fs); } +.container { max-width: 1100px; margin: 18px auto; padding: 0 12px; } +header { display: flex; flex-wrap: wrap; gap: 6px; justify-content: space-between; align-items: center; margin-bottom: 10px; } +h1 { font-size: 18px; margin: 0; } +.controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +input[type="number"], input[type="text"], select { padding: 4px 6px; border-radius: 8px; border: 1px solid var(--border); background: var(--card); color: var(--text); height: 28px; } +input[type="number"] { width: 70px; } +input[type="text"] { width: 200px; } +button { background: var(--accent); color: #fff; border: none; padding: 6px 10px; border-radius: 10px; cursor: pointer; font-weight: 600; height: 28px; } +button:hover { filter: brightness(1.05); } + +.toggle { display: inline-flex; align-items: center; gap: 6px; } +.toggle input { display: none; } +.toggle .slider { position: relative; width: 42px; height: 22px; background: var(--border); border-radius: 999px; cursor: pointer; } +.toggle .slider::after { content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; background: #fff; border-radius: 50%; transition: transform .15s ease-in-out; } +.toggle input:checked + .slider::after { transform: translateX(20px); } +.toggle .label { user-select: none; } + +.error { background: #c62828; color: #fff; padding: 6px 10px; border-radius: 10px; margin-bottom: 8px; } + +.card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--pad-card); margin-bottom: 8px; } + +.row { display: grid; gap: var(--gap-row); align-items: center; padding: var(--pad-row) 4px; } +.grid-stacks { grid-template-columns: var(--grid-stacks); } +.grid-containers, .grid-flat { grid-template-columns: var(--grid-containers); } + +.header-row { font-weight: 700; margin: 6px 0; padding: 6px; opacity: .95; background: color-mix(in oklab, var(--card) 88%, var(--text) 12%); border-radius: var(--radius); } +.header-row input { width: 100%; margin-top: 4px; height: 26px; } +.header-row .sort { background: transparent; color: var(--text); border: none; padding: 0; cursor: pointer; font-weight: 700; } + +.stack-name, .container-name, .col-name { text-align: left; } +.col-num { text-align: right; font-variant-numeric: tabular-nums; } + +.clickable { cursor: pointer; } +.details { margin-top: 4px; padding-top: 2px; } +.hidden { display: none; } +.hint { color: var(--muted); font-style: italic; } + +.sys-row { grid-template-columns: 1fr auto auto auto; } diff --git a/server.js b/server.js new file mode 100644 index 0000000..34c589e --- /dev/null +++ b/server.js @@ -0,0 +1,208 @@ +import express from 'express'; +import Docker from 'dockerode'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import crypto from 'crypto'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const app = express(); +const docker = new Docker({ socketPath: '/var/run/docker.sock' }); +const PORT = process.env.PORT || 8080; + +const ENABLE_AUTH = !!(process.env.BASIC_AUTH_USER || process.env.BASIC_AUTH_PASS); +const AUTH_USER = process.env.BASIC_AUTH_USER || ''; +const AUTH_PASS = process.env.BASIC_AUTH_PASS || ''; +const MEM_SUBTRACT_CACHE = String(process.env.MEM_SUBTRACT_CACHE || 'true').toLowerCase() !== 'false'; + +function constantTimeEqual(a, b) { + const aa = Buffer.from(a || ''); + const bb = Buffer.from(b || ''); + if (aa.length !== bb.length) return false; + return crypto.timingSafeEqual(aa, bb); +} + +function basicAuth(req, res, next) { + if (!ENABLE_AUTH) return next(); + const hdr = req.headers['authorization'] || ''; + const m = /^Basic\s+([A-Za-z0-9+/=]+)$/.exec(hdr); + if (!m) return unauthorized(res); + const dec = Buffer.from(m[1], 'base64').toString('utf8'); + const idx = dec.indexOf(':'); + if (idx < 0) return unauthorized(res); + const u = dec.slice(0, idx); + const p = dec.slice(idx + 1); + if (constantTimeEqual(u, AUTH_USER) && constantTimeEqual(p, AUTH_PASS)) return next(); + return unauthorized(res); +} + +function unauthorized(res) { + res.set('WWW-Authenticate', 'Basic realm="Stack Stats"'); + return res.status(401).send('Authentication required'); +} + +app.use(basicAuth); +app.use(express.static(path.join(__dirname, 'public'))); + +function calcCpuPercentUnix(_, current) { + try { + if (!current || !current.cpu_stats || !current.precpu_stats) return 0; + const cpuDelta = (current.cpu_stats.cpu_usage?.total_usage ?? 0) - (current.precpu_stats.cpu_usage?.total_usage ?? 0); + const systemDelta = (current.cpu_stats.system_cpu_usage ?? 0) - (current.precpu_stats.system_cpu_usage ?? 0); + const onlineCPUs = current.cpu_stats.online_cpus || (current.cpu_stats.cpu_usage?.percpu_usage?.length ?? 1) || 1; + if (systemDelta <= 0 || cpuDelta <= 0) return 0; + return (cpuDelta / systemDelta) * onlineCPUs * 100; + } catch { return 0; } +} + +function bytesToMiB(bytes) { return (bytes || 0) / (1024 * 1024); } + +function stackOrProject(labels = {}) { + const stack = labels['com.docker.stack.namespace']; + const proj = labels['com.docker.compose.project']; + if (stack && stack !== '(unknown)') return stack; + if (proj && proj !== '(unknown)') return proj; + return '__no_stack__'; +} + +function memoryMiBFromStats(stats) { + try { + if (!stats || !stats.memory_stats) return 0; + let usage = stats.memory_stats.usage || 0; + if (String(process.env.MEM_SUBTRACT_CACHE || 'true').toLowerCase() !== 'false') { + const s = stats.memory_stats.stats || {}; + const cacheLike = (typeof s.cache === 'number' ? s.cache : 0) || (typeof s.inactive_file === 'number' ? s.inactive_file : 0); + if (cacheLike > 0 && usage > cacheLike) usage -= cacheLike; + } + return bytesToMiB(Math.max(0, usage)); + } catch { return 0; } +} + +async function dockerInfo() { + try { + const info = await docker.info(); + return { + serverVersion: info.ServerVersion, + ncpu: info.NCPU, + memTotalMiB: Math.round((info.MemTotal || 0) / (1024*1024)), + os: info.OperatingSystem, + kernel: info.KernelVersion, + architecture: info.Architecture, + containers: { total: info.Containers, running: info.ContainersRunning, paused: info.ContainersPaused, stopped: info.ContainersStopped } + }; + } catch (e) { + return { error: String(e) }; + } +} + +async function fetchAllStats() { + const containers = await docker.listContainers({ all: false }); + const info = await dockerInfo(); + const sysMemMiB = info.memTotalMiB || 0; + + const results = await Promise.allSettled(containers.map(async (c) => { + const id = c.Id; + const name = (c.Names?.[0] || '').replace(/^\//, '') || c.Names?.[0] || id.slice(0,12); + const group = stackOrProject(c.Labels || {}); + const container = docker.getContainer(id); + let statsRaw; + try { + statsRaw = await container.stats({ stream: false }); + } catch (e) { + return { id, name, group, cpu: 0, memMiB: 0, memPctSys: 0 }; + } + const cpu = calcCpuPercentUnix(null, statsRaw); + const memMiB = memoryMiBFromStats(statsRaw); + const memPctSys = sysMemMiB > 0 ? (memMiB / sysMemMiB) * 100 : 0; + return { id, name, group, cpu, memMiB, memPctSys }; + })); + + const data = results.filter(r => r.status === 'fulfilled').map(r => r.value); + + const groups = {}; + for (const row of data) { + if (!groups[row.group]) groups[row.group] = { group: row.group, cpuSum: 0, memSumMiB: 0, memPctSys: 0, containers: [] }; + groups[row.group].cpuSum += row.cpu; + groups[row.group].memSumMiB += row.memMiB; + groups[row.group].containers.push(row); + } + const infoMem = info.memTotalMiB || 0; + if (infoMem > 0) { + for (const g of Object.values(groups)) { + g.memPctSys = (g.memSumMiB / infoMem) * 100; + } + } + + const groupList = Object.values(groups) + .sort((a, b) => b.memSumMiB - a.memSumMiB) + .map(g => ({ + group: g.group, + cpuSum: Number(g.cpuSum.toFixed(2)), + memSumMiB: Number(g.memSumMiB.toFixed(2)), + memPctSys: Number(g.memPctSys.toFixed(2)), + count: g.containers.length + })); + + const total = groupList.reduce((acc, g) => ({ + cpuSum: Number((acc.cpuSum + g.cpuSum).toFixed(2)), + memSumMiB: Number((acc.memSumMiB + g.memSumMiB).toFixed(2)), + memPctSys: Number((acc.memPctSys + g.memPctSys).toFixed(2)) + }), { cpuSum: 0, memSumMiB: 0, memPctSys: 0 }); + + for (const g of Object.values(groups)) { + g.containers.sort((a, b) => b.cpu - a.cpu); + g.containers = g.containers.map(c => ({ + id: c.id, name: c.name, group: c.group, + cpu: Number(c.cpu.toFixed(2)), + memMiB: Number(c.memMiB.toFixed(2)), + memPctSys: Number((c.memPctSys || 0).toFixed(2)) + })); + } + + return { total, groups, groupList, sys: info, containers: data }; +} + +// endpoints +app.get('/api/summary', async (req, res) => { + try { + const { total, groupList, sys } = await fetchAllStats(); + res.json({ total, groups: groupList, sys }); + } catch (e) { + res.status(500).json({ error: e.message || String(e) }); + } +}); + +app.get('/api/group/:name', async (req, res) => { + try { + const { groups } = await fetchAllStats(); + const g = groups[req.params.name]; + if (!g) return res.json({ group: req.params.name, containers: [], cpuSum: 0, memSumMiB: 0, memPctSys: 0 }); + res.json({ group: g.group, containers: g.containers, cpuSum: g.cpuSum, memSumMiB: g.memSumMiB, memPctSys: g.memPctSys }); + } catch (e) { + res.status(500).json({ error: e.message || String(e) }); + } +}); + +app.get('/api/containers', async (req, res) => { + try { + const { containers, sys } = await fetchAllStats(); + res.json({ containers, sys }); + } catch (e) { + res.status(500).json({ error: e.message || String(e) }); + } +}); + +app.get('/health', async (req, res) => { + try { + await docker.ping(); + const cont = await docker.listContainers({ all: false }); + res.json({ ok: true, containers: cont.length }); + } catch (e) { + res.status(500).json({ ok: false, error: String(e) }); + } +}); + +app.listen(PORT, () => { + console.log(`docker-stack-stats-ui listening on :${PORT}`); +}); diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..9f2e48c --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,9 @@ +FROM nginx:alpine + +COPY index.html /usr/share/nginx/html/ +COPY style.css /usr/share/nginx/html/ +COPY app.js /usr/share/nginx/html/ + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..7a7d689 --- /dev/null +++ b/web/app.js @@ -0,0 +1,887 @@ +const API_URL = 'https://fb.srv.medeba-media.de/api'; + +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' +}; + +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; + +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'); + } catch (error) { + console.warn('Ungültiges Datum:', error); + return ''; + } +} + +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 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 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 computePostStatus(post, profileNumber = currentProfile) { + const requiredProfiles = normalizeRequiredProfiles(post); + const checks = normalizeChecks(post.checks); + + 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, + completedProfilesSet, + checkedCount, + targetCount, + isComplete, + isCurrentProfileRequired, + isCurrentProfileDone, + canCurrentProfileCheck, + waitingForProfiles, + waitingForNames, + nextRequiredProfile, + profileNumber + }; +} + +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 fetch(`${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 fetch(`${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); + } + + 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', () => { + document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + currentTab = btn.dataset.tab; + renderPosts(); + }); +}); + +// Fetch all posts +async function fetchPosts() { + try { + showLoading(); + const response = await fetch(`${API_URL}/posts`); + + if (!response.ok) { + throw new Error('Failed to fetch posts'); + } + + posts = await response.json(); + renderPosts(); + } catch (error) { + showError('Fehler beim Laden der Beiträge. Stelle sicher, dass das Backend läuft.'); + console.error('Error fetching posts:', error); + } +} + +// Render posts +function renderPosts() { + hideLoading(); + hideError(); + + const container = document.getElementById('postsContainer'); + + const postItems = posts.map((post) => ({ + post, + status: computePostStatus(post) + })); + + let filteredItems = postItems; + + if (currentTab === 'pending') { + filteredItems = postItems.filter((item) => item.status.canCurrentProfileCheck && !item.status.isComplete); + } + + if (filteredItems.length === 0) { + container.innerHTML = ` +
+
🎉
+
+ ${currentTab === 'pending' ? 'Keine offenen Beiträge!' : 'Noch keine Beiträge erfasst.'} +
+
+ `; + return; + } + + container.innerHTML = filteredItems + .map(({ post, status }) => createPostCard(post, status)) + .join(''); + + filteredItems.forEach(({ 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 editBtn = card.querySelector('.btn-edit-target'); + if (editBtn) { + editBtn.addEventListener('click', () => promptEditTarget(post.id, status.targetCount)); + } + + 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); + } + }); + } + }); +} + +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) { + const createdDate = formatDateTime(post.created_at) || '—'; + + const resolvedScreenshotPath = post.screenshot_path + ? (post.screenshot_path.startsWith('http') + ? post.screenshot_path + : `${API_URL.replace(/\/api$/, '')}${post.screenshot_path}`) + : null; + + const screenshotHtml = resolvedScreenshotPath + ? ` +
+ Screenshot zum Beitrag +
+ ` + : ''; + + const profileRowsHtml = status.profileStatuses.map((profileStatus) => { + const classes = ['profile-line', `profile-line--${profileStatus.status}`]; + 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'; + } + + return ` +
+ ${escapeHtml(profileStatus.profile_name)} + ${escapeHtml(label)} +
+ `; + }).join(''); + + const infoMessages = []; + 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 + ? ` + + ` + : ''; + + return ` +
+
+
${escapeHtml(post.title || '')}
+
+ ${status.checkedCount}/${status.targetCount} ${status.isComplete ? '✓' : ''} +
+
+ + + + ${directLinkHtml} + +
+ ${profileRowsHtml} +
+ + ${infoHtml} + + ${screenshotHtml} + +
+ ${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 fetch(`${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(); + } catch (error) { + alert('Fehler beim Abhaken des Beitrags'); + console.error('Error checking post:', error); + } +} + +async function promptEditTarget(postId, currentTarget) { + const defaultValue = Number.isFinite(currentTarget) ? String(currentTarget) : ''; + const newValue = prompt(`Neue Anzahl (1-${MAX_PROFILES}):`, defaultValue); + + if (newValue === null) { + return; + } + + const parsed = parseInt(newValue, 10); + if (Number.isNaN(parsed) || parsed < 1 || parsed > MAX_PROFILES) { + alert(`Bitte gib eine Zahl zwischen 1 und ${MAX_PROFILES} ein.`); + return; + } + + try { + const response = await fetch(`${API_URL}/posts/${postId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target_count: parsed }) + }); + + if (!response.ok) { + const data = await response.json().catch(() => null); + const message = data && data.error ? data.error : 'Fehler beim Aktualisieren der Anzahl.'; + alert(message); + return; + } + + await fetchPosts(); + } catch (error) { + alert('Fehler beim Aktualisieren der Anzahl.'); + console.error('Error updating target count:', error); + } +} + +// Delete post +async function deletePost(postId) { + if (!confirm('Beitrag wirklich löschen?')) { + return; + } + + try { + const response = await fetch(`${API_URL}/posts/${postId}`, { + method: 'DELETE' + }); + + if (!response.ok) { + throw new Error('Failed to delete post'); + } + + await fetchPosts(); + } 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 + fetch(`${API_URL}/check-by-url`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: decodeURIComponent(autoCheckUrl), + profile_number: currentProfile + }) + }).then(() => { + // Remove the parameter from URL + window.history.replaceState({}, document.title, window.location.pathname); + fetchPosts(); + }).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') { + if (screenshotModalZoomed) { + resetScreenshotZoom(); + return; + } + closeScreenshotModal(); + } +}); + +window.addEventListener('resize', () => { + if (screenshotModal && screenshotModal.classList.contains('open') && !screenshotModalZoomed) { + applyScreenshotModalSize(); + } +}); + +// Initialize +loadProfile(); +startProfilePolling(); +fetchPosts(); +checkAutoCheck(); + +// Auto-refresh every 30 seconds +setInterval(fetchPosts, 30000); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..f9416c6 --- /dev/null +++ b/web/index.html @@ -0,0 +1,46 @@ + + + + + + Facebook Post Tracker - Web Interface + + + +
+
+

📋 Facebook Post Tracker

+
+ + +
+
+ +
+ + +
+ +
Lade Beiträge...
+ + +
+
+ + + + + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..2bfe9a1 --- /dev/null +++ b/web/style.css @@ -0,0 +1,534 @@ +* { + 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: 20px; + border-radius: 8px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + margin-bottom: 20px; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 16px; +} + +h1 { + font-size: 24px; + font-weight: 700; +} + +.profile-selector { + display: flex; + align-items: center; + gap: 12px; +} + +.profile-selector label { + font-size: 14px; + font-weight: 600; +} + +.profile-selector select { + padding: 8px 12px; + border: 1px solid #ccd0d5; + border-radius: 6px; + font-size: 14px; + background: white; + cursor: pointer; +} + +.tabs { + display: flex; + gap: 8px; + margin-bottom: 20px; +} + +.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; +} + +.post-card { + 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; +} + +.post-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +} + +.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-title { + flex: 1; + 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-meta { + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: space-between; + margin-bottom: 12px; +} + +.post-info { + font-size: 13px; + color: #65676b; + margin-bottom: 0; +} + +.post-target { + display: flex; + align-items: center; + gap: 12px; + font-size: 13px; + color: #1f2937; +} + +.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; + margin-bottom: 12px; +} + +.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; + margin-bottom: 12px; +} + +.profile-line { + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 13px; + padding: 6px 8px; + border-radius: 6px; +} + +.profile-line__name { + font-weight: 600; +} + +.profile-line__status { + font-weight: 500; +} + +.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; +} + +.post-hints { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 12px; +} + +.post-hint { + font-size: 13px; + color: #92400e; + background: #fff7ed; + border-radius: 6px; + padding: 8px 10px; +} + +.post-hint--success { + color: #065f46; + background: #ecfdf5; +} + +.post-screenshot { + margin-bottom: 12px; + 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: auto; + height: 100%; + 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; +} + +.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; + } + + header { + padding: 16px; + } + + h1 { + font-size: 20px; + } + + .post-header { + flex-direction: column; + } + + .post-actions { + flex-direction: column; + } + + .btn { + width: 100%; + } +}