Initial commit

This commit is contained in:
MDeeApp
2025-10-04 16:30:22 +02:00
commit 3a17854242
100 changed files with 14764 additions and 0 deletions

16
web/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM nginx:alpine
COPY index.html /usr/share/nginx/html/
COPY dashboard.html /usr/share/nginx/html/
COPY settings.html /usr/share/nginx/html/
COPY style.css /usr/share/nginx/html/
COPY dashboard.css /usr/share/nginx/html/
COPY settings.css /usr/share/nginx/html/
COPY app.js /usr/share/nginx/html/
COPY dashboard.js /usr/share/nginx/html/
COPY settings.js /usr/share/nginx/html/
COPY assets /usr/share/nginx/html/assets/
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

2869
web/app.js Normal file

File diff suppressed because it is too large Load Diff

BIN
web/assets/app-icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
web/assets/app-icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
web/assets/app-icon-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 B

804
web/dashboard.css Normal file
View File

@@ -0,0 +1,804 @@
/* ==========================================
DASHBOARD - UNIFIED DESIGN SYSTEM
========================================== */
/* Color Palette */
:root {
--color-primary: #1877f2;
--color-success: #42b983;
--color-warning: #f39c12;
--color-danger: #e74c3c;
--color-info: #3498db;
--bg-card: #ffffff;
--bg-section: #f8f9fa;
--bg-hover: #f0f2f5;
--text-primary: #1c1e21;
--text-secondary: #65676b;
--text-muted: #8a8d91;
--border-light: #e4e6eb;
--border-medium: #d1d5db;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.12);
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--spacing-xs: 8px;
--spacing-sm: 12px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
}
/* Dashboard Container */
.dashboard-container {
display: flex;
flex-direction: column;
gap: var(--spacing-xl);
padding: var(--spacing-lg) 0;
}
/* ==========================================
SECTIONS
========================================== */
.dashboard-section {
background: var(--bg-section);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
}
.section-title {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 var(--spacing-lg) 0;
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
/* ==========================================
OVERVIEW SECTION
========================================== */
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.stat-card {
background: var(--bg-card);
border-radius: var(--radius-md);
padding: var(--spacing-md) var(--spacing-lg);
box-shadow: var(--shadow-sm);
display: flex;
align-items: center;
gap: var(--spacing-md);
transition: all 0.2s ease;
border-left: 4px solid transparent;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.stat-card--primary { border-left-color: var(--color-primary); }
.stat-card--success { border-left-color: var(--color-success); }
.stat-card--warning { border-left-color: var(--color-warning); }
.stat-card--danger { border-left-color: var(--color-danger); }
.stat-card--info { border-left-color: var(--color-info); }
.stat-card__icon {
font-size: 32px;
line-height: 1;
opacity: 0.9;
}
.stat-card__content {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-card__label {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.stat-card__value {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
line-height: 1;
}
/* Metrics Grid */
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--spacing-md);
}
.metric-card {
background: var(--bg-card);
border-radius: var(--radius-md);
padding: var(--spacing-lg);
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
transition: all 0.2s ease;
}
.metric-card:hover {
box-shadow: var(--shadow-md);
}
.metric-card__label {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.metric-card__value {
font-size: 32px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.1;
}
.metric-card__change {
font-size: 12px;
font-weight: 600;
padding: 4px 8px;
border-radius: var(--radius-sm);
display: inline-block;
align-self: flex-start;
}
.metric-card__change--up {
color: var(--color-success);
background: rgba(66, 185, 131, 0.1);
}
.metric-card__change--down {
color: var(--color-danger);
background: rgba(231, 76, 60, 0.1);
}
.metric-card__change--neutral {
color: var(--text-muted);
background: var(--bg-hover);
}
.metric-card__subtext {
font-size: 12px;
color: var(--text-muted);
font-weight: 500;
}
/* ==========================================
ANALYTICS SECTION
========================================== */
.charts-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(450px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
}
.charts-row:last-child {
margin-bottom: 0;
}
.chart-card {
background: var(--bg-card);
border-radius: var(--radius-md);
padding: var(--spacing-lg);
box-shadow: var(--shadow-sm);
transition: all 0.2s ease;
}
.chart-card--full {
grid-column: 1 / -1;
}
.chart-card:hover {
box-shadow: var(--shadow-md);
}
.chart-card__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
flex-wrap: wrap;
gap: var(--spacing-sm);
}
.chart-card__title {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.chart-card__subtitle {
font-size: 12px;
color: var(--text-muted);
font-weight: 500;
}
.chart-card__body {
min-height: 200px;
position: relative;
}
/* Bar Chart (for profile chart) */
.bar-chart {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.bar-chart-item {
display: grid;
grid-template-columns: 80px 1fr 60px;
gap: var(--spacing-sm);
align-items: center;
padding: var(--spacing-xs);
border-radius: var(--radius-sm);
transition: background 0.2s ease;
}
.bar-chart-item:hover {
background: var(--bg-hover);
}
.bar-chart-item__label {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.bar-chart-item__bar-container {
background: var(--border-light);
border-radius: var(--radius-sm);
height: 32px;
overflow: hidden;
position: relative;
}
.bar-chart-item__bar {
background: linear-gradient(135deg, var(--color-primary) 0%, #4a9eff 100%);
height: 100%;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: var(--spacing-xs);
font-size: 12px;
font-weight: 700;
color: white;
border-radius: var(--radius-sm);
transition: width 0.4s ease;
min-width: 2px;
}
.bar-chart-item__value {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
text-align: right;
}
/* ==========================================
PERFORMANCE COMPARISONS SECTION
========================================== */
.comparison-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
}
.comparison-card {
background: var(--bg-card);
border-radius: var(--radius-md);
padding: var(--spacing-lg);
box-shadow: var(--shadow-sm);
transition: all 0.2s ease;
}
.comparison-card:hover {
box-shadow: var(--shadow-md);
}
.comparison-card__title {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 var(--spacing-md) 0;
padding-bottom: var(--spacing-sm);
border-bottom: 2px solid var(--border-light);
}
.comparison-card__content {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.comparison-item {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.comparison-item__header {
display: flex;
justify-content: space-between;
align-items: center;
}
.comparison-item__label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.comparison-item__value {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
}
.comparison-item__bar {
background: var(--border-light);
border-radius: 100px;
height: 8px;
overflow: hidden;
}
.comparison-item__bar-fill {
height: 100%;
border-radius: 100px;
transition: width 0.4s ease;
}
.comparison-item__bar-fill--current {
background: linear-gradient(90deg, var(--color-primary), #4a9eff);
}
.comparison-item__bar-fill--success {
background: linear-gradient(90deg, var(--color-success), #5dd39e);
}
.comparison-item__subtext {
font-size: 11px;
color: var(--text-muted);
font-weight: 500;
}
.comparison-item__change {
font-size: 11px;
font-weight: 700;
padding: 3px 8px;
border-radius: var(--radius-sm);
}
.comparison-item__change--up {
color: var(--color-success);
background: rgba(66, 185, 131, 0.12);
}
.comparison-item__change--down {
color: var(--color-danger);
background: rgba(231, 76, 60, 0.12);
}
.comparison-item__change--neutral {
color: var(--text-muted);
background: var(--bg-hover);
}
.comparison-divider {
height: 1px;
background: var(--border-light);
margin: var(--spacing-xs) 0;
}
/* Success Comparison Grid */
.success-comparison-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: var(--spacing-lg);
}
.success-comparison-card {
background: linear-gradient(135deg, #f5f7fa 0%, #ffffff 100%);
border-radius: var(--radius-md);
padding: var(--spacing-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-light);
transition: all 0.2s ease;
}
.success-comparison-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-success);
}
.success-comparison-card__title {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 var(--spacing-md) 0;
padding-bottom: var(--spacing-sm);
border-bottom: 2px solid var(--color-success);
}
.success-comparison-card__content {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
/* ==========================================
DETAILS SECTION
========================================== */
.details-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: var(--spacing-lg);
}
.detail-card {
background: var(--bg-card);
border-radius: var(--radius-md);
padding: var(--spacing-lg);
box-shadow: var(--shadow-sm);
transition: all 0.2s ease;
}
.detail-card--full {
grid-column: 1 / -1;
}
.detail-card:hover {
box-shadow: var(--shadow-md);
}
.detail-card__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
padding-bottom: var(--spacing-sm);
border-bottom: 2px solid var(--border-light);
}
.detail-card__title {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.detail-card__badge {
background: var(--color-primary);
color: white;
font-size: 12px;
font-weight: 700;
padding: 4px 10px;
border-radius: 100px;
}
.detail-card__body {
max-height: 400px;
overflow-y: auto;
}
/* Performers List */
.performers-list {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.performer-item {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-sm);
background: var(--bg-hover);
border-radius: var(--radius-sm);
transition: all 0.2s ease;
}
.performer-item:hover {
background: var(--border-light);
transform: translateX(4px);
}
.performer-item--gold {
background: linear-gradient(135deg, #ffd700 20%, #fff9e6 100%);
border: 2px solid #ffd700;
}
.performer-item--silver {
background: linear-gradient(135deg, #c0c0c0 20%, #f5f5f5 100%);
border: 2px solid #c0c0c0;
}
.performer-item--bronze {
background: linear-gradient(135deg, #cd7f32 20%, #fff5e6 100%);
border: 2px solid #cd7f32;
}
.performer-item__rank {
font-size: 16px;
font-weight: 700;
color: var(--text-secondary);
min-width: 24px;
text-align: center;
}
.performer-item__avatar {
font-size: 24px;
line-height: 1;
}
.performer-item__content {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.performer-item__name {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
}
.performer-item__stats {
font-size: 12px;
color: var(--text-secondary);
font-weight: 500;
}
.performer-item__badge {
background: var(--color-primary);
color: white;
font-size: 12px;
font-weight: 700;
padding: 4px 12px;
border-radius: 100px;
}
/* Deadline List */
.deadline-list {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.deadline-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-sm);
background: var(--bg-hover);
border-radius: var(--radius-sm);
border-left: 4px solid var(--color-info);
transition: all 0.2s ease;
}
.deadline-item:hover {
background: var(--border-light);
}
.deadline-item--warning {
border-left-color: var(--color-warning);
background: rgba(243, 156, 18, 0.05);
}
.deadline-item--danger {
border-left-color: var(--color-danger);
background: rgba(231, 76, 60, 0.05);
}
.deadline-item__content {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.deadline-item__title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.deadline-item__progress {
font-size: 11px;
color: var(--text-secondary);
font-weight: 500;
}
.deadline-item__time {
font-size: 12px;
font-weight: 700;
color: var(--text-secondary);
padding: 4px 10px;
background: white;
border-radius: var(--radius-sm);
}
/* Activity List */
.activity-list {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.activity-item {
display: flex;
align-items: flex-start;
gap: var(--spacing-sm);
padding: var(--spacing-sm);
background: var(--bg-hover);
border-radius: var(--radius-sm);
transition: all 0.2s ease;
}
.activity-item:hover {
background: var(--border-light);
}
.activity-item__icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-success);
color: white;
border-radius: 50%;
font-size: 14px;
font-weight: 700;
flex-shrink: 0;
}
.activity-item__content {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.activity-item__text {
font-size: 13px;
color: var(--text-primary);
line-height: 1.4;
}
.activity-item__profile {
font-weight: 700;
color: var(--color-primary);
}
.activity-item__time {
font-size: 11px;
color: var(--text-muted);
font-weight: 500;
}
/* ==========================================
EMPTY STATES
========================================== */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-xl);
text-align: center;
color: var(--text-muted);
}
.empty-state-icon {
font-size: 48px;
margin-bottom: var(--spacing-sm);
opacity: 0.5;
}
.empty-state-text {
font-size: 14px;
font-weight: 500;
}
/* ==========================================
RESPONSIVE DESIGN
========================================== */
@media (max-width: 1024px) {
.charts-row {
grid-template-columns: 1fr;
}
.chart-card--full {
grid-column: auto;
}
}
@media (max-width: 768px) {
.dashboard-section {
padding: var(--spacing-md);
}
.stats-grid,
.metrics-grid,
.comparison-grid,
.success-comparison-grid,
.details-grid {
grid-template-columns: 1fr;
}
.detail-card--full {
grid-column: auto;
}
.stat-card__value,
.metric-card__value {
font-size: 24px;
}
}
/* ==========================================
ANIMATIONS
========================================== */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.dashboard-section {
animation: fadeInUp 0.4s ease forwards;
}
.dashboard-section:nth-child(1) { animation-delay: 0s; }
.dashboard-section:nth-child(2) { animation-delay: 0.1s; }
.dashboard-section:nth-child(3) { animation-delay: 0.2s; }
.dashboard-section:nth-child(4) { animation-delay: 0.3s; }

274
web/dashboard.html Normal file
View File

@@ -0,0 +1,274 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - Facebook Post Tracker</title>
<link rel="icon" type="image/png" sizes="32x32" href="assets/app-icon-64.png">
<link rel="icon" type="image/png" sizes="192x192" href="assets/app-icon-192.png">
<link rel="apple-touch-icon" href="assets/app-icon-192.png">
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="dashboard.css">
</head>
<body>
<div class="container">
<header>
<div class="header-main">
<h1>📊 Dashboard</h1>
<a href="?view=posts" class="btn btn-secondary">Zurück zu Beiträgen</a>
</div>
<div class="header-controls">
<div class="control-group">
<label for="timeFilter">Zeitraum:</label>
<select id="timeFilter" class="control-select">
<option value="all">Alle</option>
<option value="today">Heute</option>
<option value="week" selected>Diese Woche</option>
<option value="month">Dieser Monat</option>
<option value="year">Dieses Jahr</option>
</select>
</div>
<div class="control-group">
<label for="profileFilter">Profil-Filter:</label>
<select id="profileFilter" class="control-select">
<option value="all">Alle Profile</option>
<option value="1">Profil 1</option>
<option value="2">Profil 2</option>
<option value="3">Profil 3</option>
<option value="4">Profil 4</option>
<option value="5">Profil 5</option>
</select>
</div>
<button type="button" class="btn btn-primary" id="refreshBtn">
🔄 Aktualisieren
</button>
</div>
</header>
<div id="loading" class="loading">Lade Statistiken...</div>
<div id="error" class="error" style="display: none;"></div>
<div id="dashboardContainer" class="dashboard-container" style="display: none;">
<!-- SECTION 1: OVERVIEW -->
<section class="dashboard-section">
<h2 class="section-title">Übersicht</h2>
<!-- Primary Stats -->
<div class="stats-grid">
<div class="stat-card stat-card--primary">
<div class="stat-card__icon">📋</div>
<div class="stat-card__content">
<div class="stat-card__label">Gesamt Beiträge</div>
<div class="stat-card__value" id="totalPosts">0</div>
</div>
</div>
<div class="stat-card stat-card--success">
<div class="stat-card__icon"></div>
<div class="stat-card__content">
<div class="stat-card__label">Abgeschlossen</div>
<div class="stat-card__value" id="completedPosts">0</div>
</div>
</div>
<div class="stat-card stat-card--warning">
<div class="stat-card__icon"></div>
<div class="stat-card__content">
<div class="stat-card__label">In Bearbeitung</div>
<div class="stat-card__value" id="activePosts">0</div>
</div>
</div>
<div class="stat-card stat-card--danger">
<div class="stat-card__icon">⚠️</div>
<div class="stat-card__content">
<div class="stat-card__label">Abgelaufen</div>
<div class="stat-card__value" id="expiredPosts">0</div>
</div>
</div>
<div class="stat-card stat-card--info">
<div class="stat-card__icon">🏆</div>
<div class="stat-card__content">
<div class="stat-card__label">Erfolgreich</div>
<div class="stat-card__value" id="successfulPosts">0</div>
</div>
</div>
</div>
<!-- Key Metrics -->
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-card__label">Erfolgsquote</div>
<div class="metric-card__value" id="successRateMetric">0%</div>
<div class="metric-card__change" id="successRateChange"></div>
</div>
<div class="metric-card">
<div class="metric-card__label">Ø Bearbeitungszeit</div>
<div class="metric-card__value" id="avgCompletionTime">-</div>
<div class="metric-card__subtext">bis Abschluss</div>
</div>
<div class="metric-card">
<div class="metric-card__label">Teilnahmen heute</div>
<div class="metric-card__value" id="checksToday">0</div>
<div class="metric-card__change" id="checksTodayChange"></div>
</div>
<div class="metric-card">
<div class="metric-card__label">Deadline-Risiko</div>
<div class="metric-card__value" id="deadlineRiskValue">0</div>
<div class="metric-card__subtext" id="deadlineRiskText">keine Risiken</div>
</div>
</div>
</section>
<!-- SECTION 2: ANALYTICS -->
<section class="dashboard-section">
<h2 class="section-title">Analyse</h2>
<div class="charts-row">
<!-- Activity Timeline -->
<div class="chart-card chart-card--full">
<div class="chart-card__header">
<h3 class="chart-card__title">Aktivitätsverlauf</h3>
<span class="chart-card__subtitle" id="timelineSubtitle"></span>
</div>
<div class="chart-card__body">
<canvas id="timelineChart" width="1200" height="280"></canvas>
</div>
</div>
</div>
<div class="charts-row">
<!-- Profile Performance -->
<div class="chart-card">
<div class="chart-card__header">
<h3 class="chart-card__title">Teilnahmen pro Profil</h3>
<span class="chart-card__subtitle" id="profileChartSubtitle"></span>
</div>
<div class="chart-card__body">
<div id="profileChart" class="bar-chart"></div>
</div>
</div>
<!-- Progress Overview -->
<div class="chart-card">
<div class="chart-card__header">
<h3 class="chart-card__title">Status-Verteilung</h3>
<span class="chart-card__subtitle" id="progressChartSubtitle"></span>
</div>
<div class="chart-card__body">
<canvas id="progressChart" width="400" height="300"></canvas>
</div>
</div>
</div>
<div class="charts-row">
<!-- Period Trend -->
<div class="chart-card">
<div class="chart-card__header">
<h3 class="chart-card__title">Teilnahmen-Trend</h3>
<span class="chart-card__subtitle" id="trendChartSubtitle"></span>
</div>
<div class="chart-card__body">
<canvas id="periodTrendChart" width="500" height="300"></canvas>
</div>
</div>
<!-- Profile Comparison -->
<div class="chart-card">
<div class="chart-card__header">
<h3 class="chart-card__title">Profilvergleich</h3>
<span class="chart-card__subtitle" id="profileComparisonSubtitle"></span>
</div>
<div class="chart-card__body">
<canvas id="profileComparisonChart" width="500" height="300"></canvas>
</div>
</div>
</div>
</section>
<!-- SECTION 3: PERFORMANCE COMPARISONS -->
<section class="dashboard-section">
<h2 class="section-title">Performance-Vergleiche</h2>
<div class="comparison-grid">
<div class="comparison-card">
<h3 class="comparison-card__title">Wochenvergleich</h3>
<div class="comparison-card__content" id="weeklyComparison"></div>
</div>
<div class="comparison-card">
<h3 class="comparison-card__title">Monatsvergleich</h3>
<div class="comparison-card__content" id="monthlyComparison"></div>
</div>
<div class="comparison-card">
<h3 class="comparison-card__title">Jahresvergleich</h3>
<div class="comparison-card__content" id="yearlyComparison"></div>
</div>
</div>
<div class="success-comparison-grid">
<div class="success-comparison-card">
<h3 class="success-comparison-card__title">Erfolgsanalyse: Woche</h3>
<div class="success-comparison-card__content" id="weeklySuccessComparison"></div>
</div>
<div class="success-comparison-card">
<h3 class="success-comparison-card__title">Erfolgsanalyse: Monat</h3>
<div class="success-comparison-card__content" id="monthlySuccessComparison"></div>
</div>
<div class="success-comparison-card">
<h3 class="success-comparison-card__title">Erfolgsanalyse: Jahr</h3>
<div class="success-comparison-card__content" id="yearlySuccessComparison"></div>
</div>
</div>
</section>
<!-- SECTION 4: DETAILS -->
<section class="dashboard-section">
<h2 class="section-title">Details</h2>
<div class="details-grid">
<!-- Top Performers -->
<div class="detail-card">
<div class="detail-card__header">
<h3 class="detail-card__title">Top Performers</h3>
<span class="detail-card__badge" id="performersCount">0</span>
</div>
<div class="detail-card__body">
<div id="topPerformers" class="performers-list"></div>
</div>
</div>
<!-- Upcoming Deadlines -->
<div class="detail-card">
<div class="detail-card__header">
<h3 class="detail-card__title">Anstehende Deadlines</h3>
<span class="detail-card__badge" id="deadlinesCount">0</span>
</div>
<div class="detail-card__body">
<div id="upcomingDeadlines" class="deadline-list"></div>
</div>
</div>
<!-- Recent Activity -->
<div class="detail-card detail-card--full">
<div class="detail-card__header">
<h3 class="detail-card__title">Letzte Aktivitäten</h3>
<span class="detail-card__badge" id="activityCount">0</span>
</div>
<div class="detail-card__body">
<div id="recentActivity" class="activity-list"></div>
</div>
</div>
</div>
</section>
</div>
</div>
<script src="dashboard.js"></script>
</body>
</html>

1581
web/dashboard.js Normal file

File diff suppressed because it is too large Load Diff

136
web/index.html Normal file
View File

@@ -0,0 +1,136 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Post Tracker - Web Interface</title>
<link rel="icon" type="image/png" sizes="32x32" href="assets/app-icon-64.png">
<link rel="icon" type="image/png" sizes="192x192" href="assets/app-icon-192.png">
<link rel="apple-touch-icon" href="assets/app-icon-192.png">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<header>
<div class="header-main">
<h1>📋 Post Tracker</h1>
<div style="display: flex; gap: 10px;">
<a href="?view=dashboard" class="btn btn-secondary">Dashboard</a>
<a href="settings.html" class="btn btn-secondary">⚙️ Einstellungen</a>
<button type="button" class="btn btn-primary" id="openManualPostModalBtn">Beitrag hinzufügen</button>
</div>
</div>
<div class="header-controls">
<div class="control-group">
<label for="profileSelect">Dein Profil:</label>
<select id="profileSelect" class="control-select">
<option value="1">Profil 1</option>
<option value="2">Profil 2</option>
<option value="3">Profil 3</option>
<option value="4">Profil 4</option>
<option value="5">Profil 5</option>
</select>
</div>
<div class="control-group">
<label class="switch">
<input type="checkbox" id="autoRefreshToggle" checked>
<span>Auto-Refresh</span>
</label>
<select id="autoRefreshInterval" class="control-select">
<option value="15000">15 s</option>
<option value="30000">30 s</option>
<option value="60000">1 min</option>
<option value="120000">2 min</option>
<option value="300000">5 min</option>
</select>
<button type="button" id="manualRefreshBtn" class="refresh-btn" aria-label="Aktualisieren" title="Aktualisieren">🔄</button>
</div>
<div class="control-group">
<label for="sortMode">Sortierung:</label>
<div class="sort-controls">
<select id="sortMode" class="control-select">
<option value="created">Erstelldatum</option>
<option value="deadline">Deadline</option>
<option value="lastCheck">Letzte Teilnahme</option>
<option value="lastChange">Letzte Änderung</option>
<option value="smart">Smart (Dringlichkeit)</option>
</select>
<button type="button" id="sortDirectionToggle" class="sort-direction-toggle" aria-label="Absteigend" aria-pressed="false" title="Absteigend">
<span class="sort-direction-toggle__icon" aria-hidden="true"></span>
</button>
</div>
</div>
</div>
</header>
<div class="tabs-section">
<div class="tabs">
<button class="tab-btn active" data-tab="pending">Offene Beiträge</button>
<button class="tab-btn" data-tab="expired">Abgelaufen/Abgeschlossen</button>
<button class="tab-btn" data-tab="all">Alle Beiträge</button>
</div>
<div class="search-container">
<input type="text" id="searchInput" class="search-input" placeholder="Beiträge durchsuchen...">
</div>
</div>
<div id="loading" class="loading">Lade Beiträge...</div>
<div id="error" class="error" style="display: none;"></div>
<div id="postsContainer" class="posts-container"></div>
</div>
<div id="screenshotModal" class="screenshot-modal" hidden>
<div id="screenshotModalBackdrop" class="screenshot-modal__backdrop" aria-hidden="true"></div>
<div id="screenshotModalContent" class="screenshot-modal__content" role="dialog" aria-modal="true">
<button type="button" id="screenshotModalClose" class="screenshot-modal__close" aria-label="Schließen">×</button>
<img id="screenshotModalImage" alt="Screenshot zum Beitrag" />
</div>
</div>
<div id="manualPostModal" class="modal" hidden>
<div id="manualPostModalBackdrop" class="modal__backdrop" aria-hidden="true"></div>
<div id="manualPostModalContent" class="modal__content" role="dialog" aria-modal="true" aria-labelledby="manualPostModalTitle" tabindex="-1">
<button type="button" id="manualPostModalClose" class="modal__close" aria-label="Schließen">×</button>
<h2 id="manualPostModalTitle">Beitrag hinzufügen</h2>
<form id="manualPostForm" novalidate>
<div class="form-grid">
<label class="form-field">
<span>Direktlink *</span>
<input type="url" id="manualPostUrl" placeholder="https://www.facebook.com/..." required>
</label>
<label class="form-field">
<span>Titel</span>
<input type="text" id="manualPostTitle" placeholder="Kurzbeschreibung" maxlength="200">
</label>
<label class="form-field">
<span>Benötigte Profile *</span>
<select id="manualPostTarget" required>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
</select>
</label>
<label class="form-field">
<span>Erstellt von (Facebook-Name)</span>
<input type="text" id="manualPostCreatorName" placeholder="z.B. Max Mustermann">
</label>
<label class="form-field">
<span>Deadline</span>
<input type="datetime-local" id="manualPostDeadline">
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" id="manualPostSubmitBtn">Speichern</button>
<button type="button" class="btn btn-secondary" id="manualPostReset">Zurücksetzen</button>
</div>
<div id="manualPostMessage" class="form-message" role="status" aria-live="polite"></div>
</form>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

339
web/settings.css Normal file
View File

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

202
web/settings.html Normal file
View File

@@ -0,0 +1,202 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Einstellungen - Facebook Post Tracker</title>
<link rel="icon" type="image/png" sizes="32x32" href="assets/app-icon-64.png">
<link rel="icon" type="image/png" sizes="192x192" href="assets/app-icon-192.png">
<link rel="apple-touch-icon" href="assets/app-icon-192.png">
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="settings.css">
</head>
<body>
<div class="container">
<header>
<div class="header-main">
<h1>⚙️ Einstellungen</h1>
<a href="index.html" class="btn btn-secondary">Zurück zu Beiträgen</a>
</div>
</header>
<div id="loading" class="loading" style="display: none;">Lade Einstellungen...</div>
<div id="error" class="error" style="display: none;"></div>
<div id="success" class="success" style="display: none;"></div>
<div class="settings-container">
<!-- AI Credentials Section -->
<section class="settings-section">
<h2 class="section-title">AI-Anmeldedaten</h2>
<p class="section-description">
Verwalte deine API-Schlüssel für verschiedene AI-Provider. Du kannst mehrere Credentials speichern und schnell zwischen ihnen wechseln.
</p>
<div id="credentialsList" class="credentials-list"></div>
<button type="button" class="btn btn-primary" id="addCredentialBtn">
Neue Anmeldedaten hinzufügen
</button>
</section>
<!-- AI Settings Section -->
<section class="settings-section">
<h2 class="section-title">AI-Kommentar-Generator</h2>
<p class="section-description">
Konfiguriere die automatische Generierung von Kommentaren durch KI.
</p>
<form id="aiSettingsForm">
<div class="form-group">
<label class="form-label">
<input type="checkbox" id="aiEnabled" class="form-checkbox">
<span>AI-Kommentar-Generator aktivieren</span>
</label>
</div>
<div class="form-group">
<label for="activeCredential" class="form-label">Aktive Anmeldedaten</label>
<select id="activeCredential" class="form-select">
<option value="">-- Bitte wählen --</option>
</select>
<p class="form-help">
Wähle welche API-Anmeldedaten verwendet werden sollen
</p>
</div>
<div class="form-group">
<label for="aiPromptPrefix" class="form-label">Prompt-Präfix</label>
<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.
</p>
</div>
</form>
</section>
<!-- Profile Friends Section -->
<section class="settings-section">
<h2 class="section-title">👥 Freundesnamen pro Profil</h2>
<p class="section-description">
Gib für jedes Profil eine Liste von Freundesnamen an, die im Prompt verwendet werden können.
</p>
<div id="profileFriendsList"></div>
</section>
<!-- Save Button at the end -->
<section class="settings-section">
<div class="form-actions">
<button type="button" class="btn btn-primary" id="saveAllBtn">
💾 Einstellungen speichern
</button>
<button type="button" class="btn btn-secondary" id="testBtn">
🧪 Kommentar testen
</button>
</div>
</section>
</div>
</div>
<!-- Add/Edit Credential Modal -->
<div id="credentialModal" class="modal" hidden>
<div class="modal__backdrop"></div>
<div class="modal__content" role="dialog" aria-modal="true">
<button type="button" id="credentialModalClose" class="modal__close">×</button>
<h2 class="modal__title" id="credentialModalTitle">Anmeldedaten hinzufügen</h2>
<form id="credentialForm">
<input type="hidden" id="credentialId">
<div class="form-group">
<label for="credentialName" class="form-label">Name *</label>
<input type="text" id="credentialName" class="form-input" placeholder="z.B. Mein Gemini Key" required>
<p class="form-help">Gib einen eindeutigen Namen für diese Anmeldedaten an</p>
</div>
<div class="form-group">
<label for="credentialProvider" class="form-label">Provider *</label>
<select id="credentialProvider" class="form-select" required>
<option value="gemini">Google Gemini</option>
<option value="claude">Anthropic Claude</option>
<option value="openai">OpenAI / ChatGPT</option>
</select>
</div>
<div class="form-group">
<label for="credentialApiKey" class="form-label">API-Schlüssel</label>
<input type="password" id="credentialApiKey" class="form-input" placeholder="sk-...">
<p class="form-help" id="credentialApiKeyHelp"></p>
</div>
<div class="form-group" id="credentialBaseUrlGroup" style="display: none;">
<label for="credentialBaseUrl" class="form-label">Basis-URL</label>
<input type="text" id="credentialBaseUrl" class="form-input" placeholder="https://api.openai.com/v1">
<p class="form-help" id="credentialBaseUrlHelp"></p>
</div>
<div class="form-group">
<label for="credentialModel" class="form-label">Modell</label>
<input type="text" id="credentialModel" class="form-input" list="credentialModelOptions"
placeholder="z.B. gpt-4o-mini">
<datalist id="credentialModelOptions"></datalist>
<p class="form-help" id="credentialModelHelp"></p>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Speichern</button>
<button type="button" class="btn btn-secondary" id="credentialCancelBtn">Abbrechen</button>
</div>
</form>
</div>
</div>
<!-- Test Modal -->
<div id="testModal" class="modal" hidden>
<div class="modal__backdrop"></div>
<div class="modal__content" role="dialog" aria-modal="true">
<button type="button" id="testModalClose" class="modal__close">×</button>
<h2 class="modal__title">Kommentar-Generator testen</h2>
<div class="modal__body">
<div class="form-group">
<label for="testProfileNumber" class="form-label">Test mit Profil</label>
<select id="testProfileNumber" class="form-select">
<option value="1">Profil 1</option>
<option value="2">Profil 2</option>
<option value="3">Profil 3</option>
<option value="4">Profil 4</option>
<option value="5">Profil 5</option>
</select>
<p class="form-help">Wähle ein Profil, um die Freundesnamen zu testen</p>
</div>
<div class="form-group">
<label for="testPostText" class="form-label">Test Post-Text</label>
<textarea id="testPostText" class="form-textarea" rows="4"
placeholder="Füge hier einen Beispiel-Post-Text ein..."></textarea>
</div>
<button type="button" class="btn btn-primary" id="generateTestComment">
✨ Kommentar generieren
</button>
<div id="testLoading" class="test-loading" style="display: none;">
Generiere Kommentar...
</div>
<div id="testResult" class="test-result" style="display: none;">
<h3>Generierter Kommentar:</h3>
<div id="testComment" class="test-comment"></div>
</div>
<div id="testError" class="test-error" style="display: none;"></div>
</div>
</div>
</div>
<script src="settings.js"></script>
</body>
</html>

656
web/settings.js Normal file
View File

@@ -0,0 +1,656 @@
const API_URL = 'https://fb.srv.medeba-media.de/api';
const PROVIDER_MODELS = {
gemini: [
{ value: '', label: 'Standard (gemini-2.0-flash-exp)' },
{ value: 'gemini-2.0-flash-exp', label: 'Gemini 2.0 Flash' },
{ value: 'gemini-1.5-flash', label: 'Gemini 1.5 Flash' },
{ value: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro' }
],
claude: [
{ value: '', label: 'Standard (claude-3-5-haiku)' },
{ value: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku (schnell)' },
{ value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet (beste Qualität)' },
{ value: 'claude-3-opus-20240229', label: 'Claude 3 Opus' }
],
openai: [
{ value: '', label: 'Standard (gpt-3.5-turbo)' },
{ value: 'gpt-3.5-turbo', label: 'GPT-3.5 Turbo (günstig)' },
{ value: 'gpt-4o-mini', label: 'GPT-4o Mini' },
{ value: 'gpt-4o', label: 'GPT-4o' },
{ value: 'gpt-4-turbo', label: 'GPT-4 Turbo' }
]
};
const PROVIDER_INFO = {
gemini: {
name: 'Google Gemini',
apiKeyLink: 'https://aistudio.google.com/app/apikey',
apiKeyHelp: 'API-Schlüssel erforderlich. Erstelle ihn im Google AI Studio.'
},
claude: {
name: 'Anthropic Claude',
apiKeyLink: 'https://console.anthropic.com/settings/keys',
apiKeyHelp: 'API-Schlüssel erforderlich. Erstelle ihn in der Anthropic Console.'
},
openai: {
name: 'OpenAI',
apiKeyLink: 'https://platform.openai.com/api-keys',
apiKeyHelp: 'Für lokale OpenAI-kompatible Server (z.B. Ollama) kannst du den Schlüssel leer lassen.'
}
};
let credentials = [];
let currentSettings = null;
function apiFetch(url, options = {}) {
return fetch(url, {...options, credentials: 'include'});
}
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.style.cssText = `
position: fixed;
bottom: 24px;
right: 24px;
background: ${type === 'error' ? '#e74c3c' : type === 'success' ? '#42b72a' : '#1877f2'};
color: white;
padding: 12px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
z-index: 999999;
max-width: 350px;
animation: slideIn 0.3s ease-out;
`;
toast.textContent = message;
if (!document.getElementById('settings-toast-styles')) {
const style = document.createElement('style');
style.id = 'settings-toast-styles';
style.textContent = `
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}
`;
document.head.appendChild(style);
}
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease-out';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
function showError(msg) { showToast(msg, 'error'); }
function showSuccess(msg) { showToast(msg, 'success'); }
async function loadCredentials() {
const res = await apiFetch(`${API_URL}/ai-credentials`);
if (!res.ok) throw new Error('Failed to load credentials');
credentials = await res.json();
renderCredentials();
updateActiveCredentialSelect();
}
async function loadSettings() {
const res = await apiFetch(`${API_URL}/ai-settings`);
if (!res.ok) throw new Error('Failed to load settings');
currentSettings = await res.json();
document.getElementById('aiEnabled').checked = currentSettings.enabled === 1;
document.getElementById('activeCredential').value = currentSettings.active_credential_id || '';
document.getElementById('aiPromptPrefix').value = currentSettings.prompt_prefix ||
'Schreibe einen freundlichen, authentischen Kommentar auf Deutsch zu folgendem Facebook-Post. Der Kommentar soll natürlich wirken und maximal 2-3 Sätze lang sein:\n\n';
}
function renderCredentials() {
const list = document.getElementById('credentialsList');
if (!credentials.length) {
list.innerHTML = '<p class="empty-state">Noch keine Anmeldedaten gespeichert</p>';
return;
}
list.innerHTML = credentials.map((c, index) => {
const providerName = escapeHtml(PROVIDER_INFO[c.provider]?.name || c.provider);
const modelLabel = c.model ? ` · ${escapeHtml(c.model)}` : '';
const endpointLabel = c.base_url ? ` · ${escapeHtml(c.base_url)}` : '';
return `
<div class="credential-item" draggable="true" data-credential-id="${c.id}" data-index="${index}">
<div class="credential-item__drag-handle" title="Ziehen zum Sortieren">⋮⋮</div>
<div class="credential-item__info">
<label class="form-label" style="display: flex; align-items: center; gap: 8px; margin: 0;">
<input type="checkbox" class="form-checkbox"
${c.is_active ? 'checked' : ''}
onchange="toggleCredentialActive(${c.id}, this.checked)">
<div>
<div class="credential-item__name">${escapeHtml(c.name)}</div>
<div class="credential-item__provider">${providerName}${modelLabel}${endpointLabel}</div>
</div>
</label>
</div>
<div class="credential-item__actions">
<button onclick="editCredential(${c.id})" class="btn-icon" title="Bearbeiten">✏️</button>
<button onclick="deleteCredential(${c.id})" class="btn-icon" title="Löschen">🗑️</button>
</div>
</div>
`;
}).join('');
// Add drag and drop event listeners
setupDragAndDrop();
}
function updateActiveCredentialSelect() {
const select = document.getElementById('activeCredential');
select.innerHTML = '<option value="">-- Bitte wählen --</option>' +
credentials.map(c => `<option value="${c.id}">${escapeHtml(c.name)} (${PROVIDER_INFO[c.provider]?.name})</option>`).join('');
if (currentSettings?.active_credential_id) {
select.value = currentSettings.active_credential_id;
}
}
function updateModelOptions(provider) {
const modelInput = document.getElementById('credentialModel');
const modelList = document.getElementById('credentialModelOptions');
const apiKeyInput = document.getElementById('credentialApiKey');
const baseUrlGroup = document.getElementById('credentialBaseUrlGroup');
const baseUrlHelp = document.getElementById('credentialBaseUrlHelp');
const baseUrlInput = document.getElementById('credentialBaseUrl');
const info = PROVIDER_INFO[provider];
const models = PROVIDER_MODELS[provider] || [];
if (modelList) {
modelList.innerHTML = models.map(m => `<option value="${m.value}">${m.label}</option>`).join('');
}
if (modelInput) {
const firstSuggestion = models.find(m => m.value)?.value;
modelInput.placeholder = firstSuggestion
? `z.B. ${firstSuggestion}`
: 'Modell-ID (z.B. llama3.1)';
}
const help = document.getElementById('credentialApiKeyHelp');
if (help) {
if (info) {
const parts = [];
if (info.apiKeyHelp) {
parts.push(info.apiKeyHelp);
}
if (info.apiKeyLink) {
parts.push(`<a href="${info.apiKeyLink}" target="_blank">API-Schlüssel erstellen</a>`);
}
help.innerHTML = parts.join(' ');
} else {
help.textContent = '';
}
}
if (apiKeyInput) {
if (provider === 'openai') {
apiKeyInput.placeholder = 'sk-... oder leer für lokale Server';
} else {
apiKeyInput.placeholder = 'API-Schlüssel';
}
}
if (baseUrlGroup && baseUrlHelp) {
if (provider === 'openai') {
baseUrlGroup.style.display = 'block';
baseUrlHelp.textContent = 'Leer lassen für die offizielle OpenAI-API. Für lokale OpenAI/Ollama-Server gib die Basis-URL an, z.B. http://localhost:11434/v1';
if (baseUrlInput) {
baseUrlInput.placeholder = 'https://api.openai.com/v1 oder http://localhost:11434/v1';
}
} else {
baseUrlGroup.style.display = 'none';
baseUrlHelp.textContent = '';
if (baseUrlInput) {
baseUrlInput.placeholder = '';
}
}
}
const modelHelp = document.getElementById('credentialModelHelp');
if (modelHelp) {
modelHelp.textContent = 'Trage die Modell-ID ein. Du kannst einen Vorschlag auswählen oder einen eigenen Wert eingeben.';
}
}
function openCredentialModal(credential = null) {
const modal = document.getElementById('credentialModal');
const form = document.getElementById('credentialForm');
const apiKeyInput = document.getElementById('credentialApiKey');
const baseUrlInput = document.getElementById('credentialBaseUrl');
if (credential) {
document.getElementById('credentialModalTitle').textContent = 'Anmeldedaten bearbeiten';
document.getElementById('credentialId').value = credential.id;
document.getElementById('credentialName').value = credential.name;
document.getElementById('credentialProvider').value = credential.provider;
updateModelOptions(credential.provider);
document.getElementById('credentialModel').value = credential.model || '';
if (baseUrlInput) {
baseUrlInput.value = credential.base_url || '';
}
if (apiKeyInput) {
apiKeyInput.value = '';
apiKeyInput.placeholder = credential.provider === 'openai'
? 'Leer lassen, um den bestehenden Schlüssel zu behalten'
: 'Leer lassen, um den bestehenden Schlüssel zu behalten';
}
} else {
document.getElementById('credentialModalTitle').textContent = 'Anmeldedaten hinzufügen';
form.reset();
updateModelOptions('gemini');
document.getElementById('credentialId').value = '';
if (apiKeyInput) {
apiKeyInput.value = '';
apiKeyInput.placeholder = 'API-Schlüssel';
}
if (baseUrlInput) {
baseUrlInput.value = '';
}
}
modal.removeAttribute('hidden');
}
function closeCredentialModal() {
document.getElementById('credentialModal').setAttribute('hidden', '');
}
async function saveCredential(e) {
e.preventDefault();
try {
const id = document.getElementById('credentialId').value;
const name = document.getElementById('credentialName').value.trim();
const provider = document.getElementById('credentialProvider').value;
const apiKey = document.getElementById('credentialApiKey').value.trim();
const model = document.getElementById('credentialModel').value.trim();
const baseUrlRaw = document.getElementById('credentialBaseUrl')?.value.trim() || '';
if (!name) {
throw new Error('Bitte einen Namen angeben');
}
const data = {
name,
provider,
model: model || null,
base_url: provider === 'openai' ? baseUrlRaw : ''
};
if (!id) {
if (!apiKey && !(provider === 'openai' && baseUrlRaw)) {
throw new Error('API-Schlüssel ist erforderlich (oder Basis-URL für lokale OpenAI-kompatible Server angeben)');
}
data.api_key = apiKey;
} else if (apiKey) {
data.api_key = apiKey;
}
const url = id ? `${API_URL}/ai-credentials/${id}` : `${API_URL}/ai-credentials`;
const method = id ? 'PUT' : 'POST';
const res = await apiFetch(url, {
method,
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Fehler beim Speichern der Anmeldedaten');
}
await loadCredentials();
closeCredentialModal();
showSuccess('✅ Anmeldedaten erfolgreich gespeichert');
} catch (err) {
showError('❌ ' + err.message);
}
}
async function editCredential(id) {
const cred = credentials.find(c => c.id === id);
if (!cred) {
showError('Anmeldedaten nicht gefunden');
return;
}
openCredentialModal(cred);
}
async function toggleCredentialActive(id, isActive) {
try {
const res = await apiFetch(`${API_URL}/ai-credentials/${id}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ is_active: isActive ? 1 : 0 })
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Fehler beim Aktualisieren');
}
await loadCredentials();
showSuccess(`✅ Login ${isActive ? 'aktiviert' : 'deaktiviert'}`);
} catch (err) {
showError('❌ ' + err.message);
await loadCredentials(); // Reload to reset checkbox
}
}
async function deleteCredential(id) {
if (!confirm('Wirklich löschen?')) return;
const res = await apiFetch(`${API_URL}/ai-credentials/${id}`, {method: 'DELETE'});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to delete');
}
await loadCredentials();
showSuccess('Anmeldedaten gelöscht');
}
// Make function globally accessible
window.toggleCredentialActive = toggleCredentialActive;
// ============================================================================
// DRAG AND DROP
// ============================================================================
let draggedElement = null;
function setupDragAndDrop() {
const items = document.querySelectorAll('.credential-item');
items.forEach(item => {
item.addEventListener('dragstart', handleDragStart);
item.addEventListener('dragover', handleDragOver);
item.addEventListener('drop', handleDrop);
item.addEventListener('dragend', handleDragEnd);
item.addEventListener('dragenter', handleDragEnter);
item.addEventListener('dragleave', handleDragLeave);
});
}
function handleDragStart(e) {
draggedElement = this;
this.style.opacity = '0.4';
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/html', this.innerHTML);
}
function handleDragOver(e) {
if (e.preventDefault) {
e.preventDefault();
}
e.dataTransfer.dropEffect = 'move';
return false;
}
function handleDragEnter(e) {
if (this !== draggedElement) {
this.classList.add('drag-over');
}
}
function handleDragLeave(e) {
this.classList.remove('drag-over');
}
function handleDrop(e) {
if (e.stopPropagation) {
e.stopPropagation();
}
if (draggedElement !== this) {
// Get the container
const container = this.parentNode;
const allItems = [...container.querySelectorAll('.credential-item')];
// Get indices
const draggedIndex = allItems.indexOf(draggedElement);
const targetIndex = allItems.indexOf(this);
// Reorder in DOM
if (draggedIndex < targetIndex) {
this.parentNode.insertBefore(draggedElement, this.nextSibling);
} else {
this.parentNode.insertBefore(draggedElement, this);
}
// Update backend
saveCredentialOrder();
}
this.classList.remove('drag-over');
return false;
}
function handleDragEnd(e) {
this.style.opacity = '1';
// Remove all drag-over classes
document.querySelectorAll('.credential-item').forEach(item => {
item.classList.remove('drag-over');
});
}
async function saveCredentialOrder() {
try {
const items = document.querySelectorAll('.credential-item');
const order = Array.from(items).map(item => parseInt(item.dataset.credentialId));
const res = await apiFetch(`${API_URL}/ai-credentials/reorder`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ order })
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Fehler beim Speichern der Reihenfolge');
}
credentials = await res.json();
showSuccess('✅ Reihenfolge gespeichert');
} catch (err) {
showError('❌ ' + err.message);
await loadCredentials(); // Reload to restore original order
}
}
async function saveSettings(e) {
e.preventDefault();
try {
const data = {
enabled: document.getElementById('aiEnabled').checked,
active_credential_id: parseInt(document.getElementById('activeCredential').value) || null,
prompt_prefix: document.getElementById('aiPromptPrefix').value
};
const res = await apiFetch(`${API_URL}/ai-settings`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Fehler beim Speichern der Einstellungen');
}
currentSettings = await res.json();
showSuccess('✅ Einstellungen erfolgreich gespeichert');
} catch (err) {
showError('❌ ' + err.message);
}
}
async function testComment() {
const modal = document.getElementById('testModal');
modal.removeAttribute('hidden');
document.getElementById('testResult').style.display = 'none';
document.getElementById('testError').style.display = 'none';
// Load last test data from localStorage
const lastTest = localStorage.getItem('lastTestComment');
if (lastTest) {
try {
const data = JSON.parse(lastTest);
document.getElementById('testPostText').value = data.postText || '';
document.getElementById('testProfileNumber').value = data.profileNumber || '1';
} catch (e) {
console.error('Failed to load last test comment:', e);
}
}
}
async function generateTest() {
const text = document.getElementById('testPostText').value;
const profileNumber = parseInt(document.getElementById('testProfileNumber').value);
if (!text) return;
document.getElementById('testLoading').style.display = 'block';
document.getElementById('testResult').style.display = 'none';
document.getElementById('testError').style.display = 'none';
try {
const res = await apiFetch(`${API_URL}/ai/generate-comment`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({postText: text, profileNumber})
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed');
}
const data = await res.json();
document.getElementById('testComment').textContent = data.comment;
document.getElementById('testResult').style.display = 'block';
// Save test data to localStorage
localStorage.setItem('lastTestComment', JSON.stringify({
postText: text,
profileNumber: profileNumber,
comment: data.comment,
timestamp: new Date().toISOString()
}));
} catch (err) {
document.getElementById('testError').textContent = err.message;
document.getElementById('testError').style.display = 'block';
} finally {
document.getElementById('testLoading').style.display = 'none';
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ============================================================================
// PROFILE FRIENDS
// ============================================================================
let profileFriends = {};
async function loadProfileFriends() {
const list = document.getElementById('profileFriendsList');
list.innerHTML = '';
for (let i = 1; i <= 5; i++) {
const res = await apiFetch(`${API_URL}/profile-friends/${i}`);
const data = await res.json();
profileFriends[i] = data.friend_names || '';
const div = document.createElement('div');
div.className = 'form-group';
div.innerHTML = `
<label for="friends${i}" class="form-label">Profil ${i}</label>
<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>
`;
list.appendChild(div);
document.getElementById(`friends${i}`).addEventListener('blur', async (e) => {
const newValue = e.target.value.trim();
if (newValue !== profileFriends[i]) {
await saveFriends(i, newValue);
}
});
}
}
async function saveFriends(profileNumber, friendNames) {
try {
const res = await apiFetch(`${API_URL}/profile-friends/${profileNumber}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ friend_names: friendNames })
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Fehler beim Speichern');
}
profileFriends[profileNumber] = friendNames;
showSuccess(`✅ Freunde für Profil ${profileNumber} gespeichert`);
} catch (err) {
showError('❌ ' + err.message);
}
}
// Event listeners
document.getElementById('addCredentialBtn').addEventListener('click', () => openCredentialModal());
document.getElementById('credentialModalClose').addEventListener('click', closeCredentialModal);
document.getElementById('credentialCancelBtn').addEventListener('click', closeCredentialModal);
document.getElementById('credentialForm').addEventListener('submit', saveCredential);
document.getElementById('credentialProvider').addEventListener('change', e => updateModelOptions(e.target.value));
document.getElementById('aiSettingsForm').addEventListener('submit', (e) => {
e.preventDefault();
saveSettings(e);
});
document.getElementById('saveAllBtn').addEventListener('click', (e) => {
e.preventDefault();
saveSettings(e);
});
document.getElementById('testBtn').addEventListener('click', testComment);
document.getElementById('testModalClose').addEventListener('click', () => document.getElementById('testModal').setAttribute('hidden', ''));
document.getElementById('generateTestComment').addEventListener('click', generateTest);
// Initialize
Promise.all([loadCredentials(), loadSettings(), loadProfileFriends()]).catch(err => showError(err.message));

1225
web/style.css Normal file

File diff suppressed because it is too large Load Diff