Polish KarlDNS production UI and docs

This commit is contained in:
StasonJatham committed 2026-05-21 10:05:40 +02:00
1 parent ca92dc211a
commit 5beee0e5c6
61 files changed
+1566 -523

No files matched your search

+79 -15
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
import json
from .html_utils import SafeHtml, attr, escape, join_html, trusted
@@ -9,14 +11,22 @@ def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtm
account_html = f'<span class="navbar-text nav-label text-truncate">{escape(label)}</span>'
if links is None:
links = trusted('<a class="nav-link px-0" href="/docs">Docs</a><a class="nav-link px-0" href="/redoc">API</a>')
account_html += '<a class="btn btn-sm btn-primary rounded-pill" href="/#create">Generate</a>'
theme_toggle = '<button type="button" class="btn btn-sm btn-light theme-toggle" data-theme-toggle aria-label="Switch color theme">Light</button>'
theme_toggle = """
<button type="button" class="btn btn-sm btn-light theme-toggle" data-theme-toggle aria-label="Switch to dark theme">
<svg class="theme-icon theme-icon-moon" aria-hidden="true" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M6 0a6.8 6.8 0 0 0-.8 3.2A7 7 0 0 0 12.2 10a6.8 6.8 0 0 0 3-.7A8 8 0 1 1 6 0Z"/>
</svg>
<svg class="theme-icon theme-icon-sun" aria-hidden="true" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 11.2A3.2 3.2 0 1 0 8 4.8a3.2 3.2 0 0 0 0 6.4Zm0-9.8a.7.7 0 0 1-.7-.7V.2a.7.7 0 0 1 1.4 0v.5a.7.7 0 0 1-.7.7Zm0 14.6a.7.7 0 0 1-.7-.7v-.5a.7.7 0 1 1 1.4 0v.5a.7.7 0 0 1-.7.7ZM1.4 8a.7.7 0 0 1-.7.7H.2a.7.7 0 0 1 0-1.4h.5a.7.7 0 0 1 .7.7Zm14.6 0a.7.7 0 0 1-.7.7h-.5a.7.7 0 1 1 0-1.4h.5a.7.7 0 0 1 .7.7ZM3.3 3.3a.7.7 0 0 1-1 0L2 3a.7.7 0 0 1 1-1l.3.3a.7.7 0 0 1 0 1Zm10.7 10.7a.7.7 0 0 1-1 0l-.3-.3a.7.7 0 1 1 1-1l.3.3a.7.7 0 0 1 0 1Zm-10.7-1a.7.7 0 0 1 0 1l-.3.3a.7.7 0 1 1-1-1l.3-.3a.7.7 0 0 1 1 0ZM14 2a.7.7 0 0 1 0 1l-.3.3a.7.7 0 1 1-1-1L13 2a.7.7 0 0 1 1 0Z"/>
</svg>
</button>
"""
return trusted(
f"""
<a class="skip-link" href="#main">Skip to content</a>
<nav class="navbar navbar-expand-sm site-nav" aria-label="Main navigation">
<div class="container">
<a class="navbar-brand brand" href="/"><img src="/logo.png" alt="" width="28" height="28">KarlDNS</a>
<a class="navbar-brand brand" href="/"><img src="/logo-nav.webp?v=3" alt="" width="28" height="28" decoding="async">KarlDNS</a>
<div class="navbar-nav site-nav-actions ms-auto flex-row align-items-center justify-content-end gap-2 gap-sm-3">
{links}
{account_html}
@@ -71,7 +81,7 @@ def _manual_update_commands(update_url: str) -> str:
[
f"curl -sS '{update_base}?myip=203.0.113.10'",
f"curl -sS '{update_base}?myipv6=2001:db8::10'",
f"curl -sS '{update_base}'",
f"curl -sS '{update_base}' # use request IP",
]
)
@@ -83,10 +93,10 @@ def _manual_update_panel(commands: str) -> SafeHtml:
<div class="card card-body gap-3 mt-4">
<div>
<h3>Manual updates</h3>
<p class="section-copy">FRITZ!Box replaces <code>&lt;ipaddr&gt;</code> and <code>&lt;ip6addr&gt;</code>, then sends a GET request to the Update-URL. Scripts and other routers can do the same with curl. If no IP parameter is sent, KarlDNS uses the request source IP.</p>
<p class="section-copy">FRITZ!Box fills in the IP placeholders automatically. Scripts can use curl. If no IP is sent, KarlDNS uses the request IP.</p>
</div>
<pre class="manual-code mb-0"><code>{safe_commands}</code></pre>
{_copy_button("Copy curl examples", commands)}
{_copy_button("Copy curl", commands)}
</div>
"""
)
@@ -107,7 +117,7 @@ def _fritzbox_fields(
f"Kennwort: {password}",
]
if management_url:
lines.append(f"Private status page: {management_url}")
lines.append(f"Private dashboard: {management_url}")
return "\n".join(lines)
@@ -115,9 +125,9 @@ def _secret_notice() -> SafeHtml:
return trusted(
"""
<div class="alert alert-info my-3 py-2 small" role="note">
<strong>Public:</strong> Domainnamen and CNAME target can be shared in DNS.
<strong>Secret:</strong> Update-URL, one-box update URL, optional Kennwort, private dashboard URL, and domain claim links control the hostname.
Secrets are hidden by default on screen; Copy still copies the real value.
<strong>Public:</strong> Domain name and CNAME target can be shared.
<strong>Secret:</strong> Update URLs, router password, dashboard link, and claim links can update or manage this hostname.
Secrets are hidden on screen. Copy still copies the real value.
</div>
"""
)
@@ -127,7 +137,7 @@ def _one_time_secret_notice() -> SafeHtml:
return trusted(
"""
<div class="alert alert-warning my-3 py-2 small" role="alert">
Copy the router password and one-box update URL now. They are only shown when generated or rotated and are not recoverable from the dashboard later.
Copy the router password and full update URL now. They are only shown when created or rotated.
</div>
"""
)
@@ -165,9 +175,55 @@ def _site_footer() -> SafeHtml:
)
def _page(title: str, body: SafeHtml) -> str:
def _page(
title: str,
body: SafeHtml,
extra_styles: SafeHtml | None = None,
extra_scripts: SafeHtml | None = None,
description: str = "KarlDNS is a small self-hosted Dynamic DNS service for FRITZ!Box routers, Linux cron jobs, and home networks.",
preload_logo: bool = False,
canonical_url: str | None = None,
image_url: str | None = None,
image_alt: str | None = None,
structured_data: dict[str, object] | None = None,
) -> str:
if not isinstance(body, SafeHtml):
raise TypeError("body must be a SafeHtml fragment built by a template helper")
extra_styles_html = extra_styles or trusted("")
extra_scripts_html = extra_scripts or trusted("")
preload_logo_html = trusted('<link rel="preload" as="image" href="/logo.webp?v=3" type="image/webp">') if preload_logo else trusted("")
canonical_html = trusted(f'<link rel="canonical" href="{attr(canonical_url)}">') if canonical_url else trusted("")
social_url = canonical_url or ""
social_image = image_url or ""
social_html = trusted("")
if social_url:
social_card = "summary_large_image" if social_image else "summary"
social_image_alt = image_alt or title
social_image_html = f"""
<meta property="og:image" content="{attr(social_image)}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{attr(social_image_alt)}">
<meta name="twitter:image" content="{attr(social_image)}">
<meta name="twitter:image:alt" content="{attr(social_image_alt)}">
""" if social_image else ""
social_html = trusted(f"""
<meta property="og:type" content="website">
<meta property="og:title" content="{attr(title)}">
<meta property="og:description" content="{attr(description)}">
<meta property="og:url" content="{attr(social_url)}">
{social_image_html}
<meta name="twitter:card" content="{attr(social_card)}">
<meta name="twitter:title" content="{attr(title)}">
<meta name="twitter:description" content="{attr(description)}">
""")
structured_data_html = trusted("")
if structured_data:
structured_data_html = trusted(
'<script type="application/ld+json">'
+ escape(json.dumps(structured_data, ensure_ascii=False, separators=(",", ":")))
+ "</script>"
)
return f"""
<!doctype html>
<html lang="en">
@@ -175,15 +231,23 @@ def _page(title: str, body: SafeHtml) -> str:
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#ffffff">
<meta name="description" content="{attr(description)}">
{canonical_html}
{social_html}
<title>{escape(title)}</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest">
<script src="/app.js" defer></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<link href="/app.css" rel="stylesheet">
{preload_logo_html}
<script src="/theme.js?v=2"></script>
<script src="/clipboard.js?v=2" defer></script>
{extra_scripts_html}
<link href="/bootstrap-subset.css?v=3" rel="stylesheet">
<link href="/app.css?v=2" rel="stylesheet">
{extra_styles_html}
{structured_data_html}
</head>
<body>{body}{_site_footer()}</body>
</html>