From 90cdb7e922a1c7b9cf95b5f1200f9230aad2ff01 Mon Sep 17 00:00:00 2001 From: smueller Date: Fri, 3 Jul 2026 13:07:33 +0200 Subject: [PATCH] Enhance newsletter functionality and UI improvements - Introduce `create_outlook_html` function for generating Outlook-compatible newsletter HTML. - Update dashboard to include `outlook_html` in the response. - Modify newsletter generation logic to support separate handling of Outlook HTML. - Improve CSS styles for newsletter preview and layout adjustments. - Add new button for downloading HTML directly from the dashboard. --- app/main.py | 5 + app/services/newsletter.py | 439 ++++++++++++++++++++++++++++++----- app/static/css/main.css | 3 +- app/static/js/newsletter.js | 268 ++++++++++++++++++--- app/templates/dashboard.html | 7 +- 5 files changed, 626 insertions(+), 96 deletions(-) diff --git a/app/main.py b/app/main.py index 47bc233..fc7ca55 100644 --- a/app/main.py +++ b/app/main.py @@ -22,6 +22,7 @@ from app.services.newsletter import ( DEFAULT_DISPLAY, article_url, create_html, + create_outlook_html, create_plain_text, create_subject, filter_articles, @@ -189,6 +190,7 @@ async def dashboard(request: Request, db: Session = Depends(get_db), current_use "articles_new": [], "articles_edited": [], "raw_html": "", + "outlook_html": "", "plain_text": "", "filters": { "days": 30, @@ -226,6 +228,7 @@ async def dashboard_generate( articles_edited: list = [] plain_text = "" raw_html = "" + outlook_html = "" try: articles = await wiki_service.get_recent_changes(days=days, only_edited=only_edited_flag) filtered = filter_articles(articles, category_filter=category or None) @@ -233,6 +236,7 @@ async def dashboard_generate( title = create_subject(days, category) plain_text = create_plain_text(filtered, days, display, category) raw_html = create_html(filtered, days, display, category) + outlook_html = create_outlook_html(filtered, days, display, category) run_scheduled_send_if_due(db, title, raw_html, plain_text) except WikiFetchError as exc: wiki_error = exc.message @@ -243,6 +247,7 @@ async def dashboard_generate( "articles_new": articles_new, "articles_edited": articles_edited, "raw_html": raw_html, + "outlook_html": outlook_html, "plain_text": plain_text, "filters": { "days": days, diff --git a/app/services/newsletter.py b/app/services/newsletter.py index 464e0b4..5500e85 100644 --- a/app/services/newsletter.py +++ b/app/services/newsletter.py @@ -9,6 +9,19 @@ ORG_NAME = "Thomas-Krenn.AG" LINE = "=" * 78 SUBLINE = "-" * 78 +FONT = "Arial, Helvetica, sans-serif" +COLOR_TEXT = "#1A1A1A" +COLOR_MUTED = "#5C6670" +COLOR_HEADING = "#003366" +COLOR_LINK = "#0055A4" +COLOR_BORDER = "#D7DEE8" +COLOR_BG = "#EEF2F7" +COLOR_WHITE = "#FFFFFF" +COLOR_ACCENT_NEW = "#1F7A4D" +COLOR_ACCENT_NEW_BG = "#E8F6EE" +COLOR_ACCENT_EDIT = "#0055A4" +COLOR_ACCENT_EDIT_BG = "#E8F1FA" + class DisplayOptions(TypedDict): show_date: bool @@ -35,10 +48,14 @@ def parse_display_options( } +IGNORED_ARTICLE_TITLES = frozenset({"Hauptseite"}) + + def filter_articles( articles: list[dict[str, Any]], category_filter: str | None = None, ) -> list[dict[str, Any]]: + articles = [a for a in articles if a.get("title") not in IGNORED_ARTICLE_TITLES] if not category_filter: return articles wanted = category_filter.strip().lower() @@ -106,8 +123,7 @@ def create_plain_text( "", "Liebe Kolleginnen und Kollegen,", "", - "im Thomas-Krenn-Wiki wurden im ausgewählten Zeitraum folgende Artikel", - "neu veröffentlicht bzw. aktualisiert:", + _intro_sentence(period_start, period_end), "", ] ) @@ -152,52 +168,261 @@ def create_html( created_at = datetime.now().strftime("%d.%m.%Y %H:%M") subject = escape(create_subject(days, category)) - category_row = "" - if category.strip(): - category_row = f'

Kategorie: {escape(category.strip())}

' - if not new_articles and not edited_articles: - body = '

Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.

' + body = f'

Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.

' else: body = "\n".join( [ - _html_section("Neue Artikel", new_articles, display, "Keine neuen Artikel im Zeitraum."), - _html_section("Bearbeitete Artikel", edited_articles, display, "Keine bearbeiteten Artikel im Zeitraum."), + _html_section("Neue Artikel", new_articles, display, "Keine neuen Artikel im Zeitraum.", kind="new"), + _html_section("Bearbeitete Artikel", edited_articles, display, "Keine bearbeiteten Artikel im Zeitraum.", kind="edited"), ] ) summary = _html_summary(new_articles, edited_articles) + intro = escape(_intro_sentence(period_start, period_end)) + category_hint = ( + f'

Filter: Kategorie {escape(category.strip())}

' + if category.strip() + else "" + ) + return f""" - -{subject} - - - + + + + + {subject} + + + +
- - - - -
-

Interner Wiki-Newsletter

-

{escape(ORG_NAME)}

-
-

Zeitraum: {period_start} bis {period_end} ({days} Tage)

-

Erstellt am: {created_at}

- {category_row} -

Liebe Kolleginnen und Kollegen,

-

im Thomas-Krenn-Wiki wurden im ausgewählten Zeitraum folgende Artikel neu veröffentlicht bzw. aktualisiert:

- {body} - {summary} -

Zum Thomas-Krenn-Wiki

-
- Dieser Newsletter wurde automatisch erstellt und ist nur für den internen Gebrauch bei {escape(ORG_NAME)} bestimmt. -
-
+ + +
+ + + + + + + + + + + {category_hint} + + + + + + + + + +
 
+

Interner Newsletter

+

Thomas-Krenn Wiki Update

+

{escape(ORG_NAME)}

+
+ + + + +
+

Liebe Kolleginnen und Kollegen,

+

{intro}

+
+
+

Erstellt am {created_at}

+
+ {body} + {summary} + + + + +
+ Zum Thomas-Krenn-Wiki → +
+
+ Automatisch erstellter interner Newsletter · Nur für den Gebrauch bei {escape(ORG_NAME)} +
+
""" +OUTLOOK_FONT = "Arial, sans-serif" +OUTLOOK_TEXT = "#000000" +OUTLOOK_MUTED = "#666666" +OUTLOOK_LINK = "#0563C1" +OUTLOOK_HEADING = "#003366" +OUTLOOK_BORDER = "#DDDDDD" + + +def create_outlook_html( + articles: list[dict[str, Any]], + days: int, + display: DisplayOptions | None = None, + category: str = "", +) -> str: + """Vereinfachtes HTML für Outlook/Word – nur Tabellen und Inline-Styles.""" + display = display or DEFAULT_DISPLAY + new_articles, edited_articles = split_articles_by_type(articles) + period_start, period_end = _period_range(days) + created_at = datetime.now().strftime("%d.%m.%Y %H:%M") + intro = escape(_intro_sentence(period_start, period_end)) + subject = escape(create_subject(days, category)) + + if not new_articles and not edited_articles: + body_rows = f""" + + Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden. + +""" + else: + body_rows = "\n".join( + [ + _outlook_section("Neue Artikel", new_articles, display, "Keine neuen Artikel im Zeitraum."), + _outlook_section( + "Bearbeitete Artikel", + edited_articles, + display, + "Keine bearbeiteten Artikel im Zeitraum.", + ), + ] + ) + + category_block = "" + if category.strip(): + category_block = f"""

+ Filter: Kategorie {escape(category.strip())} +

""" + + total = len(new_articles) + len(edited_articles) + summary_row = f""" + + Zusammenfassung: + {len(new_articles)} neu · {len(edited_articles)} bearbeitet · {total} gesamt + +""" + + return f""" + + + + + + {subject} + + + + + + + + + + + +
+

Interner Newsletter

+

Thomas-Krenn Wiki Update

+

{escape(ORG_NAME)}

+
+

Liebe Kolleginnen und Kollegen,

+

{intro}

+

Erstellt am: {created_at}

+ {category_block} + + {body_rows} + {summary_row} + + + + + + +
+ Zum Thomas-Krenn-Wiki → +
+ Automatisch erstellter interner Newsletter · Nur für den Gebrauch bei {escape(ORG_NAME)} +
+
+ +""" + + +def _outlook_meta_line(item: dict[str, Any], display: DisplayOptions) -> str: + parts: list[str] = [] + if display["show_date"]: + parts.append(f"Datum: {_format_ts(item.get('timestamp'))}") + if display["show_user"]: + parts.append(f"Benutzer: {item.get('user', '-')}") + if display["show_category"]: + cat = ", ".join(item.get("categories", [])) or "Keine Kategorie" + parts.append(f"Kategorie: {cat}") + return " | ".join(parts) + + +def _outlook_article_rows(items: list[dict[str, Any]], display: DisplayOptions) -> str: + rows: list[str] = [] + for idx, item in enumerate(items, 1): + title = escape(item.get("title", "Ohne Titel")) + url = escape(_article_url(item.get("title", ""))) + meta = _outlook_meta_line(item, display) + meta_html = "" + if meta: + meta_html = ( + f'
' + f"{escape(meta)}" + ) + rows.append( + f""" + {idx}. + + {title}{meta_html} + +""" + ) + return "\n".join(rows) + + +def _outlook_section( + heading: str, + items: list[dict[str, Any]], + display: DisplayOptions, + empty_text: str, +) -> str: + header = f""" + + {escape(heading)} ({len(items)}) + +""" + if not items: + return header + f""" + {escape(empty_text)} +""" + return header + _outlook_article_rows(items, display) + + def _plain_section( heading: str, items: list[dict[str, Any]], @@ -255,52 +480,139 @@ def _plain_footer(new_articles: list[dict[str, Any]], edited_articles: list[dict ) -def _html_section(heading: str, items: list[dict[str, Any]], display: DisplayOptions, empty_text: str) -> str: +def _html_section( + heading: str, + items: list[dict[str, Any]], + display: DisplayOptions, + empty_text: str, + kind: str = "edited", +) -> str: + accent = COLOR_ACCENT_NEW if kind == "new" else COLOR_ACCENT_EDIT + accent_bg = COLOR_ACCENT_NEW_BG if kind == "new" else COLOR_ACCENT_EDIT_BG + badge = "NEU" if kind == "new" else "AKTUALISIERT" + blocks = [ - f'

{escape(heading)} ({len(items)})

' + f""" + + + +
+ + + + + +
  + + + + + +
+

{escape(heading)}

+
+ {badge} + {len(items)} +
+
+
""" ] if not items: - blocks.append(f'

{escape(empty_text)}

') + blocks.append( + f'

{escape(empty_text)}

' + ) return "\n".join(blocks) - blocks.append('') + blocks.append( + f'
' + ) for idx, item in enumerate(items, start=1): - blocks.append(_html_item_row(idx, item, display)) + blocks.append(_html_item_row(idx, item, display, accent)) blocks.append("
") return "\n".join(blocks) -def _html_item_row(idx: int, item: dict[str, Any], display: DisplayOptions) -> str: +def _html_meta_tags(item: dict[str, Any], display: DisplayOptions) -> str: + tags: list[str] = [] + if display["show_date"]: + tags.append(_html_meta_tag("Datum", _format_ts(item.get("timestamp")))) + if display["show_user"]: + tags.append(_html_meta_tag("Benutzer", str(item.get("user", "-")))) + if display["show_category"]: + cat = ", ".join(item.get("categories", [])) or "Keine Kategorie" + tags.append(_html_meta_tag("Kategorie", cat)) + if not tags: + return "" + return f'{"".join(tags)}
' + + +def _html_meta_tag(label: str, value: str) -> str: + return f""" + + + + +
+ {escape(label)}: {escape(value)} +
+""" + + +def _html_item_row(idx: int, item: dict[str, Any], display: DisplayOptions, accent: str) -> str: title = escape(item.get("title", "Ohne Titel")) url = escape(_article_url(item.get("title", ""))) - meta_parts: list[str] = [] - if display["show_date"]: - meta_parts.append(f"Datum: {escape(_format_ts(item.get('timestamp')))}") - if display["show_user"]: - meta_parts.append(f"Benutzer: {escape(str(item.get('user', '-')))}") - if display["show_category"]: - cat = escape(", ".join(item.get("categories", [])) or "Keine Kategorie") - meta_parts.append(f"Kategorie: {cat}") - meta_html = f'
{"  |  ".join(meta_parts)}
' if meta_parts else "" + meta_html = _html_meta_tags(item, display) return f""" - {idx}. - - {title} - {meta_html} + + + + + + +
  + + + + + +
{idx}. + {title} + {meta_html} +
+
""" def _html_summary(new_articles: list[dict[str, Any]], edited_articles: list[dict[str, Any]]) -> str: total = len(new_articles) + len(edited_articles) - return f""" - + return f"""
- Zusammenfassung
- Neue Artikel: {len(new_articles)}
- Bearbeitete Artikel: {len(edited_articles)}
- Gesamt: {total} -
+ + + + + +
Zusammenfassung
+ + + + + + + + +
+
{len(new_articles)}
+
Neue Artikel
+
  +
{len(edited_articles)}
+
Bearbeitet
+
  +
{total}
+
Gesamt
+
+
""" @@ -312,6 +624,13 @@ def article_url(title: str) -> str: return _article_url(title) +def _intro_sentence(period_start: str, period_end: str) -> str: + return ( + f"Im Thomas-Krenn-Wiki wurden im Zeitraum vom {period_start} bis {period_end} " + "folgende Artikel neu veröffentlicht bzw. aktualisiert:" + ) + + def _period_range(days: int) -> tuple[str, str]: end = datetime.now() start = end - timedelta(days=days) diff --git a/app/static/css/main.css b/app/static/css/main.css index 63c37a5..e887869 100644 --- a/app/static/css/main.css +++ b/app/static/css/main.css @@ -14,7 +14,8 @@ button { width: fit-content; background: #1b5dbf; color: #fff; border: 0; border .copy-status { font-size: 0.92rem; color: #64748b; } .copy-status.success { color: #027a48; } .copy-status.error { color: #b42318; } -.newsletter-preview-frame { width: 100%; min-height: 520px; border: 1px solid #dde3ef; border-radius: 6px; background: #fff; } +.newsletter-preview-frame { width: 100%; min-height: 720px; border: 1px solid #D7DEE8; border-radius: 6px; background: #EEF2F7; box-shadow: 0 2px 8px rgba(0, 51, 102, 0.08); } +.card h3 { color: #003366; margin-top: 0; } .hidden-source { display: none; } table { width: 100%; border-collapse: collapse; } th, td { border-bottom: 1px solid #e6ebf4; text-align: left; padding: 0.55rem; vertical-align: top; } diff --git a/app/static/js/newsletter.js b/app/static/js/newsletter.js index 9ccc909..592cd27 100644 --- a/app/static/js/newsletter.js +++ b/app/static/js/newsletter.js @@ -5,36 +5,134 @@ function setCopyStatus(message, isError) { status.className = isError ? "copy-status error" : "copy-status success"; } -async function copyForOutlook() { +function getNewsletterHtml() { const htmlSource = document.getElementById("newsletter-html-source"); + const htmlDisplay = document.getElementById("newsletter-html-display"); + const value = htmlSource?.value.trim() || htmlDisplay?.value.trim() || ""; + return value; +} + +function getNewsletterOutlookHtml() { + const outlookSource = document.getElementById("newsletter-outlook-source"); + const outlook = outlookSource ? outlookSource.value.trim() : ""; + if (outlook) return outlook; + return getNewsletterHtml(); +} + +function getNewsletterPlain() { const plainSource = document.getElementById("newsletter-plain-source"); - if (!htmlSource || !htmlSource.value.trim()) { - setCopyStatus("Bitte zuerst einen Newsletter generieren.", true); - return; - } + return plainSource ? plainSource.value : ""; +} - const html = htmlSource.value; - const plain = plainSource ? plainSource.value : ""; +function wrapCfHtml(html) { + const fragmentStart = ""; + const fragmentEnd = ""; + const bodyMatch = html.match(/]*>([\s\S]*)<\/body>/i); + const inner = bodyMatch ? bodyMatch[1].trim() : html; + const documentHtml = `${fragmentStart}${inner}${fragmentEnd}`; + const pad = (value) => String(value).padStart(10, "0"); + const headerPlaceholder = "StartHTML:0000000000\r\nEndHTML:0000000000\r\nStartFragment:0000000000\r\nEndFragment:0000000000\r\n"; + const prefix = `Version:0.9\r\n${headerPlaceholder}`; + const startHtml = prefix.length; + const endHtml = startHtml + documentHtml.length; + const startFragment = startHtml + documentHtml.indexOf(fragmentStart) + fragmentStart.length; + const endFragment = startHtml + documentHtml.indexOf(fragmentEnd); + + return ( + `Version:0.9\r\n` + + `StartHTML:${pad(startHtml)}\r\n` + + `EndHTML:${pad(endHtml)}\r\n` + + `StartFragment:${pad(startFragment)}\r\n` + + `EndFragment:${pad(endFragment)}\r\n` + + documentHtml + ); +} + +function copyFromTextarea(textarea) { + if (!textarea || !textarea.value) return false; + + const previousActive = document.activeElement; + textarea.removeAttribute("readonly"); + textarea.focus({ preventScroll: true }); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + + let ok = false; try { - if (navigator.clipboard && window.ClipboardItem) { - await navigator.clipboard.write([ - new ClipboardItem({ - "text/html": new Blob([html], { type: "text/html" }), - "text/plain": new Blob([plain], { type: "text/plain" }), - }), - ]); - setCopyStatus("Newsletter mit Hyperlinks kopiert. In Outlook mit Strg+V einfügen."); - return; - } + ok = document.execCommand("copy"); } catch (err) { - console.warn("Clipboard API failed, using fallback.", err); + console.warn("execCommand copy from textarea failed.", err); + ok = false; + } finally { + textarea.setAttribute("readonly", ""); + if (previousActive && typeof previousActive.focus === "function") { + previousActive.focus({ preventScroll: true }); + } + } + return ok; +} + +function copyTextWithFallback(text) { + if (!text) return false; + + const pageTextareas = [ + document.getElementById("newsletter-html-display"), + document.getElementById("newsletter-html-source"), + document.getElementById("newsletter-outlook-source"), + document.getElementById("newsletter-plain-source"), + ]; + + for (const textarea of pageTextareas) { + if (textarea && textarea.value === text && copyFromTextarea(textarea)) { + return true; + } } + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("aria-hidden", "true"); + textarea.style.position = "fixed"; + textarea.style.top = "0"; + textarea.style.left = "0"; + textarea.style.width = "2px"; + textarea.style.height = "2px"; + textarea.style.padding = "0"; + textarea.style.border = "none"; + textarea.style.outline = "none"; + textarea.style.boxShadow = "none"; + textarea.style.background = "transparent"; + document.body.appendChild(textarea); + + let ok = false; + try { + textarea.focus({ preventScroll: true }); + textarea.select(); + textarea.setSelectionRange(0, text.length); + ok = document.execCommand("copy"); + } catch (err) { + console.warn("execCommand copy from temporary textarea failed.", err); + ok = false; + } finally { + document.body.removeChild(textarea); + } + return ok; +} + +function copyHtmlWithFallback(html) { const container = document.createElement("div"); container.innerHTML = html; + container.contentEditable = "true"; + container.setAttribute("aria-hidden", "true"); container.style.position = "fixed"; - container.style.left = "-9999px"; + container.style.left = "0"; + container.style.top = "0"; + container.style.width = "600px"; + container.style.height = "1px"; + container.style.overflow = "hidden"; + container.style.opacity = "0"; + container.style.pointerEvents = "none"; + container.style.background = "#FFFFFF"; document.body.appendChild(container); const range = document.createRange(); @@ -46,38 +144,142 @@ async function copyForOutlook() { let ok = false; try { ok = document.execCommand("copy"); + } catch (err) { + console.warn("execCommand HTML copy failed.", err); + ok = false; } finally { selection.removeAllRanges(); document.body.removeChild(container); } - setCopyStatus( - ok ? "Newsletter mit Hyperlinks kopiert. In Outlook mit Strg+V einfügen." : "Kopieren fehlgeschlagen.", - !ok - ); + if (!ok) { + ok = copyTextWithFallback(wrapCfHtml(html)); + } + return ok; } -async function copyHtmlSource() { - const htmlSource = document.getElementById("newsletter-html-source"); - if (!htmlSource || !htmlSource.value.trim()) { +async function copyText(text) { + if (!text) return false; + + if (copyTextWithFallback(text)) { + return true; + } + + if (navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch (err) { + console.warn("Clipboard API text copy failed.", err); + } + } + return false; +} + +async function copyForOutlook() { + const html = getNewsletterOutlookHtml(); + const plain = getNewsletterPlain(); + if (!html) { setCopyStatus("Bitte zuerst einen Newsletter generieren.", true); return; } - try { - await navigator.clipboard.writeText(htmlSource.value); - setCopyStatus("HTML-Quelltext kopiert."); - } catch (err) { - setCopyStatus("Kopieren fehlgeschlagen.", true); + + if (copyHtmlWithFallback(html)) { + setCopyStatus("Newsletter für Outlook kopiert. Mit Strg+V einfügen (ggf. „Format beibehalten“)."); + return; } + + const cfHtml = wrapCfHtml(html); + if (navigator.clipboard && window.ClipboardItem && window.isSecureContext) { + try { + await navigator.clipboard.write([ + new ClipboardItem({ + "text/html": new Blob([cfHtml], { type: "text/html" }), + "text/plain": new Blob([plain], { type: "text/plain" }), + }), + ]); + setCopyStatus("Newsletter für Outlook kopiert. Mit Strg+V einfügen (ggf. „Format beibehalten“)."); + return; + } catch (err) { + console.warn("Clipboard API HTML copy failed.", err); + } + } + + setCopyStatus("Kopieren fehlgeschlagen. Bitte „HTML herunterladen“ verwenden.", true); +} + +async function copyHtmlSource() { + const html = getNewsletterHtml(); + if (!html) { + setCopyStatus("Bitte zuerst einen Newsletter generieren.", true); + return; + } + + const displayArea = document.getElementById("newsletter-html-display"); + const sourceArea = document.getElementById("newsletter-html-source"); + const textarea = displayArea?.value ? displayArea : sourceArea; + + if (textarea && copyFromTextarea(textarea)) { + setCopyStatus("HTML-Quelltext kopiert."); + return; + } + + if (copyTextWithFallback(html)) { + setCopyStatus("HTML-Quelltext kopiert."); + return; + } + + if (navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(html); + setCopyStatus("HTML-Quelltext kopiert."); + return; + } catch (err) { + console.warn("Clipboard API HTML source copy failed.", err); + } + } + + if (textarea) { + textarea.focus({ preventScroll: true }); + textarea.select(); + } + setCopyStatus( + "Automatisches Kopieren nicht möglich. HTML-Feld unten ist markiert – bitte Strg+C drücken.", + true + ); +} + +function downloadHtml() { + const html = getNewsletterOutlookHtml(); + if (!html) { + setCopyStatus("Bitte zuerst einen Newsletter generieren.", true); + return; + } + + const stamp = new Date().toISOString().slice(0, 10); + const filename = `wiki-newsletter-${stamp}.html`; + const blob = new Blob([html], { type: "text/html;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + setCopyStatus(`HTML gespeichert als ${filename}. Datei in Outlook öffnen oder Inhalt kopieren.`); } document.addEventListener("DOMContentLoaded", () => { + const outlookSource = document.getElementById("newsletter-outlook-source"); const htmlSource = document.getElementById("newsletter-html-source"); const frame = document.getElementById("newsletter-preview-frame"); - if (htmlSource && frame && htmlSource.value.trim()) { - frame.srcdoc = htmlSource.value; + const previewHtml = outlookSource?.value.trim() || htmlSource?.value.trim() || ""; + if (frame && previewHtml) { + frame.srcdoc = previewHtml; } document.getElementById("copy-outlook-btn")?.addEventListener("click", copyForOutlook); document.getElementById("copy-html-source-btn")?.addEventListener("click", copyHtmlSource); + document.getElementById("download-html-btn")?.addEventListener("click", downloadHtml); }); diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index da151ea..c7c7d01 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -98,14 +98,16 @@

Newsletter-Vorschau (mit Hyperlinks)

-

Für Outlook den Button „Für Outlook kopieren“ nutzen – dann bleiben Links als klickbare Hyperlinks erhalten.

+

Für Outlook „Für Outlook kopieren“ nutzen und mit Strg+V einfügen (Rechtsklick → „Format beibehalten“). Falls Kopieren nicht klappt (z. B. per HTTP/IP), „HTML herunterladen“ verwenden und die Datei in Outlook öffnen.

+
+
@@ -117,7 +119,8 @@

Output: HTML-Quelltext

- +

Bei Problemen mit „Kopieren“: Textfeld markieren (Strg+A) und manuell kopieren (Strg+C).

+