Remove unused templates for newsletter creation, settings, and submissions; update unsubscribe confirmation link; add tests for email templates API. #1

Merged
zwitschi merged 1 commits from v2 into main 2025-11-06 11:16:12 +01:00
26 changed files with 1921 additions and 1732 deletions
Showing only changes of commit c1e3ce185f - Show all commits

View File

@@ -105,5 +105,5 @@ jobs:
file: Dockerfile
push: ${{ steps.meta.outputs.on_default == 'true' && steps.meta.outputs.event_name != 'pull_request' && (env.REGISTRY_URL != '' && env.REGISTRY_USERNAME != '' && env.REGISTRY_PASSWORD != '') }}
tags: |
${{ env.REGISTRY_URL }}/allucanget/contact.allucanget.biz:latest
${{ env.REGISTRY_URL }}/allucanget/contact.allucanget.biz:${{ steps.meta.outputs.sha }}
${{ env.REGISTRY_URL }}/allucanget/contact-server:latest
${{ env.REGISTRY_URL }}/allucanget/contact-server:${{ steps.meta.outputs.sha }}

View File

@@ -1,6 +1,34 @@
# Server README
Backend service for the contact website. The app accepts contact and newsletter submissions, persists them, applies rate limiting and origin checks, and sends notification emails when SMTP is configured. Includes admin authentication for accessing application settings and managing dynamic configuration.
Backend service for a static website. The app accepts contact and newsletter submissions, persists them, applies rate limiting and origin checks, and sends notification emails when SMTP is configured. Includes admin authentication for accessing application settings and managing dynamic configuration.
## Table of Contents
- [Server README](#server-readme)
- [Table of Contents](#table-of-contents)
- [Overview](#overview)
- [Architecture](#architecture)
- [Quick Start](#quick-start)
- [Admin Access](#admin-access)
- [API Surface](#api-surface)
- [Embeddable Forms](#embeddable-forms)
- [Running With Docker](#running-with-docker)
- [Build manually](#build-manually)
- [Run with explicit environment variables](#run-with-explicit-environment-variables)
- [Run using docker-compose](#run-using-docker-compose)
- [Environment Variables](#environment-variables)
- [Core runtime](#core-runtime)
- [Admin authentication](#admin-authentication)
- [Database configuration](#database-configuration)
- [Email delivery](#email-delivery)
- [Rate limiting and caching](#rate-limiting-and-caching)
- [Request hardening](#request-hardening)
- [Observability](#observability)
- [Docker / Gunicorn runtime](#docker--gunicorn-runtime)
- [Health Checks and Monitoring](#health-checks-and-monitoring)
- [Testing](#testing)
- [Deployment Notes](#deployment-notes)
- [Email Templates](#email-templates)
## Overview
@@ -119,7 +147,7 @@ Replace `https://your-server-domain` with your actual server URL. The iframe cod
### Build manually
```pwsh
docker build -t contact.allucanget.biz -f Dockerfile .
docker build -t backend-server -f Dockerfile .
```
### Run with explicit environment variables
@@ -132,7 +160,7 @@ docker run --rm -p 5002:5002 `
-e SMTP_USERNAME=api@example.com `
-e SMTP_PASSWORD=secret `
-e SMTP_RECIPIENTS=hello@example.com `
contact.allucanget.biz
backend-server
```
### Run using docker-compose

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from flask import Flask
from . import admin, auth, contact, embed, monitoring, newsletter
from . import admin, auth, contact, embed, monitoring, newsletter, static
def register_blueprints(app: Flask) -> None:
@@ -14,3 +14,4 @@ def register_blueprints(app: Flask) -> None:
app.register_blueprint(auth.bp)
app.register_blueprint(admin.bp)
app.register_blueprint(embed.bp)
app.register_blueprint(static.bp)

View File

@@ -14,21 +14,21 @@ bp = Blueprint("admin", __name__, url_prefix="/admin")
@auth.login_required
def dashboard():
"""Display admin dashboard overview."""
return render_template("admin_dashboard.html")
return render_template("admin/admin_dashboard.html")
@bp.route("/newsletter")
@auth.login_required
def newsletter_subscribers():
"""Display newsletter subscriber management page."""
return render_template("admin_newsletter.html")
return render_template("admin/admin_newsletter.html")
@bp.route("/newsletter/create")
@auth.login_required
def newsletter_create():
"""Display newsletter creation and sending page."""
return render_template("admin_newsletter_create.html")
return render_template("admin/admin_newsletter_create.html")
@bp.route("/settings")
@@ -72,21 +72,28 @@ def settings_page():
},
}
return render_template("admin_settings.html", settings=app_settings)
return render_template("admin/admin_settings.html", settings=app_settings)
@bp.route("/submissions")
@auth.login_required
def submissions():
"""Display contact form submissions page."""
return render_template("admin_submissions.html")
return render_template("admin/admin_submissions.html")
@bp.route("/embeds")
@auth.login_required
def embeds():
"""Display embeddable forms management page."""
return render_template("admin_embeds.html")
return render_template("admin/admin_embeds.html")
@bp.route('/email-templates')
@auth.login_required
def email_templates():
"""Display admin page for editing email templates."""
return render_template('admin/admin_email_templates.html')
@bp.route("/api/settings", methods=["GET"])
@@ -110,6 +117,11 @@ def validate_setting(key: str, value: str) -> str | None:
"newsletter_enabled": lambda v: v in ["true", "false"],
"rate_limit_max": lambda v: v.isdigit() and 0 <= int(v) <= 1000,
"rate_limit_window": lambda v: v.isdigit() and 1 <= int(v) <= 3600,
# Embed dimensions (pixels)
"embed_contact_width": lambda v: v.isdigit() and 100 <= int(v) <= 2000,
"embed_contact_height": lambda v: v.isdigit() and 100 <= int(v) <= 2000,
"embed_newsletter_width": lambda v: v.isdigit() and 100 <= int(v) <= 2000,
"embed_newsletter_height": lambda v: v.isdigit() and 100 <= int(v) <= 2000,
}
if key in validations and not validations[key](value):

View File

@@ -1,8 +1,7 @@
"""Embeddable forms routes."""
from __future__ import annotations
from flask import Blueprint, render_template, send_from_directory
import os
from flask import Blueprint, render_template
bp = Blueprint("embed", __name__)
@@ -17,10 +16,3 @@ def contact_form():
def newsletter_form():
"""Serve the embeddable newsletter subscription form."""
return render_template("embed_newsletter.html")
@bp.route("/static/css/styles.css", methods=["GET"])
def serve_css():
"""Serve the unified CSS file."""
static_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'static')
return send_from_directory(static_dir, 'css/styles.css')

27
server/routes/static.py Normal file
View File

@@ -0,0 +1,27 @@
from __future__ import annotations
from flask import Blueprint, render_template, send_from_directory
import os
bp = Blueprint("static", __name__)
@bp.route("/static/css/styles.css", methods=["GET"])
def serve_css():
"""Serve the unified CSS file."""
static_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'static')
return send_from_directory(static_dir, 'css/styles.css')
@bp.route("/static/css/admin.css", methods=["GET"])
def serve_admin_css():
"""Serve the unified CSS file."""
static_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'static')
return send_from_directory(static_dir, 'css/admin.css')
@bp.route("/static/js/admin.js", methods=["GET"])
def serve_admin_js():
"""Serve the unified JS file."""
static_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'static')
return send_from_directory(static_dir, 'js/admin.js')

231
static/css/admin.css Normal file
View File

@@ -0,0 +1,231 @@
/* Navigation links */
nav a {
color: #007bff;
text-decoration: none;
margin-right: 20px;
}
nav a:hover {
text-decoration: underline;
}
.nav {
margin-bottom: 20px;
padding: 10px;
background-color: #f8f9fa;
border-radius: 5px;
text-align: center;
}
.nav a {
margin-right: 15px;
color: #007bff;
text-decoration: none;
}
.nav a:hover {
text-decoration: underline;
}
/* Admin newsletter */
.filters {
margin-bottom: 20px;
padding: 15px;
background-color: #f8f9fa;
border-radius: 5px;
display: flex;
gap: 15px;
align-items: center;
flex-wrap: wrap;
}
.filters form {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: end;
}
.filters input,
.filters select {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
min-width: 150px;
}
.filters button {
padding: 8px 16px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.filters button:hover {
background-color: #0056b3;
}
/* Subscribers table */
.subscribers-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
.subscribers-table th,
.subscribers-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
.subscribers-table th {
background-color: #f8f9fa;
font-weight: bold;
}
.subscribers-table tr:hover {
background-color: #f5f5f5;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
.actions {
display: flex;
gap: 5px;
}
/* Newsletter creation form */
.form-section {
margin-bottom: 30px;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
background-color: #f9f9f9;
}
.form-section h2 {
margin-top: 0;
color: #555;
border-bottom: 1px solid #ddd;
padding-bottom: 10px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
color: #333;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.form-group textarea {
min-height: 200px;
resize: vertical;
}
.form-row {
display: flex;
gap: 15px;
margin-bottom: 15px;
}
.form-row .form-group {
flex: 1;
margin-bottom: 0;
}
.newsletter-preview {
margin-top: 20px;
padding: 20px;
background-color: white;
border: 1px solid #ddd;
border-radius: 4px;
}
.newsletter-preview h3 {
margin-top: 0;
color: #555;
}
.newsletter-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.hidden {
display: none;
}
/* Admin settings */
.setting strong {
display: inline-block;
width: 200px;
}
.iframe-code {
width: 100%;
height: 100px;
font-family: monospace;
padding: 10px;
border: 1px solid #ccc;
border-radius: 3px;
}
/* Admin settings cards grid */
.settings-cards {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 8px 0 16px 0;
}
.settings-card {
background: #ffffff;
border: 1px solid #e6e6e6;
border-radius: 8px;
padding: 12px 14px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
flex: 1 1 300px;
min-width: 220px;
}
.card-title {
margin: 0 0 8px 0;
font-size: 1.05rem;
}
.card-body {
display: flex;
flex-direction: column;
gap: 6px;
}
/* Keep individual dynamic setting layout readable */
.setting {
font-size: 0.95rem;
color: #222;
}
@media (max-width: 720px) {
.settings-card {
flex: 1 1 100%;
}
}
/* Spinner styles used by embed pages */
#spinnerIcon {
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
#submitSpinner {
display: flex;
align-items: center;
}

View File

@@ -3,7 +3,9 @@
/* Base styles */
body {
font-family: Arial, sans-serif;
margin: 20px;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: #f9f9f9;
color: #333;
}
@@ -76,21 +78,32 @@ button:hover,
.btn:hover {
background-color: #0056b3;
}
.btn-secondary {
background-color: #6c757d;
}
.btn-secondary:hover {
background-color: #545b62;
}
.btn-danger {
background-color: #dc3545;
}
.btn-danger:hover {
background-color: #c82333;
background-color: #dc3545;
color: white;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:hover {
background-color: #0056b3;
}
.btn-success {
background-color: #28a745;
color: white;
}
.btn-success:hover {
background-color: #1e7e34;
}
/* Message styles */
@@ -164,13 +177,21 @@ button:hover,
min-width: 150px;
margin: 5px;
}
.stat-card h3 {
margin: 0;
color: #495057;
font-size: 2em;
}
.stat-card h4 {
margin: 0 0 10px 0;
color: #666;
font-size: 14px;
}
.stat-card .number {
font-size: 24px;
font-weight: bold;
color: #333;
}
.stat-card p {
margin: 5px 0 0 0;
color: #6c757d;
@@ -260,17 +281,6 @@ button:hover,
border-radius: 8px;
}
/* Navigation links */
nav a {
color: #007bff;
text-decoration: none;
margin-right: 20px;
}
nav a:hover {
text-decoration: underline;
}
/* Table styles */
table {
width: 100%;
@@ -298,6 +308,7 @@ tr:hover {
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
margin-top: 20px;
}
@@ -321,6 +332,26 @@ tr:hover {
color: white;
}
/* Pagination */
.pagination button {
padding: 8px 12px;
border: 1px solid #ddd;
background-color: white;
cursor: pointer;
border-radius: 4px;
}
.pagination button:hover {
background-color: #f8f9fa;
}
.pagination button:disabled {
background-color: #e9ecef;
cursor: not-allowed;
}
.pagination .current-page {
font-weight: bold;
color: #007bff;
}
/* Utility classes */
.text-center {
text-align: center;

986
static/js/admin.js Normal file
View File

@@ -0,0 +1,986 @@
/**
* Admin JavaScript - Consolidated functionality for all admin pages
* Provides shared utilities, API interactions, and page-specific features
*/
// ==================== UTILITY FUNCTIONS ====================
/**
* Displays a message to the user with auto-hide functionality
* @param {string} text - The message text to display
* @param {string} type - Message type ('success', 'error', 'info', etc.)
*/
function showMessage(text, type) {
let messageDiv = document.getElementById("message");
if (!messageDiv) {
messageDiv = document.createElement("div");
messageDiv.id = "message";
document.body.insertBefore(messageDiv, document.body.firstChild);
}
messageDiv.className = `message ${type}`;
messageDiv.textContent = text;
messageDiv.style.display = "block";
setTimeout(() => (messageDiv.style.display = "none"), 5000);
}
/**
* Escapes HTML characters to prevent XSS
* @param {string} text - Text to escape
* @returns {string} Escaped HTML string
*/
const escapeHtml = (text) => {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
};
/**
* Copies text to clipboard with fallback for older browsers
* @param {string} text - Text to copy
* @returns {Promise<boolean>} Success status
*/
async function copyToClipboard(text) {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (e) {
// Fall through to fallback
}
}
// Fallback method
const tmp = document.createElement("textarea");
tmp.value = text;
tmp.setAttribute("readonly", "");
tmp.style.position = "absolute";
tmp.style.left = "-9999px";
document.body.appendChild(tmp);
tmp.select();
try {
const ok = document.execCommand("copy");
document.body.removeChild(tmp);
return ok;
} catch (err) {
document.body.removeChild(tmp);
return false;
}
}
// ==================== SETTINGS MANAGEMENT ====================
/**
* Loads settings from API and displays them
*/
function loadSettingsForList() {
fetch("/admin/api/settings")
.then((response) => response.json())
.then((data) => {
if (data.status === "ok") {
appSettings = data.settings || {};
displaySettings();
} else {
showMessage(
"Error loading settings: " + (data.message || "Unknown error"),
"error"
);
}
})
.catch((error) => {
console.error("Error:", error);
showMessage("Error loading settings", "error");
});
}
/**
* Fetches embed settings from API
* @returns {Promise<Object>} Settings object or empty object on error
*/
async function fetchEmbedSettings() {
try {
const response = await fetch("/admin/api/settings");
const data = await response.json();
return data.status === "ok" && data.settings ? data.settings : {};
} catch (err) {
console.error("Failed to load embed settings", err);
return {};
}
}
/**
* Saves a setting via API
* @param {string} key - Setting key
* @param {string} inputId - Input element ID
*/
async function saveEmbedSetting(key, inputId) {
const input = document.getElementById(inputId);
if (!input) return showMessage("Input not found", "error");
const value = (input.value || "").toString().trim();
if (!value) return showMessage("Value cannot be empty", "error");
try {
const response = await fetch(
`/admin/api/settings/${encodeURIComponent(key)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value }),
}
);
const data = await response.json();
if (data.status === "ok") {
showMessage("Setting saved", "success");
// Rebuild textarea values to reflect the new size
if (typeof loadEmbedSettingsAndInit === "function") {
loadEmbedSettingsAndInit();
}
} else {
showMessage("Failed to save setting: " + (data.message || ""), "error");
}
} catch (err) {
console.error("Failed to save setting", err);
showMessage("Failed to save setting", "error");
}
}
// ==================== EMBED MANAGEMENT ====================
/**
* Initializes embed settings and populates form fields
*/
async function loadEmbedSettingsAndInit() {
const origin =
window.location && window.location.origin
? window.location.origin
: "http://your-server-domain";
// Default dimensions
let contactWidth = "600",
contactHeight = "400";
let newsletterWidth = "600",
newsletterHeight = "300";
try {
const settings = await fetchEmbedSettings();
contactWidth = settings.embed_contact_width || contactWidth;
contactHeight = settings.embed_contact_height || contactHeight;
newsletterWidth = settings.embed_newsletter_width || newsletterWidth;
newsletterHeight = settings.embed_newsletter_height || newsletterHeight;
} catch (err) {
console.error("Error loading embed settings", err);
}
// Update input fields
const inputs = {
contactWidth,
contactHeight,
newsletterWidth,
newsletterHeight,
};
Object.entries(inputs).forEach(([id, value]) => {
const element = document.getElementById(id);
if (element) element.value = value;
});
// Update iframe code textareas
const contactTextarea = document.getElementById("iframeCode");
if (contactTextarea) {
contactTextarea.value = `<iframe src="${origin}/embed/contact" width="${contactWidth}" height="${contactHeight}" frameborder="0" allowfullscreen></iframe>`;
}
const newsletterTextarea = document.getElementById("iframeNewsletterCode");
if (newsletterTextarea) {
newsletterTextarea.value = `<iframe src="${origin}/embed/newsletter" width="${newsletterWidth}" height="${newsletterHeight}" frameborder="0" allowfullscreen></iframe>`;
}
const contactPreview = document.getElementById("contactFormPreview");
if (contactPreview) {
contactPreview.innerHTML = `<iframe src="${origin}/embed/contact" width="${contactWidth}" height="${contactHeight}" frameborder="0" allowfullscreen></iframe>`;
}
const newsletterPreview = document.getElementById("newsletterFormPreview");
if (newsletterPreview) {
newsletterPreview.innerHTML = `<iframe src="${origin}/embed/newsletter" width="${newsletterWidth}" height="${newsletterHeight}" frameborder="0" allowfullscreen></iframe>`;
}
}
/**
* Copies contact iframe code to clipboard
*/
function copyIframeCode() {
const textarea = document.getElementById("iframeCode");
if (!textarea) return showMessage("Contact iframe not found", "error");
copyToClipboard(textarea.value).then((ok) =>
showMessage(
ok
? "Contact iframe code copied to clipboard!"
: "Failed to copy contact iframe code",
ok ? "success" : "error"
)
);
}
/**
* Copies newsletter iframe code to clipboard
*/
function copyNewsletterIframeCode() {
const textarea = document.getElementById("iframeNewsletterCode");
if (!textarea) return showMessage("Newsletter iframe not found", "error");
copyToClipboard(textarea.value).then((ok) =>
showMessage(
ok
? "Newsletter iframe code copied to clipboard!"
: "Failed to copy newsletter iframe code",
ok ? "success" : "error"
)
);
}
// ==================== DASHBOARD ====================
/**
* Loads and displays dashboard statistics
*/
async function loadDashboardStats() {
try {
const [contactRes, newsletterRes, settingsRes] = await Promise.all([
fetch("/admin/api/contact?page=1&per_page=1"),
fetch("/admin/api/newsletter?page=1&per_page=1"),
fetch("/admin/api/settings"),
]);
if (contactRes.ok) {
const data = await contactRes.json();
document.getElementById("contact-count").textContent =
data.pagination.total;
}
if (newsletterRes.ok) {
const data = await newsletterRes.json();
document.getElementById("newsletter-count").textContent =
data.pagination.total;
}
if (settingsRes.ok) {
const data = await settingsRes.json();
document.getElementById("settings-count").textContent = Object.keys(
data.settings || {}
).length;
}
} catch (error) {
console.error("Failed to load dashboard stats:", error);
}
}
// ==================== EMAIL TEMPLATES ====================
/**
* Loads email template from settings
*/
function loadEmailTemplate() {
const textarea = document.getElementById("newsletterTemplate");
if (!textarea) return;
fetch("/admin/api/settings")
.then((r) => r.json())
.then((data) => {
if (
data.status === "ok" &&
data.settings &&
data.settings.newsletter_confirmation_template
) {
textarea.value = data.settings.newsletter_confirmation_template;
}
})
.catch((err) => console.error("Failed to load template", err));
}
/**
* Saves email template to settings
*/
function saveEmailTemplate() {
const textarea = document.getElementById("newsletterTemplate");
const message = document.getElementById("message");
if (!textarea || !message) return;
const value = textarea.value || "";
fetch("/admin/api/settings/newsletter_confirmation_template", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value }),
})
.then((r) => r.json())
.then((data) => {
const isSuccess = data.status === "ok";
message.className = `message ${isSuccess ? "success" : "error"}`;
message.textContent = isSuccess
? "Template saved"
: `Failed to save template: ${data.message || ""}`;
setTimeout(() => (message.textContent = ""), 4000);
})
.catch((err) => {
console.error("Failed to save template", err);
message.className = "message error";
message.textContent = "Failed to save template";
});
}
// ==================== NEWSLETTER CREATION ====================
let newsletterStats = {};
/**
* Loads newsletter statistics
*/
function loadNewsletterStats() {
fetch("/admin/api/newsletter?page=1&per_page=1")
.then((response) => response.json())
.then((data) => {
if (data.status === "ok") {
const total = data.pagination.total;
document.getElementById("totalSubscribers").textContent = total;
document.getElementById("activeSubscribers").textContent = total; // Assume all active
newsletterStats.totalSubscribers = total;
}
})
.catch((error) => console.error("Error loading subscriber stats:", error))
.finally(() => {
const lastSentEl = document.getElementById("lastSent");
if (lastSentEl) lastSentEl.textContent = "N/A";
});
}
/**
* Generates newsletter preview
*/
function previewNewsletter() {
const subject = document.getElementById("subject").value.trim();
const content = document.getElementById("content").value.trim();
const senderName = document.getElementById("senderName").value.trim();
if (!subject || !content) {
return showMessage(
"Subject and content are required for preview.",
"error"
);
}
const previewContent = document.getElementById("previewContent");
if (previewContent) {
previewContent.innerHTML = `
<h2>${escapeHtml(subject)}</h2>
${
senderName
? `<p><strong>From:</strong> ${escapeHtml(senderName)}</p>`
: ""
}
<div style="margin-top: 20px; line-height: 1.6;">
${content.replace(/\n/g, "<br>")}
</div>
`;
}
const previewSection = document.getElementById("previewSection");
if (previewSection) previewSection.classList.remove("hidden");
showMessage("Newsletter preview generated.", "info");
}
/**
* Saves newsletter as draft
*/
function saveDraft() {
const form = document.getElementById("newsletterForm");
if (!form) return;
const formData = new FormData(form);
const newsletterData = {
subject: formData.get("subject"),
content: formData.get("content"),
sender_name: formData.get("sender_name"),
send_date: formData.get("send_date"),
status: "draft",
};
if (!newsletterData.subject || !newsletterData.content) {
return showMessage("Subject and content are required.", "error");
}
fetch("/admin/api/newsletters", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newsletterData),
})
.then((response) => response.json())
.then((data) => {
showMessage(
data.status === "ok"
? "Newsletter draft saved successfully!"
: data.message || "Failed to save draft.",
data.status === "ok" ? "success" : "error"
);
})
.catch((error) => {
console.error("Error saving draft:", error);
showMessage("Failed to save draft.", "error");
});
}
/**
* Sends newsletter to subscribers
*/
function sendNewsletter() {
const form = document.getElementById("newsletterForm");
if (!form) return;
const formData = new FormData(form);
const newsletterData = {
subject: formData.get("subject"),
content: formData.get("content"),
sender_name: formData.get("sender_name"),
send_date: formData.get("send_date"),
status: "sent",
};
if (!newsletterData.subject || !newsletterData.content) {
return showMessage("Subject and content are required.", "error");
}
if (
!confirm(
`Are you sure you want to send this newsletter to ${
newsletterStats.totalSubscribers || 0
} subscribers?`
)
) {
return;
}
// Save and send
fetch("/admin/api/newsletters", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newsletterData),
})
.then((response) => response.json())
.then((data) => {
if (data.status === "ok") {
const newsletterId = data.newsletter_id;
return fetch(`/admin/api/newsletters/${newsletterId}/send`, {
method: "POST",
});
} else {
throw new Error(data.message || "Failed to save newsletter.");
}
})
.then((response) => response.json())
.then((data) => {
showMessage(
data.status === "ok"
? `Newsletter sent successfully to ${data.sent_count} subscribers!`
: data.message || "Failed to send newsletter.",
data.status === "ok" ? "success" : "error"
);
})
.catch((error) => {
console.error("Error sending newsletter:", error);
showMessage("Failed to send newsletter.", "error");
});
}
/**
* Clears newsletter form with confirmation
*/
function clearForm() {
if (
!confirm(
"Are you sure you want to clear the form? All unsaved changes will be lost."
)
) {
return;
}
const form = document.getElementById("newsletterForm");
if (form) form.reset();
const previewSection = document.getElementById("previewSection");
if (previewSection) previewSection.classList.add("hidden");
showMessage("Form cleared.", "info");
}
// ==================== NEWSLETTER SUBSCRIBERS ====================
let currentPage = 1;
let currentFilters = {
email: "",
sort_by: "subscribed_at",
sort_order: "desc",
};
/**
* Applies filters to subscriber list
*/
function applyFilters() {
const emailFilter = document.getElementById("emailFilter");
const sortBy = document.getElementById("sortBy");
const sortOrder = document.getElementById("sortOrder");
if (emailFilter) currentFilters.email = emailFilter.value.trim();
if (sortBy) currentFilters.sort_by = sortBy.value;
if (sortOrder) currentFilters.sort_order = sortOrder.value;
currentPage = 1;
loadSubscribers();
}
/**
* Clears all filters
*/
function clearFilters() {
const emailFilter = document.getElementById("emailFilter");
const sortBy = document.getElementById("sortBy");
const sortOrder = document.getElementById("sortOrder");
if (emailFilter) emailFilter.value = "";
if (sortBy) sortBy.value = "subscribed_at";
if (sortOrder) sortOrder.value = "desc";
currentFilters = { email: "", sort_by: "subscribed_at", sort_order: "desc" };
currentPage = 1;
loadSubscribers();
}
/**
* Loads subscribers with current filters and pagination
*/
function loadSubscribers() {
const loading = document.getElementById("loading");
const table = document.getElementById("subscribersTable");
const pagination = document.getElementById("pagination");
if (loading) loading.style.display = "block";
if (table) table.style.display = "none";
if (pagination) pagination.style.display = "none";
const params = new URLSearchParams({
page: currentPage,
per_page: 50,
sort_by: currentFilters.sort_by,
sort_order: currentFilters.sort_order,
});
if (currentFilters.email) params.append("email", currentFilters.email);
fetch(`/admin/api/newsletter?${params}`)
.then((response) => response.json())
.then((data) => {
if (data.status === "ok") {
displaySubscribers(data.subscribers);
updatePagination(data.pagination);
} else {
showMessage(
"Error loading subscribers: " + (data.message || "Unknown error"),
"error"
);
}
})
.catch((error) => {
console.error("Error:", error);
showMessage("Error loading subscribers", "error");
})
.finally(() => {
if (loading) loading.style.display = "none";
});
}
/**
* Displays subscribers in table
* @param {Array} subscribers - Array of subscriber objects
*/
function displaySubscribers(subscribers) {
const tbody = document.getElementById("subscribersBody");
if (!tbody) return;
tbody.innerHTML = "";
if (subscribers.length === 0) {
tbody.innerHTML =
'<tr><td colspan="3" style="text-align: center; padding: 40px; color: #666;">No subscribers found</td></tr>';
} else {
subscribers.forEach((subscriber) => {
const row = document.createElement("tr");
row.innerHTML = `
<td>${escapeHtml(subscriber.email)}</td>
<td>${new Date(subscriber.subscribed_at).toLocaleDateString()}</td>
<td class="actions">
<button class="btn btn-danger" onclick="unsubscribe('${escapeHtml(
subscriber.email
)}')">Unsubscribe</button>
</td>
`;
tbody.appendChild(row);
});
}
const table = document.getElementById("subscribersTable");
if (table) table.style.display = "table";
}
/**
* Updates pagination controls
* @param {Object} pagination - Pagination data
*/
function updatePagination(pagination) {
const pageInfo = document.getElementById("pageInfo");
const prevBtn = document.getElementById("prevBtn");
const nextBtn = document.getElementById("nextBtn");
if (pageInfo)
pageInfo.textContent = `Page ${pagination.page} of ${pagination.pages} (${pagination.total} total)`;
if (prevBtn) prevBtn.disabled = pagination.page <= 1;
if (nextBtn) nextBtn.disabled = pagination.page >= pagination.pages;
const paginationDiv = document.getElementById("pagination");
if (paginationDiv) paginationDiv.style.display = "flex";
}
/**
* Changes to a different page
* @param {number} page - Page number to navigate to
*/
function changePage(page) {
currentPage = page;
loadSubscribers();
}
/**
* Unsubscribes a user from newsletter
* @param {string} email - Email address to unsubscribe
*/
function unsubscribe(email) {
if (!confirm(`Are you sure you want to unsubscribe ${email}?`)) return;
fetch("/api/newsletter", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
})
.then((response) => response.json())
.then((data) => {
if (data.status === "ok") {
showMessage("Subscriber unsubscribed successfully", "success");
loadSubscribers();
} else {
showMessage(
"Error unsubscribing: " + (data.message || "Unknown error"),
"error"
);
}
})
.catch((error) => {
console.error("Error:", error);
showMessage("Error unsubscribing subscriber", "error");
});
}
// ==================== CONTACT SUBMISSIONS ====================
let submissionsCurrentPage = 1;
let submissionsSortBy = "created_at";
let submissionsSortOrder = "desc";
/**
* Clears contact submission filters
*/
function clearSubmissionFilters() {
const email = document.getElementById("email");
const dateFrom = document.getElementById("date_from");
const dateTo = document.getElementById("date_to");
const perPage = document.getElementById("per_page");
if (email) email.value = "";
if (dateFrom) dateFrom.value = "";
if (dateTo) dateTo.value = "";
if (perPage) perPage.value = "50";
submissionsCurrentPage = 1;
submissionsSortBy = "created_at";
submissionsSortOrder = "desc";
loadSubmissions();
}
/**
* Loads contact submissions with filters
*/
function loadSubmissions() {
const loading = document.getElementById("loading");
const table = document.getElementById("submissionsTable");
if (loading) loading.style.display = "block";
if (table) table.style.opacity = "0.5";
const perPage = document.getElementById("per_page");
const email = document.getElementById("email");
const dateFrom = document.getElementById("date_from");
const dateTo = document.getElementById("date_to");
const params = new URLSearchParams({
page: submissionsCurrentPage,
per_page: perPage?.value || "50",
sort_by: submissionsSortBy,
sort_order: submissionsSortOrder,
email: email?.value || "",
date_from: dateFrom?.value || "",
date_to: dateTo?.value || "",
});
fetch(`/api/contact?${params}`)
.then((response) => response.json())
.then((data) => {
if (data.status === "ok") {
displaySubmissions(data.submissions);
displaySubmissionPagination(data.pagination);
} else {
showMessage(
"Error loading submissions: " + (data.message || "Unknown error"),
"error"
);
}
})
.catch((error) => {
console.error("Error:", error);
showMessage("Error loading submissions", "error");
})
.finally(() => {
if (loading) loading.style.display = "none";
if (table) table.style.opacity = "1";
});
}
/**
* Displays contact submissions in table
* @param {Array} submissions - Array of submission objects
*/
function displaySubmissions(submissions) {
const tbody = document.getElementById("submissionsBody");
if (!tbody) return;
if (submissions.length === 0) {
tbody.innerHTML =
'<tr><td colspan="7" class="no-data">No submissions found</td></tr>';
return;
}
tbody.innerHTML = submissions
.map(
(submission) => `
<tr>
<td>${submission.id}</td>
<td>${escapeHtml(submission.name)}</td>
<td>${escapeHtml(submission.email)}</td>
<td>${escapeHtml(submission.company || "")}</td>
<td class="submission-details">${escapeHtml(submission.message)}</td>
<td>${new Date(submission.created_at).toLocaleString()}</td>
<td><button class="delete-btn" onclick="deleteSubmission(${
submission.id
})">Delete</button></td>
</tr>
`
)
.join("");
}
/**
* Updates submission pagination controls
* @param {Object} pagination - Pagination data
*/
function displaySubmissionPagination(pagination) {
const paginationDiv = document.getElementById("pagination");
if (!paginationDiv) return;
if (pagination.pages <= 1) {
paginationDiv.innerHTML = "";
return;
}
let buttons = [];
// Previous button
buttons.push(
`<button ${
pagination.page <= 1 ? "disabled" : ""
} onclick="changeSubmissionPage(${pagination.page - 1})">Previous</button>`
);
// Page numbers
const startPage = Math.max(1, pagination.page - 2);
const endPage = Math.min(pagination.pages, pagination.page + 2);
for (let i = startPage; i <= endPage; i++) {
buttons.push(
`<button class="${
i === pagination.page ? "active" : ""
}" onclick="changeSubmissionPage(${i})">${i}</button>`
);
}
// Next button
buttons.push(
`<button ${
pagination.page >= pagination.pages ? "disabled" : ""
} onclick="changeSubmissionPage(${pagination.page + 1})">Next</button>`
);
paginationDiv.innerHTML = buttons.join("");
}
/**
* Changes to a different submission page
* @param {number} page - Page number to navigate to
*/
function changeSubmissionPage(page) {
submissionsCurrentPage = page;
loadSubmissions();
window.scrollTo(0, 0);
}
/**
* Deletes a contact submission
* @param {number} id - Submission ID to delete
*/
function deleteSubmission(id) {
if (!confirm("Are you sure you want to delete this submission?")) return;
fetch(`/api/contact/${id}`, { method: "DELETE" })
.then((response) => response.json())
.then((data) => {
if (data.status === "ok") {
showMessage("Submission deleted successfully", "success");
loadSubmissions();
} else {
showMessage(
"Error deleting submission: " + (data.message || "Unknown error"),
"error"
);
}
})
.catch((error) => {
console.error("Error:", error);
showMessage("Error deleting submission", "error");
});
}
// ==================== INITIALIZATION ====================
// Global admin object for external access
window.admin = {
showMessage,
escapeHtml,
fetchEmbedSettings,
saveEmbedSetting,
copyIframeCode,
copyNewsletterIframeCode,
loadEmbedSettingsAndInit,
loadDashboardStats,
loadEmailTemplate,
saveEmailTemplate,
loadNewsletterStats,
previewNewsletter,
saveDraft,
sendNewsletter,
clearForm,
applyFilters,
clearFilters,
loadSubscribers,
displaySubscribers,
updatePagination,
changePage,
unsubscribe,
clearSubmissionFilters,
loadSubmissions,
displaySubmissions,
displaySubmissionPagination,
changeSubmissionPage,
deleteSubmission,
};
// Auto-initialize based on page content
document.addEventListener("DOMContentLoaded", function () {
// Embed page
if (
document.getElementById("iframeCode") ||
document.getElementById("iframeNewsletterCode")
) {
loadEmbedSettingsAndInit();
}
// Dashboard
if (document.getElementById("contact-count")) {
loadDashboardStats();
}
// Email templates
if (document.getElementById("newsletterTemplate")) {
loadEmailTemplate();
const form = document.getElementById("templateForm");
if (form) {
form.addEventListener("submit", (e) => {
e.preventDefault();
saveEmailTemplate();
});
}
}
// Newsletter creation
if (document.getElementById("newsletterForm")) {
loadNewsletterStats();
}
// Newsletter subscribers
if (document.getElementById("subscribersTable")) {
loadSubscribers();
}
// Contact submissions
if (document.getElementById("submissionsTable")) {
loadSubmissions();
const filterForm = document.getElementById("filterForm");
if (filterForm) {
filterForm.addEventListener("submit", (e) => {
e.preventDefault();
submissionsCurrentPage = 1;
loadSubmissions();
});
}
// Table sorting
document.querySelectorAll("th[data-sort]").forEach((header) => {
header.addEventListener("click", function () {
const sortBy = this.dataset.sort;
if (submissionsSortBy === sortBy) {
submissionsSortOrder =
submissionsSortOrder === "asc" ? "desc" : "asc";
} else {
submissionsSortBy = sortBy;
submissionsSortOrder = "asc";
}
submissionsCurrentPage = 1;
loadSubmissions();
});
});
}
// Settings
if (document.getElementById("settingsList")) {
loadSettingsForList();
}
});

15
templates/_base.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}Newsletter Subscribers{% endblock %}</title>
<link rel="stylesheet" href="/static/css/styles.css" />
{% block extra_styles %}{% endblock %}
</head>
<body>
{% include "_nav.html" %}
<h1>{% block heading %}Newsletter Subscribers{% endblock %}</h1>
{% block content %}{% endblock %}
{% block extra_scripts %}{% endblock %}
</body>
</html>

53
templates/_nav.html Normal file
View File

@@ -0,0 +1,53 @@
<nav class="nav">
<!--
admin_dashboard.html
admin_newsletter.html
admin_newsletter_create.html
admin_settings.html
admin_submissions.html
admin_embeds.html
newsletter_manage.html
unsubscribe_confirmation.html
-->
<a
href="/admin/"
style="color: #007bff; text-decoration: none; margin-right: 20px"
>Dashboard</a
>
<a
href="/admin/submissions"
style="color: #007bff; text-decoration: none; margin-right: 20px"
>Contact Submissions</a
>
<a
href="/admin/newsletter"
style="color: #007bff; text-decoration: none; margin-right: 20px"
>Subscribers</a
>
<a
href="/admin/newsletter/create"
style="color: #007bff; text-decoration: none; margin-right: 20px"
>Create Newsletter</a
>
<a
href="/admin/embeds"
style="color: #007bff; text-decoration: none; margin-right: 20px"
>Embeds</a
>
<a
href="/admin/email-templates"
style="color: #007bff; text-decoration: none; margin-right: 20px"
>Email Templates</a
>
<a
href="/admin/settings"
style="color: #007bff; text-decoration: none; margin-right: 20px"
>Settings</a
>
<a
href="{{ url_for('auth.logout') }}"
style="color: #007bff; text-decoration: none"
>Logout</a
>
</nav>

View File

@@ -0,0 +1,68 @@
{% extends "_base.html" %} {% block title %}Admin Dashboard{% endblock %} {%
block heading %}Admin Dashboard{% endblock %} {% block extra_styles %}
<link rel="stylesheet" href="/static/css/admin.css" /> {% endblock %} {% block
content %}
<div id="message"></div>
<div class="stats">
<div class="stat-card">
<h3 id="contact-count">--</h3>
<p>Contact Submissions</p>
</div>
<div class="stat-card">
<h3 id="newsletter-count">--</h3>
<p>Newsletter Subscribers</p>
</div>
<div class="stat-card">
<h3 id="settings-count">--</h3>
<p>App Settings</p>
</div>
</div>
<div class="dashboard-grid">
<div class="dashboard-card">
<h2>Contact Form Submissions</h2>
<p>View and manage contact form submissions from your website visitors.</p>
<a href="/admin/submissions">Manage Submissions</a>
</div>
<div class="dashboard-card">
<h2>Newsletter Subscribers</h2>
<p>
Manage newsletter subscriptions and send newsletters to your subscribers.
</p>
<a href="/admin/newsletter">Manage Subscribers</a>
</div>
<div class="dashboard-card">
<h2>Application Settings</h2>
<p>Configure application settings and environment variables.</p>
<a href="/admin/settings">Manage Settings</a>
</div>
<div class="dashboard-card">
<h2>Create Newsletter</h2>
<p>Create and send newsletters to your subscribers.</p>
<a href="/admin/newsletter/create">Create Newsletter</a>
</div>
<div class="dashboard-card">
<h2>Embeddable Forms</h2>
<p>
Instructions for embedding contact and newsletter forms on other websites.
</p>
<a href="/admin/embeds">View Embed Codes</a>
</div>
</div>
<div class="logout">
<a href="/auth/logout">Logout</a>
</div>
{% endblock %}{% block extra_scripts %}
<script src="/static/js/admin.js"></script>
<script>
// Load stats when page loads
if (typeof window.admin.loadDashboardStats === "function") {
window.admin.loadDashboardStats();
}
</script>
{% endblock %}

View File

@@ -0,0 +1,21 @@
{% extends "_base.html" %} {% block title %}Email Templates{% endblock %} {%
block heading %}Email Templates{% endblock %} {% block extra_styles %}
<link rel="stylesheet" href="/static/css/admin.css" /> {% endblock %} {% block
content %}
<div class="settings-management">
<h2>Newsletter Confirmation Template</h2>
<p>Edit the HTML template used for the newsletter confirmation email.</p>
<div id="message"></div>
<form id="templateForm">
<div class="form-group">
<label for="newsletterTemplate">Template HTML</label>
<textarea id="newsletterTemplate" rows="15" cols="80"></textarea>
</div>
<div class="form-group">
<button class="btn btn-primary" type="submit">Save Template</button>
</div>
</form>
</div>
{% endblock %} {% block extra_scripts %}
<script src="/static/js/admin.js"></script>
{% endblock %}

View File

@@ -0,0 +1,93 @@
{% extends "_base.html" %} {% block title %}Embeddable Forms{% endblock %} {%
block heading %}Embeddable Forms{% endblock %} {% block extra_styles %}
<link rel="stylesheet" href="/static/css/admin.css" /> {% endblock %} {% block
content %}
<div class="settings-management">
<div id="contactForm" style="display: flex; justify-content: space-between">
<div id="contactFormSettings" style="min-height: 300px">
<h2>Contact Form</h2>
<p>
Use the following HTML code to embed the contact form on other websites:
</p>
<div class="embed-config">
<label
>Width: <input id="contactWidth" type="text" value="400"
/></label>
<label
>Height: <input id="contactHeight" type="text" value="600"
/></label>
<button
class="btn btn-primary"
onclick="saveEmbedSetting('embed_contact_width','contactWidth')"
>
Save
</button>
<button
class="btn btn-primary"
onclick="saveEmbedSetting('embed_contact_height','contactHeight')"
>
Save
</button>
</div>
<textarea id="iframeCode" class="iframe-code" readonly></textarea>
<button class="btn btn-secondary" onclick="copyIframeCode()">
Copy Contact Iframe
</button>
</div>
<div id="contactFormPreview"></div>
</div>
</div>
<div class="settings-management">
<div
id="newsletterForm"
style="display: flex; justify-content: space-between"
>
<div id="newsletterFormSettings" style="min-height: 400px">
<h2>Newsletter Subscription Form</h2>
<p>
Use the following HTML code to embed the newsletter subscription form on
other websites:
</p>
<div class="embed-config">
<label
>Width: <input id="newsletterWidth" type="text" value="600"
/></label>
<label
>Height: <input id="newsletterHeight" type="text" value="300"
/></label>
<button
class="btn btn-primary"
onclick="saveEmbedSetting('embed_newsletter_width','newsletterWidth')"
>
Save
</button>
<button
class="btn btn-primary"
onclick="saveEmbedSetting('embed_newsletter_height','newsletterHeight')"
>
Save
</button>
</div>
<textarea
id="iframeNewsletterCode"
class="iframe-code"
readonly
></textarea>
<button class="btn btn-secondary" onclick="copyNewsletterIframeCode()">
Copy Newsletter Iframe
</button>
</div>
<div id="newsletterFormPreview"></div>
</div>
</div>
<div class="settings-management">
<p>
Replace <code>http://your-server-domain</code> with your actual server
domain and port (e.g., https://yourdomain.com).
</p>
</div>
{% endblock %} {% block extra_scripts %}
<script src="/static/js/admin.js"></script>
{% endblock %}
</div>

View File

@@ -0,0 +1,42 @@
{% extends "_base.html" %} {% block title %}Newsletter Subscribers{% endblock %}
{% block heading %}Newsletter Subscribers{% endblock %} {% block extra_styles %}
<link rel="stylesheet" href="/static/css/admin.css" /> {% endblock %} {% block
content %}
<div id="message"></div>
<div class="filters">
<input type="text" id="emailFilter" placeholder="Filter by email..." />
<select id="sortBy">
<option value="subscribed_at">Sort by Date</option>
<option value="email">Sort by Email</option>
</select>
<select id="sortOrder">
<option value="desc">Newest First</option>
<option value="asc">Oldest First</option>
</select>
<button onclick="applyFilters()">Apply Filters</button>
<button onclick="clearFilters()">Clear</button>
</div>
<div id="loading" class="loading">Loading subscribers...</div>
<table id="subscribersTable" class="subscribers-table" style="display: none">
<thead>
<tr>
<th>Email</th>
<th>Subscribed Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="subscribersBody"></tbody>
</table>
<div id="pagination" class="pagination" style="display: none">
<button id="prevBtn" onclick="changePage(currentPage - 1)">Previous</button>
<span id="pageInfo"></span>
<button id="nextBtn" onclick="changePage(currentPage + 1)">Next</button>
</div>
{% endblock %} {% block extra_scripts %}
<script src="/static/js/admin.js"></script>
{% endblock %}

View File

@@ -0,0 +1,98 @@
{% extends "_base.html" %} {% block title %}Create Newsletter{% endblock %} {%
block heading %}Create Newsletter{% endblock %} {% block extra_styles %}
<link rel="stylesheet" href="/static/css/admin.css" /> {% endblock %} {% block
content %}
<div id="message"></div>
<div class="newsletter-stats">
<div class="stat-card">
<h4>Total Subscribers</h4>
<div class="number" id="totalSubscribers">--</div>
</div>
<div class="stat-card">
<h4>Active Subscribers</h4>
<div class="number" id="activeSubscribers">--</div>
</div>
<div class="stat-card">
<h4>Last Sent</h4>
<div class="number" id="lastSent">--</div>
</div>
</div>
<form id="newsletterForm">
<div class="form-section">
<h2>Newsletter Details</h2>
<div class="form-row">
<div class="form-group">
<label for="subject">Subject Line *</label>
<input
type="text"
id="subject"
name="subject"
required
placeholder="Enter newsletter subject"
/>
</div>
<div class="form-group">
<label for="senderName">Sender Name</label>
<input
type="text"
id="senderName"
name="sender_name"
placeholder="Your Name"
/>
</div>
</div>
<div class="form-group">
<label for="content">Content *</label>
<textarea
id="content"
name="content"
required
placeholder="Write your newsletter content here..."
></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="sendDate">Send Date (optional)</label>
<input type="datetime-local" id="sendDate" name="send_date" />
</div>
<div class="form-group">
<label for="status">Status</label>
<select id="status" name="status">
<option value="draft">Draft</option>
<option value="scheduled">Scheduled</option>
<option value="sent">Sent</option>
</select>
</div>
</div>
</div>
<div class="form-section">
<h2>Actions</h2>
<button
type="button"
class="btn btn-secondary"
onclick="previewNewsletter()"
>
Preview
</button>
<button type="button" class="btn btn-primary" onclick="saveDraft()">
Save Draft
</button>
<button type="button" class="btn btn-success" onclick="sendNewsletter()">
Send Newsletter
</button>
<button type="button" class="btn btn-danger" onclick="clearForm()">
Clear
</button>
</div>
</form>
<div id="previewSection" class="newsletter-preview hidden">
<h3>Newsletter Preview</h3>
<div id="previewContent"></div>
</div>
{% endblock %}{% block extra_scripts %}
<script src="/static/js/admin.js"></script>
{% endblock %}

View File

@@ -0,0 +1,59 @@
{% extends "_base.html" %} {% block title %}Settings{% endblock %} {% block
heading %}Application Settings{% endblock %} {% block extra_styles %}
<link rel="stylesheet" href="/static/css/admin.css" />
{% endblock %} {% block content %}
<div class="settings-cards">
{% for category, category_settings in settings.items() %}
<div class="settings-card">
<h2 class="card-title">{{ category }}</h2>
<div class="card-body">
{% for key, value in category_settings.items() %}
<div class="setting"><strong>{{ key }}:</strong> {{ value }}</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
<div class="settings-management">
<h2>Dynamic Settings Management</h2>
<div id="message"></div>
<div class="settings-list" id="settingsList">
<p>Loading settings...</p>
</div>
<h3>Add New Setting</h3>
<form id="addSettingForm">
<div class="form-row">
<div class="form-group">
<label for="newKey">Setting Key:</label>
<input
type="text"
id="newKey"
name="key"
required
placeholder="e.g., maintenance_mode"
/>
</div>
<div class="form-group">
<label for="newValue">Setting Value:</label>
<input
type="text"
id="newValue"
name="value"
required
placeholder="e.g., false"
/>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Add Setting</button>
</div>
</div>
</form>
</div>
{% endblock %} {% block extra_scripts %}
<script src="/static/js/admin.js"></script>
{% endblock %}

View File

@@ -0,0 +1,80 @@
{% extends "_base.html" %} {% block title %}Contact Submissions{% endblock %} {%
block heading %}Contact Form Submissions{% endblock %} {% block extra_styles %}
<link rel="stylesheet" href="/static/css/admin.css" />
<style>
.filters {
background: #f8f9fa;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.filters form {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: end;
}
</style>
{% endblock %} {% block content %}
<div id="message"></div>
<div class="filters">
<form id="filterForm">
<div>
<label for="email">Email Filter:</label>
<input
type="text"
id="email"
name="email"
placeholder="Filter by email"
/>
</div>
<div>
<label for="date_from">Date From:</label>
<input type="date" id="date_from" name="date_from" />
</div>
<div>
<label for="date_to">Date To:</label>
<input type="date" id="date_to" name="date_to" />
</div>
<div>
<label for="per_page">Items per page:</label>
<select id="per_page" name="per_page">
<option value="25">25</option>
<option value="50" selected>50</option>
<option value="100">100</option>
</select>
</div>
<button type="submit">Apply Filters</button>
<button type="button" class="clear-btn" onclick="clearFilters()">
Clear
</button>
</form>
</div>
<div id="loading" class="loading" style="display: none">Loading...</div>
<table id="submissionsTable">
<thead>
<tr>
<th data-sort="id">ID</th>
<th data-sort="name">Name</th>
<th data-sort="email">Email</th>
<th data-sort="company">Company</th>
<th>Message</th>
<th data-sort="created_at">Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="submissionsBody">
<tr>
<td colspan="7" class="no-data">Loading submissions...</td>
</tr>
</tbody>
</table>
<div class="pagination" id="pagination"></div>
{% endblock %} {% block extra_scripts %}
<script src="/static/js/admin.js"></script>
{% endblock %}

View File

@@ -1,119 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin Dashboard</title>
<link rel="stylesheet" href="/static/css/styles.css" />
<style>
body {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
</style>
</head>
<body>
<h1>Admin Dashboard</h1>
<div class="stats">
<div class="stat-card">
<h3 id="contact-count">--</h3>
<p>Contact Submissions</p>
</div>
<div class="stat-card">
<h3 id="newsletter-count">--</h3>
<p>Newsletter Subscribers</p>
</div>
<div class="stat-card">
<h3 id="settings-count">--</h3>
<p>App Settings</p>
</div>
</div>
<div class="dashboard-grid">
<div class="dashboard-card">
<h2>Contact Form Submissions</h2>
<p>
View and manage contact form submissions from your website visitors.
</p>
<a href="/admin/submissions">Manage Submissions</a>
</div>
<div class="dashboard-card">
<h2>Newsletter Subscribers</h2>
<p>
Manage newsletter subscriptions and send newsletters to your
subscribers.
</p>
<a href="/admin/newsletter">Manage Subscribers</a>
</div>
<div class="dashboard-card">
<h2>Application Settings</h2>
<p>Configure application settings and environment variables.</p>
<a href="/admin/settings">Manage Settings</a>
</div>
<div class="dashboard-card">
<h2>Create Newsletter</h2>
<p>Create and send newsletters to your subscribers.</p>
<a href="/admin/newsletter/create">Create Newsletter</a>
</div>
<div class="dashboard-card">
<h2>Embeddable Forms</h2>
<p>
Instructions for embedding contact and newsletter forms on other
websites.
</p>
<a href="/admin/embeds">View Embed Codes</a>
</div>
</div>
<div class="logout">
<a href="/auth/logout">Logout</a>
</div>
<script>
// Load dashboard statistics
async function loadStats() {
try {
// Load contact submissions count
const contactResponse = await fetch(
"/admin/api/contact?page=1&per_page=1"
);
if (contactResponse.ok) {
const contactData = await contactResponse.json();
document.getElementById("contact-count").textContent =
contactData.pagination.total;
}
// Load newsletter subscribers count
const newsletterResponse = await fetch(
"/admin/api/newsletter?page=1&per_page=1"
);
if (newsletterResponse.ok) {
const newsletterData = await newsletterResponse.json();
document.getElementById("newsletter-count").textContent =
newsletterData.pagination.total;
}
// Load settings count
const settingsResponse = await fetch("/admin/api/settings");
if (settingsResponse.ok) {
const settingsData = await settingsResponse.json();
document.getElementById("settings-count").textContent = Object.keys(
settingsData.settings
).length;
}
} catch (error) {
console.error("Failed to load dashboard stats:", error);
}
}
// Load stats when page loads
loadStats();
</script>
</body>
</html>

View File

@@ -1,112 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Embeddable Forms</title>
<link rel="stylesheet" href="/static/css/styles.css" />
<style>
.iframe-code {
width: 100%;
height: 100px;
font-family: monospace;
padding: 10px;
border: 1px solid #ccc;
border-radius: 3px;
}
</style>
</head>
<body>
<div class="logout">
<a
href="/admin/"
style="color: #007bff; text-decoration: none; margin-right: 20px"
>Dashboard</a
>
<a
href="/admin/submissions"
style="color: #007bff; text-decoration: none; margin-right: 20px"
>View Submissions</a
>
<a
href="/admin/settings"
style="color: #007bff; text-decoration: none; margin-right: 20px"
>Settings</a
>
<a
href="{{ url_for('auth.logout') }}"
style="color: #007bff; text-decoration: none"
>Logout</a
>
</div>
<h1>Embeddable Forms</h1>
<div class="settings-management">
<h2>Contact Form</h2>
<p>
Use the following HTML code to embed the contact form on other websites:
</p>
<textarea id="iframeCode" class="iframe-code" readonly>
<iframe src="http://your-server-domain/embed/contact" width="600" height="400" frameborder="0" allowfullscreen></iframe>
</textarea>
<button class="btn btn-secondary" onclick="copyIframeCode()">
Copy Contact Iframe
</button>
</div>
<div class="settings-management">
<h2>Newsletter Subscription Form</h2>
<p>
Use the following HTML code to embed the newsletter subscription form on
other websites:
</p>
<textarea id="iframeNewsletterCode" class="iframe-code" readonly>
<iframe src="http://your-server-domain/embed/newsletter" width="600" height="300" frameborder="0" allowfullscreen></iframe>
</textarea>
<button class="btn btn-secondary" onclick="copyNewsletterIframeCode()">
Copy Newsletter Iframe
</button>
</div>
<div class="settings-management">
<p>
Replace <code>http://your-server-domain</code> with your actual server
domain and port (e.g., https://yourdomain.com).
</p>
</div>
<script>
function copyIframeCode() {
const textarea = document.getElementById("iframeCode");
textarea.select();
document.execCommand("copy");
showMessage("Contact iframe code copied to clipboard!", "success");
}
function copyNewsletterIframeCode() {
const textarea = document.getElementById("iframeNewsletterCode");
textarea.select();
document.execCommand("copy");
showMessage("Newsletter iframe code copied to clipboard!", "success");
}
function showMessage(text, type) {
// Create message div if it doesn't exist
let messageDiv = document.getElementById("message");
if (!messageDiv) {
messageDiv = document.createElement("div");
messageDiv.id = "message";
document.body.insertBefore(messageDiv, document.body.firstChild);
}
messageDiv.className = `message ${type}`;
messageDiv.textContent = text;
messageDiv.style.display = "block";
setTimeout(() => {
messageDiv.style.display = "none";
}, 5000);
}
</script>
</body>
</html>

View File

@@ -1,362 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Newsletter Subscribers</title>
<link rel="stylesheet" href="/static/css/styles.css" />
<style>
body {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
h1 {
color: #333;
margin-bottom: 20px;
}
.nav {
margin-bottom: 20px;
padding: 10px;
background-color: #f8f9fa;
border-radius: 5px;
}
.nav a {
margin-right: 15px;
color: #007bff;
text-decoration: none;
}
.nav a:hover {
text-decoration: underline;
}
.filters {
margin-bottom: 20px;
padding: 15px;
background-color: #f8f9fa;
border-radius: 5px;
display: flex;
gap: 15px;
align-items: center;
flex-wrap: wrap;
}
.filters input,
.filters select {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
min-width: 150px;
}
.filters button {
padding: 8px 16px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.filters button:hover {
background-color: #0056b3;
}
.subscribers-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
.subscribers-table th,
.subscribers-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
.subscribers-table th {
background-color: #f8f9fa;
font-weight: bold;
}
.subscribers-table tr:hover {
background-color: #f5f5f5;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
margin-top: 20px;
}
.pagination button {
padding: 8px 12px;
border: 1px solid #ddd;
background-color: white;
cursor: pointer;
border-radius: 4px;
}
.pagination button:hover {
background-color: #f8f9fa;
}
.pagination button:disabled {
background-color: #e9ecef;
cursor: not-allowed;
}
.pagination .current-page {
font-weight: bold;
color: #007bff;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
.message {
padding: 10px;
margin-bottom: 20px;
border-radius: 4px;
}
.message.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.actions {
display: flex;
gap: 5px;
}
.btn {
padding: 4px 8px;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
}
.btn-danger {
background-color: #dc3545;
color: white;
}
.btn-danger:hover {
background-color: #c82333;
}
</style>
</head>
<body>
<div class="nav">
<a href="/admin/">Dashboard</a>
<a href="/admin/submissions">Contact Submissions</a>
<a href="/admin/settings">Settings</a>
<a href="/auth/logout">Logout</a>
</div>
<h1>Newsletter Subscribers</h1>
<div id="message"></div>
<div class="filters">
<input type="text" id="emailFilter" placeholder="Filter by email..." />
<select id="sortBy">
<option value="subscribed_at">Sort by Date</option>
<option value="email">Sort by Email</option>
</select>
<select id="sortOrder">
<option value="desc">Newest First</option>
<option value="asc">Oldest First</option>
</select>
<button onclick="applyFilters()">Apply Filters</button>
<button onclick="clearFilters()">Clear</button>
</div>
<div id="loading" class="loading">Loading subscribers...</div>
<table
id="subscribersTable"
class="subscribers-table"
style="display: none"
>
<thead>
<tr>
<th>Email</th>
<th>Subscribed Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="subscribersBody"></tbody>
</table>
<div id="pagination" class="pagination" style="display: none">
<button id="prevBtn" onclick="changePage(currentPage - 1)">
Previous
</button>
<span id="pageInfo"></span>
<button id="nextBtn" onclick="changePage(currentPage + 1)">Next</button>
</div>
<script>
let currentPage = 1;
let currentFilters = {
email: "",
sort_by: "subscribed_at",
sort_order: "desc",
};
// Load subscribers on page load
document.addEventListener("DOMContentLoaded", function () {
loadSubscribers();
});
function applyFilters() {
currentFilters.email = document
.getElementById("emailFilter")
.value.trim();
currentFilters.sort_by = document.getElementById("sortBy").value;
currentFilters.sort_order = document.getElementById("sortOrder").value;
currentPage = 1;
loadSubscribers();
}
function clearFilters() {
document.getElementById("emailFilter").value = "";
document.getElementById("sortBy").value = "subscribed_at";
document.getElementById("sortOrder").value = "desc";
currentFilters = {
email: "",
sort_by: "subscribed_at",
sort_order: "desc",
};
currentPage = 1;
loadSubscribers();
}
function loadSubscribers() {
document.getElementById("loading").style.display = "block";
document.getElementById("subscribersTable").style.display = "none";
document.getElementById("pagination").style.display = "none";
const params = new URLSearchParams({
page: currentPage,
per_page: 50,
sort_by: currentFilters.sort_by,
sort_order: currentFilters.sort_order,
});
if (currentFilters.email) {
params.append("email", currentFilters.email);
}
fetch(`/admin/api/newsletter?${params}`)
.then((response) => response.json())
.then((data) => {
if (data.status === "ok") {
displaySubscribers(data.subscribers);
updatePagination(data.pagination);
} else {
showMessage(
"Error loading subscribers: " +
(data.message || "Unknown error"),
"error"
);
}
})
.catch((error) => {
console.error("Error:", error);
showMessage("Error loading subscribers", "error");
})
.finally(() => {
document.getElementById("loading").style.display = "none";
});
}
function displaySubscribers(subscribers) {
const tbody = document.getElementById("subscribersBody");
tbody.innerHTML = "";
if (subscribers.length === 0) {
tbody.innerHTML =
'<tr><td colspan="3" style="text-align: center; padding: 40px; color: #666;">No subscribers found</td></tr>';
} else {
subscribers.forEach((subscriber) => {
const row = document.createElement("tr");
row.innerHTML = `
<td>${escapeHtml(subscriber.email)}</td>
<td>${new Date(
subscriber.subscribed_at
).toLocaleDateString()}</td>
<td class="actions">
<button class="btn btn-danger" onclick="unsubscribe('${escapeHtml(
subscriber.email
)}')">Unsubscribe</button>
</td>
`;
tbody.appendChild(row);
});
}
document.getElementById("subscribersTable").style.display = "table";
}
function updatePagination(pagination) {
const pageInfo = document.getElementById("pageInfo");
const prevBtn = document.getElementById("prevBtn");
const nextBtn = document.getElementById("nextBtn");
pageInfo.textContent = `Page ${pagination.page} of ${pagination.pages} (${pagination.total} total)`;
prevBtn.disabled = pagination.page <= 1;
nextBtn.disabled = pagination.page >= pagination.pages;
document.getElementById("pagination").style.display = "flex";
}
function changePage(page) {
currentPage = page;
loadSubscribers();
}
function unsubscribe(email) {
if (!confirm(`Are you sure you want to unsubscribe ${email}?`)) {
return;
}
fetch("/api/newsletter", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: email }),
})
.then((response) => response.json())
.then((data) => {
if (data.status === "ok") {
showMessage("Subscriber unsubscribed successfully", "success");
loadSubscribers(); // Reload the list
} else {
showMessage(
"Error unsubscribing: " + (data.message || "Unknown error"),
"error"
);
}
})
.catch((error) => {
console.error("Error:", error);
showMessage("Error unsubscribing subscriber", "error");
});
}
function showMessage(text, type) {
const messageDiv = document.getElementById("message");
messageDiv.className = `message ${type}`;
messageDiv.textContent = text;
messageDiv.style.display = "block";
setTimeout(() => {
messageDiv.style.display = "none";
}, 5000);
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
</script>
</body>
</html>

View File

@@ -1,458 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Create Newsletter</title>
<link rel="stylesheet" href="/static/css/styles.css" />
<style>
body {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
h1 {
color: #333;
margin-bottom: 20px;
}
.nav {
margin-bottom: 20px;
padding: 10px;
background-color: #f8f9fa;
border-radius: 5px;
}
.nav a {
margin-right: 15px;
color: #007bff;
text-decoration: none;
}
.nav a:hover {
text-decoration: underline;
}
.form-section {
margin-bottom: 30px;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
background-color: #f9f9f9;
}
.form-section h2 {
margin-top: 0;
color: #555;
border-bottom: 1px solid #ddd;
padding-bottom: 10px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
color: #333;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.form-group textarea {
min-height: 200px;
resize: vertical;
}
.form-row {
display: flex;
gap: 15px;
margin-bottom: 15px;
}
.form-row .form-group {
flex: 1;
margin-bottom: 0;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin-right: 10px;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:hover {
background-color: #0056b3;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #545b62;
}
.btn-success {
background-color: #28a745;
color: white;
}
.btn-success:hover {
background-color: #1e7e34;
}
.btn-danger {
background-color: #dc3545;
color: white;
}
.btn-danger:hover {
background-color: #c82333;
}
.message {
padding: 10px;
margin-bottom: 20px;
border-radius: 4px;
}
.message.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.message.info {
background-color: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.newsletter-preview {
margin-top: 20px;
padding: 20px;
background-color: white;
border: 1px solid #ddd;
border-radius: 4px;
}
.newsletter-preview h3 {
margin-top: 0;
color: #555;
}
.newsletter-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.stat-card {
background-color: white;
padding: 15px;
border-radius: 8px;
border: 1px solid #ddd;
text-align: center;
}
.stat-card h4 {
margin: 0 0 10px 0;
color: #666;
font-size: 14px;
}
.stat-card .number {
font-size: 24px;
font-weight: bold;
color: #333;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
.hidden {
display: none;
}
</style>
</head>
<body>
<div class="nav">
<a href="/admin/">Dashboard</a>
<a href="/admin/newsletter">Subscribers</a>
<a href="/admin/settings">Settings</a>
<a href="/auth/logout">Logout</a>
</div>
<h1>Create Newsletter</h1>
<div id="message"></div>
<div class="newsletter-stats">
<div class="stat-card">
<h4>Total Subscribers</h4>
<div class="number" id="totalSubscribers">--</div>
</div>
<div class="stat-card">
<h4>Active Subscribers</h4>
<div class="number" id="activeSubscribers">--</div>
</div>
<div class="stat-card">
<h4>Last Sent</h4>
<div class="number" id="lastSent">--</div>
</div>
</div>
<form id="newsletterForm">
<div class="form-section">
<h2>Newsletter Details</h2>
<div class="form-row">
<div class="form-group">
<label for="subject">Subject Line *</label>
<input
type="text"
id="subject"
name="subject"
required
placeholder="Enter newsletter subject"
/>
</div>
<div class="form-group">
<label for="senderName">Sender Name</label>
<input
type="text"
id="senderName"
name="sender_name"
placeholder="Your Name"
/>
</div>
</div>
<div class="form-group">
<label for="content">Content *</label>
<textarea
id="content"
name="content"
required
placeholder="Write your newsletter content here..."
></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="sendDate">Send Date (optional)</label>
<input type="datetime-local" id="sendDate" name="send_date" />
</div>
<div class="form-group">
<label for="status">Status</label>
<select id="status" name="status">
<option value="draft">Draft</option>
<option value="scheduled">Scheduled</option>
<option value="sent">Sent</option>
</select>
</div>
</div>
</div>
<div class="form-section">
<h2>Actions</h2>
<button
type="button"
class="btn btn-secondary"
onclick="previewNewsletter()"
>
Preview
</button>
<button type="button" class="btn btn-primary" onclick="saveDraft()">
Save Draft
</button>
<button
type="button"
class="btn btn-success"
onclick="sendNewsletter()"
>
Send Newsletter
</button>
<button type="button" class="btn btn-danger" onclick="clearForm()">
Clear
</button>
</div>
</form>
<div id="previewSection" class="newsletter-preview hidden">
<h3>Newsletter Preview</h3>
<div id="previewContent"></div>
</div>
<script>
let newsletterStats = {};
// Load newsletter stats on page load
document.addEventListener("DOMContentLoaded", function () {
loadNewsletterStats();
});
function loadNewsletterStats() {
// Load subscriber count
fetch("/admin/api/newsletter?page=1&per_page=1")
.then((response) => response.json())
.then((data) => {
if (data.status === "ok") {
document.getElementById("totalSubscribers").textContent =
data.pagination.total;
document.getElementById("activeSubscribers").textContent =
data.pagination.total; // For now, assume all are active
newsletterStats.totalSubscribers = data.pagination.total;
}
})
.catch((error) => {
console.error("Error loading subscriber stats:", error);
});
// For now, set last sent as N/A
document.getElementById("lastSent").textContent = "N/A";
}
function previewNewsletter() {
const subject = document.getElementById("subject").value.trim();
const content = document.getElementById("content").value.trim();
const senderName = document.getElementById("senderName").value.trim();
if (!subject || !content) {
showMessage("Subject and content are required for preview.", "error");
return;
}
const previewContent = document.getElementById("previewContent");
previewContent.innerHTML = `
<h2>${escapeHtml(subject)}</h2>
${
senderName
? `<p><strong>From:</strong> ${escapeHtml(senderName)}</p>`
: ""
}
<div style="margin-top: 20px; line-height: 1.6;">
${content.replace(/\n/g, "<br>")}
</div>
`;
document.getElementById("previewSection").classList.remove("hidden");
showMessage("Newsletter preview generated.", "info");
}
function saveDraft() {
const formData = new FormData(document.getElementById('newsletterForm'));
const newsletterData = {
subject: formData.get('subject'),
content: formData.get('content'),
sender_name: formData.get('sender_name'),
send_date: formData.get('send_date'),
status: 'draft'
};
if (!newsletterData.subject || !newsletterData.content) {
showMessage('Subject and content are required.', 'error');
return;
}
fetch('/admin/api/newsletters', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newsletterData)
})
.then(response => response.json())
.then(data => {
if (data.status === 'ok') {
showMessage('Newsletter draft saved successfully!', 'success');
} else {
showMessage(data.message || 'Failed to save draft.', 'error');
}
})
.catch(error => {
console.error('Error saving draft:', error);
showMessage('Failed to save draft.', 'error');
});
}
function sendNewsletter() {
const formData = new FormData(document.getElementById('newsletterForm'));
const newsletterData = {
subject: formData.get('subject'),
content: formData.get('content'),
sender_name: formData.get('sender_name'),
send_date: formData.get('send_date'),
status: 'sent'
};
if (!newsletterData.subject || !newsletterData.content) {
showMessage('Subject and content are required.', 'error');
return;
}
if (!confirm(`Are you sure you want to send this newsletter to ${newsletterStats.totalSubscribers || 0} subscribers?`)) {
return;
}
// First save the newsletter
fetch('/admin/api/newsletters', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newsletterData)
})
.then(response => response.json())
.then(data => {
if (data.status === 'ok') {
const newsletterId = data.newsletter_id;
// Now send it
return fetch(`/admin/api/newsletters/${newsletterId}/send`, {
method: 'POST'
});
} else {
throw new Error(data.message || 'Failed to save newsletter.');
}
})
.then(response => response.json())
.then(data => {
if (data.status === 'ok') {
showMessage(`Newsletter sent successfully to ${data.sent_count} subscribers!`, 'success');
} else {
showMessage(data.message || 'Failed to send newsletter.', 'error');
}
})
.catch(error => {
console.error('Error sending newsletter:', error);
showMessage('Failed to send newsletter.', 'error');
});
}
function clearForm() {
if (
confirm(
"Are you sure you want to clear the form? All unsaved changes will be lost."
)
) {
document.getElementById("newsletterForm").reset();
document.getElementById("previewSection").classList.add("hidden");
showMessage("Form cleared.", "info");
}
}
function showMessage(text, type) {
const messageDiv = document.getElementById("message");
messageDiv.className = `message ${type}`;
messageDiv.textContent = text;
messageDiv.style.display = "block";
setTimeout(() => {
messageDiv.style.display = "none";
}, 5000);
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
</script>
</body>
</html>

View File

@@ -1,337 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin Settings</title>
<link rel="stylesheet" href="/static/css/styles.css" />
<style>
.setting strong {
display: inline-block;
width: 200px;
}
.iframe-code {
width: 100%;
height: 100px;
font-family: monospace;
padding: 10px;
border: 1px solid #ccc;
border-radius: 3px;
}
</style>
</head>
<body>
<div class="logout">
<a
href="/admin/"
style="color: #007bff; text-decoration: none; margin-right: 20px"
>Dashboard</a
>
<a
href="/admin/submissions"
style="color: #007bff; text-decoration: none; margin-right: 20px"
>View Submissions</a
>
<a
href="/admin/embeds"
style="color: #007bff; text-decoration: none; margin-right: 20px"
>Embeds</a
>
<a
href="{{ url_for('auth.logout') }}"
style="color: #007bff; text-decoration: none"
>Logout</a
>
</div>
<h1>Application Settings</h1>
{% for category, category_settings in settings.items() %}
<div class="setting-group">
<h2>{{ category }}</h2>
{% for key, value in category_settings.items() %}
<div class="setting"><strong>{{ key }}:</strong> {{ value }}</div>
{% endfor %}
</div>
{% endfor %}
<div class="settings-management">
<h2>Dynamic Settings Management</h2>
<div id="message"></div>
<div class="settings-list" id="settingsList">
<p>Loading settings...</p>
</div>
<h3>Add New Setting</h3>
<form id="addSettingForm">
<div class="form-row">
<div class="form-group">
<label for="newKey">Setting Key:</label>
<input
type="text"
id="newKey"
name="key"
required
placeholder="e.g., maintenance_mode"
/>
</div>
<div class="form-group">
<label for="newValue">Setting Value:</label>
<input
type="text"
id="newValue"
name="value"
required
placeholder="e.g., false"
/>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Add Setting</button>
</div>
</div>
</form>
</div>
<div class="settings-management">
<h2>Embeddable Forms</h2>
<p>
Manage embeddable forms on the <a href="/admin/embeds">Embeds page</a>.
</p>
</div>
<div class="settings-management">
<h2>Email Templates</h2>
<div id="emailTemplates">
<p>Loading email templates...</p>
</div>
</div>
<script>
let appSettings = {};
// Load settings on page load
document.addEventListener("DOMContentLoaded", function () {
loadSettings();
});
// Handle add setting form
document
.getElementById("addSettingForm")
.addEventListener("submit", function (e) {
e.preventDefault();
const formData = new FormData(this);
const key = formData.get("key").trim();
const value = formData.get("value").trim();
if (!key || !value) {
showMessage("Both key and value are required.", "error");
return;
}
addSetting(key, value);
});
function loadSettings() {
fetch("/admin/api/settings")
.then((response) => response.json())
.then((data) => {
if (data.status === "ok") {
appSettings = data.settings;
displaySettings();
} else {
showMessage(
"Error loading settings: " + (data.message || "Unknown error"),
"error"
);
}
})
.catch((error) => {
console.error("Error:", error);
showMessage("Error loading settings", "error");
});
}
function displaySettings() {
const container = document.getElementById("settingsList");
if (Object.keys(appSettings).length === 0) {
container.innerHTML = "<p>No dynamic settings configured.</p>";
return;
}
const settingsHtml = Object.entries(appSettings)
.map(([key, value]) => {
const isTemplate = key === "newsletter_confirmation_template";
const inputType = isTemplate ? "textarea" : "input";
const inputAttrs = isTemplate
? 'rows="10" cols="50"'
: 'type="text"';
return `
<div class="setting-item">
<div class="setting-info">
<strong>${escapeHtml(key)}:</strong> ${
isTemplate
? "<em>HTML template</em>"
: escapeHtml(value.substring(0, 50)) +
(value.length > 50 ? "..." : "")
}
</div>
<div class="setting-actions">
<button class="btn btn-secondary" onclick="editSetting('${escapeHtml(
key
)}')">Edit</button>
<button class="btn btn-danger" onclick="deleteSetting('${escapeHtml(
key
)}')">Delete</button>
</div>
</div>
<div class="edit-form" id="edit-${escapeHtml(key)}">
<div class="form-row">
<div class="form-group">
<label>Key:</label>
<input type="text" value="${escapeHtml(key)}" readonly>
</div>
<div class="form-group">
<label>New Value:</label>
<${inputType} id="edit-value-${escapeHtml(
key
)}" ${inputAttrs} required>${escapeHtml(value)}</${inputType}>
</div>
<div class="form-group">
<button class="btn btn-primary" onclick="updateSetting('${escapeHtml(
key
)}')">Update</button>
<button class="btn btn-secondary" onclick="cancelEdit('${escapeHtml(
key
)}')">Cancel</button>
</div>
</div>
</div>
`;
})
.join("");
container.innerHTML = settingsHtml;
}
function addSetting(key, value) {
fetch(`/admin/api/settings/${encodeURIComponent(key)}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ value: value }),
})
.then((response) => response.json())
.then((data) => {
if (data.status === "ok") {
appSettings[key] = value;
displaySettings();
document.getElementById("addSettingForm").reset();
showMessage("Setting added successfully!", "success");
} else {
showMessage(
"Error adding setting: " + (data.message || "Unknown error"),
"error"
);
}
})
.catch((error) => {
console.error("Error:", error);
showMessage("Error adding setting", "error");
});
}
function editSetting(key) {
document.getElementById(`edit-${key}`).style.display = "block";
}
function cancelEdit(key) {
document.getElementById(`edit-${key}`).style.display = "none";
}
function updateSetting(key) {
const newValue = document
.getElementById(`edit-value-${key}`)
.value.trim();
if (!newValue) {
showMessage("Value cannot be empty.", "error");
return;
}
fetch(`/admin/api/settings/${encodeURIComponent(key)}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ value: newValue }),
})
.then((response) => response.json())
.then((data) => {
if (data.status === "ok") {
appSettings[key] = newValue;
displaySettings();
cancelEdit(key);
showMessage("Setting updated successfully!", "success");
} else {
showMessage(
"Error updating setting: " + (data.message || "Unknown error"),
"error"
);
}
})
.catch((error) => {
console.error("Error:", error);
showMessage("Error updating setting", "error");
});
}
function deleteSetting(key) {
if (!confirm(`Are you sure you want to delete the setting "${key}"?`)) {
return;
}
fetch(`/admin/api/settings/${encodeURIComponent(key)}`, {
method: "DELETE",
})
.then((response) => response.json())
.then((data) => {
if (data.status === "ok") {
delete appSettings[key];
displaySettings();
showMessage("Setting deleted successfully!", "success");
} else {
showMessage(
"Error deleting setting: " + (data.message || "Unknown error"),
"error"
);
}
})
.catch((error) => {
console.error("Error:", error);
showMessage("Error deleting setting", "error");
});
}
function showMessage(text, type) {
const messageDiv = document.getElementById("message");
messageDiv.className = `message ${type}`;
messageDiv.textContent = text;
messageDiv.style.display = "block";
setTimeout(() => {
messageDiv.style.display = "none";
}, 5000);
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
</script>
</body>
</html>

View File

@@ -1,303 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Contact Submissions</title>
<link rel="stylesheet" href="/static/css/styles.css" />
<style>
body {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.filters {
background: #f8f9fa;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.filters form {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: end;
}
</style>
</head>
<body>
<div class="nav">
<a href="/admin/">Dashboard</a>
<a href="/admin/settings">Settings</a>
<a href="{{ url_for('auth.logout') }}">Logout</a>
</div>
<h1>Contact Form Submissions</h1>
<div id="message"></div>
<div class="filters">
<form id="filterForm">
<div>
<label for="email">Email Filter:</label>
<input
type="text"
id="email"
name="email"
placeholder="Filter by email"
/>
</div>
<div>
<label for="date_from">Date From:</label>
<input type="date" id="date_from" name="date_from" />
</div>
<div>
<label for="date_to">Date To:</label>
<input type="date" id="date_to" name="date_to" />
</div>
<div>
<label for="per_page">Items per page:</label>
<select id="per_page" name="per_page">
<option value="25">25</option>
<option value="50" selected>50</option>
<option value="100">100</option>
</select>
</div>
<button type="submit">Apply Filters</button>
<button type="button" class="clear-btn" onclick="clearFilters()">
Clear
</button>
</form>
</div>
<div id="loading" class="loading" style="display: none">Loading...</div>
<table id="submissionsTable">
<thead>
<tr>
<th data-sort="id">ID</th>
<th data-sort="name">Name</th>
<th data-sort="email">Email</th>
<th data-sort="company">Company</th>
<th>Message</th>
<th data-sort="created_at">Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="submissionsBody">
<tr>
<td colspan="7" class="no-data">Loading submissions...</td>
</tr>
</tbody>
</table>
<div class="pagination" id="pagination"></div>
<script>
let currentPage = 1;
let currentSortBy = "created_at";
let currentSortOrder = "desc";
// Load submissions on page load
document.addEventListener("DOMContentLoaded", function () {
loadSubmissions();
});
// Handle filter form submission
document
.getElementById("filterForm")
.addEventListener("submit", function (e) {
e.preventDefault();
currentPage = 1;
loadSubmissions();
});
// Handle table header sorting
document.querySelectorAll("th[data-sort]").forEach((header) => {
header.addEventListener("click", function () {
const sortBy = this.dataset.sort;
if (currentSortBy === sortBy) {
currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
} else {
currentSortBy = sortBy;
currentSortOrder = "asc";
}
currentPage = 1;
loadSubmissions();
});
});
function clearFilters() {
document.getElementById("email").value = "";
document.getElementById("date_from").value = "";
document.getElementById("date_to").value = "";
document.getElementById("per_page").value = "50";
currentPage = 1;
currentSortBy = "created_at";
currentSortOrder = "desc";
loadSubmissions();
}
function loadSubmissions() {
const loading = document.getElementById("loading");
const table = document.getElementById("submissionsTable");
loading.style.display = "block";
table.style.opacity = "0.5";
const params = new URLSearchParams({
page: currentPage,
per_page: document.getElementById("per_page").value,
sort_by: currentSortBy,
sort_order: currentSortOrder,
email: document.getElementById("email").value,
date_from: document.getElementById("date_from").value,
date_to: document.getElementById("date_to").value,
});
fetch(`/api/contact?${params}`)
.then((response) => response.json())
.then((data) => {
if (data.status === "ok") {
displaySubmissions(data.submissions);
displayPagination(data.pagination);
} else {
showMessage(
"Error loading submissions: " +
(data.message || "Unknown error"),
"error"
);
}
})
.catch((error) => {
console.error("Error:", error);
showMessage("Error loading submissions", "error");
})
.finally(() => {
loading.style.display = "none";
table.style.opacity = "1";
});
}
function displaySubmissions(submissions) {
const tbody = document.getElementById("submissionsBody");
if (submissions.length === 0) {
tbody.innerHTML =
'<tr><td colspan="7" class="no-data">No submissions found</td></tr>';
return;
}
tbody.innerHTML = submissions
.map(
(submission) => `
<tr>
<td>${submission.id}</td>
<td>${escapeHtml(submission.name)}</td>
<td>${escapeHtml(submission.email)}</td>
<td>${escapeHtml(submission.company || "")}</td>
<td class="submission-details">${escapeHtml(
submission.message
)}</td>
<td>${new Date(submission.created_at).toLocaleString()}</td>
<td><button class="delete-btn" onclick="deleteSubmission(${
submission.id
})">Delete</button></td>
</tr>
`
)
.join("");
}
function displayPagination(pagination) {
const paginationDiv = document.getElementById("pagination");
if (pagination.pages <= 1) {
paginationDiv.innerHTML = "";
return;
}
let buttons = [];
// Previous button
buttons.push(
`<button ${
pagination.page <= 1 ? "disabled" : ""
} onclick="changePage(${pagination.page - 1})">Previous</button>`
);
// Page numbers
const startPage = Math.max(1, pagination.page - 2);
const endPage = Math.min(pagination.pages, pagination.page + 2);
for (let i = startPage; i <= endPage; i++) {
buttons.push(
`<button class="${
i === pagination.page ? "active" : ""
}" onclick="changePage(${i})">${i}</button>`
);
}
// Next button
buttons.push(
`<button ${
pagination.page >= pagination.pages ? "disabled" : ""
} onclick="changePage(${pagination.page + 1})">Next</button>`
);
paginationDiv.innerHTML = buttons.join("");
}
function changePage(page) {
currentPage = page;
loadSubmissions();
window.scrollTo(0, 0);
}
function deleteSubmission(id) {
if (!confirm("Are you sure you want to delete this submission?")) {
return;
}
fetch(`/api/contact/${id}`, {
method: "DELETE",
})
.then((response) => response.json())
.then((data) => {
if (data.status === "ok") {
showMessage("Submission deleted successfully", "success");
loadSubmissions(); // Reload the current page
} else {
showMessage(
"Error deleting submission: " +
(data.message || "Unknown error"),
"error"
);
}
})
.catch((error) => {
console.error("Error:", error);
showMessage("Error deleting submission", "error");
});
}
function showMessage(text, type) {
const messageDiv = document.getElementById("message");
messageDiv.className = `message ${type}`;
messageDiv.textContent = text;
messageDiv.style.display = "block";
// Auto-hide after 5 seconds
setTimeout(() => {
messageDiv.style.display = "none";
}, 5000);
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
</script>
</body>
</html>

View File

@@ -32,7 +32,7 @@
If you unsubscribed by mistake or have any questions, please feel free
to contact us.
</p>
<a href="/" class="back-link">Return to Homepage</a>
<a href="/newsletter/manage" class="back-link">Return to Newsletter</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,43 @@
import importlib
import pytest
server_app_module = importlib.import_module("server.app")
app = server_app_module.app
@pytest.fixture
def client():
with app.test_client() as client:
yield client
def test_get_settings_returns_dict(client):
# Login as admin first
client.post('/auth/login', data={'username': 'admin', 'password': 'admin'})
resp = client.get('/admin/api/settings')
assert resp.status_code == 200
body = resp.get_json()
assert body['status'] == 'ok'
assert isinstance(body.get('settings'), dict)
def test_update_and_get_newsletter_template(client):
key = 'newsletter_confirmation_template'
sample = '<p>Thanks for subscribing, {{email}}</p>'
# Update via PUT
# Login as admin first
client.post('/auth/login', data={'username': 'admin', 'password': 'admin'})
resp = client.put(f'/admin/api/settings/{key}', json={'value': sample})
assert resp.status_code == 200
body = resp.get_json()
assert body['status'] == 'ok'
# Retrieve via GET and ensure the value is present
resp = client.get('/admin/api/settings')
assert resp.status_code == 200
body = resp.get_json()
assert body['status'] == 'ok'
settings = body.get('settings') or {}
assert settings.get(key) == sample