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 WORKDIR /app
ENV CXXFLAGS="-std=c++20"
COPY package*.json ./ COPY package*.json ./
RUN apk add --no-cache python3 make g++ \ 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": { "dependencies": {
"express": "^4.18.2", "express": "^4.18.2",
"cors": "^2.8.5", "cors": "^2.8.5",
"better-sqlite3": "^9.2.2", "better-sqlite3": "^12.5.0",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"nodemailer": "^6.9.14" "nodemailer": "^7.0.11"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.2" "nodemon": "^3.0.2"

View File

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

View File

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

View File

@@ -2,299 +2,20 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Automationen Post Tracker</title> <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="icon" type="image/png" sizes="32x32" href="assets/app-icon-64.png">
<link rel="preconnect" href="https://fonts.googleapis.com"> <script>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> (function redirectToShell() {
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet"> const url = new URL(window.location.href);
<link rel="stylesheet" href="automation.css"> 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> </head>
<body> <body>
<div class="auto-shell"> <p>Weiterleitung zur Automations-Ansicht…</p>
<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>
</body> </body>
</html> </html>

View File

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

View File

@@ -14,13 +14,13 @@
--radius: 16px; --radius: 16px;
} }
*, .daily-bookmarks-view *,
*::before, .daily-bookmarks-view *::before,
*::after { .daily-bookmarks-view *::after {
box-sizing: border-box; box-sizing: border-box;
} }
body { .daily-bookmarks-view {
margin: 0; margin: 0;
font-family: 'Space Grotesk', 'Inter', system-ui, -apple-system, sans-serif; font-family: 'Space Grotesk', 'Inter', system-ui, -apple-system, sans-serif;
background: var(--bg); background: var(--bg);
@@ -29,12 +29,12 @@ body {
min-height: 100vh; min-height: 100vh;
} }
a { .daily-bookmarks-view a {
color: var(--accent-2); color: var(--accent-2);
text-decoration: none; text-decoration: none;
} }
a:hover { .daily-bookmarks-view a:hover {
text-decoration: underline; text-decoration: underline;
} }
@@ -49,13 +49,13 @@ a:hover {
border: 0; border: 0;
} }
.daily-shell { .daily-bookmarks-view .daily-shell {
max-width: 1600px; max-width: var(--content-max-width, 1600px);
margin: 0 auto; margin: 0 auto;
padding: 28px 18px 50px; padding: 0 18px 36px;
} }
.hero { .daily-bookmarks-view .hero {
background: var(--panel); background: var(--panel);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 20px; border-radius: 20px;
@@ -366,6 +366,39 @@ a:hover {
box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.2); 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 { .form-preview {
margin-top: 8px; margin-top: 8px;
display: flex; display: flex;
@@ -446,6 +479,15 @@ a:hover {
border-color: var(--accent); 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 { .ghost-btn--tiny {
padding: 6px 8px; padding: 6px 8px;
font-size: 12px; font-size: 12px;
@@ -588,11 +630,26 @@ a:hover {
border-bottom-color: rgba(16, 185, 129, 0.3); 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 { .bookmark-table tr.is-open td {
background: rgba(37, 99, 235, 0.08); background: rgba(37, 99, 235, 0.08);
border-bottom-color: rgba(37, 99, 235, 0.2); 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 { .chip {
border-radius: 12px; border-radius: 12px;
padding: 6px 8px; padding: 6px 8px;
@@ -611,6 +668,13 @@ a:hover {
border-color: rgba(37, 99, 235, 0.2); 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 { .list-summary {
color: var(--muted); color: var(--muted);
font-size: 14px; font-size: 14px;
@@ -631,9 +695,10 @@ a:hover {
} }
.table-actions { .table-actions {
display: flex; display: inline-flex;
gap: 6px; gap: 6px;
flex-wrap: wrap; flex-wrap: nowrap;
justify-content: flex-end;
} }
.table-actions .ghost-btn { .table-actions .ghost-btn {

View File

@@ -2,208 +2,20 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Daily Bookmarks</title> <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="icon" type="image/png" sizes="32x32" href="assets/app-icon-64.png">
<link rel="preconnect" href="https://fonts.googleapis.com"> <script>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> (function redirectToShell() {
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet"> const url = new URL(window.location.href);
<link rel="stylesheet" href="daily-bookmarks.css"> 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> </head>
<body> <body>
<div class="daily-shell"> <p>Weiterleitung zu Daily Bookmarks…</p>
<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>
</body> </body>
</html> </html>

View File

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

View File

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

View File

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