fixed SPA

This commit is contained in:
2025-12-16 15:23:40 +01:00
parent 1555dc02e9
commit 2809d18c12
13 changed files with 3096 additions and 1026 deletions

View File

@@ -1,7 +1,9 @@
FROM node:22-alpine
FROM node:24-alpine
WORKDIR /app
ENV CXXFLAGS="-std=c++20"
COPY package*.json ./
RUN apk add --no-cache python3 make g++ \

1752
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -10,9 +10,9 @@
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"better-sqlite3": "^9.2.2",
"better-sqlite3": "^12.5.0",
"uuid": "^9.0.1",
"nodemailer": "^6.9.14"
"nodemailer": "^7.0.11"
},
"devDependencies": {
"nodemon": "^3.0.2"

View File

@@ -747,6 +747,24 @@ function normalizeDailyBookmarkMarker(value) {
return marker;
}
function normalizeDailyBookmarkActive(value) {
if (value === undefined || value === null) {
return 1;
}
if (typeof value === 'string') {
const trimmed = value.trim().toLowerCase();
if (trimmed === 'false' || trimmed === '0' || trimmed === 'off') {
return 0;
}
if (trimmed === 'true' || trimmed === '1' || trimmed === 'on') {
return 1;
}
}
return value ? 1 : 0;
}
function serializeDailyBookmark(row, dayKey) {
if (!row) {
return null;
@@ -760,6 +778,7 @@ function serializeDailyBookmark(row, dayKey) {
title: row.title,
url_template: row.url_template,
marker: row.marker || '',
is_active: Number(row.is_active ?? 1) !== 0,
resolved_url: resolvedUrl,
notes: row.notes || '',
created_at: sqliteTimestampToUTC(row.created_at),
@@ -1218,6 +1237,7 @@ db.exec(`
title TEXT NOT NULL,
url_template TEXT NOT NULL,
notes TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
marker TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
@@ -1246,6 +1266,7 @@ db.exec(`
`);
ensureColumn('daily_bookmarks', 'marker', 'marker TEXT DEFAULT \'\'');
ensureColumn('daily_bookmarks', 'is_active', 'is_active INTEGER NOT NULL DEFAULT 1');
const listDailyBookmarksStmt = db.prepare(`
SELECT
@@ -1253,6 +1274,7 @@ const listDailyBookmarksStmt = db.prepare(`
b.title,
b.url_template,
b.notes,
b.is_active,
b.marker,
b.created_at,
b.updated_at,
@@ -1277,6 +1299,7 @@ const getDailyBookmarkStmt = db.prepare(`
b.title,
b.url_template,
b.notes,
b.is_active,
b.marker,
b.created_at,
b.updated_at,
@@ -1296,8 +1319,8 @@ const getDailyBookmarkStmt = db.prepare(`
`);
const insertDailyBookmarkStmt = db.prepare(`
INSERT INTO daily_bookmarks (id, title, url_template, notes, marker)
VALUES (@id, @title, @url_template, @notes, @marker)
INSERT INTO daily_bookmarks (id, title, url_template, notes, marker, is_active)
VALUES (@id, @title, @url_template, @notes, @marker, @is_active)
`);
const findDailyBookmarkByUrlStmt = db.prepare(`
@@ -1321,6 +1344,7 @@ const updateDailyBookmarkStmt = db.prepare(`
url_template = @url_template,
notes = @notes,
marker = @marker,
is_active = @is_active,
updated_at = CURRENT_TIMESTAMP
WHERE id = @id
`);
@@ -3942,6 +3966,9 @@ app.post('/api/daily-bookmarks', (req, res) => {
const normalizedTitle = normalizeDailyBookmarkTitle(payload.title || payload.label, normalizedUrl);
const normalizedNotes = normalizeDailyBookmarkNotes(payload.notes);
const normalizedMarker = normalizeDailyBookmarkMarker(payload.marker || payload.tag);
const normalizedActive = normalizeDailyBookmarkActive(
payload.is_active ?? payload.active ?? true
);
if (!normalizedUrl) {
return res.status(400).json({ error: 'URL-Template ist erforderlich' });
@@ -3958,7 +3985,8 @@ app.post('/api/daily-bookmarks', (req, res) => {
title: normalizedTitle,
url_template: normalizedUrl,
notes: normalizedNotes,
marker: normalizedMarker
marker: normalizedMarker,
is_active: normalizedActive
});
upsertDailyBookmarkCheckStmt.run({ bookmarkId: id, dayKey });
const saved = getDailyBookmarkStmt.get({ bookmarkId: id, dayKey });
@@ -3978,6 +4006,9 @@ app.post('/api/daily-bookmarks/import', (req, res) => {
}
const normalizedMarker = normalizeDailyBookmarkMarker(payload.marker || payload.tag);
const normalizedActive = normalizeDailyBookmarkActive(
payload.is_active ?? payload.active ?? true
);
const collected = [];
const addValue = (value) => {
if (typeof value !== 'string') {
@@ -4030,7 +4061,8 @@ app.post('/api/daily-bookmarks/import', (req, res) => {
title,
url_template: template,
notes: '',
marker: normalizedMarker
marker: normalizedMarker,
is_active: normalizedActive
});
const saved = getDailyBookmarkStmt.get({ bookmarkId: id, dayKey });
if (saved) {
@@ -4087,6 +4119,9 @@ app.put('/api/daily-bookmarks/:bookmarkId', (req, res) => {
const normalizedMarker = normalizeDailyBookmarkMarker(
payload.marker ?? existing.marker ?? ''
);
const normalizedActive = normalizeDailyBookmarkActive(
payload.is_active ?? payload.active ?? Number(existing.is_active ?? 1)
);
if (!normalizedUrl) {
return res.status(400).json({ error: 'URL-Template ist erforderlich' });
@@ -4106,7 +4141,8 @@ app.put('/api/daily-bookmarks/:bookmarkId', (req, res) => {
title: normalizedTitle,
url_template: normalizedUrl,
notes: normalizedNotes,
marker: normalizedMarker
marker: normalizedMarker,
is_active: normalizedActive
});
const updated = getDailyBookmarkStmt.get({ bookmarkId, dayKey });
res.json(serializeDailyBookmark(updated, dayKey));
@@ -4151,6 +4187,9 @@ app.post('/api/daily-bookmarks/:bookmarkId/check', (req, res) => {
if (!existing) {
return res.status(404).json({ error: 'Bookmark nicht gefunden' });
}
if (Number(existing.is_active ?? 1) === 0) {
return res.status(400).json({ error: 'Bookmark ist deaktiviert' });
}
upsertDailyBookmarkCheckStmt.run({ bookmarkId, dayKey });
const updated = getDailyBookmarkStmt.get({ bookmarkId, dayKey });
@@ -4178,6 +4217,9 @@ app.delete('/api/daily-bookmarks/:bookmarkId/check', (req, res) => {
if (!existing) {
return res.status(404).json({ error: 'Bookmark nicht gefunden' });
}
if (Number(existing.is_active ?? 1) === 0) {
return res.status(400).json({ error: 'Bookmark ist deaktiviert' });
}
deleteDailyBookmarkCheckStmt.run({ bookmarkId, dayKey });
const updated = getDailyBookmarkStmt.get({ bookmarkId, dayKey });

View File

@@ -1,50 +1,58 @@
:root {
--bg: #0b1020;
--card: #0f172a;
--card-soft: #131c35;
--border: rgba(255, 255, 255, 0.08);
--muted: #94a3b8;
--text: #e2e8f0;
--accent: #10b981;
--accent-2: #0ea5e9;
--danger: #ef4444;
--success: #22c55e;
--shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
--automation-bg: #f0f2f5;
--automation-card: #ffffff;
--automation-card-soft: #f7f8fa;
--automation-border: #e4e6eb;
--automation-muted: #6b7280;
--automation-text: #0f172a;
--automation-accent: #1877f2;
--automation-accent-2: #2563eb;
--automation-danger: #dc2626;
--automation-success: #059669;
--automation-shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.08);
--automation-shadow-md: 0 14px 45px rgba(15, 23, 42, 0.12);
}
* {
.automation-view * {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Manrope', 'Inter', system-ui, -apple-system, sans-serif;
background: radial-gradient(120% 120% at 10% 20%, rgba(16, 185, 129, 0.12), transparent 40%),
radial-gradient(120% 120% at 80% 0%, rgba(14, 165, 233, 0.12), transparent 40%),
var(--bg);
color: var(--text);
min-height: 100vh;
padding: 32px 18px 48px;
.automation-view {
color: var(--automation-text);
}
.auto-shell {
max-width: 1500px;
margin: 0 auto;
.automation-view .auto-shell {
display: flex;
flex-direction: column;
gap: 18px;
gap: 16px;
padding-bottom: 32px;
}
.auto-hero {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.12), rgba(14, 165, 233, 0.08)),
var(--card);
border: 1px solid var(--border);
border-radius: 18px;
padding: 22px 22px 18px;
box-shadow: var(--shadow);
.automation-view .auto-hero {
position: relative;
overflow: hidden;
background: var(--automation-card);
border: 1px solid var(--automation-border);
border-radius: 14px;
padding: 18px 20px;
box-shadow: var(--automation-shadow-sm);
margin: 0;
}
.hero-head {
.automation-view .auto-hero::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(120deg, rgba(24, 119, 242, 0.08), rgba(37, 99, 235, 0.05));
pointer-events: none;
}
.automation-view .auto-hero > * {
position: relative;
z-index: 1;
}
.automation-view .hero-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
@@ -52,91 +60,78 @@ body {
flex-wrap: wrap;
}
.hero-text h1 {
.automation-view .hero-text h1 {
margin: 4px 0;
font-size: 28px;
letter-spacing: -0.02em;
font-size: 24px;
letter-spacing: -0.01em;
color: var(--automation-text);
}
.subline {
margin: 6px 0 10px;
color: var(--muted);
}
.eyebrow {
font-size: 13px;
.automation-view .eyebrow {
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent-2);
color: var(--automation-accent-2);
margin: 0;
font-weight: 700;
}
.hero-pills {
.automation-view .back-link {
color: var(--automation-accent);
text-decoration: none;
font-size: 13px;
font-weight: 600;
}
.automation-view .back-link:hover {
text-decoration: underline;
}
.automation-view .hero-actions {
display: flex;
gap: 10px;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.pill {
background: rgba(255, 255, 255, 0.04);
color: var(--text);
border: 1px solid var(--border);
border-radius: 999px;
padding: 8px 12px;
font-size: 13px;
}
.hero-actions {
display: flex;
gap: 10px;
align-items: center;
}
.back-link {
color: var(--muted);
text-decoration: none;
font-size: 14px;
}
.hero-stats {
margin-top: 14px;
.automation-view .hero-stats {
margin-top: 12px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.stat {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border);
.automation-view .stat {
background: var(--automation-card-soft);
border: 1px solid var(--automation-border);
border-radius: 12px;
padding: 12px;
box-shadow: var(--automation-shadow-sm);
}
.stat-label {
color: var(--muted);
font-size: 13px;
.automation-view .stat-label {
color: var(--automation-muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 0 0 4px;
}
.stat-value {
.automation-view .stat-value {
font-size: 22px;
font-weight: 700;
margin: 0;
}
.auto-grid {
display: grid;
grid-template-columns: 1fr;
gap: 18px;
.automation-view .panel {
background: var(--automation-card);
border: 1px solid var(--automation-border);
border-radius: 12px;
padding: 18px 18px 16px;
box-shadow: var(--automation-shadow-sm);
}
.panel {
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 18px;
box-shadow: var(--shadow);
}
.panel-header {
.automation-view .panel-header {
display: flex;
align-items: center;
justify-content: space-between;
@@ -145,137 +140,147 @@ body {
margin-bottom: 12px;
}
.panel-eyebrow {
.automation-view .panel-eyebrow {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 12px;
color: var(--muted);
color: var(--automation-muted);
margin: 0;
font-weight: 700;
}
.panel-actions {
.automation-view .panel-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.form-grid {
.automation-view .form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.field {
.automation-view .field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field.full {
.automation-view .field.full {
grid-column: 1 / -1;
}
.field.inline {
.automation-view .field.inline {
min-width: 0;
}
.field[data-section] {
.automation-view .field[data-section] {
display: grid;
}
label {
.automation-view label {
font-weight: 600;
color: var(--text);
color: var(--automation-text);
}
input,
textarea,
select {
.automation-view input,
.automation-view textarea,
.automation-view select {
width: 100%;
border-radius: 10px;
border: 1px solid var(--border);
border: 1px solid var(--automation-border);
padding: 10px 12px;
background: #0c1427;
color: var(--text);
background: #fff;
color: var(--automation-text);
font-family: inherit;
font-size: 14px;
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
input:focus,
textarea:focus,
select:focus {
border-color: var(--accent-2);
box-shadow: 0 0 0 2px rgba(14, 165, 233, 0.15);
.automation-view input:focus,
.automation-view textarea:focus,
.automation-view select:focus {
border-color: var(--automation-accent-2);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
textarea {
.automation-view textarea {
resize: vertical;
}
small {
color: var(--muted);
.automation-view small {
color: var(--automation-muted);
}
.template-hint {
border: 1px dashed var(--border);
background: rgba(255, 255, 255, 0.02);
.automation-view .template-hint {
border: 1px dashed var(--automation-border);
background: #f8fafc;
border-radius: 12px;
padding: 10px 12px;
font-family: 'JetBrains Mono', monospace;
color: var(--muted);
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
color: var(--automation-muted);
}
.template-title {
.automation-view .template-title {
margin: 0 0 6px;
color: var(--text);
font-weight: 600;
color: var(--automation-text);
font-weight: 700;
}
.template-copy {
.automation-view .template-copy {
margin: 0;
word-break: break-all;
}
.form-status {
.automation-view .form-status {
min-height: 22px;
color: var(--muted);
color: var(--automation-muted);
}
.form-status.error {
color: var(--danger);
.automation-view .form-status.error {
color: var(--automation-danger);
}
.form-status.success {
color: var(--success);
.automation-view .form-status.success {
color: var(--automation-success);
}
.auto-table {
.automation-view .table-wrap {
overflow-x: auto;
}
.automation-view .auto-table {
width: 100%;
border-collapse: collapse;
min-width: 720px;
}
.auto-table th,
.auto-table td {
.automation-view .auto-table th,
.automation-view .auto-table td {
padding: 10px 8px;
text-align: left;
border-bottom: 1px solid var(--border);
border-bottom: 1px solid var(--automation-border);
font-size: 14px;
white-space: nowrap;
}
.auto-table th {
color: var(--muted);
font-weight: 600;
.automation-view .auto-table th {
color: var(--automation-muted);
font-weight: 700;
background: var(--automation-card-soft);
}
.auto-table th[data-sort-column="runs"],
.auto-table td.runs-count {
.automation-view .auto-table th[data-sort-column="runs"],
.automation-view .auto-table td.runs-count {
width: 1%;
white-space: nowrap;
text-align: right;
}
.auto-table .sort-indicator {
.automation-view .auto-table .sort-indicator {
display: inline-block;
margin-left: 6px;
width: 10px;
@@ -284,108 +289,112 @@ small {
border-bottom: 0;
border-left: 0;
transform: rotate(45deg);
opacity: 0.35;
opacity: 0.25;
}
.auto-table th.sort-asc .sort-indicator {
border-top: 6px solid var(--accent-2);
.automation-view .auto-table th.sort-asc .sort-indicator {
border-top: 6px solid var(--automation-accent-2);
transform: rotate(225deg);
opacity: 0.9;
}
.auto-table th.sort-desc .sort-indicator {
border-top: 6px solid var(--accent-2);
.automation-view .auto-table th.sort-desc .sort-indicator {
border-top: 6px solid var(--automation-accent-2);
transform: rotate(45deg);
opacity: 0.9;
}
.table-filter-row input,
.table-filter-row select {
.automation-view .table-filter-row th {
background: var(--automation-card);
}
.automation-view .table-filter-row input,
.automation-view .table-filter-row select {
width: 100%;
border-radius: 8px;
border: 1px solid var(--border);
border: 1px solid var(--automation-border);
padding: 8px 10px;
background: #0c1427;
color: var(--text);
background: #fff;
color: var(--automation-text);
font-size: 13px;
}
.auto-table tr.is-selected {
background: rgba(14, 165, 233, 0.08);
.automation-view .auto-table tr.is-selected td {
background: #eef2ff;
}
.row-title {
.automation-view .row-title {
font-weight: 700;
color: var(--text);
color: var(--automation-text);
}
.row-sub {
color: var(--muted);
.automation-view .row-sub {
color: var(--automation-muted);
font-size: 13px;
word-break: break-all;
}
.table-wrap {
overflow-x: auto;
}
.badge {
.automation-view .badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
font-size: 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border);
background: #eef2ff;
color: var(--automation-accent-2);
border: 1px solid rgba(37, 99, 235, 0.28);
}
.badge.success {
color: var(--success);
border-color: rgba(34, 197, 94, 0.35);
.automation-view .badge.success {
background: rgba(5, 150, 105, 0.12);
color: var(--automation-success);
border-color: rgba(5, 150, 105, 0.35);
}
.badge.error {
color: var(--danger);
border-color: rgba(239, 68, 68, 0.35);
.automation-view .badge.error {
background: rgba(220, 38, 38, 0.12);
color: var(--automation-danger);
border-color: rgba(220, 38, 38, 0.35);
}
.row-actions {
.automation-view .row-actions {
display: flex;
gap: 6px;
flex-wrap: nowrap;
}
.hidden-value {
.automation-view .hidden-value {
display: none;
}
.icon-btn {
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
.automation-view .icon-btn {
border: 1px solid var(--automation-border);
background: var(--automation-card-soft);
color: var(--automation-text);
border-radius: 8px;
padding: 6px 8px;
cursor: pointer;
font-size: 14px;
line-height: 1;
transition: transform 0.15s ease, opacity 0.15s ease, border-color 0.15s ease;
transition: transform 0.15s ease, opacity 0.15s ease, border-color 0.15s ease, box-shadow 0.2s ease;
}
.icon-btn:hover {
.automation-view .icon-btn:hover {
transform: translateY(-1px);
opacity: 0.95;
border-color: rgba(255, 255, 255, 0.2);
border-color: #d1d5db;
box-shadow: var(--automation-shadow-sm);
}
.icon-btn.danger {
color: #ef4444;
border-color: rgba(239, 68, 68, 0.35);
.automation-view .icon-btn.danger {
color: var(--automation-danger);
border-color: rgba(220, 38, 38, 0.35);
}
.primary-btn,
.secondary-btn,
.ghost-btn {
.automation-view .primary-btn,
.automation-view .secondary-btn,
.automation-view .ghost-btn {
border: 1px solid transparent;
border-radius: 10px;
padding: 10px 14px;
@@ -395,49 +404,50 @@ small {
transition: transform 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease;
}
.primary-btn {
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: #0b1020;
box-shadow: 0 10px 30px rgba(14, 165, 233, 0.35);
.automation-view .primary-btn {
background: linear-gradient(135deg, var(--automation-accent-2), var(--automation-accent));
color: #fff;
box-shadow: 0 10px 30px rgba(24, 119, 242, 0.25);
border: none;
}
.secondary-btn {
background: rgba(255, 255, 255, 0.06);
color: var(--text);
border-color: var(--border);
.automation-view .secondary-btn {
background: #e4e6eb;
color: var(--automation-text);
border-color: #d1d5db;
}
.ghost-btn {
.automation-view .ghost-btn {
background: transparent;
color: var(--text);
border-color: var(--border);
color: var(--automation-text);
border-color: var(--automation-border);
}
.primary-btn:hover,
.secondary-btn:hover,
.ghost-btn:hover {
.automation-view .primary-btn:hover,
.automation-view .secondary-btn:hover,
.automation-view .ghost-btn:hover {
transform: translateY(-1px);
opacity: 0.95;
}
.list-status,
.runs-status,
.import-status {
.automation-view .list-status,
.automation-view .runs-status,
.automation-view .import-status {
min-height: 20px;
color: var(--muted);
color: var(--automation-muted);
margin-bottom: 8px;
}
.filter-input {
.automation-view .filter-input {
border-radius: 10px;
border: 1px solid var(--border);
border: 1px solid var(--automation-border);
padding: 8px 10px;
background: #0c1427;
color: var(--text);
background: #fff;
color: var(--automation-text);
min-width: 200px;
}
.runs-list {
.automation-view .runs-list {
list-style: none;
padding: 0;
margin: 0;
@@ -445,14 +455,15 @@ small {
gap: 10px;
}
.run-item {
border: 1px solid var(--border);
.automation-view .run-item {
border: 1px solid var(--automation-border);
border-radius: 12px;
padding: 12px;
background: #0c1427;
background: var(--automation-card-soft);
box-shadow: var(--automation-shadow-sm);
}
.run-top {
.automation-view .run-top {
display: flex;
align-items: center;
justify-content: space-between;
@@ -460,58 +471,57 @@ small {
flex-wrap: wrap;
}
.run-meta {
.automation-view .run-meta {
display: flex;
gap: 8px;
align-items: center;
color: var(--muted);
color: var(--automation-muted);
font-size: 13px;
}
.run-body {
.automation-view .run-body {
margin: 8px 0 0;
color: var(--text);
font-family: 'JetBrains Mono', monospace;
color: var(--automation-text);
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 13px;
white-space: pre-wrap;
word-break: break-all;
}
.runs-hint {
color: var(--muted);
.automation-view .runs-hint {
color: var(--automation-muted);
margin: 0;
}
.import-panel textarea {
width: 100%;
}
.import-hint {
color: var(--muted);
.automation-view .import-hint {
color: var(--automation-muted);
margin: 0 0 8px;
}
.switch {
.automation-view .switch {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-weight: 600;
color: var(--automation-text);
}
.switch input {
.automation-view .switch input {
display: none;
}
.switch-slider {
.automation-view .switch-slider {
width: 42px;
height: 22px;
background: rgba(255, 255, 255, 0.2);
background: #d1d5db;
border-radius: 999px;
position: relative;
transition: background 0.2s ease;
}
.switch-slider::after {
content: '';
.automation-view .switch-slider::after {
content: "";
width: 18px;
height: 18px;
background: #fff;
@@ -519,51 +529,52 @@ small {
position: absolute;
top: 2px;
left: 2px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.25);
transition: transform 0.2s ease;
}
.switch input:checked + .switch-slider {
background: var(--accent);
.automation-view .switch input:checked + .switch-slider {
background: var(--automation-accent);
}
.switch input:checked + .switch-slider::after {
.automation-view .switch input:checked + .switch-slider::after {
transform: translateX(20px);
}
.switch-label {
color: var(--muted);
.automation-view .switch-label {
color: var(--automation-muted);
font-weight: 600;
}
.modal {
.automation-view .modal {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(2px);
padding: 24px;
background: rgba(15, 23, 42, 0.55);
z-index: 900;
}
.modal__backdrop {
.automation-view .modal__backdrop {
position: absolute;
inset: 0;
}
.modal__content {
.automation-view .modal__content {
position: relative;
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 18px;
background: var(--automation-card);
border: 1px solid var(--automation-border);
border-radius: 14px;
padding: 20px 20px 16px;
width: min(1100px, 96vw);
max-height: 92vh;
overflow-y: auto;
box-shadow: var(--shadow);
box-shadow: var(--automation-shadow-md);
}
.modal__header {
.automation-view .modal__header {
display: flex;
align-items: center;
justify-content: space-between;
@@ -571,25 +582,25 @@ small {
margin-bottom: 10px;
}
.modal-actions {
.automation-view .modal-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.modal[hidden] {
.automation-view .modal[hidden] {
display: none;
}
.preview-panel {
border: 1px solid var(--border);
.automation-view .preview-panel {
border: 1px solid var(--automation-border);
border-radius: 12px;
padding: 12px;
background: rgba(255, 255, 255, 0.02);
background: var(--automation-card-soft);
}
.preview-header {
.automation-view .preview-header {
display: flex;
align-items: center;
justify-content: space-between;
@@ -597,29 +608,29 @@ small {
margin-bottom: 8px;
}
.preview-grid {
.automation-view .preview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 10px;
}
.preview-block {
border: 1px dashed var(--border);
.automation-view .preview-block {
border: 1px dashed var(--automation-border);
border-radius: 10px;
padding: 8px;
background: #0c1427;
background: #fff;
}
.preview-label {
.automation-view .preview-label {
margin: 0 0 4px;
color: var(--muted);
color: var(--automation-muted);
font-size: 13px;
}
.preview-value {
.automation-view .preview-value {
margin: 0;
color: var(--text);
font-family: 'JetBrains Mono', monospace;
color: var(--automation-text);
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 13px;
white-space: pre-wrap;
word-break: break-all;
@@ -627,65 +638,65 @@ small {
overflow: auto;
}
.preview-hint {
color: var(--muted);
.automation-view .preview-hint {
color: var(--automation-muted);
margin: 8px 0 0;
font-size: 13px;
}
.placeholder-table {
.automation-view .placeholder-table {
width: 100%;
border-collapse: collapse;
margin-top: 6px;
}
.placeholder-table td {
.automation-view .placeholder-table td {
padding: 6px 8px;
border-bottom: 1px solid var(--border);
font-family: 'JetBrains Mono', monospace;
border-bottom: 1px solid var(--automation-border);
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 13px;
color: var(--text);
color: var(--automation-text);
vertical-align: top;
}
.placeholder-table td.placeholder-key {
.automation-view .placeholder-table td.placeholder-key {
width: 30%;
color: var(--muted);
color: var(--automation-muted);
}
.placeholder-hint {
color: var(--muted);
.automation-view .placeholder-hint {
color: var(--automation-muted);
font-size: 12px;
margin: 6px 0 0;
}
@media (max-width: 1050px) {
.auto-grid {
.automation-view .form-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
body {
padding: 16px 12px 32px;
.automation-view .auto-shell {
gap: 12px;
}
.panel-header {
.automation-view .panel-header {
flex-direction: column;
align-items: flex-start;
}
.panel-actions {
.automation-view .panel-actions {
width: 100%;
justify-content: flex-start;
}
.form-grid {
grid-template-columns: 1fr;
}
.hero-head {
.automation-view .hero-head {
flex-direction: column;
align-items: flex-start;
}
.automation-view .modal {
padding: 12px;
}
}

View File

@@ -2,299 +2,20 @@
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Automationen Post Tracker</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/png" sizes="32x32" href="assets/app-icon-64.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="automation.css">
<script>
(function redirectToShell() {
const url = new URL(window.location.href);
const params = new URLSearchParams(url.search);
params.set('view', 'automation');
const target = `index.html${params.toString() ? `?${params}` : ''}${url.hash}`;
window.location.replace(target);
})();
</script>
</head>
<body>
<div class="auto-shell">
<header class="auto-hero">
<div class="hero-head">
<div class="hero-text">
<a class="back-link" href="index.html">← Zurück zur App</a>
<p class="eyebrow">Automatisierte Requests</p>
<h1>Request Automationen</h1>
</div>
<div class="hero-actions">
<button class="ghost-btn" id="openImportBtn" type="button">📥 Vorlage importieren</button>
<button class="primary-btn" id="newAutomationBtn" type="button">+ Neue Automation</button>
</div>
</div>
<div class="hero-stats" id="heroStats"></div>
</header>
<main class="auto-grid">
<section class="panel list-panel">
<div class="panel-header">
<div>
<p class="panel-eyebrow">Geplante Requests</p>
<h2>Automationen</h2>
</div>
<div class="panel-actions">
<input id="tableFilterInput" class="filter-input" type="search" placeholder="Filtern nach Name/Typ…" />
<button class="ghost-btn" id="refreshBtn" type="button">Aktualisieren</button>
</div>
</div>
<div id="listStatus" class="list-status" aria-live="polite"></div>
<div class="table-wrap" id="automationTable">
<table class="auto-table">
<thead>
<tr>
<th data-sort-column="name">Name<span class="sort-indicator"></span></th>
<th data-sort-column="next">Nächster Lauf<span class="sort-indicator"></span></th>
<th data-sort-column="last">Letzter Lauf<span class="sort-indicator"></span></th>
<th data-sort-column="status">Status<span class="sort-indicator"></span></th>
<th data-sort-column="runs">#Läufe<span class="sort-indicator"></span></th>
<th>Aktionen</th>
</tr>
<tr class="table-filter-row">
<th><input id="filterName" type="search" placeholder="Name/Typ/E-Mail/URL"></th>
<th><input id="filterNext" type="search" placeholder="z.B. heute"></th>
<th><input id="filterLast" type="search" placeholder="z.B. HTTP 200"></th>
<th>
<select id="filterStatus">
<option value="">Alle</option>
<option value="success">OK</option>
<option value="error">Fehler</option>
</select>
</th>
<th><input id="filterRuns" type="number" min="0" placeholder="≥"></th>
<th></th>
</tr>
</thead>
<tbody id="requestTableBody" class="list"></tbody>
</table>
</div>
</section>
</main>
<section class="panel runs-panel">
<div class="panel-header">
<div>
<p class="panel-eyebrow">Verlauf</p>
<h2>Run-Historie</h2>
</div>
<p class="runs-hint">Letzte Läufe der ausgewählten Automation.</p>
</div>
<div id="runsStatus" class="runs-status" aria-live="polite"></div>
<ul id="runsList" class="runs-list"></ul>
</section>
</div>
<div id="formModal" class="modal" hidden>
<div class="modal__backdrop" id="formModalBackdrop"></div>
<div class="modal__content" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="modal__header">
<div>
<p class="panel-eyebrow" id="formModeLabel">Neue Automation</p>
<h2 id="modalTitle">Request & Zeitplan</h2>
</div>
<button class="ghost-btn" id="modalCloseBtn" type="button">×</button>
</div>
<form id="automationForm" class="form-grid" novalidate>
<div class="field">
<label for="typeSelect">Typ</label>
<select id="typeSelect">
<option value="request">HTTP Request</option>
<option value="email">E-Mail</option>
<option value="flow">Flow (bis 3 Schritte)</option>
</select>
</div>
<div class="field">
<label for="nameInput">Name *</label>
<input id="nameInput" type="text" placeholder="API Ping (stündlich)" required maxlength="160">
</div>
<div class="field">
<label for="descriptionInput">Notizen</label>
<textarea id="descriptionInput" rows="2" placeholder="Kurzbeschreibung oder Zweck"></textarea>
</div>
<div class="field" data-section="http">
<label for="urlInput">URL-Template *</label>
<input id="urlInput" type="url" placeholder="https://api.example.com/{{date}}/trigger" required>
</div>
<div class="field inline" data-section="http">
<label for="methodSelect">Methode</label>
<select id="methodSelect">
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
<option value="DELETE">DELETE</option>
</select>
</div>
<div class="field inline">
<label for="activeToggle">Aktiv</label>
<label class="switch">
<input type="checkbox" id="activeToggle" checked>
<span class="switch-slider"></span>
<span class="switch-label">Plan aktiv</span>
</label>
</div>
<div class="field" data-section="http">
<label for="headersInput">Headers (Key: Value pro Zeile oder JSON)</label>
<textarea id="headersInput" rows="3" placeholder="Authorization: Bearer {{token}}\nContent-Type: application/json"></textarea>
</div>
<div class="field" data-section="http">
<label for="bodyInput">Body (optional, Templates möglich)</label>
<textarea id="bodyInput" rows="5" placeholder='{"date":"{{date}}","id":"{{uuid}}"}'></textarea>
</div>
<div class="field full" data-section="email">
<label for="emailToInput">E-Mail Empfänger *</label>
<input id="emailToInput" type="text" placeholder="max@example.com, lisa@example.com">
</div>
<div class="field full" data-section="email">
<label for="emailSubjectInput">Betreff *</label>
<input id="emailSubjectInput" type="text" placeholder="Status Update {{date}}">
</div>
<div class="field full" data-section="email">
<label for="emailBodyInput">Body *</label>
<textarea id="emailBodyInput" rows="6" placeholder="Hallo,\nheutiger Status: {{uuid}}"></textarea>
</div>
<div class="field full" data-section="flow">
<div class="template-hint">
<p class="template-title">Flow Schritte</p>
<p class="template-copy">Max. 3 Schritte, Kontext steht als {{step1_json}}, {{step1_text}}, {{step1_status_code}} usw. im nächsten Schritt zur Verfügung.</p>
</div>
</div>
<div class="field" data-section="flow">
<label for="flowStep1Url">Step 1 URL *</label>
<input id="flowStep1Url" type="url" placeholder="https://api.example.com/first">
</div>
<div class="field inline" data-section="flow">
<label for="flowStep1Method">Step 1 Methode</label>
<select id="flowStep1Method">
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
<option value="DELETE">DELETE</option>
</select>
</div>
<div class="field" data-section="flow">
<label for="flowStep1Headers">Step 1 Headers</label>
<textarea id="flowStep1Headers" rows="2" placeholder="Authorization: Bearer {{token}}"></textarea>
</div>
<div class="field" data-section="flow">
<label for="flowStep1Body">Step 1 Body</label>
<textarea id="flowStep1Body" rows="3" placeholder='{"id":"{{uuid}}"}'></textarea>
</div>
<div class="field" data-section="flow">
<label for="flowStep2Url">Step 2 URL (optional)</label>
<input id="flowStep2Url" type="url" placeholder="https://api.example.com/second">
</div>
<div class="field inline" data-section="flow">
<label for="flowStep2Method">Step 2 Methode</label>
<select id="flowStep2Method">
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
<option value="DELETE">DELETE</option>
</select>
</div>
<div class="field" data-section="flow">
<label for="flowStep2Headers">Step 2 Headers</label>
<textarea id="flowStep2Headers" rows="2" placeholder="Authorization: Bearer {{token}}"></textarea>
</div>
<div class="field" data-section="flow">
<label for="flowStep2Body">Step 2 Body</label>
<textarea id="flowStep2Body" rows="3" placeholder='{"fromStep1":"{{step1_json.id}}"}'></textarea>
</div>
<div class="field inline">
<label for="intervalPreset">Intervall</label>
<select id="intervalPreset">
<option value="hourly">Jede Stunde</option>
<option value="daily">Jeden Tag</option>
<option value="custom">Eigene Minuten</option>
</select>
</div>
<div class="field inline">
<label for="intervalMinutesInput">Intervall (Minuten)</label>
<input id="intervalMinutesInput" type="number" min="5" max="20160" step="5" value="60">
</div>
<div class="field inline">
<label for="jitterInput">Varianz (Minuten)</label>
<input id="jitterInput" type="number" min="0" max="120" step="5" value="10">
<small>Auslösung erfolgt zufällig +0…Varianz Min nach dem Intervall.</small>
</div>
<div class="field inline">
<label for="startAtInput">Start ab</label>
<input id="startAtInput" type="datetime-local">
</div>
<div class="field inline">
<label for="runUntilInput">Läuft bis</label>
<input id="runUntilInput" type="datetime-local">
</div>
<div class="field full">
<div class="template-hint">
<p class="template-title">Platzhalter</p>
<table class="placeholder-table">
<tbody id="placeholderTableBody"></tbody>
</table>
<p class="placeholder-hint">Beispiele beziehen sich auf den aktuellen Zeitpunkt.</p>
</div>
</div>
<div class="field full">
<div class="preview-panel">
<div class="preview-header">
<div>
<p class="panel-eyebrow">Vorschau</p>
<h3>Aufgelöste Werte</h3>
</div>
<button class="secondary-btn" type="button" id="refreshPreviewBtn">Aktualisieren</button>
</div>
<div class="preview-grid">
<div class="preview-block">
<p class="preview-label">URL</p>
<pre id="previewUrl" class="preview-value"></pre>
</div>
<div class="preview-block">
<p class="preview-label">Headers</p>
<pre id="previewHeaders" class="preview-value"></pre>
</div>
<div class="preview-block">
<p class="preview-label">Body</p>
<pre id="previewBody" class="preview-value"></pre>
</div>
</div>
<p class="preview-hint">Die Vorschau nutzt die aktuellen Formularwerte und füllt Platzhalter ({{date}}, {{uuid}}, …) mit Beispielwerten.</p>
</div>
</div>
<div class="field full modal-actions">
<div id="formStatus" class="form-status" aria-live="polite"></div>
<div class="panel-actions">
<button class="ghost-btn" id="resetFormBtn" type="button">Zurücksetzen</button>
<button class="primary-btn" id="saveBtn" type="submit">Speichern</button>
</div>
</div>
</form>
</div>
</div>
<div id="importModal" class="modal" hidden>
<div class="modal__backdrop" id="importModalBackdrop"></div>
<div class="modal__content" role="dialog" aria-modal="true" aria-labelledby="importTitle">
<div class="modal__header">
<div>
<p class="panel-eyebrow">Import</p>
<h2 id="importTitle">Vorlage einfügen</h2>
</div>
<button class="ghost-btn" id="importCloseBtn" type="button">×</button>
</div>
<p class="import-hint">Füge hier "Copy as cURL", "Copy as fetch" oder Powershell ein. Header, Methode, Body und URL werden übernommen.</p>
<textarea id="importInput" rows="7" placeholder="curl https://api.example.com -X POST -H 'Authorization: Bearer token' --data '{\"hello\":\"world\"}'"></textarea>
<div class="modal-actions">
<div id="importStatus" class="import-status" aria-live="polite"></div>
<button class="secondary-btn" id="applyImportBtn" type="button">Vorlage übernehmen</button>
</div>
</div>
</div>
<script src="vendor/list.min.js"></script>
<script src="automation.js"></script>
<p>Weiterleitung zur Automations-Ansicht…</p>
</body>
</html>

View File

@@ -1,4 +1,6 @@
(function () {
let active = false;
let initialized = false;
const API_URL = (() => {
if (window.API_URL) return window.API_URL;
try {
@@ -455,6 +457,7 @@
}
async function loadRequests() {
if (!active) return;
if (!state.loading) {
setStatus(listStatus, 'Lade Automationen...');
}
@@ -511,6 +514,7 @@
}
function updateRelativeTimes() {
if (!active) return;
renderHero();
if (!requestTableBody || !state.requests.length) return;
const byId = new Map(state.requests.map((req) => [String(req.id), req]));
@@ -1105,9 +1109,16 @@
}
function handleSseMessage(payload) {
if (!active) return;
if (!payload || !payload.type) return;
const { type, request_id: requestId } = payload;
switch (payload.type) {
case 'automation-run':
loadRequests();
if (requestId && state.selectedId && String(requestId) === String(state.selectedId)) {
loadRuns(state.selectedId);
}
break;
case 'automation-upsert':
loadRequests();
break;
@@ -1117,6 +1128,7 @@
}
function startSse() {
if (!active) return;
if (sse || typeof EventSource === 'undefined') return;
try {
sse = new EventSource(`${API_URL}/events`, { withCredentials: true });
@@ -1143,19 +1155,33 @@
});
}
function init() {
applyPresetDisabling();
resetForm();
loadRequests();
function ensureRelativeTimer() {
if (!relativeTimer) {
updateRelativeTimes();
relativeTimer = setInterval(updateRelativeTimes, 60000);
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
updateRelativeTimes();
}
});
}
}
function cleanup(options = {}) {
const { keepActive = false } = options;
if (!keepActive) {
active = false;
}
if (relativeTimer) {
clearInterval(relativeTimer);
relativeTimer = null;
}
if (sse) {
sse.close();
sse = null;
}
}
function init() {
if (initialized) return;
applyPresetDisabling();
resetForm();
ensureRelativeTimer();
form.addEventListener('submit', handleSubmit);
intervalPreset.addEventListener('change', applyPresetDisabling);
@@ -1217,8 +1243,32 @@
}
}
});
startSse();
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
cleanup({ keepActive: true });
} else {
if (active) {
ensureRelativeTimer();
startSse();
}
}
});
window.addEventListener('beforeunload', cleanup);
window.addEventListener('pagehide', cleanup);
window.addEventListener('unload', cleanup);
initialized = true;
}
init();
function activate() {
init();
active = true;
ensureRelativeTimer();
startSse();
loadRequests();
}
window.AutomationPage = {
activate,
deactivate: cleanup
};
})();

View File

@@ -14,13 +14,13 @@
--radius: 16px;
}
*,
*::before,
*::after {
.daily-bookmarks-view *,
.daily-bookmarks-view *::before,
.daily-bookmarks-view *::after {
box-sizing: border-box;
}
body {
.daily-bookmarks-view {
margin: 0;
font-family: 'Space Grotesk', 'Inter', system-ui, -apple-system, sans-serif;
background: var(--bg);
@@ -29,12 +29,12 @@ body {
min-height: 100vh;
}
a {
.daily-bookmarks-view a {
color: var(--accent-2);
text-decoration: none;
}
a:hover {
.daily-bookmarks-view a:hover {
text-decoration: underline;
}
@@ -49,13 +49,13 @@ a:hover {
border: 0;
}
.daily-shell {
max-width: 1600px;
.daily-bookmarks-view .daily-shell {
max-width: var(--content-max-width, 1600px);
margin: 0 auto;
padding: 28px 18px 50px;
padding: 0 18px 36px;
}
.hero {
.daily-bookmarks-view .hero {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 20px;
@@ -366,6 +366,39 @@ a:hover {
box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.2);
}
.field__hint {
margin: 0;
color: var(--muted);
font-size: 13px;
}
.field--switch {
align-items: flex-start;
}
.switch-control {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.14);
}
.switch-control__label {
color: var(--text);
font-weight: 600;
}
.field--switch input[type='checkbox'] {
width: auto;
min-width: 18px;
height: 18px;
accent-color: var(--accent);
margin: 0;
}
.form-preview {
margin-top: 8px;
display: flex;
@@ -446,6 +479,15 @@ a:hover {
border-color: var(--accent);
}
.primary-btn:disabled,
.secondary-btn:disabled,
.ghost-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.ghost-btn--tiny {
padding: 6px 8px;
font-size: 12px;
@@ -588,11 +630,26 @@ a:hover {
border-bottom-color: rgba(16, 185, 129, 0.3);
}
.bookmark-table td:last-child {
white-space: nowrap;
text-align: right;
}
.bookmark-table tr.is-open td {
background: rgba(37, 99, 235, 0.08);
border-bottom-color: rgba(37, 99, 235, 0.2);
}
.bookmark-table tr.is-inactive td {
background: rgba(107, 114, 128, 0.16);
border-bottom-color: rgba(107, 114, 128, 0.28);
color: var(--muted);
}
.bookmark-table tr.is-inactive a {
color: var(--muted);
}
.chip {
border-radius: 12px;
padding: 6px 8px;
@@ -611,6 +668,13 @@ a:hover {
border-color: rgba(37, 99, 235, 0.2);
}
.chip--inactive {
background: rgba(107, 114, 128, 0.22);
color: #0f172a;
border-color: rgba(107, 114, 128, 0.32);
margin-left: 8px;
}
.list-summary {
color: var(--muted);
font-size: 14px;
@@ -631,9 +695,10 @@ a:hover {
}
.table-actions {
display: flex;
display: inline-flex;
gap: 6px;
flex-wrap: wrap;
flex-wrap: nowrap;
justify-content: flex-end;
}
.table-actions .ghost-btn {

View File

@@ -2,208 +2,20 @@
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Daily Bookmarks</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/png" sizes="32x32" href="assets/app-icon-64.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="daily-bookmarks.css">
<script>
(function redirectToShell() {
const url = new URL(window.location.href);
const params = new URLSearchParams(url.search);
params.set('view', 'daily-bookmarks');
const target = `index.html${params.toString() ? `?${params}` : ''}${url.hash}`;
window.location.replace(target);
})();
</script>
</head>
<body>
<div class="daily-shell">
<header class="hero">
<a class="back-link" href="index.html">← Zurück zur App</a>
<h1 class="hero__title">Daily Bookmarks</h1>
<div class="hero__controls">
<div class="day-switch">
<button class="ghost-btn" id="prevDayBtn" aria-label="Vorheriger Tag"></button>
<div class="day-switch__label">
<div id="dayLabel" class="day-switch__day">Heute</div>
<div id="daySubLabel" class="day-switch__sub"></div>
</div>
<button class="ghost-btn" id="nextDayBtn" aria-label="Nächster Tag"></button>
<button class="ghost-btn ghost-btn--today" id="todayBtn">Heute</button>
</div>
<div class="hero__actions">
<div class="bulk-actions">
<label for="bulkCountSelect">Anzahl</label>
<select id="bulkCountSelect">
<option value="1">1</option>
<option value="5">5</option>
<option value="10">10</option>
<option value="15">15</option>
<option value="20">20</option>
</select>
<label class="auto-open-toggle">
<input type="checkbox" id="autoOpenToggle">
<span>Auto öffnen</span>
</label>
<button class="secondary-btn" id="bulkOpenBtn" type="button">Öffnen & abhaken</button>
</div>
<button class="primary-btn" id="openCreateBtn" type="button">+ Bookmark</button>
<button class="secondary-btn" id="openImportBtn" type="button">Liste importieren</button>
<button class="ghost-btn" id="refreshBtn" type="button">Aktualisieren</button>
<span id="heroStats" class="hero__stats"></span>
</div>
</div>
</header>
<div id="autoOpenOverlay" class="auto-open-overlay" hidden>
<div class="auto-open-overlay__panel" id="autoOpenOverlayPanel">
<div class="auto-open-overlay__badge">Auto-Öffnen startet gleich</div>
<div class="auto-open-overlay__timer">
<span id="autoOpenCountdown" class="auto-open-overlay__count">0.0</span>
<span class="auto-open-overlay__unit">Sek.</span>
</div>
<p class="auto-open-overlay__text">
Die nächste Runde deiner gefilterten Bookmarks öffnet automatisch. Abbrechen, falls du noch warten willst.
</p>
<p class="auto-open-overlay__hint">Klicke irgendwo in dieses Panel, um abzubrechen.</p>
</div>
</div>
<main class="panel list-panel">
<header class="panel__header panel__header--row">
<div>
<p class="panel__eyebrow">Tägliche Liste</p>
<h2 class="panel__title">Alle Bookmarks</h2>
</div>
<div class="list-summary" id="listSummary"></div>
</header>
<div id="listStatus" class="list-status" role="status" aria-live="polite"></div>
<div class="table-wrapper">
<table class="bookmark-table">
<thead>
<tr>
<th class="col-url">
<button type="button" class="sort-btn" data-sort-key="url_template">URL (aufgelöst)</button>
</th>
<th class="col-marker">
<button type="button" class="sort-btn" data-sort-key="marker">Marker</button>
</th>
<th class="col-created">
<button type="button" class="sort-btn" data-sort-key="created_at">Erstelldatum</button>
</th>
<th class="col-last">
<button type="button" class="sort-btn" data-sort-key="last_completed_at">Letzte Erledigung</button>
</th>
<th>Aktionen</th>
</tr>
<tr class="table-filter-row">
<th>
<label class="visually-hidden" for="urlFilter">Nach URL filtern</label>
<input id="urlFilter" type="search" placeholder="URL filtern">
</th>
<th>
<label class="visually-hidden" for="markerFilter">Nach Marker filtern</label>
<select id="markerFilter">
<option value="">Alle Marker</option>
<option value="__none">Ohne Marker</option>
</select>
</th>
<th></th>
<th></th>
<th class="filter-hint">
<button type="button" class="ghost-btn ghost-btn--tiny" id="resetViewBtn" aria-label="Filter und Sortierung zurücksetzen"></button>
</th>
</tr>
</thead>
<tbody id="tableBody"></tbody>
</table>
</div>
</main>
</div>
<div id="bookmarkModal" class="modal" hidden>
<div class="modal__backdrop"></div>
<div class="modal__content" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<header class="modal__header">
<div>
<p class="panel__eyebrow" id="formModeLabel">Neues Bookmark</p>
<h2 id="modalTitle" class="panel__title">Bookmark pflegen</h2>
<p class="panel__subtitle">Titel optional, URL-Template erforderlich. Platzhalter werden für den gewählten Tag aufgelöst.</p>
</div>
<button class="ghost-btn modal__close" type="button" id="modalCloseBtn" aria-label="Schließen">×</button>
</header>
<form id="bookmarkForm" class="bookmark-form" autocomplete="off">
<label class="field">
<span>Titel (optional)</span>
<input id="titleInput" type="text" name="title" maxlength="160" placeholder="z.B. Daily Gewinnspielrunde">
</label>
<label class="field">
<span>URL-Template *</span>
<input id="urlInput" type="url" name="url_template" maxlength="800" placeholder="https://www.test.de/tag-{{day}}/" required>
</label>
<div id="urlSuggestionBox" class="suggestion-box" hidden></div>
<label class="field">
<span>Notiz (optional)</span>
<textarea id="notesInput" name="notes" maxlength="800" rows="3" placeholder="Kurze Hinweise oder To-do für diesen Link"></textarea>
</label>
<label class="field">
<span>Marker (optional)</span>
<input id="markerInput" type="text" name="marker" maxlength="120" placeholder="z.B. März-Import oder Kampagne A">
</label>
<div class="form-preview">
<div>
<p class="form-preview__label">Aufgelöste URL für den gewählten Tag:</p>
<a id="previewLink" class="form-preview__link" href="#" target="_blank" rel="noopener"></a>
</div>
<div class="form-preview__actions">
<button class="secondary-btn" type="button" id="resetBtn">Zurücksetzen</button>
<button class="primary-btn" type="submit" id="submitBtn">Speichern</button>
</div>
</div>
<div class="placeholder-help">
<p class="placeholder-help__title">Dynamische Platzhalter</p>
<ul class="placeholder-help__list">
<li><code>{{day}}</code> Tag des Monats (131), <code>{{dd}}</code> zweistellig</li>
<li><code>{{date}}</code> liefert <code>YYYY-MM-DD</code></li>
<li><code>{{mm}}</code> Monat zweistellig, <code>{{yyyy}}</code> Jahr</li>
<li><code>{{day+1}}</code> oder <code>{{date-2}}</code> verschieben um Tage</li>
<li><code>{{counter:477}}</code> Basiswert + aktueller Tag, z.B. <code>https://www.test.de/sweepstakes/{{counter:477}}/</code></li>
</ul>
</div>
<div id="formStatus" class="form-status" role="status" aria-live="polite"></div>
</form>
</div>
</div>
<div id="importModal" class="modal" hidden>
<div class="modal__backdrop"></div>
<div class="modal__content" role="dialog" aria-modal="true" aria-labelledby="importModalTitle">
<header class="modal__header">
<div>
<p class="panel__eyebrow">Masseneingabe</p>
<h2 id="importModalTitle" class="panel__title">Viele Bookmarks importieren</h2>
<p class="panel__subtitle">Füge hunderte Links gleichzeitig hinzu und vergebe einen gemeinsamen Marker.</p>
</div>
<button class="ghost-btn modal__close" type="button" id="importCloseBtn" aria-label="Schließen">×</button>
</header>
<form id="importForm" class="bookmark-form" autocomplete="off">
<label class="field">
<span>Liste der URL-Templates *</span>
<textarea id="importInput" name="import_urls" maxlength="120000" rows="8" placeholder="Jeder Link in eine neue Zeile, z.B. https://example.com/{{date}}/"></textarea>
</label>
<label class="field">
<span>Marker für alle Einträge (optional)</span>
<input id="importMarkerInput" type="text" name="import_marker" maxlength="120" placeholder="z.B. Batch März 2024">
</label>
<p class="import-hint">Doppelte oder ungültige Zeilen werden automatisch übersprungen.</p>
<div class="form-preview">
<div>
<p class="form-preview__label">Filter und Sortierung gelten auch beim Batch-Öffnen.</p>
</div>
<div class="form-preview__actions">
<button class="secondary-btn" type="button" id="importResetBtn">Zurücksetzen</button>
<button class="primary-btn" type="submit" id="importSubmitBtn">Import starten</button>
</div>
</div>
<div id="importStatus" class="form-status" role="status" aria-live="polite"></div>
</form>
</div>
</div>
<script src="daily-bookmarks.js"></script>
<p>Weiterleitung zu Daily Bookmarks…</p>
</body>
</html>

View File

@@ -1,4 +1,6 @@
(function () {
let active = false;
let initialized = false;
const API_URL = window.API_URL || 'https://fb.srv.medeba-media.de/api';
const BULK_COUNT_STORAGE_KEY = 'dailyBookmarkBulkCount';
const FILTER_STORAGE_KEY = 'dailyBookmarkFilters';
@@ -27,55 +29,63 @@
let editingId = null;
const dayLabel = document.getElementById('dayLabel');
const daySubLabel = document.getElementById('daySubLabel');
const prevDayBtn = document.getElementById('prevDayBtn');
const nextDayBtn = document.getElementById('nextDayBtn');
const todayBtn = document.getElementById('todayBtn');
const refreshBtn = document.getElementById('refreshBtn');
const heroStats = document.getElementById('heroStats');
const listSummary = document.getElementById('listSummary');
const listStatus = document.getElementById('listStatus');
const tableBody = document.getElementById('tableBody');
const bulkCountSelect = document.getElementById('bulkCountSelect');
const bulkOpenBtn = document.getElementById('bulkOpenBtn');
const autoOpenToggle = document.getElementById('autoOpenToggle');
const autoOpenOverlay = document.getElementById('autoOpenOverlay');
const autoOpenOverlayPanel = document.getElementById('autoOpenOverlayPanel');
const autoOpenCountdown = document.getElementById('autoOpenCountdown');
const openCreateBtn = document.getElementById('openCreateBtn');
const dailyDayLabel = document.getElementById('dailyDayLabel');
const dailyDaySubLabel = document.getElementById('dailyDaySubLabel');
const dailyPrevDayBtn = document.getElementById('dailyPrevDayBtn');
const dailyNextDayBtn = document.getElementById('dailyNextDayBtn');
const dailyTodayBtn = document.getElementById('dailyTodayBtn');
const dailyRefreshBtn = document.getElementById('dailyRefreshBtn');
const dailyHeroStats = document.getElementById('dailyHeroStats');
const dailyListSummary = document.getElementById('dailyListSummary');
const dailyListStatus = document.getElementById('dailyListStatus');
const dailyTableBody = document.getElementById('dailyTableBody');
const dailyBulkCountSelect = document.getElementById('dailyBulkCountSelect');
const dailyBulkOpenBtn = document.getElementById('dailyBulkOpenBtn');
const dailyAutoOpenToggle = document.getElementById('dailyAutoOpenToggle');
const dailyAutoOpenOverlay = document.getElementById('dailyAutoOpenOverlay');
const dailyAutoOpenOverlayPanel = document.getElementById('dailyAutoOpenOverlayPanel');
const dailyAutoOpenCountdown = document.getElementById('dailyAutoOpenCountdown');
const dailyOpenCreateBtn = document.getElementById('dailyOpenCreateBtn');
const modal = document.getElementById('bookmarkModal');
const modalCloseBtn = document.getElementById('modalCloseBtn');
const modalBackdrop = modal ? modal.querySelector('.modal__backdrop') : null;
const formEl = document.getElementById('bookmarkForm');
const titleInput = document.getElementById('titleInput');
const urlInput = document.getElementById('urlInput');
const notesInput = document.getElementById('notesInput');
const resetBtn = document.getElementById('resetBtn');
const submitBtn = document.getElementById('submitBtn');
const previewLink = document.getElementById('previewLink');
const formStatus = document.getElementById('formStatus');
const formModeLabel = document.getElementById('formModeLabel');
const urlSuggestionBox = document.getElementById('urlSuggestionBox');
const markerInput = document.getElementById('markerInput');
const markerFilterSelect = document.getElementById('markerFilter');
const urlFilterInput = document.getElementById('urlFilter');
const resetViewBtn = document.getElementById('resetViewBtn');
const modal = document.getElementById('dailyBookmarkModal');
const dailyModalCloseBtn = document.getElementById('dailyModalCloseBtn');
const dailyModalBackdrop = modal ? modal.querySelector('.modal__backdrop') : null;
const formEl = document.getElementById('dailyBookmarkForm');
const dailyTitleInput = document.getElementById('dailyTitleInput');
const dailyUrlInput = document.getElementById('dailyUrlInput');
const dailyNotesInput = document.getElementById('dailyNotesInput');
const dailyResetBtn = document.getElementById('dailyResetBtn');
const dailySubmitBtn = document.getElementById('dailySubmitBtn');
const dailyPreviewLink = document.getElementById('dailyPreviewLink');
const dailyFormStatus = document.getElementById('dailyFormStatus');
const dailyFormModeLabel = document.getElementById('dailyFormModeLabel');
const dailyUrlSuggestionBox = document.getElementById('dailyUrlSuggestionBox');
const dailyMarkerInput = document.getElementById('dailyMarkerInput');
const dailyActiveInput = document.getElementById('dailyActiveInput');
const markerFilterSelect = document.getElementById('dailyMarkerFilter');
const urlFilterInput = document.getElementById('dailyUrlFilter');
const dailyResetViewBtn = document.getElementById('dailyResetViewBtn');
const sortButtons = Array.from(document.querySelectorAll('[data-sort-key]'));
const openImportBtn = document.getElementById('openImportBtn');
const importModal = document.getElementById('importModal');
const importCloseBtn = document.getElementById('importCloseBtn');
const importBackdrop = importModal ? importModal.querySelector('.modal__backdrop') : null;
const importForm = document.getElementById('importForm');
const importInput = document.getElementById('importInput');
const importMarkerInput = document.getElementById('importMarkerInput');
const importResetBtn = document.getElementById('importResetBtn');
const importSubmitBtn = document.getElementById('importSubmitBtn');
const importStatus = document.getElementById('importStatus');
const dailyOpenImportBtn = document.getElementById('dailyOpenImportBtn');
const dailyImportModal = document.getElementById('dailyImportModal');
const dailyImportCloseBtn = document.getElementById('dailyImportCloseBtn');
const dailyImportBackdrop = dailyImportModal ? dailyImportModal.querySelector('.modal__backdrop') : null;
const dailyImportForm = document.getElementById('dailyImportForm');
const dailyImportInput = document.getElementById('dailyImportInput');
const dailyImportMarkerInput = document.getElementById('dailyImportMarkerInput');
const dailyImportResetBtn = document.getElementById('dailyImportResetBtn');
const dailyImportSubmitBtn = document.getElementById('dailyImportSubmitBtn');
const dailyImportStatus = document.getElementById('dailyImportStatus');
const PLACEHOLDER_PATTERN = /\{\{\s*([^}]+)\s*\}\}/gi;
function ensureStyles(enabled) {
const link = document.getElementById('dailyBookmarksCss');
if (link) {
link.disabled = !enabled;
}
}
function formatDayKey(date) {
const d = date instanceof Date ? date : new Date();
const year = d.getFullYear();
@@ -214,6 +224,16 @@
}
}
function normalizeItem(item) {
if (!item || typeof item !== 'object') {
return null;
}
return {
...item,
is_active: Number(item.is_active ?? 1) !== 0
};
}
function resolveTemplate(template, dayKey) {
if (typeof template !== 'string') {
return '';
@@ -343,32 +363,32 @@
}
function renderUrlSuggestions() {
if (!urlSuggestionBox) {
if (!dailyUrlSuggestionBox) {
return;
}
const suggestions = buildUrlSuggestions(urlInput ? urlInput.value : '');
urlSuggestionBox.innerHTML = '';
const suggestions = buildUrlSuggestions(dailyUrlInput ? dailyUrlInput.value : '');
dailyUrlSuggestionBox.innerHTML = '';
if (!suggestions.length) {
urlSuggestionBox.hidden = true;
dailyUrlSuggestionBox.hidden = true;
return;
}
const text = document.createElement('span');
text.className = 'suggestion-box__text';
text.textContent = 'Mögliche Platzhalter:';
urlSuggestionBox.appendChild(text);
dailyUrlSuggestionBox.appendChild(text);
const applySuggestion = (value) => {
if (!urlInput) {
if (!dailyUrlInput) {
return;
}
urlInput.value = value;
dailyUrlInput.value = value;
updatePreviewLink();
renderUrlSuggestions();
const end = urlInput.value.length;
urlInput.focus();
const end = dailyUrlInput.value.length;
dailyUrlInput.focus();
try {
urlInput.setSelectionRange(end, end);
dailyUrlInput.setSelectionRange(end, end);
} catch (error) {
// ignore if not supported
}
@@ -400,10 +420,10 @@
});
item.appendChild(preview);
urlSuggestionBox.appendChild(item);
dailyUrlSuggestionBox.appendChild(item);
});
urlSuggestionBox.hidden = false;
dailyUrlSuggestionBox.hidden = false;
}
async function apiFetch(url, options = {}) {
@@ -438,60 +458,64 @@
}
function updateDayUI() {
if (dayLabel) {
dayLabel.textContent = formatDayLabel(state.dayKey);
if (dailyDayLabel) {
dailyDayLabel.textContent = formatDayLabel(state.dayKey);
}
if (daySubLabel) {
daySubLabel.textContent = formatRelativeDay(state.dayKey);
if (dailyDaySubLabel) {
dailyDaySubLabel.textContent = formatRelativeDay(state.dayKey);
}
updatePreviewLink();
}
function setDayKey(dayKey) {
state.dayKey = formatDayKey(parseDayKey(dayKey));
if (!active) return;
updateDayUI();
loadDailyBookmarks();
}
function setFormStatus(message, isError = false) {
if (!formStatus) {
if (!dailyFormStatus) {
return;
}
formStatus.textContent = message || '';
formStatus.classList.toggle('form-status--error', !!isError);
dailyFormStatus.textContent = message || '';
dailyFormStatus.classList.toggle('form-status--error', !!isError);
}
function setListStatus(message, isError = false) {
if (!listStatus) {
if (!dailyListStatus) {
return;
}
listStatus.textContent = message || '';
listStatus.classList.toggle('list-status--error', !!isError);
dailyListStatus.textContent = message || '';
dailyListStatus.classList.toggle('list-status--error', !!isError);
}
function updatePreviewLink() {
if (!previewLink || !urlInput) {
if (!dailyPreviewLink || !dailyUrlInput) {
return;
}
const resolved = resolveTemplate(urlInput.value || '', state.dayKey);
previewLink.textContent = resolved || '';
const resolved = resolveTemplate(dailyUrlInput.value || '', state.dayKey);
dailyPreviewLink.textContent = resolved || '';
if (resolved) {
previewLink.href = resolved;
previewLink.target = '_blank';
previewLink.rel = 'noopener';
dailyPreviewLink.href = resolved;
dailyPreviewLink.target = '_blank';
dailyPreviewLink.rel = 'noopener';
} else {
previewLink.removeAttribute('href');
dailyPreviewLink.removeAttribute('href');
}
renderUrlSuggestions();
}
function resetForm() {
editingId = null;
formModeLabel.textContent = 'Neues Bookmark';
submitBtn.textContent = 'Speichern';
dailyFormModeLabel.textContent = 'Neues Bookmark';
dailySubmitBtn.textContent = 'Speichern';
formEl.reset();
if (markerInput) {
markerInput.value = '';
if (dailyMarkerInput) {
dailyMarkerInput.value = '';
}
if (dailyActiveInput) {
dailyActiveInput.checked = true;
}
setFormStatus('');
updatePreviewLink();
@@ -499,18 +523,21 @@
}
function openModal(mode, bookmark) {
if (importModal && !importModal.hidden) {
if (dailyImportModal && !dailyImportModal.hidden) {
closeImportModal();
}
if (mode === 'edit' && bookmark) {
editingId = bookmark.id;
formModeLabel.textContent = 'Bookmark bearbeiten';
submitBtn.textContent = 'Aktualisieren';
titleInput.value = bookmark.title || '';
urlInput.value = bookmark.url_template || '';
notesInput.value = bookmark.notes || '';
if (markerInput) {
markerInput.value = bookmark.marker || '';
dailyFormModeLabel.textContent = 'Bookmark bearbeiten';
dailySubmitBtn.textContent = 'Aktualisieren';
dailyTitleInput.value = bookmark.title || '';
dailyUrlInput.value = bookmark.url_template || '';
dailyNotesInput.value = bookmark.notes || '';
if (dailyMarkerInput) {
dailyMarkerInput.value = bookmark.marker || '';
}
if (dailyActiveInput) {
dailyActiveInput.checked = bookmark.is_active !== false;
}
setFormStatus('Bearbeite vorhandenes Bookmark');
} else {
@@ -522,10 +549,10 @@
modal.focus();
}
updatePreviewLink();
if (mode === 'edit' && titleInput) {
titleInput.focus();
} else if (urlInput) {
urlInput.focus();
if (mode === 'edit' && dailyTitleInput) {
dailyTitleInput.focus();
} else if (dailyUrlInput) {
dailyUrlInput.focus();
}
}
@@ -572,22 +599,22 @@
}
function getFilteredItems() {
const markerFilter = state.filters.marker || '';
const urlFilter = (state.filters.url || '').toLowerCase();
const dailyMarkerFilter = state.filters.marker || '';
const dailyUrlFilter = (state.filters.url || '').toLowerCase();
return state.items.filter((item) => {
const currentMarker = normalizeMarkerValue(item.marker).toLowerCase();
if (markerFilter === '__none') {
if (dailyMarkerFilter === '__none') {
if (currentMarker) {
return false;
}
} else if (markerFilter) {
if (currentMarker !== markerFilter.toLowerCase()) {
} else if (dailyMarkerFilter) {
if (currentMarker !== dailyMarkerFilter.toLowerCase()) {
return false;
}
}
if (urlFilter) {
if (dailyUrlFilter) {
const urlValue = (item.resolved_url || item.url_template || '').toLowerCase();
if (!urlValue.includes(urlFilter)) {
if (!urlValue.includes(dailyUrlFilter)) {
return false;
}
}
@@ -676,11 +703,11 @@
}
function renderTable() {
if (!tableBody) {
if (!dailyTableBody) {
return;
}
tableBody.innerHTML = '';
dailyTableBody.innerHTML = '';
updateSortIndicators();
if (state.loading) {
@@ -706,7 +733,12 @@
visibleItems.forEach((item) => {
const tr = document.createElement('tr');
tr.classList.add(item.completed_for_day ? 'is-done' : 'is-open');
const isActive = item.is_active !== false;
if (isActive) {
tr.classList.add(item.completed_for_day ? 'is-done' : 'is-open');
} else {
tr.classList.add('is-inactive');
}
const urlTd = document.createElement('td');
urlTd.className = 'url-cell';
@@ -722,14 +754,23 @@
const markerTd = document.createElement('td');
markerTd.className = 'marker-cell';
markerTd.textContent = '';
if (normalizeMarkerValue(item.marker)) {
const markerChip = document.createElement('span');
markerChip.className = 'chip chip--marker';
markerChip.textContent = item.marker;
markerTd.appendChild(markerChip);
} else {
markerTd.textContent = '';
markerTd.classList.add('muted');
const placeholder = document.createElement('span');
placeholder.textContent = '';
placeholder.classList.add('muted');
markerTd.appendChild(placeholder);
}
if (!isActive) {
const inactiveChip = document.createElement('span');
inactiveChip.className = 'chip chip--inactive';
inactiveChip.textContent = 'Deaktiviert';
markerTd.appendChild(inactiveChip);
}
tr.appendChild(markerTd);
@@ -748,9 +789,11 @@
openBtn.className = 'ghost-btn';
openBtn.type = 'button';
openBtn.textContent = '🔗';
openBtn.title = 'Öffnen';
openBtn.title = isActive ? 'Öffnen' : 'Deaktiviert';
openBtn.disabled = !isActive;
openBtn.addEventListener('click', () => {
const target = item.resolved_url || item.url_template;
if (!isActive) return;
if (target) {
window.open(target, '_blank', 'noopener');
}
@@ -761,7 +804,10 @@
toggleBtn.className = 'ghost-btn';
toggleBtn.type = 'button';
toggleBtn.textContent = item.completed_for_day ? '↩️' : '✅';
toggleBtn.title = item.completed_for_day ? 'Zurücksetzen' : 'Heute erledigt';
toggleBtn.title = isActive
? (item.completed_for_day ? 'Zurücksetzen' : 'Heute erledigt')
: 'Deaktiviert';
toggleBtn.disabled = !isActive;
toggleBtn.addEventListener('click', () => {
if (item.completed_for_day) {
undoDailyBookmark(item.id);
@@ -792,7 +838,7 @@
actionsTd.appendChild(deleteBtn);
tr.appendChild(actionsTd);
tableBody.appendChild(tr);
dailyTableBody.appendChild(tr);
});
}
@@ -803,16 +849,20 @@
td.className = className;
td.textContent = text;
tr.appendChild(td);
tableBody.appendChild(tr);
dailyTableBody.appendChild(tr);
}
function updateHeroStats() {
const total = state.items.length;
const done = state.items.filter((item) => item.completed_for_day).length;
const visibleItems = getFilteredItems();
const activeItems = state.items.filter((item) => item.is_active !== false);
const totalActive = activeItems.length;
const inactiveCount = state.items.length - totalActive;
const done = activeItems.filter((item) => item.completed_for_day).length;
const visibleItems = getFilteredItems().filter((item) => item.is_active !== false);
const visibleDone = visibleItems.filter((item) => item.completed_for_day).length;
const filterSuffix = visibleItems.length !== total ? ` · Gefiltert: ${visibleDone}/${visibleItems.length}` : '';
const text = total ? `${done}/${total} erledigt${filterSuffix}` : 'Keine Bookmarks vorhanden';
const filterSuffix = visibleItems.length !== totalActive ? ` · Gefiltert: ${visibleDone}/${visibleItems.length}` : '';
const inactiveSuffix = inactiveCount ? ` · ${inactiveCount} deaktiviert` : '';
const baseText = totalActive ? `${done}/${totalActive} erledigt${filterSuffix}` : 'Keine aktiven Bookmarks';
const text = `${baseText}${inactiveSuffix}`;
const filterParts = [];
if (state.filters.marker) {
filterParts.push(state.filters.marker === '__none' ? 'ohne Marker' : `Marker: ${state.filters.marker}`);
@@ -821,11 +871,11 @@
filterParts.push(`URL enthält „${state.filters.url}`);
}
const filterText = filterParts.length ? ` · Filter: ${filterParts.join(' · ')}` : '';
if (heroStats) {
heroStats.textContent = `${text}${filterText}`;
if (dailyHeroStats) {
dailyHeroStats.textContent = `${text}${filterText}`;
}
if (listSummary) {
listSummary.textContent = `${text} Tag ${state.dayKey}${filterText}`;
if (dailyListSummary) {
dailyListSummary.textContent = `${text} Tag ${state.dayKey}${filterText}`;
}
}
@@ -837,27 +887,27 @@
}
function updateAutoOpenCountdownLabel(remainingMs) {
if (!autoOpenCountdown) return;
if (!dailyAutoOpenCountdown) return;
const safeMs = Math.max(0, remainingMs);
const seconds = safeMs / 1000;
const formatted = seconds >= 10 ? seconds.toFixed(0) : seconds.toFixed(1);
autoOpenCountdown.textContent = formatted;
dailyAutoOpenCountdown.textContent = formatted;
}
function hideAutoOpenOverlay() {
clearAutoOpenCountdown();
if (autoOpenOverlay) {
autoOpenOverlay.classList.remove('visible');
autoOpenOverlay.hidden = true;
if (dailyAutoOpenOverlay) {
dailyAutoOpenOverlay.classList.remove('visible');
dailyAutoOpenOverlay.hidden = true;
}
}
function showAutoOpenOverlay(delayMs) {
if (!autoOpenOverlay) return;
if (!dailyAutoOpenOverlay) return;
const duration = Math.max(0, delayMs);
hideAutoOpenOverlay();
autoOpenOverlay.hidden = false;
requestAnimationFrame(() => autoOpenOverlay.classList.add('visible'));
dailyAutoOpenOverlay.hidden = false;
requestAnimationFrame(() => dailyAutoOpenOverlay.classList.add('visible'));
updateAutoOpenCountdownLabel(duration);
const start = Date.now();
state.autoOpenCountdownIntervalId = setInterval(() => {
@@ -882,13 +932,19 @@
}
function maybeAutoOpen(reason = '', delayMs = AUTO_OPEN_DELAY_MS) {
if (!active) {
hideAutoOpenOverlay();
return;
}
if (!state.autoOpenEnabled) {
hideAutoOpenOverlay();
return;
}
if (state.processingBatch) return;
if (state.autoOpenTriggered) return;
const undone = getVisibleItems().filter((item) => !item.completed_for_day);
const undone = getVisibleItems().filter(
(item) => item.is_active !== false && !item.completed_for_day
);
if (!undone.length) {
hideAutoOpenOverlay();
return;
@@ -921,6 +977,7 @@
}
async function loadDailyBookmarks() {
if (!active) return;
state.loading = true;
if (state.autoOpenTimerId) {
clearTimeout(state.autoOpenTimerId);
@@ -933,7 +990,8 @@
renderTable();
try {
const data = await apiFetch(`${API_URL}/daily-bookmarks?day=${encodeURIComponent(state.dayKey)}`);
state.items = Array.isArray(data) ? data : [];
const rows = Array.isArray(data) ? data : [];
state.items = rows.map((item) => normalizeItem(item)).filter(Boolean);
state.loading = false;
updateHeroStats();
renderMarkerFilterOptions();
@@ -949,13 +1007,19 @@
async function completeDailyBookmark(id) {
if (!id) return;
const target = state.items.find((item) => item.id === id);
if (target && target.is_active === false) {
setListStatus('Bookmark ist deaktiviert.', true);
return;
}
state.error = '';
try {
const updated = await apiFetch(`${API_URL}/daily-bookmarks/${encodeURIComponent(id)}/check`, {
method: 'POST',
body: JSON.stringify({ day: state.dayKey })
});
state.items = state.items.map((item) => (item.id === id ? updated : item));
const normalized = normalizeItem(updated);
state.items = state.items.map((item) => (item.id === id ? normalized || item : item));
updateHeroStats();
renderTable();
} catch (error) {
@@ -967,12 +1031,18 @@
async function undoDailyBookmark(id) {
if (!id) return;
const target = state.items.find((item) => item.id === id);
if (target && target.is_active === false) {
setListStatus('Bookmark ist deaktiviert.', true);
return;
}
state.error = '';
try {
const updated = await apiFetch(`${API_URL}/daily-bookmarks/${encodeURIComponent(id)}/check?day=${encodeURIComponent(state.dayKey)}`, {
method: 'DELETE'
});
state.items = state.items.map((item) => (item.id === id ? updated : item));
const normalized = normalizeItem(updated);
state.items = state.items.map((item) => (item.id === id ? normalized || item : item));
updateHeroStats();
renderTable();
} catch (error) {
@@ -1007,19 +1077,19 @@
event.preventDefault();
if (state.saving) return;
const title = titleInput.value.trim();
const url = urlInput.value.trim();
const notes = notesInput.value.trim();
const marker = markerInput ? markerInput.value.trim() : '';
const title = dailyTitleInput.value.trim();
const url = dailyUrlInput.value.trim();
const notes = dailyNotesInput.value.trim();
const marker = dailyMarkerInput ? dailyMarkerInput.value.trim() : '';
if (!url) {
setFormStatus('URL-Template ist Pflicht.', true);
urlInput.focus();
dailyUrlInput.focus();
return;
}
state.saving = true;
submitBtn.disabled = true;
dailySubmitBtn.disabled = true;
setFormStatus('');
const payload = {
@@ -1027,6 +1097,7 @@
url_template: url,
notes,
marker,
is_active: dailyActiveInput ? dailyActiveInput.checked : true,
day: state.dayKey
};
@@ -1040,10 +1111,11 @@
method,
body: JSON.stringify(payload)
});
const normalized = normalizeItem(saved);
if (editingId) {
state.items = state.items.map((item) => (item.id === editingId ? saved : item));
} else {
state.items = [saved, ...state.items];
state.items = state.items.map((item) => (item.id === editingId ? normalized || item : item));
} else if (normalized) {
state.items = [normalized, ...state.items];
}
updateHeroStats();
renderMarkerFilterOptions();
@@ -1053,7 +1125,7 @@
setFormStatus('Speichern fehlgeschlagen.', true);
} finally {
state.saving = false;
submitBtn.disabled = false;
dailySubmitBtn.disabled = false;
}
}
@@ -1062,7 +1134,9 @@
if (!auto) {
cancelAutoOpen(false);
}
const undone = getVisibleItems().filter((item) => !item.completed_for_day);
const undone = getVisibleItems().filter(
(item) => item.is_active !== false && !item.completed_for_day
);
if (!undone.length) {
if (!auto) {
setListStatus('Keine offenen Bookmarks für den gewählten Tag.', true);
@@ -1074,8 +1148,8 @@
const selection = undone.slice(0, count);
state.processingBatch = true;
if (bulkOpenBtn) {
bulkOpenBtn.disabled = true;
if (dailyBulkOpenBtn) {
dailyBulkOpenBtn.disabled = true;
}
if (!auto) {
setListStatus('');
@@ -1093,15 +1167,16 @@
method: 'POST',
body: JSON.stringify({ day: state.dayKey })
});
state.items = state.items.map((entry) => (entry.id === item.id ? updated : entry));
const normalized = normalizeItem(updated);
state.items = state.items.map((entry) => (entry.id === item.id ? normalized || entry : entry));
} catch (error) {
setListStatus('Einige Bookmarks konnten nicht abgehakt werden.', true);
}
}
state.processingBatch = false;
if (bulkOpenBtn) {
bulkOpenBtn.disabled = false;
if (dailyBulkOpenBtn) {
dailyBulkOpenBtn.disabled = false;
}
if (auto) {
setListStatus('');
@@ -1111,20 +1186,20 @@
}
function setImportStatus(message, isError = false) {
if (!importStatus) {
if (!dailyImportStatus) {
return;
}
importStatus.textContent = message || '';
importStatus.classList.toggle('form-status--error', !!isError);
dailyImportStatus.textContent = message || '';
dailyImportStatus.classList.toggle('form-status--error', !!isError);
}
function resetImportForm() {
if (importForm) {
importForm.reset();
if (dailyImportForm) {
dailyImportForm.reset();
}
const suggestedMarker = state.filters.marker && state.filters.marker !== '__none' ? state.filters.marker : '';
if (importMarkerInput) {
importMarkerInput.value = suggestedMarker;
if (dailyImportMarkerInput) {
dailyImportMarkerInput.value = suggestedMarker;
}
setImportStatus('');
}
@@ -1134,18 +1209,18 @@
closeModal();
}
resetImportForm();
if (importModal) {
importModal.hidden = false;
importModal.focus();
if (dailyImportModal) {
dailyImportModal.hidden = false;
dailyImportModal.focus();
}
if (importInput) {
importInput.focus();
if (dailyImportInput) {
dailyImportInput.focus();
}
}
function closeImportModal() {
if (importModal) {
importModal.hidden = true;
if (dailyImportModal) {
dailyImportModal.hidden = true;
}
resetImportForm();
}
@@ -1154,12 +1229,12 @@
event.preventDefault();
if (state.importing) return;
const rawText = importInput ? importInput.value.trim() : '';
const marker = importMarkerInput ? importMarkerInput.value.trim() : '';
const rawText = dailyImportInput ? dailyImportInput.value.trim() : '';
const marker = dailyImportMarkerInput ? dailyImportMarkerInput.value.trim() : '';
if (!rawText) {
setImportStatus('Bitte füge mindestens eine URL ein.', true);
if (importInput) {
importInput.focus();
if (dailyImportInput) {
dailyImportInput.focus();
}
return;
}
@@ -1175,8 +1250,8 @@
}
state.importing = true;
if (importSubmitBtn) {
importSubmitBtn.disabled = true;
if (dailyImportSubmitBtn) {
dailyImportSubmitBtn.disabled = true;
}
setImportStatus('Import läuft...');
@@ -1186,7 +1261,8 @@
body: JSON.stringify({ urls, marker, day: state.dayKey })
});
const importedItems = Array.isArray(result && result.items) ? result.items : [];
const importedItemsRaw = Array.isArray(result && result.items) ? result.items : [];
const importedItems = importedItemsRaw.map((item) => normalizeItem(item)).filter(Boolean);
const importedIds = new Set(importedItems.map((entry) => entry.id));
const remaining = state.items.filter((item) => !importedIds.has(item.id));
state.items = [...importedItems, ...remaining];
@@ -1207,30 +1283,30 @@
setImportStatus(error && error.message ? error.message : 'Import fehlgeschlagen.', true);
} finally {
state.importing = false;
if (importSubmitBtn) {
importSubmitBtn.disabled = false;
if (dailyImportSubmitBtn) {
dailyImportSubmitBtn.disabled = false;
}
}
}
function setupEvents() {
if (prevDayBtn) {
prevDayBtn.addEventListener('click', () => setDayKey(formatDayKey(addDays(parseDayKey(state.dayKey), -1))));
if (dailyPrevDayBtn) {
dailyPrevDayBtn.addEventListener('click', () => setDayKey(formatDayKey(addDays(parseDayKey(state.dayKey), -1))));
}
if (nextDayBtn) {
nextDayBtn.addEventListener('click', () => setDayKey(formatDayKey(addDays(parseDayKey(state.dayKey), 1))));
if (dailyNextDayBtn) {
dailyNextDayBtn.addEventListener('click', () => setDayKey(formatDayKey(addDays(parseDayKey(state.dayKey), 1))));
}
if (todayBtn) {
todayBtn.addEventListener('click', () => setDayKey(formatDayKey(new Date())));
if (dailyTodayBtn) {
dailyTodayBtn.addEventListener('click', () => setDayKey(formatDayKey(new Date())));
}
if (refreshBtn) {
refreshBtn.addEventListener('click', () => loadDailyBookmarks());
if (dailyRefreshBtn) {
dailyRefreshBtn.addEventListener('click', () => loadDailyBookmarks());
}
if (openCreateBtn) {
openCreateBtn.addEventListener('click', () => openModal('create'));
if (dailyOpenCreateBtn) {
dailyOpenCreateBtn.addEventListener('click', () => openModal('create'));
}
if (modalCloseBtn) {
modalCloseBtn.addEventListener('click', closeModal);
if (dailyModalCloseBtn) {
dailyModalCloseBtn.addEventListener('click', closeModal);
}
if (modal) {
modal.addEventListener('click', (event) => {
@@ -1239,49 +1315,50 @@
}
});
}
if (modalBackdrop) {
modalBackdrop.addEventListener('click', closeModal);
if (dailyModalBackdrop) {
dailyModalBackdrop.addEventListener('click', closeModal);
}
document.addEventListener('keydown', (event) => {
if (!active) return;
if (event.key === 'Escape') {
if (modal && !modal.hidden) {
closeModal();
}
if (importModal && !importModal.hidden) {
if (dailyImportModal && !dailyImportModal.hidden) {
closeImportModal();
}
}
});
if (urlInput) {
urlInput.addEventListener('input', updatePreviewLink);
urlInput.addEventListener('blur', renderUrlSuggestions);
if (dailyUrlInput) {
dailyUrlInput.addEventListener('input', updatePreviewLink);
dailyUrlInput.addEventListener('blur', renderUrlSuggestions);
}
if (formEl) {
formEl.addEventListener('submit', submitForm);
}
if (resetBtn) {
resetBtn.addEventListener('click', resetForm);
if (dailyResetBtn) {
dailyResetBtn.addEventListener('click', resetForm);
}
if (bulkCountSelect) {
bulkCountSelect.value = String(state.bulkCount);
bulkCountSelect.addEventListener('change', () => {
const value = parseInt(bulkCountSelect.value, 10);
if (dailyBulkCountSelect) {
dailyBulkCountSelect.value = String(state.bulkCount);
dailyBulkCountSelect.addEventListener('change', () => {
const value = parseInt(dailyBulkCountSelect.value, 10);
if (!Number.isNaN(value)) {
state.bulkCount = value;
persistBulkCount(value);
}
});
}
if (bulkOpenBtn) {
bulkOpenBtn.addEventListener('click', () => openBatch());
if (dailyBulkOpenBtn) {
dailyBulkOpenBtn.addEventListener('click', () => openBatch());
}
if (autoOpenOverlayPanel) {
autoOpenOverlayPanel.addEventListener('click', () => cancelAutoOpen(true));
if (dailyAutoOpenOverlayPanel) {
dailyAutoOpenOverlayPanel.addEventListener('click', () => cancelAutoOpen(true));
}
if (autoOpenToggle) {
autoOpenToggle.checked = !!state.autoOpenEnabled;
autoOpenToggle.addEventListener('change', () => {
state.autoOpenEnabled = autoOpenToggle.checked;
if (dailyAutoOpenToggle) {
dailyAutoOpenToggle.checked = !!state.autoOpenEnabled;
dailyAutoOpenToggle.addEventListener('change', () => {
state.autoOpenEnabled = dailyAutoOpenToggle.checked;
persistAutoOpenEnabled(state.autoOpenEnabled);
state.autoOpenTriggered = false;
if (!state.autoOpenEnabled && state.autoOpenTimerId) {
@@ -1322,8 +1399,8 @@
renderTable();
});
}
if (resetViewBtn) {
resetViewBtn.addEventListener('click', () => {
if (dailyResetViewBtn) {
dailyResetViewBtn.addEventListener('click', () => {
state.filters = { marker: '', url: '' };
state.sort = { ...DEFAULT_SORT };
persistFilters(state.filters);
@@ -1352,36 +1429,54 @@
});
});
}
if (openImportBtn) {
openImportBtn.addEventListener('click', openImportModal);
if (dailyOpenImportBtn) {
dailyOpenImportBtn.addEventListener('click', openImportModal);
}
if (importCloseBtn) {
importCloseBtn.addEventListener('click', closeImportModal);
if (dailyImportCloseBtn) {
dailyImportCloseBtn.addEventListener('click', closeImportModal);
}
if (importModal) {
importModal.addEventListener('click', (event) => {
if (event.target === importModal) {
if (dailyImportModal) {
dailyImportModal.addEventListener('click', (event) => {
if (event.target === dailyImportModal) {
closeImportModal();
}
});
}
if (importBackdrop) {
importBackdrop.addEventListener('click', closeImportModal);
if (dailyImportBackdrop) {
dailyImportBackdrop.addEventListener('click', closeImportModal);
}
if (importForm) {
importForm.addEventListener('submit', submitImportForm);
if (dailyImportForm) {
dailyImportForm.addEventListener('submit', submitImportForm);
}
if (importResetBtn) {
importResetBtn.addEventListener('click', resetImportForm);
if (dailyImportResetBtn) {
dailyImportResetBtn.addEventListener('click', resetImportForm);
}
}
function init() {
updateDayUI();
if (initialized) return;
setupEvents();
loadDailyBookmarks();
updatePreviewLink();
initialized = true;
}
init();
function cleanup() {
active = false;
cancelAutoOpen(false);
ensureStyles(false);
hideAutoOpenOverlay();
}
function activate() {
ensureStyles(true);
init();
active = true;
updateDayUI();
updatePreviewLink();
loadDailyBookmarks();
}
window.DailyBookmarksPage = {
activate,
deactivate: cleanup
};
})();

View File

@@ -10,6 +10,8 @@
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="dashboard.css">
<link rel="stylesheet" href="settings.css">
<link rel="stylesheet" href="automation.css">
<link id="dailyBookmarksCss" rel="stylesheet" href="daily-bookmarks.css" disabled>
</head>
<body>
<div class="shell">
@@ -24,8 +26,8 @@
<a class="site-nav__btn" data-view-target="dashboard" href="dashboard.html">📊 Dashboard</a>
<a class="site-nav__btn" data-view-target="settings" href="settings.html">⚙️ Einstellungen</a>
<a class="site-nav__btn" data-view-target="bookmarks" href="bookmarks.html">🔖 Bookmarks</a>
<a class="site-nav__btn" href="automation.html">⚡️ Automationen</a>
<a class="site-nav__btn" href="daily-bookmarks.html">✅ Daily Bookmarks</a>
<a class="site-nav__btn" data-view-target="automation" href="index.html?view=automation">⚡️ Automationen</a>
<a class="site-nav__btn" data-view-target="daily-bookmarks" href="index.html?view=daily-bookmarks">✅ Daily Bookmarks</a>
</div>
</div>
</div>
@@ -83,8 +85,8 @@
<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="all">Alle Beiträge</button>
<button class="tab-btn active" data-tab="pending">Offene Beiträge</button>
</div>
<div class="merge-controls" id="mergeControls" hidden>
<div class="merge-actions">
@@ -158,6 +160,486 @@
</div>
</div>
</section>
<section id="view-automation" class="app-view automation-view" data-view="automation">
<div class="container">
<div class="auto-shell">
<header class="auto-hero">
<div class="hero-head">
<div class="hero-text">
<h1>Request Automationen</h1>
</div>
<div class="hero-actions">
<button class="ghost-btn" id="openImportBtn" type="button">📥 Vorlage importieren</button>
<button class="primary-btn" id="newAutomationBtn" type="button">+ Neue Automation</button>
</div>
</div>
<div class="hero-stats" id="heroStats"></div>
</header>
<section class="panel list-panel">
<div class="panel-header">
<div>
<p class="panel-eyebrow">Geplante Requests</p>
<h2>Automationen</h2>
</div>
<div class="panel-actions">
<input id="tableFilterInput" class="filter-input" type="search" placeholder="Filtern nach Name/Typ…" />
<button class="ghost-btn" id="refreshBtn" type="button">Aktualisieren</button>
</div>
</div>
<div id="listStatus" class="list-status" aria-live="polite"></div>
<div class="table-wrap" id="automationTable">
<table class="auto-table">
<thead>
<tr>
<th data-sort-column="name">Name<span class="sort-indicator"></span></th>
<th data-sort-column="next">Nächster Lauf<span class="sort-indicator"></span></th>
<th data-sort-column="last">Letzter Lauf<span class="sort-indicator"></span></th>
<th data-sort-column="status">Status<span class="sort-indicator"></span></th>
<th data-sort-column="runs">#Läufe<span class="sort-indicator"></span></th>
<th>Aktionen</th>
</tr>
<tr class="table-filter-row">
<th><input id="filterName" type="search" placeholder="Name/Typ/E-Mail/URL"></th>
<th><input id="filterNext" type="search" placeholder="z.B. heute"></th>
<th><input id="filterLast" type="search" placeholder="z.B. HTTP 200"></th>
<th>
<select id="filterStatus">
<option value="">Alle</option>
<option value="success">OK</option>
<option value="error">Fehler</option>
</select>
</th>
<th><input id="filterRuns" type="number" min="0" placeholder="≥"></th>
<th></th>
</tr>
</thead>
<tbody id="requestTableBody" class="list"></tbody>
</table>
</div>
</section>
<section class="panel runs-panel">
<div class="panel-header">
<div>
<p class="panel-eyebrow">Verlauf</p>
<h2>Run-Historie</h2>
</div>
<p class="runs-hint">Letzte Läufe der ausgewählten Automation.</p>
</div>
<div id="runsStatus" class="runs-status" aria-live="polite"></div>
<ul id="runsList" class="runs-list"></ul>
</section>
</div>
</div>
<div id="formModal" class="modal" hidden>
<div class="modal__backdrop" id="formModalBackdrop"></div>
<div class="modal__content" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="modal__header">
<div>
<p class="panel-eyebrow" id="formModeLabel">Neue Automation</p>
<h2 id="modalTitle">Request & Zeitplan</h2>
</div>
<button class="ghost-btn" id="modalCloseBtn" type="button">×</button>
</div>
<form id="automationForm" class="form-grid" novalidate>
<div class="field">
<label for="typeSelect">Typ</label>
<select id="typeSelect">
<option value="request">HTTP Request</option>
<option value="email">E-Mail</option>
<option value="flow">Flow (bis 3 Schritte)</option>
</select>
</div>
<div class="field">
<label for="nameInput">Name *</label>
<input id="nameInput" type="text" placeholder="API Ping (stündlich)" required maxlength="160">
</div>
<div class="field">
<label for="descriptionInput">Notizen</label>
<textarea id="descriptionInput" rows="2" placeholder="Kurzbeschreibung oder Zweck"></textarea>
</div>
<div class="field" data-section="http">
<label for="urlInput">URL-Template *</label>
<input id="urlInput" type="url" placeholder="https://api.example.com/{{date}}/trigger" required>
</div>
<div class="field inline" data-section="http">
<label for="methodSelect">Methode</label>
<select id="methodSelect">
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
<option value="DELETE">DELETE</option>
</select>
</div>
<div class="field inline">
<label for="activeToggle">Aktiv</label>
<label class="switch">
<input type="checkbox" id="activeToggle" checked>
<span class="switch-slider"></span>
<span class="switch-label">Plan aktiv</span>
</label>
</div>
<div class="field" data-section="http">
<label for="headersInput">Headers (Key: Value pro Zeile oder JSON)</label>
<textarea id="headersInput" rows="3" placeholder="Authorization: Bearer {{token}}\nContent-Type: application/json"></textarea>
</div>
<div class="field" data-section="http">
<label for="bodyInput">Body (optional, Templates möglich)</label>
<textarea id="bodyInput" rows="5" placeholder='{"date":"{{date}}","id":"{{uuid}}"}'></textarea>
</div>
<div class="field full" data-section="email">
<label for="emailToInput">E-Mail Empfänger *</label>
<input id="emailToInput" type="text" placeholder="max@example.com, lisa@example.com">
</div>
<div class="field full" data-section="email">
<label for="emailSubjectInput">Betreff *</label>
<input id="emailSubjectInput" type="text" placeholder="Status Update {{date}}">
</div>
<div class="field full" data-section="email">
<label for="emailBodyInput">Body *</label>
<textarea id="emailBodyInput" rows="6" placeholder="Hallo,\nheutiger Status: {{uuid}}"></textarea>
</div>
<div class="field full" data-section="flow">
<div class="template-hint">
<p class="template-title">Flow Schritte</p>
<p class="template-copy">Max. 3 Schritte, Kontext steht als {{step1_json}}, {{step1_text}}, {{step1_status_code}} usw. im nächsten Schritt zur Verfügung.</p>
</div>
</div>
<div class="field" data-section="flow">
<label for="flowStep1Url">Step 1 URL *</label>
<input id="flowStep1Url" type="url" placeholder="https://api.example.com/first">
</div>
<div class="field inline" data-section="flow">
<label for="flowStep1Method">Step 1 Methode</label>
<select id="flowStep1Method">
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
<option value="DELETE">DELETE</option>
</select>
</div>
<div class="field" data-section="flow">
<label for="flowStep1Headers">Step 1 Headers</label>
<textarea id="flowStep1Headers" rows="2" placeholder="Authorization: Bearer {{token}}"></textarea>
</div>
<div class="field" data-section="flow">
<label for="flowStep1Body">Step 1 Body</label>
<textarea id="flowStep1Body" rows="3" placeholder='{"id":"{{uuid}}"}'></textarea>
</div>
<div class="field" data-section="flow">
<label for="flowStep2Url">Step 2 URL (optional)</label>
<input id="flowStep2Url" type="url" placeholder="https://api.example.com/second">
</div>
<div class="field inline" data-section="flow">
<label for="flowStep2Method">Step 2 Methode</label>
<select id="flowStep2Method">
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
<option value="DELETE">DELETE</option>
</select>
</div>
<div class="field" data-section="flow">
<label for="flowStep2Headers">Step 2 Headers</label>
<textarea id="flowStep2Headers" rows="2" placeholder="Authorization: Bearer {{token}}"></textarea>
</div>
<div class="field" data-section="flow">
<label for="flowStep2Body">Step 2 Body</label>
<textarea id="flowStep2Body" rows="3" placeholder='{"fromStep1":"{{step1_json.id}}"}'></textarea>
</div>
<div class="field inline">
<label for="intervalPreset">Intervall</label>
<select id="intervalPreset">
<option value="hourly">Jede Stunde</option>
<option value="daily">Jeden Tag</option>
<option value="custom">Eigene Minuten</option>
</select>
</div>
<div class="field inline">
<label for="intervalMinutesInput">Intervall (Minuten)</label>
<input id="intervalMinutesInput" type="number" min="5" max="20160" step="5" value="60">
</div>
<div class="field inline">
<label for="jitterInput">Varianz (Minuten)</label>
<input id="jitterInput" type="number" min="0" max="120" step="5" value="10">
<small>Auslösung erfolgt zufällig +0…Varianz Min nach dem Intervall.</small>
</div>
<div class="field inline">
<label for="startAtInput">Start ab</label>
<input id="startAtInput" type="datetime-local">
</div>
<div class="field inline">
<label for="runUntilInput">Läuft bis</label>
<input id="runUntilInput" type="datetime-local">
</div>
<div class="field full">
<div class="template-hint">
<p class="template-title">Platzhalter</p>
<table class="placeholder-table">
<tbody id="placeholderTableBody"></tbody>
</table>
<p class="placeholder-hint">Beispiele beziehen sich auf den aktuellen Zeitpunkt.</p>
</div>
</div>
<div class="field full">
<div class="preview-panel">
<div class="preview-header">
<div>
<p class="panel-eyebrow">Vorschau</p>
<h3>Aufgelöste Werte</h3>
</div>
<button class="secondary-btn" type="button" id="refreshPreviewBtn">Aktualisieren</button>
</div>
<div class="preview-grid">
<div class="preview-block">
<p class="preview-label">URL</p>
<pre id="previewUrl" class="preview-value"></pre>
</div>
<div class="preview-block">
<p class="preview-label">Headers</p>
<pre id="previewHeaders" class="preview-value"></pre>
</div>
<div class="preview-block">
<p class="preview-label">Body</p>
<pre id="previewBody" class="preview-value"></pre>
</div>
</div>
<p class="preview-hint">Die Vorschau nutzt die aktuellen Formularwerte und füllt Platzhalter ({{date}}, {{uuid}}, …) mit Beispielwerten.</p>
</div>
</div>
<div class="field full modal-actions">
<div id="formStatus" class="form-status" aria-live="polite"></div>
<div class="panel-actions">
<button class="ghost-btn" id="resetFormBtn" type="button">Zurücksetzen</button>
<button class="primary-btn" id="saveBtn" type="submit">Speichern</button>
</div>
</div>
</form>
</div>
</div>
<div id="importModal" class="modal" hidden>
<div class="modal__backdrop" id="importModalBackdrop"></div>
<div class="modal__content" role="dialog" aria-modal="true" aria-labelledby="importTitle">
<div class="modal__header">
<div>
<p class="panel-eyebrow">Import</p>
<h2 id="importTitle">Vorlage einfügen</h2>
</div>
<button class="ghost-btn" id="importCloseBtn" type="button">×</button>
</div>
<p class="import-hint">Füge hier "Copy as cURL", "Copy as fetch" oder Powershell ein. Header, Methode, Body und URL werden übernommen.</p>
<textarea id="importInput" rows="7" placeholder="curl https://api.example.com -X POST -H 'Authorization: Bearer token' --data '{\"hello\":\"world\"}'"></textarea>
<div class="modal-actions">
<div id="importStatus" class="import-status" aria-live="polite"></div>
<button class="secondary-btn" id="applyImportBtn" type="button">Vorlage übernehmen</button>
</div>
</div>
</div>
</section>
<section id="view-daily-bookmarks" class="app-view daily-bookmarks-view" data-view="daily-bookmarks">
<div class="container">
<div class="daily-shell">
<header class="hero">
<h1 class="hero__title">Daily Bookmarks</h1>
<div class="hero__controls">
<div class="day-switch">
<button class="ghost-btn" id="dailyPrevDayBtn" aria-label="Vorheriger Tag"></button>
<div class="day-switch__label">
<div id="dailyDayLabel" class="day-switch__day">Heute</div>
<div id="dailyDaySubLabel" class="day-switch__sub"></div>
</div>
<button class="ghost-btn" id="dailyNextDayBtn" aria-label="Nächster Tag"></button>
<button class="ghost-btn ghost-btn--today" id="dailyTodayBtn">Heute</button>
</div>
<div class="hero__actions">
<div class="bulk-actions">
<label for="dailyBulkCountSelect">Anzahl</label>
<select id="dailyBulkCountSelect">
<option value="1">1</option>
<option value="5">5</option>
<option value="10">10</option>
<option value="15">15</option>
<option value="20">20</option>
</select>
<label class="auto-open-toggle">
<input type="checkbox" id="dailyAutoOpenToggle">
<span>Auto öffnen</span>
</label>
<button class="secondary-btn" id="dailyBulkOpenBtn" type="button">Öffnen & abhaken</button>
</div>
<button class="primary-btn" id="dailyOpenCreateBtn" type="button">+ Bookmark</button>
<button class="secondary-btn" id="dailyOpenImportBtn" type="button">Liste importieren</button>
<button class="ghost-btn" id="dailyRefreshBtn" type="button">Aktualisieren</button>
<span id="dailyHeroStats" class="hero__stats"></span>
</div>
</div>
</header>
<div id="dailyAutoOpenOverlay" class="auto-open-overlay" hidden>
<div class="auto-open-overlay__panel" id="dailyAutoOpenOverlayPanel">
<div class="auto-open-overlay__badge">Auto-Öffnen startet gleich</div>
<div class="auto-open-overlay__timer">
<span id="dailyAutoOpenCountdown" class="auto-open-overlay__count">0.0</span>
<span class="auto-open-overlay__unit">Sek.</span>
</div>
<p class="auto-open-overlay__text">
Die nächste Runde deiner gefilterten Bookmarks öffnet automatisch. Abbrechen, falls du noch warten willst.
</p>
<p class="auto-open-overlay__hint">Klicke irgendwo in dieses Panel, um abzubrechen.</p>
</div>
</div>
<main class="panel list-panel">
<header class="panel__header panel__header--row">
<div>
<p class="panel__eyebrow">Tägliche Liste</p>
<h2 class="panel__title">Alle Bookmarks</h2>
</div>
<div class="list-summary" id="dailyListSummary"></div>
</header>
<div id="dailyListStatus" class="list-status" role="status" aria-live="polite"></div>
<div class="table-wrapper">
<table class="bookmark-table">
<thead>
<tr>
<th class="col-url">
<button type="button" class="sort-btn" data-sort-key="url_template">URL (aufgelöst)</button>
</th>
<th class="col-marker">
<button type="button" class="sort-btn" data-sort-key="marker">Marker</button>
</th>
<th class="col-created">
<button type="button" class="sort-btn" data-sort-key="created_at">Erstelldatum</button>
</th>
<th class="col-last">
<button type="button" class="sort-btn" data-sort-key="last_completed_at">Letzte Erledigung</button>
</th>
<th>Aktionen</th>
</tr>
<tr class="table-filter-row">
<th>
<label class="visually-hidden" for="dailyUrlFilter">Nach URL filtern</label>
<input id="dailyUrlFilter" type="search" placeholder="URL filtern">
</th>
<th>
<label class="visually-hidden" for="dailyMarkerFilter">Nach Marker filtern</label>
<select id="dailyMarkerFilter">
<option value="">Alle Marker</option>
<option value="__none">Ohne Marker</option>
</select>
</th>
<th></th>
<th></th>
<th class="filter-hint">
<button type="button" class="ghost-btn ghost-btn--tiny" id="dailyResetViewBtn" aria-label="Filter und Sortierung zurücksetzen"></button>
</th>
</tr>
</thead>
<tbody id="dailyTableBody"></tbody>
</table>
</div>
</main>
</div>
</div>
<div id="dailyBookmarkModal" class="modal" hidden>
<div class="modal__backdrop" id="dailyModalBackdrop"></div>
<div class="modal__content" role="dialog" aria-modal="true" aria-labelledby="dailyModalTitle">
<header class="modal__header">
<div>
<p class="panel__eyebrow" id="dailyFormModeLabel">Neues Bookmark</p>
<h2 id="dailyModalTitle" class="panel__title">Bookmark pflegen</h2>
<p class="panel__subtitle">Titel optional, URL-Template erforderlich. Platzhalter werden für den gewählten Tag aufgelöst.</p>
</div>
<button class="ghost-btn modal__close" type="button" id="dailyModalCloseBtn" aria-label="Schließen">×</button>
</header>
<form id="dailyBookmarkForm" class="bookmark-form" autocomplete="off">
<label class="field">
<span>Titel (optional)</span>
<input id="dailyTitleInput" type="text" name="title" maxlength="160" placeholder="z.B. Daily Gewinnspielrunde">
</label>
<label class="field">
<span>URL-Template *</span>
<input id="dailyUrlInput" type="url" name="url_template" maxlength="800" placeholder="https://www.test.de/tag-{{day}}/" required>
</label>
<div id="dailyUrlSuggestionBox" class="suggestion-box" hidden></div>
<label class="field">
<span>Notiz (optional)</span>
<textarea id="dailyNotesInput" name="notes" maxlength="800" rows="3" placeholder="Kurze Hinweise oder To-do für diesen Link"></textarea>
</label>
<label class="field">
<span>Marker (optional)</span>
<input id="dailyMarkerInput" type="text" name="marker" maxlength="120" placeholder="z.B. März-Import oder Kampagne A">
</label>
<div class="field field--switch">
<span>Status</span>
<label class="switch-control">
<input id="dailyActiveInput" type="checkbox" name="is_active" checked>
<span class="switch-control__label">Bookmark ist aktiv</span>
</label>
<p class="field__hint">Deaktivierte Bookmarks bleiben erhalten, werden aber beim Auto-Öffnen und Abhaken übersprungen.</p>
</div>
<div class="form-preview">
<div>
<p class="form-preview__label">Aufgelöste URL für den gewählten Tag:</p>
<a id="dailyPreviewLink" class="form-preview__link" href="#" target="_blank" rel="noopener"></a>
</div>
<div class="form-preview__actions">
<button class="secondary-btn" type="button" id="dailyResetBtn">Zurücksetzen</button>
<button class="primary-btn" type="submit" id="dailySubmitBtn">Speichern</button>
</div>
</div>
<div class="placeholder-help">
<p class="placeholder-help__title">Dynamische Platzhalter</p>
<ul class="placeholder-help__list">
<li><code>{{day}}</code> Tag des Monats (131), <code>{{dd}}</code> zweistellig</li>
<li><code>{{date}}</code> liefert <code>YYYY-MM-DD</code></li>
<li><code>{{mm}}</code> Monat zweistellig, <code>{{yyyy}}</code> Jahr</li>
<li><code>{{day+1}}</code> oder <code>{{date-2}}</code> verschieben um Tage</li>
<li><code>{{counter:477}}</code> Basiswert + aktueller Tag, z.B. <code>https://www.test.de/sweepstakes/{{counter:477}}/</code></li>
</ul>
</div>
<div id="dailyFormStatus" class="form-status" role="status" aria-live="polite"></div>
</form>
</div>
</div>
<div id="dailyImportModal" class="modal" hidden>
<div class="modal__backdrop" id="dailyImportBackdrop"></div>
<div class="modal__content" role="dialog" aria-modal="true" aria-labelledby="dailyImportModalTitle">
<header class="modal__header">
<div>
<p class="panel__eyebrow">Masseneingabe</p>
<h2 id="dailyImportModalTitle" class="panel__title">Viele Bookmarks importieren</h2>
<p class="panel__subtitle">Füge hunderte Links gleichzeitig hinzu und vergebe einen gemeinsamen Marker.</p>
</div>
<button class="ghost-btn modal__close" type="button" id="dailyImportCloseBtn" aria-label="Schließen">×</button>
</header>
<form id="dailyImportForm" class="bookmark-form" autocomplete="off">
<label class="field">
<span>Liste der URL-Templates *</span>
<textarea id="dailyImportInput" name="import_urls" maxlength="120000" rows="8" placeholder="Jeder Link in eine neue Zeile, z.B. https://example.com/{{date}}/"></textarea>
</label>
<label class="field">
<span>Marker für alle Einträge (optional)</span>
<input id="dailyImportMarkerInput" type="text" name="import_marker" maxlength="120" placeholder="z.B. Batch März 2024">
</label>
<p class="import-hint">Doppelte oder ungültige Zeilen werden automatisch übersprungen.</p>
<div class="import-actions">
<button class="ghost-btn" type="button" id="dailyImportResetBtn">Zurücksetzen</button>
<button class="primary-btn" type="submit" id="dailyImportSubmitBtn">Importieren</button>
</div>
<div id="dailyImportStatus" class="import-status" role="status" aria-live="polite"></div>
</form>
</div>
</div>
</section>
<section id="view-dashboard" class="app-view" data-view="dashboard">
<div class="container">
<div class="page-toolbar page-toolbar--dashboard">
@@ -183,7 +665,7 @@
<option value="5">Profil 5</option>
</select>
</div>
<button type="button" class="btn btn-primary" id="refreshBtn">
<button type="button" class="btn btn-primary" id="dailyRefreshBtn">
🔄 Aktualisieren
</button>
</div>
@@ -813,6 +1295,9 @@
<script src="app.js"></script>
<script src="dashboard.js"></script>
<script src="settings.js"></script>
<script src="vendor/list.min.js"></script>
<script src="automation.js"></script>
<script src="daily-bookmarks.js"></script>
<script>
(function() {
const buttons = Array.from(document.querySelectorAll('[data-view-target]'));
@@ -889,5 +1374,35 @@
window.history.replaceState({ view: defaultView }, '', initQuery ? `${window.location.pathname}?${initQuery}` : window.location.pathname);
})();
</script>
<script>
(function() {
const AUTOMATION_VIEW = 'automation';
const DAILY_VIEW = 'daily-bookmarks';
function handleViewChange(event) {
const view = event?.detail?.view;
if (view === AUTOMATION_VIEW) {
window.AutomationPage?.activate?.();
} else {
window.AutomationPage?.deactivate?.();
}
if (view === DAILY_VIEW) {
window.DailyBookmarksPage?.activate?.();
} else {
window.DailyBookmarksPage?.deactivate?.();
}
}
window.addEventListener('app:view-change', handleViewChange);
const automationSection = document.querySelector('[data-view="automation"]');
if (automationSection && automationSection.classList.contains('app-view--active')) {
window.AutomationPage?.activate?.();
}
const dailySection = document.querySelector('[data-view="daily-bookmarks"]');
if (dailySection && dailySection.classList.contains('app-view--active')) {
window.DailyBookmarksPage?.activate?.();
}
})();
</script>
</body>
</html>

View File

@@ -1,9 +1,9 @@
/* Settings Page Styles */
.settings-container {
max-width: 800px;
max-width: var(--content-max-width, 1600px);
margin: 0 auto;
padding: 24px 0;
padding: 0 0 32px;
}
.settings-section {

View File

@@ -4,6 +4,11 @@
box-sizing: border-box;
}
:root {
--content-max-width: 1300px;
--top-gap: 12px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #f0f2f5;
@@ -24,7 +29,7 @@ body {
}
.container {
max-width: 1200px;
max-width: var(--content-max-width);
margin: 0 auto;
padding: 20px;
}
@@ -35,7 +40,7 @@ header {
padding: 16px 18px;
border-radius: 10px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
margin-bottom: 18px;
margin-bottom: var(--top-gap);
display: flex;
flex-direction: column;
gap: 12px;
@@ -1533,13 +1538,13 @@ h1 {
}
.bookmark-page {
margin-top: 32px;
display: flex;
justify-content: center;
}
.bookmark-page__panel {
width: min(960px, 100%);
width: 100%;
max-width: var(--content-max-width);
background: #ffffff;
border-radius: 20px;
border: 1px solid #e5e7eb;