Compare commits

...

50 Commits

Author SHA1 Message Date
10047b778d Reject already expired post deadlines 2026-04-12 22:11:27 +02:00
9b329e513a Add support for disabling broken profiles 2026-04-12 19:23:34 +02:00
ad673f29ad Add extension pause toggle 2026-04-07 19:12:08 +02:00
b2647bdeab Separate AI credential cooldowns from button limits 2026-04-07 16:59:01 +02:00
b6f8572aae Exempt repeat AI comments on same post 2026-04-07 16:54:24 +02:00
52a21ca089 Prefer longest AI rate limit blocker 2026-04-07 16:35:58 +02:00
1a66f27507 Fix AI comment rate limit regression 2026-04-07 16:30:13 +02:00
2cba15e85a Count AI cooldown from response time 2026-04-07 16:26:03 +02:00
a265160624 Add AI button reactivation countdown 2026-04-07 16:20:51 +02:00
9144bdea21 Auto-refresh AI limit status in visible tabs 2026-04-07 16:17:41 +02:00
2fea16f6de Sync AI limit status across tabs 2026-04-07 16:16:09 +02:00
d600b2a3b6 Bump extension version to 1.2.1 2026-04-07 16:11:32 +02:00
af3d4f3dc7 Improve AI limit UX in extension 2026-04-07 16:04:03 +02:00
b5b44d1304 Expand AI auto comment rate limiting 2026-04-07 15:55:04 +02:00
9ca9233bf6 Document container restart workflow 2026-04-07 15:39:17 +02:00
66221f27c7 Add per-profile AI comment action limits 2026-04-07 15:34:18 +02:00
81dfb06f24 Add Stuttgart spring fair bookmark links 2026-03-09 13:45:27 +01:00
b688bfdeb6 Revert "Link trade fair names to source pages"
This reverts commit 0f9bbe160c.
2026-03-07 21:17:08 +01:00
0f9bbe160c Link trade fair names to source pages 2026-03-07 19:15:14 +01:00
94fdb5aa0d Add Stuttgarter Frühjahrsmessen to trade fairs list 2026-03-07 19:07:31 +01:00
d82892125a Refresh bookmark relative times in background 2026-03-03 15:35:33 +01:00
438c0ce77c feat: support middle-click on bookmark open buttons 2026-03-02 16:56:48 +01:00
7ee22b6c8f Prevent tracker realign from closing open dropdowns and datepicker 2026-02-26 16:06:06 +01:00
c9f2259180 Stabilize tracker action bar placement across Facebook layout variants 2026-02-26 09:11:04 +01:00
5d3a165921 Apply ten iterative posts UI improvements 2026-02-24 23:09:49 +01:00
ad6e156268 Improve posts UI in three iterative passes 2026-02-24 23:02:36 +01:00
2c54c96cc7 Add UI critic and GUI developer agent workflow 2026-02-24 22:56:50 +01:00
5b05d9ce1e Fix umlaut spelling in selected trade fair names 2026-02-23 21:54:48 +01:00
055b4382a0 Make days filter toggle unobtrusive when collapsed 2026-02-23 21:50:33 +01:00
4291b56fd9 Toggle days filter via icon and persist filter value 2026-02-23 21:48:00 +01:00
85f9de74bf Implement direct days-until-start column filter 2026-02-23 20:48:55 +01:00
6a54f3652a Add days-to-start filter input in trade fairs header 2026-02-23 20:47:00 +01:00
6f55c6dff8 Merge start/end dates into compact trade fair term column 2026-02-23 16:42:33 +01:00
b73dff8207 Expand trade fair dataset to top 35 visible entries 2026-02-23 16:38:29 +01:00
2df99072f7 Add CREATIVA Dortmund to trade fair dataset 2026-02-23 16:36:00 +01:00
7abb98e924 Add trade fair column settings modal with visibility controls 2026-02-21 20:14:08 +01:00
4b1e935000 Split combined trade-fair bookmark links and add shared-ticket notes 2026-02-21 20:02:43 +01:00
758ffd158f Fix trade fair links to use bookmark keyword suffixes 2026-02-21 13:19:26 +01:00
97b101b05e Exclude free trade fairs from table results 2026-02-21 13:08:54 +01:00
bf25e0f70e Add draggable trade fair columns with persisted order and last-searched column 2026-02-21 13:03:58 +01:00
3ba24fd969 Expand trade fair table to top 30 with ticket pricing and saved sorting 2026-02-21 12:55:15 +01:00
093fd0a652 Include trade-fairs asset in web image build 2026-02-21 12:32:29 +01:00
71e4d48d76 Fix login redirect loop on optional asset load failure 2026-02-21 12:30:03 +01:00
a0926fcd1a Add sortable trade fair bookmarks subpage 2026-02-21 12:26:08 +01:00
46fc27600e Visualize AI debug runs with phase bar charts 2026-02-20 14:16:29 +01:00
af3b07b80f Add end-to-end AI timing traces and AI debug view 2026-02-20 14:10:06 +01:00
4c187dab3c Fix latest credential event timestamp UTC formatting 2026-02-14 10:35:18 +01:00
8e3040f368 chore: add mandatory clean-tree and commit-push workflow 2026-02-12 18:00:22 +01:00
2feba4e585 feat: store bookmark last-opened timestamps only in DB 2026-02-12 17:57:21 +01:00
bbfa93a586 chore: checkpoint current working state 2026-02-12 17:40:52 +01:00
21 changed files with 8612 additions and 427 deletions

View File

@@ -0,0 +1,39 @@
---
name: gui-developer
description: Implements iterative, production-safe improvements for the web UI in /web.
tools: Bash, Read, Edit, Write, Grep, Glob
---
You are the GUI Developer agent for this repository.
Mission:
- Improve the web interface iteratively with small, testable diffs.
- Prefer practical UX gains over large redesigns.
- Keep compatibility with the current architecture (Vanilla JS SPA).
Default scope:
- `web/index.html`
- `web/style.css`
- `web/app.js`
- View-specific files only when needed (`web/dashboard.*`, `web/settings.*`, `web/automation.*`, `web/daily-bookmarks.*`)
Iteration workflow:
1. Read the latest lead task and critic feedback.
2. Pick exactly one high-impact UI improvement for this iteration.
3. Write 2-4 acceptance criteria before editing.
4. Implement the smallest viable diff that satisfies those criteria.
5. Validate touched JS files with syntax checks:
- `node -c web/app.js`
- `node -c <other touched web/*.js files>`
6. Return a concise handoff:
- Goal
- Files changed
- Acceptance criteria status
- Risks/open questions for critic
Quality bar:
- Maintain desktop and mobile usability.
- Preserve keyboard and focus usability for interactive controls.
- Avoid adding new dependencies unless explicitly requested.
- Keep naming and structure consistent with existing code.

View File

@@ -0,0 +1,35 @@
---
name: ui-critic
description: Reviews web UI changes for usability, accessibility, consistency, and regression risk.
tools: Read, Grep, Glob, Bash
---
You are the UI Critic agent for this repository.
Mission:
- Critique each GUI iteration with high signal and clear priorities.
- Prevent regressions while pushing for measurable UX quality.
Review rubric (use all categories):
- Clarity and information hierarchy
- Interaction quality (forms, feedback, affordances)
- Accessibility (labels, focus, keyboard, contrast, semantics)
- Responsive behavior (mobile + desktop)
- Visual consistency with existing product language
- Performance/regression risk (DOM churn, expensive JS/CSS patterns)
Output rules:
- Provide findings first, ordered by severity: `blocking`, `high`, `medium`, `low`.
- For each finding include:
- File path
- Exact issue
- User impact
- Concrete fix direction
- Cap to max 5 findings per iteration.
- If no findings, state `No blocking findings` and list residual risks/test gaps.
Collaboration constraints:
- Be strict, but keep suggestions implementable in small diffs.
- Prefer iterative corrections over broad rewrites.
- End every review with one recommended next-iteration focus.

3
.gitignore vendored
View File

@@ -4,4 +4,5 @@ backend/data/*.db-shm
backend/data/*.db-wal
.DS_Store
*.log
.env
.env
screenshots/

16
AGENTS.md Normal file
View File

@@ -0,0 +1,16 @@
# AGENTS.md
## Mandatory Git Workflow
Before making any code or file change that results in a diff:
- Ensure `git status --porcelain` is empty.
- If there are open changes, stop and ask the user how to proceed.
After completing a requested change:
- Create a commit with a clear message.
- Push the commit to the current remote branch.
- Rebuild and restart affected services with `docker compose -f /root/fb/docker-compose.yml up -d --build backend web` so backend and settings changes are live.
## Scope
These rules apply to all tasks in this repository unless the user explicitly requests a different workflow.

View File

@@ -178,6 +178,16 @@ npm run dev
- **Chrome**: Gehe zu `chrome://extensions/` und klicke auf das Reload-Symbol
- **Firefox**: Gehe zu `about:debugging` und klicke auf "Neu laden"
## Agenten-Workflow fuer UI-Verbesserungen
Fuer iterative Verbesserungen der Weboberflaeche stehen zwei Agenten bereit:
- `gui-developer`: Implementiert kleine, testbare UI-Verbesserungen in `web/*`
- `ui-critic`: Prueft jeden Schritt auf UX-, Accessibility- und Regressionsrisiken
Details zum Ablauf: `docs/ui-iteration-loop.md`
Agenten-Definitionen: `.claude/agents/gui-developer.md`, `.claude/agents/ui-critic.md`
## Troubleshooting
### Backend nicht erreichbar

File diff suppressed because it is too large Load Diff

57
docs/ui-iteration-loop.md Normal file
View File

@@ -0,0 +1,57 @@
# UI Iteration Loop (Lead + GUI Developer + UI Critic)
This project uses a 3-role loop for steady UI improvement:
1. Lead defines one iteration target.
2. GUI Developer implements one focused improvement.
3. UI Critic reviews and prioritizes issues.
4. Lead decides: ship, revise, or run next iteration.
## Lead Checklist Per Iteration
1. Set a narrow goal (example: "Improve bookmark quick search usability on mobile").
2. Define success criteria (2-4 points, testable).
3. Assign implementation to `gui-developer`.
4. Send produced diff to `ui-critic`.
5. Resolve critic findings:
- blocking/high -> must fix before merge
- medium/low -> schedule for next iterations
6. Start next cycle with exactly one new high-impact focus.
## Handoff Templates
Use these short templates to keep cycles fast.
### Lead -> GUI Developer
```
Iteration goal:
Scope:
Acceptance criteria:
Constraints:
```
### GUI Developer -> UI Critic
```
Goal:
Files changed:
Acceptance criteria status:
Open questions:
```
### UI Critic -> Lead
```
Findings (blocking/high/medium/low):
Residual risks:
Recommended next focus:
```
## Guardrails
- Keep diffs small and reversible.
- Preserve existing app behavior outside the current scope.
- Validate desktop and mobile behavior in every iteration.
- Prefer measurable improvements over subjective redesign debates.

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Facebook Post Tracker",
"version": "1.2.0",
"version": "1.2.4",
"description": "Track Facebook posts across multiple profiles",
"permissions": [
"storage",

View File

@@ -112,6 +112,7 @@
<div class="section">
<label for="profileSelect">Aktuelles Profil:</label>
<select id="profileSelect">
<option value="">-- Profil wählen --</option>
<option value="1">Profil 1</option>
<option value="2">Profil 2</option>
<option value="3">Profil 3</option>
@@ -136,6 +137,10 @@
<button id="webInterfaceBtn" class="secondary">Web-Interface</button>
</div>
<div class="btn-group">
<button id="pauseBtn" class="secondary">Extension pausieren</button>
</div>
<script src="config.js"></script>
<script src="popup.js"></script>
</body>

View File

@@ -1,6 +1,12 @@
const profileSelect = document.getElementById('profileSelect');
const statusEl = document.getElementById('status');
const debugToggle = document.getElementById('debugLoggingToggle');
const pauseBtn = document.getElementById('pauseBtn');
let extensionPaused = false;
function isValidProfileNumber(value) {
return Number.isInteger(value) && value >= 1 && value <= 5;
}
function apiFetch(url, options = {}) {
const config = {
@@ -59,9 +65,16 @@ function updateStatus(message, saved = false) {
statusEl.className = saved ? 'status saved' : 'status';
}
function updatePauseButton() {
if (!pauseBtn) {
return;
}
pauseBtn.textContent = extensionPaused ? 'Extension fortsetzen' : 'Extension pausieren';
}
async function initProfileSelect() {
const backendProfile = await fetchProfileState();
if (backendProfile) {
if (isValidProfileNumber(backendProfile)) {
profileSelect.value = String(backendProfile);
chrome.storage.sync.set({ profileNumber: backendProfile });
updateStatus(`Profil ${backendProfile} ausgewählt`);
@@ -69,9 +82,14 @@ async function initProfileSelect() {
}
chrome.storage.sync.get(['profileNumber'], (result) => {
const profileNumber = result.profileNumber || 1;
profileSelect.value = String(profileNumber);
updateStatus(`Profil ${profileNumber} ausgewählt (lokal)`);
const profileNumber = result.profileNumber;
if (isValidProfileNumber(profileNumber)) {
profileSelect.value = String(profileNumber);
updateStatus(`Profil ${profileNumber} ausgewählt (lokal)`);
return;
}
profileSelect.value = '';
updateStatus('Bitte zuerst ein Profil auswählen.');
});
}
@@ -91,6 +109,11 @@ if (debugToggle) {
});
}
chrome.storage.sync.get(['extensionPaused'], (result) => {
extensionPaused = Boolean(result && result.extensionPaused);
updatePauseButton();
});
function reloadFacebookTabs() {
chrome.tabs.query({ url: ['https://www.facebook.com/*', 'https://facebook.com/*'] }, (tabs) => {
tabs.forEach(tab => {
@@ -101,6 +124,10 @@ function reloadFacebookTabs() {
document.getElementById('saveBtn').addEventListener('click', async () => {
const profileNumber = parseInt(profileSelect.value, 10);
if (!isValidProfileNumber(profileNumber)) {
updateStatus('Bitte zuerst ein Profil auswählen.');
return;
}
chrome.storage.sync.set({ profileNumber }, async () => {
updateStatus(`Profil ${profileNumber} gespeichert!`, true);
@@ -112,3 +139,14 @@ document.getElementById('saveBtn').addEventListener('click', async () => {
document.getElementById('webInterfaceBtn').addEventListener('click', () => {
chrome.tabs.create({ url: API_BASE_URL });
});
if (pauseBtn) {
pauseBtn.addEventListener('click', () => {
const nextPaused = !extensionPaused;
chrome.storage.sync.set({ extensionPaused: nextPaused }, () => {
extensionPaused = nextPaused;
updatePauseButton();
updateStatus(`Extension ${nextPaused ? 'pausiert' : 'fortgesetzt'} (wirksam ohne Reload)`, true);
});
});
}

View File

@@ -12,12 +12,15 @@ COPY login.html /usr/share/nginx/html/
COPY style.css /usr/share/nginx/html/
COPY dashboard.css /usr/share/nginx/html/
COPY settings.css /usr/share/nginx/html/
COPY ai-debug.css /usr/share/nginx/html/
COPY daily-bookmarks.css /usr/share/nginx/html/
COPY automation.css /usr/share/nginx/html/
COPY app.js /usr/share/nginx/html/
COPY dashboard.js /usr/share/nginx/html/
COPY settings.js /usr/share/nginx/html/
COPY ai-debug.js /usr/share/nginx/html/
COPY daily-bookmarks.js /usr/share/nginx/html/
COPY trade-fairs.js /usr/share/nginx/html/
COPY automation.js /usr/share/nginx/html/
COPY login.js /usr/share/nginx/html/
COPY vendor /usr/share/nginx/html/vendor/
@@ -28,13 +31,16 @@ RUN set -e; \
/usr/share/nginx/html/app.js \
/usr/share/nginx/html/dashboard.js \
/usr/share/nginx/html/settings.js \
/usr/share/nginx/html/ai-debug.js \
/usr/share/nginx/html/daily-bookmarks.js \
/usr/share/nginx/html/trade-fairs.js \
/usr/share/nginx/html/automation.js \
/usr/share/nginx/html/login.js \
/usr/share/nginx/html/vendor/list.min.js \
/usr/share/nginx/html/style.css \
/usr/share/nginx/html/dashboard.css \
/usr/share/nginx/html/settings.css \
/usr/share/nginx/html/ai-debug.css \
/usr/share/nginx/html/daily-bookmarks.css \
/usr/share/nginx/html/automation.css \
| sha256sum | awk '{print $1}')"; \

285
web/ai-debug.css Normal file
View File

@@ -0,0 +1,285 @@
.ai-debug-shell {
display: flex;
flex-direction: column;
gap: 16px;
}
.ai-debug-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
}
.ai-debug-header h2 {
margin: 0;
}
.ai-debug-subtitle {
margin: 6px 0 0;
color: #64748b;
}
.ai-debug-toolbar {
display: flex;
gap: 10px;
align-items: flex-end;
flex-wrap: wrap;
}
.ai-debug-toolbar__item {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
color: #334155;
}
.ai-debug-toolbar__item select {
min-width: 120px;
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 7px 10px;
background: #fff;
}
.ai-debug-status {
min-height: 20px;
color: #1f2937;
font-size: 14px;
}
.ai-debug-status--error {
color: #b91c1c;
}
.ai-debug-layout {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
gap: 16px;
}
.ai-debug-list-panel,
.ai-debug-detail-panel {
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
padding: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.ai-debug-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.ai-debug-table th,
.ai-debug-table td {
padding: 9px 8px;
border-bottom: 1px solid #eef2f7;
text-align: left;
vertical-align: top;
}
.ai-debug-table th {
font-size: 12px;
letter-spacing: 0.02em;
text-transform: uppercase;
color: #64748b;
}
.ai-debug-table tbody tr {
cursor: pointer;
}
.ai-debug-table tbody tr:hover {
background: #f8fafc;
}
.ai-debug-table tbody tr.is-selected {
background: #e0f2fe;
}
.ai-debug-empty {
text-align: center;
color: #64748b;
}
.ai-debug-badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 2px 8px;
font-size: 11px;
line-height: 1.6;
background: #e2e8f0;
color: #0f172a;
}
.ai-debug-badge--success {
background: #dcfce7;
color: #166534;
}
.ai-debug-badge--clipboard_fallback {
background: #ffedd5;
color: #9a3412;
}
.ai-debug-badge--cancelled {
background: #f1f5f9;
color: #334155;
}
.ai-debug-badge--error,
.ai-debug-badge--backend_error {
background: #fee2e2;
color: #991b1b;
}
.ai-debug-badge--backend_rejected {
background: #fef3c7;
color: #92400e;
}
.ai-debug-detail-panel h3 {
margin: 0 0 8px;
}
.ai-debug-detail-meta {
color: #475569;
font-size: 13px;
margin-bottom: 10px;
}
.ai-debug-inline-metric {
display: flex;
flex-direction: column;
gap: 4px;
}
.ai-debug-inline-bar {
display: block;
width: 100%;
height: 6px;
border-radius: 999px;
background: #e2e8f0;
overflow: hidden;
}
.ai-debug-inline-bar > span {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #0ea5e9 0%, #0284c7 100%);
}
.ai-debug-viz {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 10px;
}
.ai-debug-viz-empty {
color: #64748b;
font-size: 13px;
}
.ai-debug-viz-group {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px;
background: #f8fafc;
}
.ai-debug-viz-group h4 {
margin: 0 0 8px;
font-size: 13px;
color: #1e293b;
}
.ai-debug-bars {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 7px;
}
.ai-debug-bar-row {
display: grid;
grid-template-columns: minmax(120px, 180px) minmax(60px, 1fr) auto;
align-items: center;
gap: 8px;
}
.ai-debug-bar-label {
font-size: 12px;
color: #334155;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ai-debug-bar-track {
display: block;
width: 100%;
height: 10px;
border-radius: 999px;
background: #dbeafe;
overflow: hidden;
}
.ai-debug-bar-fill {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #38bdf8 0%, #2563eb 100%);
}
.ai-debug-bar-value {
font-size: 12px;
color: #0f172a;
white-space: nowrap;
}
.ai-debug-bar-row--peak .ai-debug-bar-track {
background: #fee2e2;
}
.ai-debug-bar-row--peak .ai-debug-bar-fill {
background: linear-gradient(90deg, #fb7185 0%, #dc2626 100%);
}
.ai-debug-json {
margin: 0;
max-height: 60vh;
overflow: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #0f172a;
color: #e2e8f0;
padding: 12px;
font-size: 12px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 1080px) {
.ai-debug-layout {
grid-template-columns: 1fr;
}
.ai-debug-json {
max-height: 42vh;
}
.ai-debug-bar-row {
grid-template-columns: minmax(100px, 140px) minmax(40px, 1fr) auto;
}
}

416
web/ai-debug.js Normal file
View File

@@ -0,0 +1,416 @@
(function () {
let active = false;
let initialized = false;
const API_URL = (() => {
if (window.API_URL) return window.API_URL;
try {
return `${window.location.origin}/api`;
} catch (error) {
return 'https://fb.srv.medeba-media.de/api';
}
})();
const LOGIN_PAGE = 'login.html';
const state = {
traces: [],
selectedTraceId: null,
loading: false
};
const tableBody = document.getElementById('aiDebugTableBody');
const statusEl = document.getElementById('aiDebugStatus');
const detailMeta = document.getElementById('aiDebugDetailMeta');
const detailViz = document.getElementById('aiDebugDetailViz');
const detailJson = document.getElementById('aiDebugDetailJson');
const refreshBtn = document.getElementById('aiDebugRefreshBtn');
const statusFilter = document.getElementById('aiDebugStatusFilter');
const limitFilter = document.getElementById('aiDebugLimitFilter');
function handleUnauthorized(response) {
if (response && response.status === 401) {
if (typeof redirectToLogin === 'function') {
redirectToLogin();
} else {
window.location.href = LOGIN_PAGE;
}
return true;
}
return false;
}
async function apiFetchJSON(path, options = {}) {
const response = await fetch(`${API_URL}${path}`, {
credentials: 'include',
...options
});
if (handleUnauthorized(response)) {
throw new Error('Nicht angemeldet');
}
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.error || 'Unbekannter Fehler');
}
return response.json();
}
function setStatus(message, isError = false) {
if (!statusEl) return;
statusEl.textContent = message || '';
statusEl.classList.toggle('ai-debug-status--error', !!isError);
}
function formatDate(value) {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
function formatMs(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric < 0) {
return '—';
}
return `${Math.round(numeric)} ms`;
}
function compactId(value) {
if (!value || typeof value !== 'string') return '—';
if (value.length <= 16) return value;
return `${value.slice(0, 8)}${value.slice(-6)}`;
}
function toPositiveNumber(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric < 0) {
return null;
}
return numeric;
}
function getBackendTotalMs(item) {
return item
&& item.backend_timings
&& typeof item.backend_timings === 'object'
? item.backend_timings.totalMs
: null;
}
function getAiRequestMs(item) {
return item
&& item.frontend_timings
&& typeof item.frontend_timings === 'object'
? item.frontend_timings.aiRequestMs
: null;
}
function renderTable() {
if (!tableBody) return;
tableBody.innerHTML = '';
if (!state.traces.length) {
const tr = document.createElement('tr');
tr.innerHTML = '<td colspan="6" class="ai-debug-empty">Keine Debug-Läufe gefunden.</td>';
tableBody.appendChild(tr);
return;
}
const totals = state.traces
.map((item) => toPositiveNumber(item.total_duration_ms))
.filter((value) => value !== null);
const maxTotal = totals.length ? Math.max(...totals) : 0;
state.traces.forEach((item) => {
const tr = document.createElement('tr');
tr.dataset.traceId = item.trace_id;
if (item.trace_id === state.selectedTraceId) {
tr.classList.add('is-selected');
}
const totalMs = toPositiveNumber(item.total_duration_ms);
const totalBarPercent = maxTotal > 0 && totalMs !== null
? Math.max(4, Math.round((totalMs / maxTotal) * 100))
: 0;
tr.innerHTML = `
<td>${formatDate(item.created_at)}</td>
<td><span class="ai-debug-badge ai-debug-badge--${(item.status || 'unknown').replace(/[^a-z0-9_-]/gi, '')}">${item.status || '—'}</span></td>
<td>
<div class="ai-debug-inline-metric">
<span>${formatMs(item.total_duration_ms)}</span>
<span class="ai-debug-inline-bar" aria-hidden="true"><span style="width: ${totalBarPercent}%"></span></span>
</div>
</td>
<td>${formatMs(getBackendTotalMs(item))}</td>
<td>${formatMs(getAiRequestMs(item))}</td>
<td><code>${compactId(item.flow_id)}</code> / <code>${compactId(item.trace_id)}</code></td>
`;
tableBody.appendChild(tr);
});
}
function collectFrontendBars(trace) {
const front = trace && trace.frontend_timings && typeof trace.frontend_timings === 'object'
? trace.frontend_timings
: {};
const mapping = [
['extractPostTextMs', 'Text-Extraktion'],
['profileLookupMs', 'Profil-Lookup'],
['aiRequestMs', 'AI-Request'],
['waitForCommentInputMs', 'Wartezeit Kommentarfeld'],
['setCommentTextMs', 'Kommentar einfügen'],
['confirmParticipationMs', 'Auto-Bestätigung'],
['clipboardWriteMs', 'Clipboard-Fallback'],
['totalMs', 'Frontend Total']
];
return mapping
.map(([key, label]) => ({
key,
label,
value: toPositiveNumber(front[key])
}))
.filter((entry) => entry.value !== null);
}
function collectBackendBars(trace) {
const back = trace && trace.backend_timings && typeof trace.backend_timings === 'object'
? trace.backend_timings
: {};
const mapping = [
['loadSettingsMs', 'Settings laden'],
['reactivateCredentialsMs', 'Credentials reaktivieren'],
['loadCredentialsMs', 'Credentials laden'],
['buildPromptMs', 'Prompt bauen'],
['credentialLoopMs', 'Provider-/Credential-Loop'],
['totalMs', 'Backend Total']
];
return mapping
.map(([key, label]) => ({
key,
label,
value: toPositiveNumber(back[key])
}))
.filter((entry) => entry.value !== null);
}
function collectCredentialBars(trace) {
const attempts = trace && Array.isArray(trace.backend_attempts)
? trace.backend_attempts
: [];
return attempts
.map((attempt, index) => {
const credential = attempt && attempt.credentialName ? attempt.credentialName : `Credential ${index + 1}`;
const status = attempt && attempt.status ? attempt.status : 'unknown';
return {
key: `credential_${index + 1}`,
label: `${credential} (${status})`,
value: toPositiveNumber(attempt && attempt.duration_ms)
};
})
.filter((entry) => entry.value !== null);
}
function renderBarGroup(title, rows) {
if (!Array.isArray(rows) || !rows.length) {
return '';
}
const max = Math.max(...rows.map((entry) => entry.value));
const sorted = [...rows].sort((a, b) => b.value - a.value);
const peakKey = sorted[0] ? sorted[0].key : null;
const items = rows.map((entry) => {
const width = max > 0
? Math.max(4, Math.round((entry.value / max) * 100))
: 0;
const peakClass = entry.key === peakKey ? ' ai-debug-bar-row--peak' : '';
return `
<li class="ai-debug-bar-row${peakClass}">
<span class="ai-debug-bar-label">${entry.label}</span>
<span class="ai-debug-bar-track" aria-hidden="true">
<span class="ai-debug-bar-fill" style="width: ${width}%"></span>
</span>
<span class="ai-debug-bar-value">${formatMs(entry.value)}</span>
</li>
`;
}).join('');
return `
<section class="ai-debug-viz-group">
<h4>${title}</h4>
<ul class="ai-debug-bars">
${items}
</ul>
</section>
`;
}
function renderDetailVisualization(trace) {
if (!detailViz) {
return;
}
if (!trace) {
detailViz.innerHTML = '';
return;
}
const frontendBars = collectFrontendBars(trace);
const backendBars = collectBackendBars(trace);
const credentialBars = collectCredentialBars(trace);
const sections = [
renderBarGroup('Frontend-Phasen', frontendBars),
renderBarGroup('Backend-Phasen', backendBars),
renderBarGroup('Credential-Attempts', credentialBars)
].filter(Boolean);
if (!sections.length) {
detailViz.innerHTML = '<div class="ai-debug-viz-empty">Keine Timing-Daten für diesen Lauf verfügbar.</div>';
return;
}
detailViz.innerHTML = sections.join('');
}
function renderDetail(trace) {
if (!detailMeta || !detailJson) {
return;
}
if (!trace) {
detailMeta.textContent = 'Bitte einen Eintrag auswählen.';
renderDetailVisualization(null);
detailJson.textContent = '';
return;
}
detailMeta.textContent = `${trace.status || '—'} · ${formatDate(trace.created_at)} · Trace ${trace.trace_id || '—'}`;
renderDetailVisualization(trace);
detailJson.textContent = JSON.stringify(trace, null, 2);
}
async function loadTraceDetail(traceId) {
if (!traceId) {
renderDetail(null);
return;
}
try {
const trace = await apiFetchJSON(`/ai/debug-traces/${encodeURIComponent(traceId)}`);
if (!active) return;
renderDetail(trace);
} catch (error) {
if (!active) return;
setStatus(`Details konnten nicht geladen werden: ${error.message}`, true);
}
}
async function loadTraces() {
if (!active || state.loading) {
return;
}
state.loading = true;
setStatus('Lade Debug-Läufe...');
try {
const params = new URLSearchParams();
const status = statusFilter ? statusFilter.value.trim() : '';
const limit = limitFilter ? parseInt(limitFilter.value, 10) : 50;
if (status) {
params.set('status', status);
}
if (Number.isFinite(limit) && limit > 0) {
params.set('limit', String(limit));
}
const data = await apiFetchJSON(`/ai/debug-traces?${params.toString()}`);
if (!active) return;
state.traces = Array.isArray(data.items) ? data.items : [];
if (!state.selectedTraceId || !state.traces.some((item) => item.trace_id === state.selectedTraceId)) {
state.selectedTraceId = state.traces.length ? state.traces[0].trace_id : null;
}
renderTable();
await loadTraceDetail(state.selectedTraceId);
setStatus(`${state.traces.length} Lauf/Läufe geladen.`);
} catch (error) {
if (!active) return;
state.traces = [];
renderTable();
renderDetail(null);
setStatus(`Debug-Läufe konnten nicht geladen werden: ${error.message}`, true);
} finally {
state.loading = false;
}
}
function setupEvents() {
if (refreshBtn) {
refreshBtn.addEventListener('click', () => loadTraces());
}
if (statusFilter) {
statusFilter.addEventListener('change', () => loadTraces());
}
if (limitFilter) {
limitFilter.addEventListener('change', () => loadTraces());
}
if (tableBody) {
tableBody.addEventListener('click', (event) => {
const row = event.target.closest('tr[data-trace-id]');
if (!row) return;
const traceId = row.dataset.traceId;
if (!traceId) return;
state.selectedTraceId = traceId;
renderTable();
loadTraceDetail(traceId);
});
}
}
function init() {
if (initialized) return;
setupEvents();
initialized = true;
}
function activate() {
init();
active = true;
loadTraces();
}
function deactivate() {
active = false;
}
window.AIDebugPage = {
activate,
deactivate
};
const section = document.querySelector('[data-view="ai-debug"]');
if (section && section.classList.contains('app-view--active')) {
activate();
}
})();

File diff suppressed because it is too large Load Diff

View File

@@ -808,13 +808,17 @@
openBtn.textContent = '🔗';
openBtn.title = isActive ? 'Öffnen' : 'Deaktiviert';
openBtn.disabled = !isActive;
openBtn.addEventListener('click', () => {
const handleOpenBookmark = () => {
const target = item.resolved_url || item.url_template;
if (!isActive) return;
if (target) {
window.open(target, '_blank', 'noopener');
}
});
};
openBtn.addEventListener('click', handleOpenBookmark);
if (typeof window.bindMiddleMouseOpen === 'function') {
window.bindMiddleMouseOpen(openBtn, handleOpenBookmark);
}
actionsTd.appendChild(openBtn);
const toggleBtn = document.createElement('button');

View File

@@ -22,16 +22,20 @@
{ href: 'style.css' },
{ href: 'dashboard.css' },
{ href: 'settings.css' },
{ href: 'ai-debug.css' },
{ href: 'automation.css' },
{ href: 'daily-bookmarks.css', id: 'dailyBookmarksCss', disabled: true }
];
const jsFiles = [
'app.js',
'dashboard.js',
'settings.js',
'vendor/list.min.js',
'automation.js',
'daily-bookmarks.js'
{ src: 'app.js' },
{ src: 'dashboard.js' },
{ src: 'settings.js' },
{ src: 'ai-debug.js' },
{ src: 'vendor/list.min.js' },
{ src: 'automation.js' },
{ src: 'daily-bookmarks.js' },
// Optional: new bookmark subpage. If not yet deployed/cached, app must still boot.
{ src: 'trade-fairs.js', optional: true }
];
function withVersion(value) {
@@ -62,16 +66,51 @@
});
}
function normalizeScriptEntry(entry) {
if (typeof entry === 'string') {
return { src: entry, optional: false };
}
return {
src: entry && entry.src ? entry.src : '',
optional: Boolean(entry && entry.optional)
};
}
function showBootstrapError(message) {
document.body.classList.remove('auth-pending');
const loadingEl = document.getElementById('loading');
const errorEl = document.getElementById('error');
if (loadingEl) {
loadingEl.style.display = 'none';
}
if (errorEl) {
errorEl.style.display = 'block';
errorEl.textContent = message || 'Die Anwendung konnte nicht geladen werden.';
}
}
function loadScriptsSequentially(list, index = 0) {
return new Promise((resolve, reject) => {
if (index >= list.length) {
resolve();
return;
}
const entry = normalizeScriptEntry(list[index]);
if (!entry.src) {
loadScriptsSequentially(list, index + 1).then(resolve).catch(reject);
return;
}
const script = document.createElement('script');
script.src = withVersion(list[index]);
script.src = withVersion(entry.src);
script.onload = () => loadScriptsSequentially(list, index + 1).then(resolve).catch(reject);
script.onerror = reject;
script.onerror = () => {
if (entry.optional) {
console.warn('Optionales Script konnte nicht geladen werden:', entry.src);
loadScriptsSequentially(list, index + 1).then(resolve).catch(reject);
return;
}
reject(new Error(`Script konnte nicht geladen werden: ${entry.src}`));
};
document.body.appendChild(script);
});
}
@@ -83,10 +122,14 @@
redirectToLogin();
return;
}
if (!res.ok) {
throw new Error(`Session-Check fehlgeschlagen (${res.status})`);
}
loadStyles();
await loadScriptsSequentially(jsFiles);
} catch (_error) {
redirectToLogin();
} catch (error) {
console.error('Bootstrap fehlgeschlagen:', error);
showBootstrapError('Fehler beim Laden der Anwendung. Bitte Seite neu laden.');
}
}
@@ -130,6 +173,7 @@
<div class="control-group">
<label for="profileSelect">Dein Profil:</label>
<select id="profileSelect" class="control-select">
<option value="">-- Profil wählen --</option>
<option value="1">Profil 1</option>
<option value="2">Profil 2</option>
<option value="3">Profil 3</option>
@@ -170,9 +214,9 @@
</div>
<div class="tabs-section">
<div class="tabs">
<button class="tab-btn" data-tab="all">Alle Beiträge</button>
<button class="tab-btn active" data-tab="pending">Offene Beiträge</button>
<div class="tabs" role="tablist" aria-label="Beitragsansicht filtern">
<button class="tab-btn" id="postsTabAll" type="button" role="tab" aria-selected="false" aria-controls="postsContainer" tabindex="-1" data-tab="all">Alle Beiträge</button>
<button class="tab-btn active" id="postsTabPending" type="button" role="tab" aria-selected="true" aria-controls="postsContainer" tabindex="0" data-tab="pending">Offene Beiträge</button>
</div>
<div class="merge-controls" id="mergeControls" hidden>
<div class="merge-actions">
@@ -185,7 +229,14 @@
<input type="checkbox" id="includeExpiredToggle">
<span>Abgelaufene/abgeschlossene anzeigen</span>
</label>
<input type="text" id="searchInput" class="search-input" placeholder="Beiträge durchsuchen...">
<div class="search-field">
<label for="searchInput" class="search-field__label">Suche</label>
<div class="search-field__input-wrap">
<input type="search" id="searchInput" class="search-input" placeholder="Beiträge durchsuchen..." autocomplete="off" enterkeyhint="search" aria-describedby="searchInputHint">
<button type="button" id="searchClearBtn" class="search-clear-btn" aria-label="Suche leeren" title="Suche leeren" hidden>×</button>
</div>
<p id="searchInputHint" class="search-field__hint">Tipp: <kbd>/</kbd> fokussiert die Suche, <kbd>Esc</kbd> leert sie.</p>
</div>
</div>
<div class="posts-bulk-controls" id="pendingBulkControls" hidden>
<div class="bulk-actions">
@@ -225,6 +276,7 @@
</div>
<div id="postsContainer" class="posts-container"></div>
<button type="button" id="postsScrollTopBtn" class="posts-scroll-top" aria-label="Nach oben scrollen" title="Nach oben" hidden></button>
</div>
<div id="screenshotModal" class="screenshot-modal" hidden>
@@ -1050,6 +1102,16 @@
</section>
<!-- Profile Friends Section -->
<section class="settings-section">
<h2 class="section-title">🧩 Aktive Profile</h2>
<p class="section-description">
Deaktivierte Profile werden in der Bestätigungsreihenfolge übersprungen. Die maximale Anzahl benötigter Profile reduziert sich automatisch auf die Zahl der aktiven Profile.
</p>
<div id="profileActivationList" class="profile-activation-list"></div>
<p id="profileActivationSummary" class="form-help"></p>
</section>
<section class="settings-section">
<h2 class="section-title">👥 Freundesnamen pro Profil</h2>
<p class="section-description">
@@ -1089,14 +1151,83 @@
<textarea id="aiPromptPrefix" class="form-textarea" rows="4"
placeholder="Anweisungen für die KI vor dem Post-Text..."></textarea>
<p class="form-help">
Dieser Text wird vor dem eigentlichen Post-Text an die KI gesendet. Verwende <code>{FREUNDE}</code> als Platzhalter für Freundesnamen.
Dieser Text wird vor dem eigentlichen Post-Text an die KI gesendet. Platzhalter: <code>{FREUNDE}</code> (Freundesnamen), <code>{DATUM}</code> (heutiges Datum), <code>{Profil-1?"Text1":"Text2"}</code> bzw. <code>{Profil-1?"Text1"}</code> für profilabhängige Varianten (auch mit <code>Profile</code>) und <code>{ZUFALL-1-5}</code> für eine Zufallszahl im Bereich.
</p>
</div>
<div class="form-group">
<label class="form-label">
<input type="checkbox" id="aiAutoCommentRateLimitsEnabled" class="form-checkbox">
<span>Limitschutz für den AI-Kommentar-Button aktivieren</span>
</label>
<p class="form-help">
Gilt nur für die Aktion <code>AI - generiere automatisch einen passenden Kommentar</code> im Tracker. Gezählt wird separat je Profil, die Regeln gelten aber für alle Profile gleich.
</p>
</div>
<div class="form-group">
<label for="aiRequestsPerMinute" class="form-label">Max. Aktionen pro Minute</label>
<input type="number" id="aiRequestsPerMinute" class="form-input" min="0" max="60" step="1">
<p class="form-help"><code>0</code> deaktiviert dieses Teillimit.</p>
</div>
<div class="form-group">
<label for="aiRequestsPerHour" class="form-label">Max. Aktionen pro Stunde</label>
<input type="number" id="aiRequestsPerHour" class="form-input" min="0" max="500" step="1">
<p class="form-help"><code>0</code> deaktiviert dieses Teillimit.</p>
</div>
<div class="form-group">
<label for="aiRequestsPerDay" class="form-label">Max. Aktionen pro Tag</label>
<input type="number" id="aiRequestsPerDay" class="form-input" min="0" max="5000" step="1">
<p class="form-help"><code>0</code> deaktiviert dieses Teillimit.</p>
</div>
<div class="form-group">
<label for="aiMinDelaySeconds" class="form-label">Mindestabstand zwischen Aktionen (Sekunden)</label>
<input type="number" id="aiMinDelaySeconds" class="form-input" min="0" max="3600" step="1">
<p class="form-help"><code>0</code> deaktiviert dieses Teillimit.</p>
</div>
<div class="form-group">
<label for="aiBurstLimit" class="form-label">Burst-Limit für kurze Spitzen</label>
<input type="number" id="aiBurstLimit" class="form-input" min="0" max="100" step="1">
<p class="form-help">Maximale Aktionen in 10 Minuten. <code>0</code> deaktiviert dieses Teillimit.</p>
</div>
<div class="form-group">
<label for="aiCooldownMinutes" class="form-label">Cooldown nach 429/403/Warnsignal (Minuten)</label>
<input type="number" id="aiCooldownMinutes" class="form-input" min="0" max="1440" step="1">
<p class="form-help"><code>0</code> deaktiviert den zusätzlichen Cooldown.</p>
</div>
<div class="form-group">
<label class="form-label">Aktivzeiten optional</label>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px;">
<div>
<label for="aiActiveHoursStart" class="form-label">Von</label>
<input type="time" id="aiActiveHoursStart" class="form-input">
</div>
<div>
<label for="aiActiveHoursEnd" class="form-label">Bis</label>
<input type="time" id="aiActiveHoursEnd" class="form-input">
</div>
</div>
<p class="form-help">Leer lassen für 24/7. Zeiten gelten täglich und dürfen über Mitternacht laufen.</p>
</div>
<div class="form-group">
<label class="form-label">Aktueller Status je Profil</label>
<div id="aiRateLimitProfileStatuses"></div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="testBtn">
🧪 Kommentar testen
</button>
<a class="btn btn-secondary" id="openAiDebugViewBtn" data-view-target="ai-debug" href="index.html?view=ai-debug">
🧭 AI-Debug öffnen
</a>
</div>
</form>
</section>
@@ -1391,6 +1522,69 @@
</div>
</div>
</section>
<section id="view-ai-debug" class="app-view ai-debug-view" data-view="ai-debug">
<div class="container">
<div class="ai-debug-shell">
<header class="ai-debug-header">
<div>
<h2>🧭 AI-Debug</h2>
<p class="ai-debug-subtitle">Zeitliche Ablaufanalyse für AI-Kommentar-Generierung (Backend + Extension).</p>
</div>
<div class="ai-debug-toolbar">
<label class="ai-debug-toolbar__item" for="aiDebugStatusFilter">
<span>Status</span>
<select id="aiDebugStatusFilter">
<option value="">Alle</option>
<option value="success">Erfolg</option>
<option value="clipboard_fallback">Clipboard Fallback</option>
<option value="cancelled">Abgebrochen</option>
<option value="error">Fehler</option>
<option value="backend_error">Backend-Fehler</option>
<option value="backend_rejected">Backend-Rejected</option>
</select>
</label>
<label class="ai-debug-toolbar__item" for="aiDebugLimitFilter">
<span>Einträge</span>
<select id="aiDebugLimitFilter">
<option value="25">25</option>
<option value="50" selected>50</option>
<option value="100">100</option>
<option value="200">200</option>
</select>
</label>
<button type="button" class="btn btn-secondary" id="aiDebugRefreshBtn">Aktualisieren</button>
</div>
</header>
<div id="aiDebugStatus" class="ai-debug-status" role="status" aria-live="polite"></div>
<div class="ai-debug-layout">
<section class="ai-debug-list-panel">
<table class="ai-debug-table">
<thead>
<tr>
<th>Zeit</th>
<th>Status</th>
<th>Total (ms)</th>
<th>Backend (ms)</th>
<th>AI-Request (ms)</th>
<th>Flow/Trace</th>
</tr>
</thead>
<tbody id="aiDebugTableBody"></tbody>
</table>
</section>
<section class="ai-debug-detail-panel">
<h3>Ablaufdetails</h3>
<div id="aiDebugDetailMeta" class="ai-debug-detail-meta">Bitte einen Eintrag auswählen.</div>
<div id="aiDebugDetailViz" class="ai-debug-viz"></div>
<pre id="aiDebugDetailJson" class="ai-debug-json"></pre>
</section>
</div>
</div>
</div>
</section>
<section id="view-bookmarks" class="app-view" data-view="bookmarks">
<div class="container">
<main class="bookmark-page">
@@ -1428,6 +1622,71 @@
<p class="bookmark-quicksearch__hint">Öffnet die drei Varianten ohne ein Bookmark anzulegen.</p>
<div id="bookmarkQuickStatus" class="bookmark-status" aria-live="polite" hidden></div>
</form>
<details class="bookmark-subpage" id="tradeFairsSubpage">
<summary class="bookmark-subpage__summary">📍 Top Messen in Deutschland (nach Besucherzahlen, ohne kostenlose Messen)</summary>
<div class="bookmark-subpage__content">
<div class="bookmark-subpage__toolbar">
<label class="bookmark-panel__search">
<span>Messesuche</span>
<input type="search" id="tradeFairSearchInput" placeholder="Direkt nach Messe suchen (z.B. IFA, CMT, offerta)">
</label>
<div class="bookmark-subpage__toolbar-actions">
<div class="bookmark-subpage__meta" id="tradeFairMeta"></div>
<button type="button" class="bookmark-subpage__config-btn" id="tradeFairColumnSettingsBtn" title="Spalten konfigurieren" aria-label="Spalten konfigurieren">⚙️</button>
</div>
</div>
<div class="bookmark-columns-modal" id="tradeFairColumnsModal" hidden>
<div class="bookmark-columns-modal__backdrop" id="tradeFairColumnsModalBackdrop"></div>
<div class="bookmark-columns-modal__content" role="dialog" aria-modal="true" aria-labelledby="tradeFairColumnsModalTitle">
<button type="button" class="bookmark-columns-modal__close" id="tradeFairColumnsModalClose" aria-label="Schließen">×</button>
<h3 id="tradeFairColumnsModalTitle">Spalten konfigurieren</h3>
<p class="bookmark-columns-modal__hint">Reihenfolge mit Pfeilen anpassen und Spalten ein-/ausblenden.</p>
<div class="bookmark-columns-modal__list" id="tradeFairColumnsList"></div>
<div class="bookmark-columns-modal__actions">
<button type="button" class="btn btn-secondary" id="tradeFairColumnsResetBtn">Standard wiederherstellen</button>
<button type="button" class="btn btn-primary" id="tradeFairColumnsDoneBtn">Fertig</button>
</div>
</div>
</div>
<div class="bookmark-subpage__table-wrap">
<table class="bookmark-subpage__table">
<thead>
<tr>
<th>
<div class="bookmark-subpage__th-stack" id="tradeFairDaysFilterContainer">
<div class="bookmark-subpage__th-controls">
<button type="button" class="bookmark-subpage__sort" data-trade-sort="tage_bis_start">Tage bis Start</button>
<button type="button" id="tradeFairDaysFilterToggle" class="bookmark-subpage__filter-toggle" aria-label="Filter fuer Tage bis Start umschalten" aria-expanded="false" title="Tage bis Start filtern">
<svg viewBox="0 0 20 20" aria-hidden="true" focusable="false">
<path d="M3 4.5C3 4.224 3.224 4 3.5 4h13c.276 0 .5.224.5.5 0 .118-.042.233-.117.323L12 10.409V15a.5.5 0 0 1-.224.416l-3 2A.5.5 0 0 1 8 17v-6.591L3.117 4.823A.5.5 0 0 1 3 4.5z"/>
</svg>
</button>
</div>
<input type="text" id="tradeFairDaysFilterInput" class="bookmark-subpage__column-filter" placeholder=">0" autocomplete="off" spellcheck="false">
</div>
</th>
<th><button type="button" class="bookmark-subpage__sort" data-trade-sort="rang">Rang</button></th>
<th><button type="button" class="bookmark-subpage__sort" data-trade-sort="messe">Messe</button></th>
<th><button type="button" class="bookmark-subpage__sort" data-trade-sort="zuletzt_gesucht_am">Zuletzt gesucht am</button></th>
<th><button type="button" class="bookmark-subpage__sort" data-trade-sort="thema">Thema</button></th>
<th><button type="button" class="bookmark-subpage__sort" data-trade-sort="stadt">Stadt</button></th>
<th><button type="button" class="bookmark-subpage__sort" data-trade-sort="bundesland">Bundesland</button></th>
<th><button type="button" class="bookmark-subpage__sort" data-trade-sort="termin">Termin</button></th>
<th><button type="button" class="bookmark-subpage__sort" data-trade-sort="besucher">Besucher</button></th>
<th><button type="button" class="bookmark-subpage__sort" data-trade-sort="besucher_jahr">Besucher Jahr</button></th>
<th><button type="button" class="bookmark-subpage__sort" data-trade-sort="besucher_status">Besucher Status</button></th>
<th><button type="button" class="bookmark-subpage__sort" data-trade-sort="ausstellungsflaeche_m2">Ausstellungsfläche (m²)</button></th>
<th><button type="button" class="bookmark-subpage__sort" data-trade-sort="ticketpreis_we_eur">Ticketpreis Wochenende (EUR)</button></th>
<th><button type="button" class="bookmark-subpage__sort" data-trade-sort="ticketpreis_unterderwoche_eur">Ticketpreis unter der Woche (EUR)</button></th>
<th><button type="button" class="bookmark-subpage__sort" data-trade-sort="notiz">Notiz</button></th>
<th><button type="button" class="bookmark-subpage__sort" data-trade-sort="quelle_homepage">Quelle Homepage</button></th>
</tr>
</thead>
<tbody id="tradeFairTableBody"></tbody>
</table>
</div>
</div>
</details>
<div id="bookmarksList" class="bookmark-list" role="list" aria-live="polite"></div>
<form id="bookmarkForm" class="bookmark-form" autocomplete="off">
<div class="bookmark-form__fields">
@@ -1467,6 +1726,7 @@
posts: 'Beiträge',
dashboard: 'Dashboard',
settings: 'Einstellungen',
'ai-debug': 'AI-Debug',
bookmarks: 'Bookmarks',
automation: 'Automationen',
'daily-bookmarks': 'Daily Bookmarks'
@@ -1548,6 +1808,7 @@
(function() {
const AUTOMATION_VIEW = 'automation';
const DAILY_VIEW = 'daily-bookmarks';
const AI_DEBUG_VIEW = 'ai-debug';
function handleViewChange(event) {
const view = event?.detail?.view;
if (view === AUTOMATION_VIEW) {
@@ -1560,6 +1821,11 @@
} else {
window.DailyBookmarksPage?.deactivate?.();
}
if (view === AI_DEBUG_VIEW) {
window.AIDebugPage?.activate?.();
} else {
window.AIDebugPage?.deactivate?.();
}
}
window.addEventListener('app:view-change', handleViewChange);
@@ -1572,6 +1838,10 @@
if (dailySection && dailySection.classList.contains('app-view--active')) {
window.DailyBookmarksPage?.activate?.();
}
const aiDebugSection = document.querySelector('[data-view="ai-debug"]');
if (aiDebugSection && aiDebugSection.classList.contains('app-view--active')) {
window.AIDebugPage?.activate?.();
}
})();
</script>
</body>

View File

@@ -95,6 +95,39 @@
line-height: 1.4;
}
.profile-activation-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.profile-activation-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 14px 16px;
border: 1px solid #e4e6eb;
border-radius: 10px;
background: #f8fafc;
}
.profile-activation-item__copy {
display: flex;
flex-direction: column;
gap: 4px;
}
.profile-activation-item__title {
font-size: 14px;
font-weight: 600;
color: #1c1e21;
}
.profile-activation-item__hint {
font-size: 12px;
color: #65676b;
}
.grid-weights {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));

View File

@@ -41,10 +41,24 @@ const PROVIDER_INFO = {
apiKeyHelp: 'Für lokale OpenAI-kompatible Server (z.B. Ollama) kannst du den Schlüssel leer lassen.'
}
};
const AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS = Object.freeze({
enabled: 1,
requests_per_minute: 2,
requests_per_hour: 20,
requests_per_day: 80,
min_delay_seconds: 45,
burst_limit: 5,
cooldown_minutes: 15,
active_hours_start: '',
active_hours_end: ''
});
const MAX_PROFILES = 5;
const DEFAULT_ACTIVE_PROFILES = Array.from({ length: MAX_PROFILES }, (_unused, index) => index + 1);
let credentials = [];
let currentSettings = null;
let hiddenSettings = { auto_purge_enabled: true, retention_days: 90 };
let profileSettings = { active_profiles: [...DEFAULT_ACTIVE_PROFILES], max_target_count: MAX_PROFILES };
const SPORT_WEIGHT_FIELDS = [
{ key: 'scoreline', id: 'sportWeightScoreline' },
{ key: 'scoreEmoji', id: 'sportWeightScoreEmoji' },
@@ -173,6 +187,8 @@ async function loadSettings() {
document.getElementById('activeCredential').value = currentSettings.active_credential_id || '';
document.getElementById('aiPromptPrefix').value = currentSettings.prompt_prefix ||
'Schreibe einen freundlichen, authentischen Kommentar auf Deutsch zu folgendem Facebook-Post. Der Kommentar soll natürlich wirken und maximal 2-3 Sätze lang sein:\n\n';
applyAIAutoCommentRateLimitSettingsUI();
renderAIAutoCommentRateLimitStatuses();
}
async function loadHiddenSettings() {
@@ -425,6 +441,172 @@ function normalizeSimilarityImageThresholdInput(value) {
return Math.min(64, Math.max(0, parsed));
}
function normalizeAIAutoCommentRateLimitValue(value, fallback, max) {
const parsed = parseInt(value, 10);
if (Number.isNaN(parsed) || parsed < 0) {
return fallback;
}
return Math.min(max, parsed);
}
function normalizeAIAutoCommentTimeInput(value) {
if (!value || typeof value !== 'string') {
return '';
}
const trimmed = value.trim();
if (!trimmed) {
return '';
}
return /^\d{2}:\d{2}$/.test(trimmed) ? trimmed : '';
}
function getAIAutoCommentRateLimitSettings() {
const raw = currentSettings?.rate_limit_settings || {};
return {
enabled: raw.enabled === undefined || raw.enabled === null
? AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.enabled
: (raw.enabled ? 1 : 0),
requests_per_minute: normalizeAIAutoCommentRateLimitValue(raw.requests_per_minute, AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.requests_per_minute, 60),
requests_per_hour: normalizeAIAutoCommentRateLimitValue(raw.requests_per_hour, AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.requests_per_hour, 500),
requests_per_day: normalizeAIAutoCommentRateLimitValue(raw.requests_per_day, AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.requests_per_day, 5000),
min_delay_seconds: normalizeAIAutoCommentRateLimitValue(raw.min_delay_seconds, AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.min_delay_seconds, 3600),
burst_limit: normalizeAIAutoCommentRateLimitValue(raw.burst_limit, AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.burst_limit, 100),
cooldown_minutes: normalizeAIAutoCommentRateLimitValue(raw.cooldown_minutes, AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.cooldown_minutes, 1440),
active_hours_start: normalizeAIAutoCommentTimeInput(raw.active_hours_start),
active_hours_end: normalizeAIAutoCommentTimeInput(raw.active_hours_end)
};
}
function applyAIAutoCommentRateLimitSettingsUI() {
const settings = getAIAutoCommentRateLimitSettings();
const toggle = document.getElementById('aiAutoCommentRateLimitsEnabled');
const requestsPerMinute = document.getElementById('aiRequestsPerMinute');
const requestsPerHour = document.getElementById('aiRequestsPerHour');
const requestsPerDay = document.getElementById('aiRequestsPerDay');
const minDelaySeconds = document.getElementById('aiMinDelaySeconds');
const burstLimit = document.getElementById('aiBurstLimit');
const cooldownMinutes = document.getElementById('aiCooldownMinutes');
const activeHoursStart = document.getElementById('aiActiveHoursStart');
const activeHoursEnd = document.getElementById('aiActiveHoursEnd');
if (toggle) toggle.checked = !!settings.enabled;
if (requestsPerMinute) requestsPerMinute.value = settings.requests_per_minute;
if (requestsPerHour) requestsPerHour.value = settings.requests_per_hour;
if (requestsPerDay) requestsPerDay.value = settings.requests_per_day;
if (minDelaySeconds) minDelaySeconds.value = settings.min_delay_seconds;
if (burstLimit) burstLimit.value = settings.burst_limit;
if (cooldownMinutes) cooldownMinutes.value = settings.cooldown_minutes;
if (activeHoursStart) activeHoursStart.value = settings.active_hours_start || '';
if (activeHoursEnd) activeHoursEnd.value = settings.active_hours_end || '';
applyAIAutoCommentRateLimitEnabledState(!!settings.enabled);
}
function applyAIAutoCommentRateLimitEnabledState(enabled) {
[
'aiRequestsPerMinute',
'aiRequestsPerHour',
'aiRequestsPerDay',
'aiMinDelaySeconds',
'aiBurstLimit',
'aiCooldownMinutes',
'aiActiveHoursStart',
'aiActiveHoursEnd'
].forEach((id) => {
const input = document.getElementById(id);
if (input) {
input.disabled = !enabled;
}
});
}
function renderAIAutoCommentRateLimitStatuses() {
const container = document.getElementById('aiRateLimitProfileStatuses');
if (!container) {
return;
}
const statuses = Array.isArray(currentSettings?.rate_limit_statuses)
? currentSettings.rate_limit_statuses
: [];
container.innerHTML = '';
if (!statuses.length) {
const empty = document.createElement('p');
empty.className = 'form-help';
empty.textContent = 'Noch keine Statusdaten verfügbar.';
container.appendChild(empty);
return;
}
statuses.forEach((status) => {
const card = document.createElement('div');
card.className = 'form-group';
card.style.marginBottom = '12px';
card.style.padding = '12px';
card.style.border = '1px solid #dfe3e8';
card.style.borderRadius = '8px';
card.style.background = status.blocked ? '#fff7f7' : '#f8fafc';
const title = document.createElement('div');
title.className = 'form-label';
title.textContent = status.profile_name || `Profil ${status.profile_number}`;
card.appendChild(title);
const summary = document.createElement('p');
summary.className = 'form-help';
const usage = status.usage || {};
const remaining = status.remaining || {};
summary.textContent = `Minute ${usage.minute ?? 0}${remaining.minute !== null && remaining.minute !== undefined ? ` (${remaining.minute} frei)` : ''} · Stunde ${usage.hour ?? 0}${remaining.hour !== null && remaining.hour !== undefined ? ` (${remaining.hour} frei)` : ''} · Tag ${usage.day ?? 0}${remaining.day !== null && remaining.day !== undefined ? ` (${remaining.day} frei)` : ''} · Burst ${usage.burst ?? 0}${remaining.burst !== null && remaining.burst !== undefined ? ` (${remaining.burst} frei)` : ''}`;
card.appendChild(summary);
const details = document.createElement('p');
details.className = 'form-help';
const detailParts = [];
if (status.last_action_at) {
detailParts.push(`Letzte Aktion ${formatRelativePast(status.last_action_at)}`);
}
if (status.active_hours?.configured) {
detailParts.push(`Aktivzeit ${status.active_hours.start}${status.active_hours.end}`);
} else {
detailParts.push('Aktivzeit 24/7');
}
if (status.cooldown_until) {
detailParts.push(`Cooldown bis ${formatRelativeFuture(status.cooldown_until)}`);
}
details.textContent = detailParts.join(' · ');
card.appendChild(details);
const state = document.createElement('p');
state.className = 'form-help';
state.style.marginBottom = '0';
if (status.blocked) {
const blockedReasonMap = {
cooldown: 'Cooldown aktiv',
active_hours: 'außerhalb der Aktivzeiten',
min_delay: 'Mindestabstand noch nicht erreicht',
per_minute: 'Minutenlimit erreicht',
burst: 'Burst-Limit erreicht',
per_hour: 'Stundenlimit erreicht',
per_day: 'Tageslimit erreicht'
};
const untilText = status.blocked_until ? ` bis ${formatRelativeFuture(status.blocked_until)}` : '';
state.textContent = `Gesperrt: ${blockedReasonMap[status.blocked_reason] || 'Limit aktiv'}${untilText}`;
state.style.color = '#b42318';
if (status.cooldown_reason) {
state.title = status.cooldown_reason;
}
} else {
state.textContent = 'Aktuell freigegeben';
state.style.color = '#027a48';
}
card.appendChild(state);
container.appendChild(card);
});
}
function applySimilaritySettingsUI() {
const textInput = document.getElementById('similarityTextThreshold');
const imageInput = document.getElementById('similarityImageThreshold');
@@ -998,7 +1180,18 @@ async function saveSettings(e, { silent = false } = {}) {
const data = {
enabled: document.getElementById('aiEnabled').checked,
active_credential_id: parseInt(document.getElementById('activeCredential').value) || null,
prompt_prefix: document.getElementById('aiPromptPrefix').value
prompt_prefix: document.getElementById('aiPromptPrefix').value,
rate_limit_settings: {
enabled: document.getElementById('aiAutoCommentRateLimitsEnabled').checked ? 1 : 0,
requests_per_minute: normalizeAIAutoCommentRateLimitValue(document.getElementById('aiRequestsPerMinute').value, AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.requests_per_minute, 60),
requests_per_hour: normalizeAIAutoCommentRateLimitValue(document.getElementById('aiRequestsPerHour').value, AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.requests_per_hour, 500),
requests_per_day: normalizeAIAutoCommentRateLimitValue(document.getElementById('aiRequestsPerDay').value, AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.requests_per_day, 5000),
min_delay_seconds: normalizeAIAutoCommentRateLimitValue(document.getElementById('aiMinDelaySeconds').value, AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.min_delay_seconds, 3600),
burst_limit: normalizeAIAutoCommentRateLimitValue(document.getElementById('aiBurstLimit').value, AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.burst_limit, 100),
cooldown_minutes: normalizeAIAutoCommentRateLimitValue(document.getElementById('aiCooldownMinutes').value, AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.cooldown_minutes, 1440),
active_hours_start: normalizeAIAutoCommentTimeInput(document.getElementById('aiActiveHoursStart').value),
active_hours_end: normalizeAIAutoCommentTimeInput(document.getElementById('aiActiveHoursEnd').value)
}
};
const res = await apiFetch(`${API_URL}/ai-settings`, {
@@ -1013,6 +1206,8 @@ async function saveSettings(e, { silent = false } = {}) {
}
currentSettings = await res.json();
applyAIAutoCommentRateLimitSettingsUI();
renderAIAutoCommentRateLimitStatuses();
if (!silent) {
showSuccess('✅ Einstellungen erfolgreich gespeichert');
}
@@ -1037,7 +1232,7 @@ async function testComment() {
try {
const data = JSON.parse(lastTest);
document.getElementById('testPostText').value = data.postText || '';
document.getElementById('testProfileNumber').value = data.profileNumber || '1';
renderTestProfileOptions(data.profileNumber || profileSettings.active_profiles[0] || 1);
} catch (e) {
console.error('Failed to load last test comment:', e);
}
@@ -1058,7 +1253,7 @@ async function generateTest() {
const res = await apiFetch(`${API_URL}/ai/generate-comment`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({postText: text, profileNumber})
body: JSON.stringify({postText: text, profileNumber, traceSource: 'settings-test'})
});
if (!res.ok) {
@@ -1121,6 +1316,7 @@ async function saveAllSettings(event) {
const results = await Promise.all([
saveSettings(null, { silent: true }),
saveProfileSettings(null, { silent: true }),
saveHiddenSettings(null, { silent: true }),
saveModerationSettings(null, { silent: true }),
saveSimilaritySettings(null, { silent: true }),
@@ -1151,6 +1347,146 @@ async function saveAllSettings(event) {
}
}
function normalizeActiveProfileNumbers(values) {
if (!Array.isArray(values)) {
return [...DEFAULT_ACTIVE_PROFILES];
}
const seen = new Set();
const normalized = values
.map((value) => parseInt(value, 10))
.filter((value) => Number.isInteger(value) && value >= 1 && value <= MAX_PROFILES && !seen.has(value) && seen.add(value))
.sort((a, b) => a - b);
return normalized.length ? normalized : [...DEFAULT_ACTIVE_PROFILES];
}
function collectActiveProfilesFromInputs() {
return Array.from(document.querySelectorAll('.profile-activation-checkbox:checked'))
.map((input) => parseInt(input.value, 10))
.filter((value) => Number.isInteger(value) && value >= 1 && value <= MAX_PROFILES)
.sort((a, b) => a - b);
}
function renderTestProfileOptions(selectedValue = null) {
const select = document.getElementById('testProfileNumber');
if (!select) {
return;
}
const activeProfiles = normalizeActiveProfileNumbers(profileSettings.active_profiles);
const fallbackValue = activeProfiles[0] || 1;
const normalizedSelected = activeProfiles.includes(parseInt(selectedValue, 10))
? parseInt(selectedValue, 10)
: fallbackValue;
select.innerHTML = activeProfiles.map((profileNumber) => (
`<option value="${profileNumber}">Profil ${profileNumber}</option>`
)).join('');
select.value = String(normalizedSelected);
}
function updateProfileActivationSummary() {
const summary = document.getElementById('profileActivationSummary');
if (!summary) {
return;
}
const activeProfiles = collectActiveProfilesFromInputs();
summary.textContent = `Aktiv: ${activeProfiles.length} von ${MAX_PROFILES}. Max. benötigte Profile: ${Math.max(1, activeProfiles.length)}.`;
}
function renderProfileActivationList() {
const container = document.getElementById('profileActivationList');
if (!container) {
return;
}
const activeProfiles = normalizeActiveProfileNumbers(profileSettings.active_profiles);
container.innerHTML = DEFAULT_ACTIVE_PROFILES.map((profileNumber) => {
const checked = activeProfiles.includes(profileNumber);
return `
<label class="profile-activation-item">
<input
type="checkbox"
class="form-checkbox profile-activation-checkbox"
value="${profileNumber}"
${checked ? 'checked' : ''}
>
<span class="profile-activation-item__copy">
<span class="profile-activation-item__title">Profil ${profileNumber}</span>
<span class="profile-activation-item__hint">${checked ? 'Aktiv im Bestätigungsfluss' : 'Wird übersprungen'}</span>
</span>
</label>
`;
}).join('');
container.querySelectorAll('.profile-activation-checkbox').forEach((checkbox) => {
checkbox.addEventListener('change', (event) => {
const checkedProfiles = collectActiveProfilesFromInputs();
if (!checkedProfiles.length) {
event.target.checked = true;
showError('❌ Mindestens ein Profil muss aktiv bleiben');
return;
}
updateProfileActivationSummary();
renderTestProfileOptions(document.getElementById('testProfileNumber')?.value || checkedProfiles[0]);
});
});
updateProfileActivationSummary();
renderTestProfileOptions(document.getElementById('testProfileNumber')?.value || activeProfiles[0] || 1);
}
async function loadProfileSettings() {
const res = await apiFetch(`${API_URL}/profile-settings`);
if (!res.ok) throw new Error('Failed to load profile settings');
profileSettings = await res.json();
profileSettings.active_profiles = normalizeActiveProfileNumbers(profileSettings.active_profiles);
renderProfileActivationList();
}
async function saveProfileSettings(event, { silent = false } = {}) {
if (event && typeof event.preventDefault === 'function') {
event.preventDefault();
}
try {
const activeProfiles = collectActiveProfilesFromInputs();
if (!activeProfiles.length) {
throw new Error('Mindestens ein Profil muss aktiv bleiben');
}
const res = await apiFetch(`${API_URL}/profile-settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ active_profiles: activeProfiles })
});
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error((err && err.error) || 'Fehler beim Speichern');
}
profileSettings = await res.json();
profileSettings.active_profiles = normalizeActiveProfileNumbers(profileSettings.active_profiles);
renderProfileActivationList();
if (!silent) {
await loadProfileFriends();
}
window.dispatchEvent(new CustomEvent('profile-settings-updated', { detail: profileSettings }));
if (!silent) {
showSuccess('✅ Aktive Profile gespeichert');
}
return true;
} catch (err) {
if (!silent) {
showError('❌ ' + err.message);
}
return false;
}
}
// ============================================================================
// PROFILE FRIENDS
// ============================================================================
@@ -1161,10 +1497,11 @@ async function loadProfileFriends() {
const list = document.getElementById('profileFriendsList');
list.innerHTML = '';
for (let i = 1; i <= 5; i++) {
for (let i = 1; i <= MAX_PROFILES; i++) {
const res = await apiFetch(`${API_URL}/profile-friends/${i}`);
const data = await res.json();
profileFriends[i] = data.friend_names || '';
const isActive = normalizeActiveProfileNumbers(profileSettings.active_profiles).includes(i);
const div = document.createElement('div');
div.className = 'form-group';
@@ -1173,7 +1510,7 @@ async function loadProfileFriends() {
<input type="text" id="friends${i}" class="form-input"
placeholder="z.B. Anna, Max, Lisa"
value="${escapeHtml(profileFriends[i])}">
<p class="form-help">Kommagetrennte Liste von Freundesnamen für Profil ${i}</p>
<p class="form-help">Kommagetrennte Liste von Freundesnamen für Profil ${i}${isActive ? '' : ' (derzeit deaktiviert)'}</p>
`;
list.appendChild(div);
@@ -1214,7 +1551,7 @@ async function saveFriends(profileNumber, friendNames, { silent = false } = {})
async function saveAllFriends({ silent = false } = {}) {
let success = true;
for (let i = 1; i <= 5; i++) {
for (let i = 1; i <= MAX_PROFILES; i++) {
const input = document.getElementById(`friends${i}`);
if (!input) {
continue;
@@ -1244,6 +1581,38 @@ document.getElementById('generateTestComment').addEventListener('click', generat
document.getElementById('purgeHiddenNowBtn').addEventListener('click', purgeHiddenNow);
document.getElementById('saveAllFloatingBtn').addEventListener('click', saveAllSettings);
const aiAutoCommentRateLimitsEnabled = document.getElementById('aiAutoCommentRateLimitsEnabled');
if (aiAutoCommentRateLimitsEnabled) {
aiAutoCommentRateLimitsEnabled.addEventListener('change', () => {
applyAIAutoCommentRateLimitEnabledState(aiAutoCommentRateLimitsEnabled.checked);
});
}
[
['aiRequestsPerMinute', AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.requests_per_minute, 60],
['aiRequestsPerHour', AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.requests_per_hour, 500],
['aiRequestsPerDay', AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.requests_per_day, 5000],
['aiMinDelaySeconds', AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.min_delay_seconds, 3600],
['aiBurstLimit', AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.burst_limit, 100],
['aiCooldownMinutes', AI_AUTO_COMMENT_RATE_LIMIT_DEFAULTS.cooldown_minutes, 1440]
].forEach(([id, fallback, max]) => {
const input = document.getElementById(id);
if (input) {
input.addEventListener('blur', () => {
input.value = normalizeAIAutoCommentRateLimitValue(input.value, fallback, max);
});
}
});
['aiActiveHoursStart', 'aiActiveHoursEnd'].forEach((id) => {
const input = document.getElementById(id);
if (input) {
input.addEventListener('blur', () => {
input.value = normalizeAIAutoCommentTimeInput(input.value);
});
}
});
const autoPurgeHiddenToggle = document.getElementById('autoPurgeHiddenToggle');
if (autoPurgeHiddenToggle) {
autoPurgeHiddenToggle.addEventListener('change', () => {
@@ -1309,12 +1678,19 @@ if (similarityForm) {
}
// Initialize
Promise.all([
loadCredentials(),
loadSettings(),
loadHiddenSettings(),
loadModerationSettings(),
loadSimilaritySettings(),
loadProfileFriends()
]).catch(err => showError(err.message));
(async () => {
try {
await Promise.all([
loadCredentials(),
loadSettings(),
loadProfileSettings(),
loadHiddenSettings(),
loadModerationSettings(),
loadSimilaritySettings()
]);
await loadProfileFriends();
} catch (err) {
showError(err.message);
}
})();
})();

View File

@@ -4,6 +4,22 @@
box-sizing: border-box;
}
@media (prefers-reduced-motion: reduce) {
.tab-btn,
.search-clear-btn,
.posts-scroll-top,
.post-card,
.auto-open-overlay,
.auto-open-overlay__panel {
transition: none;
}
.post-card--highlight {
animation: none;
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.45);
}
}
:root {
--content-max-width: 1300px;
--top-gap: 12px;
@@ -269,11 +285,12 @@ header {
}
.search-input {
padding: 6px 12px;
padding: 8px 38px 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 13px;
min-width: 200px;
min-width: 0;
width: 100%;
}
.search-input:focus {
@@ -282,6 +299,71 @@ header {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
.search-field {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 240px;
}
.search-field__label {
font-size: 12px;
font-weight: 700;
color: #475569;
}
.search-field__input-wrap {
position: relative;
display: flex;
align-items: center;
}
.search-field__hint {
margin: 0;
font-size: 11px;
color: #64748b;
line-height: 1.35;
}
.search-field__hint kbd {
display: inline-block;
min-width: 18px;
padding: 1px 6px;
border-radius: 6px;
border: 1px solid #cbd5e1;
background: #f8fafc;
color: #0f172a;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
font-size: 11px;
font-weight: 700;
text-align: center;
}
.search-clear-btn {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
width: 26px;
height: 26px;
border: none;
border-radius: 6px;
background: transparent;
color: #6b7280;
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
}
.search-clear-btn:hover {
background: #e5e7eb;
color: #1f2937;
}
.search-clear-btn:focus-visible {
outline: 2px solid #1d4ed8;
outline-offset: 1px;
}
.switch {
display: inline-flex;
align-items: center;
@@ -530,6 +612,11 @@ h1 {
background: #f8f9fa;
}
.tab-btn:focus-visible {
outline: 2px solid #1d4ed8;
outline-offset: 2px;
}
.tab-btn.active {
background: #1877f2;
color: white;
@@ -637,10 +724,27 @@ h1 {
.bulk-status {
font-size: 13px;
color: #6b7280;
min-height: 32px;
display: inline-flex;
align-items: center;
}
.bulk-status--hint {
padding: 6px 12px;
border-radius: 999px;
border: 1px solid #c7d2fe;
background: #eef2ff;
color: #1e3a8a;
font-weight: 600;
}
.bulk-status--error {
color: #dc2626;
color: #991b1b;
border: 1px solid #fecaca;
background: #fee2e2;
border-radius: 999px;
padding: 6px 12px;
font-weight: 600;
}
.auto-open-overlay {
@@ -739,6 +843,34 @@ h1 {
gap: 6px;
}
.posts-scroll-top {
position: fixed;
right: 20px;
bottom: 20px;
width: 42px;
height: 42px;
border: none;
border-radius: 999px;
background: #0f172a;
color: #fff;
font-size: 22px;
line-height: 1;
cursor: pointer;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.28);
z-index: 45;
transition: transform 0.2s ease, background-color 0.2s ease;
}
.posts-scroll-top:hover {
background: #1d4ed8;
transform: translateY(-2px);
}
.posts-scroll-top:focus-visible {
outline: 2px solid #1d4ed8;
outline-offset: 2px;
}
.post-card {
position: relative;
background: white;
@@ -1810,12 +1942,402 @@ h1 {
border-color: #a5b4fc;
}
.bookmark-subpage {
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #f9fafb;
padding: 10px 12px;
}
.bookmark-subpage[open] {
background: #ffffff;
}
.bookmark-subpage__summary {
cursor: pointer;
font-weight: 600;
color: #1f2937;
list-style: none;
}
.bookmark-subpage__summary::-webkit-details-marker {
display: none;
}
.bookmark-subpage__summary::before {
content: "▸";
margin-right: 8px;
color: #6b7280;
}
.bookmark-subpage[open] .bookmark-subpage__summary::before {
content: "▾";
}
.bookmark-subpage__content {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.bookmark-subpage__toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-end;
}
.bookmark-subpage__toolbar-actions {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 8px;
}
.bookmark-subpage__meta {
font-size: 12px;
color: #4b5563;
}
.bookmark-subpage__config-btn {
border: 1px solid #d1d5db;
background: #ffffff;
color: #1f2937;
border-radius: 8px;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 16px;
}
.bookmark-subpage__config-btn:hover {
background: #f3f4f6;
}
.bookmark-columns-modal {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.bookmark-columns-modal[hidden] {
display: none;
}
.bookmark-columns-modal__backdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.5);
}
.bookmark-columns-modal__content {
position: relative;
background: #ffffff;
border-radius: 12px;
width: min(640px, 94vw);
max-height: 86vh;
overflow: auto;
padding: 16px;
box-shadow: 0 16px 44px rgba(15, 23, 42, 0.25);
}
.bookmark-columns-modal__content h3 {
margin: 0 0 6px;
font-size: 18px;
}
.bookmark-columns-modal__hint {
margin: 0 0 12px;
color: #4b5563;
font-size: 13px;
}
.bookmark-columns-modal__close {
position: absolute;
right: 12px;
top: 8px;
border: none;
background: transparent;
font-size: 24px;
line-height: 1;
cursor: pointer;
color: #374151;
}
.bookmark-columns-modal__list {
display: flex;
flex-direction: column;
gap: 8px;
}
.bookmark-columns-modal__item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 8px 10px;
background: #f9fafb;
}
.bookmark-columns-modal__toggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #1f2937;
}
.bookmark-columns-modal__item-actions {
display: inline-flex;
gap: 6px;
}
.bookmark-columns-modal__move {
width: 28px;
height: 28px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
cursor: pointer;
font-size: 14px;
color: #374151;
}
.bookmark-columns-modal__move:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.bookmark-columns-modal__actions {
margin-top: 14px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.bookmark-subpage__table-wrap {
width: 100%;
overflow-x: auto;
}
.bookmark-subpage__table {
width: 100%;
border-collapse: collapse;
min-width: 1680px;
}
.bookmark-subpage__table th,
.bookmark-subpage__table td {
border: 1px solid #e5e7eb;
padding: 8px 10px;
font-size: 12px;
vertical-align: top;
}
.bookmark-subpage__table th {
background: #f3f4f6;
white-space: nowrap;
position: relative;
}
.bookmark-subpage__sort {
border: none;
background: transparent;
cursor: pointer;
font-weight: 600;
color: #1f2937;
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 4px;
}
.bookmark-subpage__sort:hover {
color: #111827;
}
.bookmark-subpage__sort.is-active {
color: #0f4bb8;
}
.bookmark-subpage__th-stack {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 5px;
}
.bookmark-subpage__th-controls {
display: inline-flex;
align-items: center;
gap: 6px;
}
.bookmark-subpage__filter-toggle {
width: 22px;
height: 22px;
border: none;
border-radius: 4px;
background: transparent;
color: #94a3b8;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
cursor: pointer;
}
.bookmark-subpage__filter-toggle svg {
width: 13px;
height: 13px;
fill: currentColor;
}
.bookmark-subpage__filter-toggle:hover {
color: #64748b;
}
.bookmark-subpage__filter-toggle:focus-visible {
outline: 2px solid #93c5fd;
outline-offset: 1px;
}
.bookmark-subpage__filter-toggle.is-active {
color: #0f4bb8;
}
.bookmark-subpage__filter-toggle.is-open {
border-color: #60a5fa;
border-style: solid;
border-width: 1px;
background: #eff6ff;
}
.bookmark-subpage__column-filter {
width: 84px;
max-width: 100%;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #fff;
color: #111827;
font-size: 11px;
padding: 2px 6px;
display: none;
}
.bookmark-subpage__th-stack.is-filter-open .bookmark-subpage__column-filter {
display: block;
}
.bookmark-subpage__column-filter:focus {
outline: 2px solid #93c5fd;
outline-offset: 0;
border-color: #60a5fa;
}
.bookmark-subpage__table th[draggable="true"] {
cursor: move;
}
.bookmark-subpage__table th.is-dragging {
opacity: 0.55;
}
.bookmark-subpage__table th.is-drop-before::before,
.bookmark-subpage__table th.is-drop-after::after {
content: '';
position: absolute;
top: 3px;
bottom: 3px;
width: 2px;
background: #0f4bb8;
}
.bookmark-subpage__table th.is-drop-before::before {
left: -1px;
}
.bookmark-subpage__table th.is-drop-after::after {
right: -1px;
}
.bookmark-subpage__messe-link {
border: none;
background: transparent;
color: #0f4bb8;
cursor: pointer;
font: inherit;
text-decoration: underline;
text-underline-offset: 2px;
padding: 0;
text-align: left;
}
.bookmark-subpage__messe-links {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
row-gap: 2px;
}
.bookmark-subpage__messe-separator {
color: #6b7280;
margin: 0 2px;
}
.bookmark-subpage__messe-link:hover {
color: #1d4ed8;
}
.bookmark-subpage__table th[data-column-key="thema"],
.bookmark-subpage__table td[data-column="thema"],
.bookmark-subpage__table th[data-column-key="notiz"],
.bookmark-subpage__table td[data-column="notiz"] {
min-width: 320px;
}
.bookmark-subpage__table th[data-column-key="zuletzt_gesucht_am"],
.bookmark-subpage__table td[data-column="zuletzt_gesucht_am"] {
min-width: 170px;
}
.bookmark-subpage__table a {
color: #1d4ed8;
}
@media (max-width: 640px) {
.bookmark-panel {
width: min(480px, 94vw);
max-height: 75vh;
}
.search-field {
min-width: 100%;
}
.search-field__hint {
font-size: 10px;
}
.bulk-actions {
flex-direction: column;
align-items: stretch;
}
.bulk-actions .btn {
width: 100%;
}
.bookmark-section__list {
gap: 5px;
}
@@ -1823,6 +2345,20 @@ h1 {
.bookmark-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.bookmark-subpage {
padding: 10px;
}
.bookmark-subpage__toolbar {
gap: 8px;
}
.bookmark-subpage__toolbar-actions {
margin-left: 0;
width: 100%;
justify-content: space-between;
}
}
.screenshot-modal {
@@ -1967,6 +2503,60 @@ h1 {
align-items: flex-start;
}
.tabs-section {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.tabs {
width: 100%;
}
.tab-btn {
flex: 1 1 0;
text-align: center;
}
.search-container {
margin-left: 0;
width: 100%;
justify-content: flex-start;
}
.search-filter-toggle {
order: 2;
width: 100%;
justify-content: flex-start;
}
.search-field {
order: 1;
width: 100%;
flex: 1 1 260px;
}
.posts-bulk-controls {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.bulk-actions {
width: 100%;
justify-content: flex-start;
}
.bulk-status {
width: 100%;
justify-content: flex-start;
}
.posts-scroll-top {
right: 14px;
bottom: 14px;
}
.form-actions {
flex-direction: column;
}

1898
web/trade-fairs.js Normal file

File diff suppressed because it is too large Load Diff