This commit is contained in:
2026-05-23 21:31:34 +02:00
parent 51b5ddc6f2
commit 641bb10479
6155 changed files with 3775717 additions and 291 deletions

View File

@@ -0,0 +1,72 @@
# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller one-file Build: gleiche Web-Shell wie ``python aza_empfang_webview.py URL``.
Wird vom AzA-Hauptfenster (basis14) bevorzugt gestartet, damit Windows in der Taskleiste
die eingebettete Icon-Ressource der EXE zeigt statt ``python.exe``.
Die eigenständige Hülle ``AZA_Empfang.exe`` (``aza_empfang_app.py``) bleibt unverändert.
"""
from pathlib import Path
from PyInstaller.utils.hooks import collect_submodules
project_root = Path(SPECPATH)
datas = []
_build_info = project_root / "_build_info.py"
if _build_info.is_file():
datas.append((str(_build_info), "."))
_logo_ico = project_root / "logo.ico"
if _logo_ico.is_file():
datas.append((str(_logo_ico), "."))
hiddenimports = list(collect_submodules("webview")) + [
"aza_empfang_app",
"aza_empfang_region_snapshot",
"PIL.ImageGrab",
"PIL.Image",
]
a = Analysis(
[str(project_root / "aza_empfang_webview.py")],
pathex=[str(project_root)],
binaries=[],
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
_logo = project_root / "logo.ico"
_icon_arg = {}
if _logo.is_file():
_icon_arg["icon"] = str(_logo)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name="AZA_EmpfangShell",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
**_icon_arg,
)

View File

@@ -0,0 +1,9 @@
AZA Empfang - Rollback (Backup 20260517_215927)
Projektwurzel: C:\Users\surov\Documents\AZA_GIT\aza\AzA march 2026
PowerShell aus Projektwurzel:
$b = ".\backup_empfang_shell_minichat_ui_20260517_215927"
Copy-Item "$b\empfang.html" "web\empfang.html" -Force
Copy-Item "$b\aza_empfang_webview.py" "aza_empfang_webview.py" -Force
Hinweis: empfang.html -> Hetzner Docker no-cache/recreate; aza_empfang_webview.py -> Shell-EXE neu bauen.

View File

@@ -0,0 +1,556 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
AZA Empfang - Schlanke Desktop-Huelle fuer die Empfangs-Weboberflaeche.
Unabhaengig von AzA Office / Hauptfenster: Standard-URL ist die oeffentliche
Empfang-Instanz (empfang.aza-medwork.ch). Kein lokales Backend noetig.
URL-Reihenfolge:
1) Umgebung AZA_EMPFANG_URL oder EMPFANG_URL (volle Basis, siehe unten)
2) backend_url.txt neben der EXE (optional, erste nicht-leere Zeile)
3) Im EXE-Bundle nur mit AZA_EMPFANG_USE_BUNDLED_URL=1
4) Fallback: https://empfang.aza-medwork.ch/empfang/
Lokale URLs (localhost / 127.0.0.1 / 192.168...): bei gebundelter EXE nur mit
AZA_EMPFANG_ALLOW_LOCAL=1, sonst Fallback auf (4) — damit die Huelle ohne
Haupt-App und ohne laufenden lokalen Server funktioniert.
Standard: Laedt die URL direkt im Fenster (kein iframe).
Optional: AZA_EMPFANG_IFRAME=1 = alte iframe-Huelle mit HTML-Toolbar.
"""
import json
import os
import ssl
import sys
import threading
import webbrowser
_APP_TITLE = "AZA Empfang"
_MIN_SIZE = (380, 500)
_DEFAULT_W, _DEFAULT_H = 480, 820
_PUBLIC_EMPFANG_URL = "https://empfang.aza-medwork.ch"
def _data_dir():
if getattr(sys, "frozen", False):
return sys._MEIPASS
return os.path.dirname(os.path.abspath(__file__))
def _user_dir():
if getattr(sys, "frozen", False):
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.abspath(__file__))
def _empfang_pywebview_icon_path() -> str | None:
"""logo.ico fuer pywebview.start(icon=...): zuerst neben EXE, sonst Projektroot."""
candidates = []
if getattr(sys, "frozen", False):
candidates.append(os.path.join(os.path.dirname(sys.executable), "logo.ico"))
meipass = getattr(sys, "_MEIPASS", "")
if meipass:
candidates.append(os.path.join(meipass, "logo.ico"))
here = os.path.dirname(os.path.abspath(__file__))
candidates.append(os.path.join(here, "logo.ico"))
for c in candidates:
if c and os.path.isfile(c):
return c
return None
def _normalize_to_empfang_url(base: str) -> str:
"""Aus Basis-URL (Host oder .../empfang) wird .../empfang/ mit Slash am Ende."""
b = (base or "").strip().rstrip("/")
if not b:
b = _PUBLIC_EMPFANG_URL.rstrip("/")
if b.endswith("/empfang"):
return b + "/"
return b + "/empfang/"
def _is_local_or_lan_url(url: str) -> bool:
u = (url or "").lower()
if "localhost" in u or "127.0.0.1" in u:
return True
if u.startswith("http://192.168.") or u.startswith("http://10."):
return True
if u.startswith("https://192.168.") or u.startswith("https://10."):
return True
return False
def _read_backend_url_file_in_dir(dir_path: str) -> str | None:
try:
p = os.path.join(dir_path, "backend_url.txt")
if not os.path.isfile(p):
return None
with open(p, "r", encoding="utf-8") as f:
for ln in f:
s = ln.strip()
if s and not s.startswith("#"):
return s.rstrip("/")
except Exception:
return None
return None
def _empfang_url() -> str:
env = (
(os.environ.get("AZA_EMPFANG_URL") or os.environ.get("EMPFANG_URL") or "")
.strip()
)
if env:
return _normalize_to_empfang_url(env)
search_dirs: list[str] = [_user_dir()]
if getattr(sys, "frozen", False):
if os.environ.get("AZA_EMPFANG_USE_BUNDLED_URL", "").strip() == "1":
search_dirs.append(_data_dir())
else:
# Entwicklung: Skript-Verzeichnis (identisch zu _user_dir bei direktem Start)
dd = _data_dir()
if dd not in search_dirs:
search_dirs.append(dd)
raw: str | None = None
for d in search_dirs:
raw = _read_backend_url_file_in_dir(d)
if raw:
break
if raw:
if getattr(sys, "frozen", False) and _is_local_or_lan_url(raw):
if os.environ.get("AZA_EMPFANG_ALLOW_LOCAL", "").strip() != "1":
raw = None
if raw:
return _normalize_to_empfang_url(raw)
return _normalize_to_empfang_url(_PUBLIC_EMPFANG_URL)
_SETTINGS_FILE = os.path.join(_user_dir(), "empfang_app_settings.json")
def _load_settings() -> dict:
defaults = {"width": _DEFAULT_W, "height": _DEFAULT_H,
"x": None, "y": None, "on_top": False}
try:
with open(_SETTINGS_FILE, "r", encoding="utf-8") as f:
return {**defaults, **json.load(f)}
except Exception:
return defaults
def _save_settings(s: dict):
try:
with open(_SETTINGS_FILE, "w", encoding="utf-8") as f:
json.dump(s, f, indent=2)
except Exception:
pass
class _Api:
def __init__(self, window, on_top: bool, url: str):
self._w = window
self._on_top = on_top
self._url = url
self._pin_lock = threading.Lock()
def check_url(self):
"""Diagnostic connectivity check (GET with SSL fallback)."""
from urllib.request import urlopen, Request
for verify in (True, False):
try:
ctx = ssl.create_default_context() if verify else ssl._create_unverified_context()
req = Request(self._url)
with urlopen(req, timeout=10, context=ctx) as r:
r.read(512)
return {"ok": True, "url": self._url, "error": ""}
except ssl.SSLError:
if verify:
continue
return {"ok": False, "url": self._url,
"error": "SSL/TLS-Zertifikatsfehler. Bitte IT kontaktieren."}
except Exception as e:
if verify:
continue
return {"ok": False, "url": self._url, "error": str(e)}
return {"ok": False, "url": self._url,
"error": "Server nicht erreichbar. Bitte Netzwerk pruefen."}
def open_in_browser(self):
"""Öffnet dieselbe Ziel-URL wie im Fenster (lokal oder Produktion)."""
webbrowser.open(self._url)
def toggle_on_top(self):
if not self._pin_lock.acquire(blocking=False):
return self._on_top
try:
self._on_top = not self._on_top
new_val = self._on_top
finally:
self._pin_lock.release()
def _apply():
try:
self._w.on_top = new_val
except Exception:
pass
threading.Thread(target=_apply, daemon=True).start()
return new_val
def get_on_top(self):
return self._on_top
def get_version(self):
try:
sys.path.insert(0, _data_dir())
from _build_info import BUILD_TIME, GIT_COMMIT
return f"Build: {BUILD_TIME} ({GIT_COMMIT})"
except Exception:
return "Entwicklungsversion"
def get_url(self):
return self._url
def get_public_url(self):
return _PUBLIC_EMPFANG_URL
_SHELL_HTML = r'''<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%;overflow:hidden}
body{font-family:'Segoe UI',system-ui,sans-serif;background:#f0f4f8}
#toolbar{
height:34px;
background:linear-gradient(135deg,#5B8DB3,#3a6d93);
display:flex;align-items:center;justify-content:space-between;
padding:0 10px;box-shadow:0 1px 4px rgba(0,0,0,.15);
position:relative;z-index:20;
}
.tb-l,.tb-r{display:flex;align-items:center;gap:6px}
.tb-title{color:#fff;font-size:12px;font-weight:600;letter-spacing:.3px}
.tb-btn{
background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.18);
color:#fff;border-radius:4px;padding:3px 9px;font-size:11px;
cursor:pointer;transition:all .15s;font-family:inherit;white-space:nowrap;
}
.tb-btn:hover{background:rgba(255,255,255,.2)}
.tb-btn.on{background:rgba(255,255,255,.35);border-color:rgba(255,255,255,.6);font-weight:600}
.tb-btn.busy{opacity:.5;pointer-events:none}
#content{position:absolute;top:34px;left:0;right:0;bottom:0}
#frame{width:100%;height:100%;border:none;display:none}
#loading{
position:absolute;top:0;left:0;right:0;bottom:0;
display:flex;flex-direction:column;align-items:center;justify-content:center;
background:#f0f4f8;z-index:10;
}
.spin{width:34px;height:34px;border:3px solid #dde8f0;border-top-color:#5B8DB3;
border-radius:50%;animation:sp .8s linear infinite;margin-bottom:16px}
@keyframes sp{to{transform:rotate(360deg)}}
.ld-t{color:#5B8DB3;font-size:13px;font-weight:500}
.ld-s{color:#8aa8bc;font-size:11px;margin-top:5px}
#error{
position:absolute;top:0;left:0;right:0;bottom:0;
display:none;flex-direction:column;align-items:center;justify-content:center;
background:#f0f4f8;padding:30px;text-align:center;z-index:10;
}
.er-i{font-size:44px;margin-bottom:14px;opacity:.65}
.er-h{font-size:15px;font-weight:600;color:#1a4d6d;margin-bottom:6px}
.er-m{font-size:12px;color:#6a8a9a;margin-bottom:20px;line-height:1.6}
.er-b{display:flex;gap:8px}
.er-btn{background:#5B8DB3;color:#fff;border:none;border-radius:6px;
padding:8px 18px;font-size:12px;cursor:pointer;font-weight:500;font-family:inherit}
.er-btn:hover{background:#4A7A9E}
.er-btn.s{background:#dde8f0;color:#1a4d6d}
.er-btn.s:hover{background:#ccd8e0}
</style>
</head>
<body>
<div id="toolbar">
<div class="tb-l"><span class="tb-title">AZA Empfang</span></div>
<div class="tb-r">
<button class="tb-btn" onclick="doReload()" title="Seite neu laden">&#8635; Laden</button>
<button class="tb-btn" onclick="doOpen()" title="Im Browser oeffnen">&#8599; Browser</button>
<button class="tb-btn" id="bp" onclick="doPin()" title="Immer im Vordergrund">&#128204;</button>
<button class="tb-btn" onclick="doInfo()" title="Version / Info">&#8505;</button>
</div>
</div>
<div id="content">
<div id="loading">
<div class="spin"></div>
<div class="ld-t">Empfang wird geladen</div>
<div class="ld-s">Verbindung wird hergestellt&hellip;</div>
</div>
<div id="error">
<div class="er-i">&#9888;&#65039;</div>
<div class="er-h">Empfang nicht erreichbar</div>
<div class="er-m" id="erMsg">Die Empfangsseite konnte nicht geladen werden.</div>
<div class="er-b">
<button class="er-btn" onclick="doReload()">&#8635; Neu laden</button>
<button class="er-btn s" onclick="doOpen()">&#8599; Im Browser oeffnen</button>
</div>
</div>
<iframe id="frame" src="about:blank"></iframe>
</div>
<script>
var RDY=false, URL='';
window.addEventListener('pywebviewready',function(){
RDY=true;
pywebview.api.get_url().then(function(u){URL=u;boot()});
pinInit();
});
function view(id){
['loading','error','frame'].forEach(function(k){
var e=document.getElementById(k);
e.style.display=(k===id)?(k==='frame'?'block':'flex'):'none';
});
}
function boot(){
view('loading');
var f=document.getElementById('frame');
var done=false;
f.onload=function(){
if(!done&&f.src!=='about:blank'){done=true;view('frame')}
};
var cacheBust = '?_t=' + Date.now();
f.src=URL + (URL.indexOf('?')>=0 ? '&_t=' : '?_t=') + Date.now();
setTimeout(function(){
if(!done){diagnose()}
},12000);
}
async function diagnose(){
try{
var r=await pywebview.api.check_url();
if(r&&r.ok){
view('frame');
}else{
document.getElementById('erMsg').innerHTML=
(r&&r.error?r.error:'Server nicht erreichbar.')+'<br><br><small>URL: '+(r&&r.url||'')+'</small>';
view('error');
}
}catch(e){view('error')}
}
async function doReload(){
view('loading');
var f=document.getElementById('frame');
var done=false;
f.onload=function(){
if(!done&&f.src!=='about:blank'){done=true;view('frame')}
};
f.src='about:blank';
setTimeout(function(){f.src=URL + (URL.indexOf('?')>=0 ? '&_t=' : '?_t=') + Date.now()},100);
setTimeout(function(){if(!done)diagnose()},12000);
}
function doOpen(){if(RDY)pywebview.api.open_in_browser()}
var pinBusy=false;
async function doPin(){
if(!RDY||pinBusy)return;
var b=document.getElementById('bp');
pinBusy=true;
b.classList.add('busy');
try{
var v=await pywebview.api.toggle_on_top();
pinUI(v);
}catch(e){}
setTimeout(function(){pinBusy=false;b.classList.remove('busy')},300);
}
async function pinInit(){
try{var v=await pywebview.api.get_on_top();pinUI(v)}catch(e){}
}
function pinUI(on){
var b=document.getElementById('bp');
if(on){b.classList.add('on');b.textContent='\uD83D\uDCCC An';b.title='Immer im Vordergrund (aktiv)'}
else {b.classList.remove('on');b.textContent='\uD83D\uDCCC';b.title='Immer im Vordergrund'}
}
async function doInfo(){
if(!RDY)return;
var v=await pywebview.api.get_version();
alert('AZA Empfang\n\n'+v+'\n'+URL);
}
</script>
</body>
</html>'''
def main():
try:
import webview
except ImportError:
print("FEHLER: pywebview ist nicht installiert.")
print("Bitte ausfuehren: pip install pywebview")
sys.exit(1)
try:
from webview.menu import Menu, MenuAction, MenuSeparator
except ImportError:
Menu = None # type: ignore
MenuAction = None # type: ignore
MenuSeparator = None # type: ignore
settings = _load_settings()
url = _empfang_url()
# iframe-Huelle (alt): nur setzen wenn noetig: AZA_EMPFANG_IFRAME=1
use_iframe_shell = os.environ.get("AZA_EMPFANG_IFRAME", "").strip() == "1"
w = max(_MIN_SIZE[0], min(1920, settings.get("width") or _DEFAULT_W))
h = max(_MIN_SIZE[1], min(1200, settings.get("height") or _DEFAULT_H))
x = settings.get("x")
y = settings.get("y")
if isinstance(x, (int, float)) and isinstance(y, (int, float)):
if x < -200 or y < -200 or x > 3800 or y > 2200:
x, y = None, None
else:
x, y = None, None
on_top = bool(settings.get("on_top", False))
if use_iframe_shell:
window = webview.create_window(
_APP_TITLE,
html=_SHELL_HTML,
width=w,
height=h,
x=x,
y=y,
min_size=_MIN_SIZE,
on_top=on_top,
text_select=True,
background_color="#f0f4f8",
)
api = _Api(window, on_top, url)
window.expose(
api.check_url,
api.open_in_browser,
api.toggle_on_top,
api.get_on_top,
api.get_version,
api.get_url,
api.get_public_url,
)
else:
window = webview.create_window(
_APP_TITLE,
url=url,
width=w,
height=h,
x=x,
y=y,
min_size=_MIN_SIZE,
on_top=on_top,
text_select=True,
background_color="#f0f4f8",
)
api = _Api(window, on_top, url)
window.expose(
api.toggle_on_top,
api.get_on_top,
api.open_in_browser,
api.get_version,
api.get_url,
api.get_public_url,
api.check_url,
)
def _reload():
try:
window.evaluate_js("window.location.reload()")
except Exception as exc:
print(f"[AZA Empfang] Neu laden: {exc}")
def _info_box():
try:
msg = f"{api.get_version()}\n\n{api._url}"
try:
import ctypes
ctypes.windll.user32.MessageBoxW(0, msg, _APP_TITLE, 0)
except Exception:
print(msg)
except Exception:
pass
def _toggle_pin_menu():
api.toggle_on_top()
menu = None
if Menu is not None:
menu = [
Menu(
"Empfang",
[
MenuAction("Neu laden", _reload),
MenuAction("Im Browser \u00f6ffnen", api.open_in_browser),
MenuAction("Immer im Vordergrund", _toggle_pin_menu),
MenuSeparator(),
MenuAction("Version / Info", _info_box),
],
),
]
def _on_closing():
try:
_save_settings({
"x": window.x,
"y": window.y,
"width": window.width,
"height": window.height,
"on_top": api._on_top,
})
except Exception:
pass
return True
try:
window.events.closing += _on_closing
except Exception:
pass
_icon = _empfang_pywebview_icon_path()
_start_kw = {}
if _icon:
_start_kw["icon"] = _icon
try:
if use_iframe_shell:
webview.start(**_start_kw)
elif menu:
try:
webview.start(menu=menu, **_start_kw)
except TypeError:
webview.start(**_start_kw)
else:
webview.start(**_start_kw)
except Exception as e:
print(f"[AZA Empfang] Fehler: {e}")
if __name__ == "__main__":
try:
import ctypes
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
"ch.aza-medwork.empfang.v2")
except Exception:
pass
try:
main()
except Exception as e:
print(f"[AZA Empfang] Kritischer Fehler: {e}")
import traceback
traceback.print_exc()

View File

@@ -0,0 +1,236 @@
# -*- coding: utf-8 -*-
"""
Vollbild-Overlay zur Bereichswahl fuer Snapshot in der Empfang-Desktop-WebView.
- Nur nach expliziter Nutzeraktion (Klick auf Snapshot) gestartet.
- Keine Netz-/Log-Ausgabe von Bildinhalten oder Praxisdaten.
- Windows: virtuelle Bildschirmflaeche; PIL ImageGrab fuer den gewaehlten Bereich.
"""
from __future__ import annotations
import base64
import io
import sys
import threading
import tkinter as tk
from typing import Callable
_LOCK = threading.Lock()
_ACTIVE = False
# Sehr kleine Auswahlen ignorieren (Klicks ohne Zieh-Bewegung).
_MIN_SIDE_PX = 6
def _virtual_screen_win32() -> tuple[int, int, int, int] | None:
if sys.platform != "win32":
return None
try:
import ctypes
user32 = ctypes.windll.user32
SM_XVIRTUALSCREEN = 76
SM_YVIRTUALSCREEN = 77
SM_CXVIRTUALSCREEN = 78
SM_CYVIRTUALSCREEN = 79
x = int(user32.GetSystemMetrics(SM_XVIRTUALSCREEN))
y = int(user32.GetSystemMetrics(SM_YVIRTUALSCREEN))
w = int(user32.GetSystemMetrics(SM_CXVIRTUALSCREEN))
h = int(user32.GetSystemMetrics(SM_CYVIRTUALSCREEN))
if w <= 0 or h <= 0:
return None
return (x, y, w, h)
except Exception:
return None
def _grab_bbox_to_png_data_url(left: int, top: int, right: int, bottom: int) -> str | None:
"""Liefert data:image/png;base64,... oder None bei Fehler."""
try:
from PIL import ImageGrab
except Exception:
return None
L = min(left, right)
T = min(top, bottom)
R = max(left, right)
B = max(top, bottom)
if R - L < _MIN_SIDE_PX or B - T < _MIN_SIDE_PX:
return None
try:
try:
img = ImageGrab.grab(bbox=(L, T, R, B), all_screens=True) # type: ignore[call-arg]
except TypeError:
img = ImageGrab.grab(bbox=(L, T, R, B))
except Exception:
return None
try:
buf = io.BytesIO()
img.save(buf, format="PNG", optimize=True)
raw = buf.getvalue()
if not raw or len(raw) < 32:
return None
b64 = base64.b64encode(raw).decode("ascii")
return f"data:image/png;base64,{b64}"
except Exception:
return None
def _run_overlay(on_done: Callable[[str | None], None]) -> None:
global _ACTIVE
root = tk.Tk()
root.title("")
root.configure(cursor="crosshair", bg="#101820")
try:
root.attributes("-alpha", 0.28)
except Exception:
pass
try:
root.attributes("-topmost", True)
except Exception:
pass
root.overrideredirect(True)
box = _virtual_screen_win32()
if box:
vx, vy, vw, vh = box
root.geometry(f"{vw}x{vh}+{vx}+{vy}")
else:
try:
root.attributes("-fullscreen", True)
except Exception:
root.state("zoomed")
canvas = tk.Canvas(root, highlightthickness=0, bg="#000010", cursor="crosshair")
canvas.pack(fill=tk.BOTH, expand=True)
hint = tk.Label(
root,
text="Bereich mit der Maus aufziehen. Esc oder Rechtsklick: Abbrechen.",
fg="#e8eef4",
bg="#1a2530",
font=("Segoe UI", 10),
padx=10,
pady=6,
)
hint.place(x=8, y=8)
sel: dict[str, int | None] = {"x0": None, "y0": None, "rid": None}
def finish(data_url: str | None) -> None:
global _ACTIVE
try:
root.destroy()
except Exception:
pass
with _LOCK:
_ACTIVE = False
try:
on_done(data_url)
except Exception:
pass
def on_press(event: tk.Event) -> None: # type: ignore[name-defined]
if int(getattr(event, "num", 1) or 1) == 3:
finish(None)
return
sel["x0"] = int(event.x_root)
sel["y0"] = int(event.y_root)
if sel["rid"] is not None:
try:
canvas.delete(sel["rid"])
except Exception:
pass
sel["rid"] = None
def _scr_to_cv(sx: int, sy: int) -> tuple[int, int]:
return int(sx - canvas.winfo_rootx()), int(sy - canvas.winfo_rooty())
def on_motion(event: tk.Event) -> None: # type: ignore[name-defined]
x0, y0 = sel["x0"], sel["y0"]
if x0 is None or y0 is None:
return
x1, y1 = int(event.x_root), int(event.y_root)
if sel["rid"] is not None:
try:
canvas.delete(sel["rid"])
except Exception:
pass
cx0, cy0 = _scr_to_cv(int(x0), int(y0))
cx1, cy1 = _scr_to_cv(x1, y1)
sel["rid"] = canvas.create_rectangle(
cx0,
cy0,
cx1,
cy1,
outline="#4af",
width=2,
dash=(4, 4),
)
def on_release(event: tk.Event) -> None: # type: ignore[name-defined]
x0, y0 = sel["x0"], sel["y0"]
if x0 is None or y0 is None:
return
x1, y1 = int(event.x_root), int(event.y_root)
data = _grab_bbox_to_png_data_url(x0, y0, x1, y1)
finish(data)
def on_esc(_event: tk.Event | None = None) -> None: # type: ignore[name-defined]
finish(None)
canvas.bind("<ButtonPress-1>", on_press)
canvas.bind("<B1-Motion>", on_motion)
canvas.bind("<ButtonRelease-1>", on_release)
canvas.bind("<ButtonPress-3>", lambda e: finish(None))
root.bind("<Escape>", on_esc)
try:
root.focus_force()
except Exception:
pass
try:
root.mainloop()
except Exception:
with _LOCK:
_ACTIVE = False
try:
on_done(None)
except Exception:
pass
def start_region_snapshot_capture(
on_done: Callable[[str | None], None],
) -> str:
"""
Startet die Bereichswahl in einem eigenen Thread (eigenes Tk-Fenster).
Returns:
"started" | "busy"
"""
global _ACTIVE
with _LOCK:
if _ACTIVE:
return "busy"
_ACTIVE = True
def _thread_main() -> None:
global _ACTIVE
try:
_run_overlay(on_done)
except Exception:
with _LOCK:
_ACTIVE = False
try:
on_done(None)
except Exception:
pass
try:
threading.Thread(target=_thread_main, daemon=True).start()
except Exception:
with _LOCK:
_ACTIVE = False
return "busy"
return "started"

View File

@@ -0,0 +1,903 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AzA-Empfang Chat</title>
<link rel="icon" href="/empfang/favicon.ico" sizes="any"/>
<link rel="icon" href="/empfang/aza_logo.png" type="image/png"/>
<style>
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;font-family:'Segoe UI',system-ui,sans-serif;background:#eaf1f8;color:#1a2a3a;font-size:10pt}
:root{--aza-blue:#5B8DB3;--aza-deep:#356488;--aza-rail:linear-gradient(180deg,#3d6f92 0%,#2a5472 100%);--aza-sh:0 4px 18px rgba(28,62,94,.08)}
body{display:flex;flex-direction:column;min-height:100%;overflow:hidden}
header{background:linear-gradient(135deg,#5B8DB3,#3a6d93);color:#fff;padding:8px 14px;flex-shrink:0;z-index:30;box-shadow:0 2px 12px rgba(40,80,110,.2)}
.hdr-row1{display:flex;flex-wrap:wrap;align-items:flex-start;gap:10px}
.hdr-main-titles{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
header h1{font-size:.95rem;font-weight:700;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin:0}
.hdr-sub{font-size:.62rem;font-weight:500;opacity:.88;line-height:1.32;white-space:normal;word-wrap:break-word}
.hdr-tools{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-left:auto}
.hdr-tools .cw-ton-lbl{font-size:.62rem;opacity:.92;text-transform:uppercase;letter-spacing:.04em}
.hdr-tools input[type=range]{width:80px;accent-color:#fff;cursor:pointer}
.hdr-tools span.vol-lbl{font-size:.68rem;min-width:36px;text-align:right;font-weight:600}
.hdr-tools button{background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.38);color:#fff;border-radius:8px;padding:5px 10px;font-size:.75rem;cursor:pointer;font-family:inherit}
.hdr-tools button:hover{background:rgba(255,255,255,.24)}
.hdr-tools button.on{background:rgba(255,255,255,.32)}
.hdr-tools button.muted{opacity:.6}
#cw-settings{background:#fff;border-bottom:1px solid #dde8f0;padding:12px 16px;font-size:.82rem;flex-shrink:0;box-shadow:var(--aza-sh)}
#cw-settings.hidden{display:none!important}
#cw-settings label{display:block;margin-bottom:4px;color:#3a5a7a;font-weight:600}
#cw-settings select{width:100%;max-width:340px;padding:8px;border:1px solid #d0dce8;border-radius:10px}
#cw-settings .set-row{display:flex;gap:10px;flex-wrap:wrap;margin-top:8px}
.shell-body{display:flex;flex:1;min-height:0;overflow:hidden}
#nav-mini{width:72px;min-width:72px;flex-shrink:0;background:var(--aza-rail);display:flex;flex-direction:column;align-items:center;padding:14px 0;border-right:1px solid rgba(255,255,255,.12)}
.nr-m-logo{width:44px;height:44px;border-radius:12px;background:rgba(255,255,255,.16);border:1px solid rgba(255,255,255,.28);display:flex;align-items:center;justify-content:center;text-align:center;padding:4px;margin-bottom:12px;overflow:hidden}
.nr-m-logo img{width:100%;height:100%;object-fit:contain;display:block;border-radius:8px}
.nr-m-btn{width:44px;height:44px;border:none;border-radius:11px;background:transparent;color:#fff;font-size:1.2rem;cursor:pointer;margin-bottom:10px;display:flex;align-items:center;justify-content:center}
.nr-m-btn:hover{background:rgba(255,255,255,.12)}
#main-col-wrap{flex:1;display:flex;flex-direction:column;min-width:0;background:#f0f6fb}
.chat-strip{padding:10px 20px;background:#fafcfe;border-bottom:1px solid #e2ebf5;font-size:.72rem;font-weight:700;color:#62809a;text-transform:uppercase;letter-spacing:.07em}
#main-col{flex:1;display:flex;flex-direction:column;min-height:0;overflow:hidden}
#pending-strip{display:none;flex-shrink:0;padding:10px 16px;background:#e8f2fa;border-bottom:1px solid #d2e4f4;gap:10px;flex-wrap:wrap}
#pending-strip.has-items{display:flex}
.pend-item{position:relative;border:1px solid #b8cce0;border-radius:12px;overflow:hidden;background:#fff;box-shadow:var(--aza-sh)}
.pend-item img{display:block;width:76px;height:76px;object-fit:cover}
.pend-item .rm{position:absolute;top:4px;right:4px;width:22px;height:22px;border:none;border-radius:50%;background:#c0392b;color:#fff;font-size:12px;cursor:pointer}
#log{flex:1;overflow-y:auto;padding:20px clamp(16px,4vw,40px);display:flex;flex-direction:column;gap:4px}
.msg-date-sep{text-align:center;margin:14px 0 8px}
.msg-date-sep span{display:inline-block;padding:4px 12px;border-radius:999px;background:rgba(255,255,255,.95);border:1px solid #d8e6f4;font-size:.65rem;font-weight:700;color:#5a7590}
#log::-webkit-scrollbar{width:10px}
#log::-webkit-scrollbar-thumb{background:#bfd5e8;border-radius:999px;border:2px solid #f0f6fb}
.msg{max-width:min(92%,640px);padding:13px 16px;border-radius:16px;font-size:.89rem;line-height:1.55;word-wrap:break-word;border:1px solid transparent;box-shadow:0 1px 3px rgba(28,62,94,.06)}
.msg.me{align-self:flex-end;background:linear-gradient(165deg,#d9ebf8,#cae3f5);border-color:#aecfe4;border-bottom-right-radius:5px}
.msg.them{align-self:flex-start;background:#fff;border-color:#e4ebf4;border-bottom-left-radius:5px;box-shadow:0 4px 14px rgba(28,62,94,.06)}
.msg .meta{font-size:.66rem;color:#7693ab;margin-bottom:6px;font-weight:600}
.msg img{max-width:100%;max-height:260px;border-radius:12px;margin-top:8px;border:1px solid #e8eef4}
.drop-hint{font-size:.72rem;color:#6a8499;text-align:center;padding:10px;background:#eef4fa;border-top:1px dashed #cddfea;flex-shrink:0}
.drop-hint.drag{background:#e0eef8}
#bar{flex-shrink:0;background:#fafcfe;border-top:1px solid #dfe8ef;padding:14px clamp(14px,3vw,28px) 18px;box-shadow:0 -6px 24px rgba(40,76,103,.07)}
#bar-inner{max-width:720px;margin:0 auto;display:flex;gap:12px;align-items:flex-end}
#bar .grow-inp{flex:1;min-width:0;display:flex;flex-direction:column;gap:6px}
#bar textarea{width:100%;border:1px solid #d0dde9;border-radius:16px;padding:14px 16px;font-family:inherit;font-size:.92rem;resize:none;min-height:52px;max-height:148px;background:#fff;outline:none}
#bar textarea:focus{border-color:#8eb8d6;box-shadow:0 0 0 3px rgba(91,141,179,.13)}
#bar .row{display:flex;gap:10px;flex-shrink:0;align-items:center}
#bar button{background:linear-gradient(180deg,var(--aza-blue),#4a86ad);color:#fff;border:none;border-radius:13px;padding:14px 22px;font-weight:700;font-size:.82rem;font-family:inherit;cursor:pointer;box-shadow:0 4px 12px rgba(61,118,157,.32)}
#bar button:hover{transform:translateY(-1px)}
#bar button:disabled{opacity:.52;cursor:not-allowed;transform:none}
#bar button.btn-dictate{background:#eef5fb;color:#356488;border:1px solid #c8dae8;box-shadow:none}
#bar button.btn-dictate:hover{background:#e4eef6}
#bar button.btn-dictate.recording{background:#fde8e8;border-color:#e8bcbc;color:#943535}
#att-preview{font-size:.72rem;color:var(--aza-deep);font-weight:600;min-height:1.15em}
#gate{position:fixed;inset:0;background:#eaf1f8;display:flex;align-items:center;justify-content:center;padding:20px;z-index:100}
#gate .box{background:#fff;padding:28px;border-radius:16px;max-width:380px;border:1px solid #e6eef6;text-align:center;box-shadow:var(--aza-sh)}
#gate a{color:#5B8DB3;font-weight:700}
.hidden{display:none!important}
@media (max-width:640px){#nav-mini{width:56px;min-width:56px}.nr-m-btn,.nr-m-logo{width:40px;height:40px;font-size:1rem}}
</style>
</head>
<body>
<div id="gate" class="hidden">
<div class="box">
<p id="gate-msg">Bitte zuerst im Empfang anmelden.</p>
<p style="margin-top:12px"><a id="gate-link" href="#">Empfang öffnen</a></p>
</div>
</div>
<header>
<div class="hdr-row1">
<div class="hdr-main-titles">
<h1 id="hdr-title">Chat</h1>
<div class="hdr-sub" id="hdr-practice-sub" aria-live="polite"></div>
</div>
<div class="hdr-tools" title="Ton (Benachrichtigungen)">
<span class="cw-ton-lbl" aria-hidden="true">Ton</span>
<input type="range" id="cw-vol" min="0" max="300" step="5" title="Lautstärke" aria-label="Lautstärke Benachrichtigung">
<span class="vol-lbl" id="cw-vol-disp">100%</span>
<button type="button" id="cw-sound" onclick="toggleSound()" title="Ton an/aus">&#128276;</button>
<button type="button" id="cw-gear" onclick="toggleSettings()" title="Klangsignal wählen">&#9881;</button>
<button type="button" onclick="reloadThread()" title="Aktualisieren">&#8635;</button>
</div>
</div>
</header>
<div id="cw-settings" class="hidden">
<label for="cw-tone">Signal bei neuer Nachricht</label>
<select id="cw-tone" onchange="saveToneChoice()"></select>
<div class="set-row">
<button type="button" class="btn-test" onclick="testSound()" style="background:#e8f0f8;color:#2a5a8a;border:1px solid #c8d8e8;border-radius:6px;padding:6px 12px;cursor:pointer;font-family:inherit;font-size:.8rem">Klang testen</button>
</div>
<p style="margin-top:10px;font-size:.75rem;color:#8a9aaa">Einstellungen werden wie im Haupt-Empfang gespeichert (dieser Browser).</p>
</div>
<div class="shell-body">
<nav id="nav-mini" aria-label="Kurzbefehle">
<div class="nr-m-logo" title="AzA-Empfang"><img src="/empfang/aza_logo.png" alt="AzA" width="44" height="44" decoding="async"/></div>
<button type="button" class="nr-m-btn" onclick="reloadThread()" title="Aktualisieren">&#8635;</button>
<button type="button" class="nr-m-btn" onclick="toggleSettings()" title="Einstellungen">&#9881;</button>
</nav>
<div id="main-col-wrap">
<div class="chat-strip" id="chat-context-strip">Nachrichten</div>
<div id="main-col">
<div id="pending-strip"></div>
<div id="log"></div>
</div>
<div class="drop-hint" id="drop-hint">Bilder hierher ziehen oder in das Textfeld einfügen (Strg+V)</div>
<div id="bar">
<div id="bar-inner">
<div class="grow-inp">
<textarea id="tx" placeholder="Nachricht… (Enter = senden, Umschalt+Enter = Zeile)" rows="2"></textarea>
<div id="att-preview"></div>
</div>
<div class="row">
<button type="button" id="btn-dictate" class="btn-dictate" onclick="toggleDictate()">Diktieren</button>
<button type="button" id="btn-send" onclick="doSend()">Senden</button>
</div>
</div>
</div>
</div>
</div>
<script>
var API_BASE = window.location.origin + '/empfang';
var MAX_B64 = 2 * 1024 * 1024;
var mode = 'general';
var peerName = '';
var peerUserId = '';
var threadId = null;
var currentSession = null;
var pollTimer = null;
var pendingAttach = [];
var lastPollSig = '';
var lastServerTick = -1;
var lastConvMessages = [];
var dictateRec = null;
var dictateListening = false;
function stopDictateIfActive() {
if (!dictateListening) return;
dictateListening = false;
try {
if (dictateRec) dictateRec.stop();
} catch (e) {}
dictateRec = null;
var b = document.getElementById('btn-dictate');
if (b) {
b.textContent = 'Diktieren';
b.classList.remove('recording');
}
}
function toggleDictate() {
var SR = window.SpeechRecognition || window.webkitSpeechRecognition;
var btn = document.getElementById('btn-dictate');
if (!SR) {
alert('Spracherkennung wird in diesem Browser nicht unterstützt.');
return;
}
if (dictateListening) {
stopDictateIfActive();
return;
}
dictateRec = new SR();
dictateRec.lang = 'de-DE';
dictateRec.continuous = true;
dictateRec.interimResults = true;
dictateListening = true;
btn.textContent = 'Stoppen';
btn.classList.add('recording');
var tx = document.getElementById('tx');
try {
tx.focus();
} catch (e0) {}
dictateRec.onresult = function(ev) {
var finalChunk = '';
for (var i = ev.resultIndex; i < ev.results.length; i++) {
if (ev.results[i].isFinal) finalChunk += ev.results[i][0].transcript;
}
if (!finalChunk) return;
var t = (tx.value || '');
var add = finalChunk.trim();
if (!add) return;
if (t && !/\s$/.test(t)) tx.value = t + ' ';
tx.value += add + ' ';
try {
tx.scrollTop = tx.scrollHeight;
} catch (e1) {}
};
dictateRec.onerror = function(ev) {
var code = ev && ev.error ? String(ev.error) : '';
if (code === 'no-speech' || code === 'aborted') return;
stopDictateIfActive();
if (code === 'not-allowed') alert('Mikrofonzugriff wurde verweigert.');
};
dictateRec.onend = function() {
if (dictateListening) {
try {
dictateRec.start();
} catch (e2) {}
}
};
try {
dictateRec.start();
} catch (e3) {
dictateListening = false;
btn.textContent = 'Diktieren';
btn.classList.remove('recording');
alert('Diktieren konnte nicht gestartet werden.');
}
}
var soundEnabled = localStorage.getItem('empfang_sound') !== 'off';
var audioCtx = null;
var volume = parseFloat(localStorage.getItem('empfang_volume') || '1');
if (isNaN(volume)) volume = 1;
var currentToneIdx = parseInt(localStorage.getItem('empfang_tone_idx') || '0', 10);
var TONE_PRESETS = [
{name:'Sanftes Glockenspiel', notes:[{f:523,d:.15},{f:659,d:.15},{f:784,d:.3}], wave:'sine', vol:.12},
{name:'Zwei-Ton Harmonisch', notes:[{f:392,d:.2},{f:523,d:.3}], wave:'sine', vol:.12},
{name:'Drei-Ton Melodie', notes:[{f:523,d:.12},{f:587,d:.12},{f:659,d:.28}], wave:'sine', vol:.11},
{name:'Kristallklar', notes:[{f:1319,d:.5}], wave:'sine', vol:.07},
{name:'Warmer Akkord', notes:[{f:262,d:.4}], wave:'triangle', vol:.14},
{name:'Aufstieg', notes:[{f:262,d:.09},{f:330,d:.09},{f:392,d:.09},{f:523,d:.22}], wave:'sine', vol:.10},
{name:'Sanfte Welle', notes:[{f:440,d:.55}], wave:'sine', vol:.10},
{name:'Tropfen', notes:[{f:659,d:.12},{f:587,d:.12},{f:523,d:.28}], wave:'sine', vol:.10},
{name:'Morgengruss', notes:[{f:523,d:.14},{f:392,d:.14},{f:523,d:.28}], wave:'sine', vol:.12},
{name:'Zephyr', notes:[{f:880,d:.45}], wave:'sine', vol:.06},
{name:'Bambus', notes:[{f:330,d:.18},{f:440,d:.28}], wave:'triangle', vol:.12},
{name:'Silberglocke', notes:[{f:988,d:.45}], wave:'sine', vol:.08},
{name:'Meditation', notes:[{f:262,d:.65}], wave:'sine', vol:.12},
{name:'Horizont', notes:[{f:587,d:.18},{f:880,d:.32}], wave:'sine', vol:.10},
{name:'Stille Post', notes:[{f:784,d:.4}], wave:'sine', vol:.08},
];
var empfangPracticeInfoCache = null;
function parseQs() {
var p = new URLSearchParams(window.location.search);
mode = (p.get('mode') || 'general').toLowerCase() === 'dm' ? 'dm' : 'general';
try {
peerName = decodeURIComponent((p.get('peer') || '').trim());
} catch (e) {
peerName = (p.get('peer') || '').trim();
}
peerUserId = (p.get('peer_uid') || '').trim();
if (mode === 'dm' && !peerName) mode = 'general';
}
async function checkAuth() {
try {
var r = await fetch(API_BASE + '/auth/me', { credentials: 'include' });
if (r.status === 401) return null;
var d = await r.json();
if (d.authenticated) return d;
} catch (e) {}
return null;
}
function showGate(msg) {
document.getElementById('gate').classList.remove('hidden');
document.getElementById('gate-msg').textContent = msg;
var base = window.location.origin + '/empfang/';
document.getElementById('gate-link').href = base;
document.getElementById('gate-link').onclick = function(e) {
e.preventDefault();
window.open(base, '_blank');
};
}
function setTitle() {
var t = document.getElementById('hdr-title');
if (mode === 'dm' && peerName) t.textContent = 'Chat mit ' + peerName;
else t.textContent = 'Neuer Chat Allgemein';
var cs = document.getElementById('chat-context-strip');
if (cs) cs.textContent = mode === 'dm' && peerName ? 'Direkt · ' + peerName : 'Praxis-Posteingang · Allgemein';
}
async function loadPracticeSubline() {
try {
var r = await fetch(API_BASE + '/practice/info', { credentials: 'include' });
if (!r.ok) return;
var d = await r.json();
empfangPracticeInfoCache = d;
var el = document.getElementById('hdr-practice-sub');
if (!el) return;
var pname = String(d.practice_name || '').trim() || 'Praxis-Chat';
var pid = String(d.practice_id || '').trim();
var sid = pid ? (pid.length > 14 ? pid.slice(0, 12) + '\u2026' : pid) : '';
if (!pid) {
el.textContent = pname + ' \u2013 Praxiszuordnung pr\u00fcfen';
return;
}
el.textContent = pname + ' \u00b7 ID ' + sid + ' \u00b7 mit Praxis-Chat verbunden';
} catch (e) {}
}
function mimeForAttachment(name, fallbackMime) {
var fm = (fallbackMime || '').toLowerCase();
if (fm.indexOf('image/') === 0) return fm;
var n = (name || '').toLowerCase();
if (n.endsWith('.png')) return 'image/png';
if (n.endsWith('.gif')) return 'image/gif';
if (n.endsWith('.webp')) return 'image/webp';
if (n.endsWith('.bmp')) return 'image/bmp';
if (n.endsWith('.jpg') || n.endsWith('.jpeg')) return 'image/jpeg';
return 'image/png';
}
function fileToAttach(file) {
return new Promise(function(resolve, reject) {
if (file.size > MAX_B64) {
reject(new Error('Datei zu groß (max. ca. 2 MB)'));
return;
}
var fr = new FileReader();
fr.onload = function() {
var s = fr.result;
var i = s.indexOf(',');
resolve({
name: file.name || 'bild.png',
data: i >= 0 ? s.slice(i + 1) : s,
mime: file.type || mimeForAttachment(file.name, '')
});
};
fr.onerror = function() { reject(new Error('Lesefehler')); };
fr.readAsDataURL(file);
});
}
function renderPendingStrip() {
var strip = document.getElementById('pending-strip');
if (!strip) return;
strip.innerHTML = '';
if (!pendingAttach.length) {
strip.classList.remove('has-items');
return;
}
strip.classList.add('has-items');
pendingAttach.forEach(function(a, idx) {
var wrap = document.createElement('div');
wrap.className = 'pend-item';
var img = document.createElement('img');
img.src = 'data:' + (a.mime || mimeForAttachment(a.name)) + ';base64,' + a.data;
img.alt = a.name || '';
var rm = document.createElement('button');
rm.type = 'button';
rm.className = 'rm';
rm.innerHTML = '\u00d7';
rm.title = 'Entfernen';
rm.onclick = function() {
pendingAttach.splice(idx, 1);
renderPendingStrip();
document.getElementById('att-preview').textContent =
pendingAttach.length ? (pendingAttach.length + ' Bild(er) werden mitgesendet') : '';
};
wrap.appendChild(img);
wrap.appendChild(rm);
strip.appendChild(wrap);
});
}
function addFiles(files) {
var arr = Array.from(files || []);
var work = arr.filter(function(f) { return f.type && f.type.indexOf('image/') === 0; });
if (!work.length) return;
Promise.all(work.map(function(f) {
return fileToAttach(f).then(function(a) { pendingAttach.push(a); });
})).then(function() {
renderPendingStrip();
document.getElementById('att-preview').textContent =
pendingAttach.length ? (pendingAttach.length + ' Bild(er) werden mitgesendet') : '';
}).catch(function(e) {
alert(e.message || String(e));
});
}
function setupDropPaste() {
var hint = document.getElementById('drop-hint');
var tx = document.getElementById('tx');
if (!hint || !tx) return;
['dragenter','dragover'].forEach(function(ev) {
hint.addEventListener(ev, function(e) {
e.preventDefault();
e.stopPropagation();
hint.classList.add('drag');
});
});
hint.addEventListener('dragleave', function(e) {
hint.classList.remove('drag');
});
hint.addEventListener('drop', function(e) {
e.preventDefault();
hint.classList.remove('drag');
addFiles(e.dataTransfer && e.dataTransfer.files);
});
document.body.addEventListener('dragover', function(e) { e.preventDefault(); });
document.body.addEventListener('drop', function(e) {
if (e.target === hint || hint.contains(e.target)) return;
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) {
e.preventDefault();
addFiles(e.dataTransfer.files);
}
});
tx.addEventListener('paste', function(e) {
var items = e.clipboardData && e.clipboardData.items;
if (!items) return;
for (var i = 0; i < items.length; i++) {
if (items[i].type && items[i].type.indexOf('image/') === 0) {
e.preventDefault();
var f = items[i].getAsFile();
if (f) addFiles([f]);
}
}
});
tx.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
doSend();
}
});
}
function patientLine() {
var now = new Date();
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
return 'Chat · ' + pad(now.getDate()) + '.' + pad(now.getMonth() + 1) + '. ' +
pad(now.getHours()) + ':' + pad(now.getMinutes());
}
function conversationAudienceParam() {
if (mode === 'dm' && peerName) return peerName;
return '';
}
var AZA_DEFAULT_PRACTICE_TZ = 'Europe/Zurich';
function empfangChatTimeZoneId() {
try {
var d = empfangPracticeInfoCache;
var tz = d && String(d.practice_timezone || d.timezone || '').trim();
if (tz) return tz;
} catch (e) {}
return AZA_DEFAULT_PRACTICE_TZ;
}
function parseEmpfangChatInstantMs(raw) {
var s = String(raw || '').trim();
if (!s) return NaN;
var norm = s.replace(' ', 'T');
var hasTz = norm.indexOf('Z') >= 0 || /[+\-]\d{2}:\d{2}$/.test(norm);
if (hasTz) {
var tzParsed = Date.parse(norm);
return isFinite(tzParsed) ? tzParsed : NaN;
}
var m = s.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}(?::\d{2})?)/);
if (m) {
var u = Date.parse(m[1] + 'T' + m[2] + 'Z');
if (isFinite(u)) return u;
}
var fb = Date.parse(norm);
return isFinite(fb) ? fb : NaN;
}
function formatEmpfangChatClockFromMs(ms) {
if (!isFinite(ms)) return '';
try {
return new Intl.DateTimeFormat('de-CH', {
timeZone: empfangChatTimeZoneId(),
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format(new Date(ms));
} catch (e) {
var d = new Date(ms);
var h = d.getHours();
var mi = d.getMinutes();
return (h < 10 ? '0' : '') + h + ':' + (mi < 10 ? '0' : '') + mi;
}
}
function formatMessengerDayKeyFromMs(ms) {
if (!isFinite(ms)) return '';
try {
var parts = new Intl.DateTimeFormat('en-CA', {
timeZone: empfangChatTimeZoneId(),
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).formatToParts(new Date(ms));
var y = '';
var mo = '';
var da = '';
for (var ip = 0; ip < parts.length; ip++) {
var p = parts[ip];
if (p.type === 'year') y = p.value;
if (p.type === 'month') mo = p.value;
if (p.type === 'day') da = p.value;
}
if (y && mo && da) return y + '-' + mo + '-' + da;
} catch (e) {}
return '';
}
function formatEmpfangChatDateTimeLong(raw) {
var ms = parseEmpfangChatInstantMs(raw);
if (!isFinite(ms)) return String(raw || '');
try {
return new Intl.DateTimeFormat('de-CH', {
timeZone: empfangChatTimeZoneId(),
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format(new Date(ms));
} catch (e) {
return String(raw || '');
}
}
function conversationSortChrono(msgs) {
return msgs.slice().sort(function(a, b) {
var sa = a.empfangen || a.zeitstempel || '';
var sb = b.empfangen || b.zeitstempel || '';
var ta = parseEmpfangChatInstantMs(sa);
var tb = parseEmpfangChatInstantMs(sb);
if (isFinite(ta) && isFinite(tb) && ta !== tb) return ta - tb;
return sa.localeCompare(sb);
});
}
function conversationDayKey(iso) {
return formatMessengerDayKeyFromMs(parseEmpfangChatInstantMs(iso));
}
function conversationDateLabel(iso) {
var ms = parseEmpfangChatInstantMs(iso);
if (!isFinite(ms)) return '';
var msgKey = formatMessengerDayKeyFromMs(ms);
var todayKey = formatMessengerDayKeyFromMs(Date.now());
if (msgKey === todayKey) return 'Heute';
var yestKey = formatMessengerDayKeyFromMs(Date.now() - 86400000);
if (msgKey === yestKey) return 'Gestern';
try {
var parts = new Intl.DateTimeFormat('de-CH', {
timeZone: empfangChatTimeZoneId(),
day: 'numeric',
month: 'numeric',
year: 'numeric'
}).formatToParts(new Date(ms));
var da = '';
var mo = '';
var ye = '';
for (var ip = 0; ip < parts.length; ip++) {
var p = parts[ip];
if (p.type === 'day') da = p.value;
if (p.type === 'month') mo = p.value;
if (p.type === 'year') ye = p.value;
}
if (da && mo && ye) return da + '.' + mo + '.' + ye;
} catch (e) {}
return '';
}
function conversationAnchorLastId() {
var s = conversationSortChrono(lastConvMessages);
if (!s.length) return null;
return s[s.length - 1].id || null;
}
function messageBodyLines(m) {
var parts = [];
if (m.therapieplan && String(m.therapieplan).trim()) parts.push('Therapieplan:\n' + m.therapieplan);
if (m.procedere && String(m.procedere).trim()) parts.push('Procedere:\n' + m.procedere);
if (m.medikamente && String(m.medikamente).trim()) parts.push('Medikamente:\n' + m.medikamente);
var k = (m.kommentar || '').trim();
if (k && k !== '\u200b') parts.push(k);
return parts.join('\n\n');
}
async function doSend() {
stopDictateIfActive();
var tx = document.getElementById('tx');
var text = (tx.value || '').trim();
if (!text && !pendingAttach.length) return;
if (!currentSession) return;
var extras = {};
if (mode === 'dm' && peerName) {
extras.recipient = peerName;
if (peerUserId) extras.recipient_user_id = peerUserId;
}
var selfUid = String((currentSession.user_id || '')).trim();
if (selfUid) extras.sender_user_id = selfUid;
var anchor = conversationAnchorLastId();
if (anchor) extras.reply_to = anchor;
if (pendingAttach.length) {
extras.attachments = pendingAttach.map(function(a) {
return { name: a.name, data: a.data };
});
}
var payload = {
medikamente: '', therapieplan: '', procedere: '',
kommentar: text || (pendingAttach.length ? '\u200b' : ''),
patient: mode === 'dm' && peerName ? ('Direkt: ' + peerName) : patientLine(),
absender: currentSession.display_name + ' (Empfang)',
extras: extras
};
document.getElementById('btn-send').disabled = true;
try {
var r = await fetch(API_BASE + '/send', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
var d = await r.json().catch(function() { return {}; });
if (r.ok && d.success) {
threadId = d.thread_id || d.id || threadId;
tx.value = '';
pendingAttach = [];
renderPendingStrip();
document.getElementById('att-preview').textContent = '';
lastPollSig = '';
lastServerTick = -1;
await loadConversationFromServer();
} else {
alert(d.detail || 'Senden fehlgeschlagen');
}
} catch (e) {
alert('Verbindungsfehler');
}
document.getElementById('btn-send').disabled = false;
}
function appendImagesToEl(parent, att) {
if (!att || !att.length) return;
att.forEach(function(a) {
if (!a || !a.data) return;
var mime = mimeForAttachment(a.name, a.mime || '');
var img = document.createElement('img');
img.src = 'data:' + mime + ';base64,' + a.data;
img.alt = a.name || 'Bild';
parent.appendChild(img);
});
}
function renderOne(m, isMe) {
var div = document.createElement('div');
div.className = 'msg ' + (isMe ? 'me' : 'them');
var meta = document.createElement('div');
meta.className = 'meta';
var core = (m.absender || '').split('(')[0].trim();
meta.textContent = core + ' · ' + formatEmpfangChatDateTimeLong(m.zeitstempel || m.empfangen || '');
div.appendChild(meta);
var body = messageBodyLines(m);
if (body) {
var t = document.createElement('div');
t.style.whiteSpace = 'pre-wrap';
t.textContent = body;
div.appendChild(t);
}
var att = m.extras && m.extras.attachments;
appendImagesToEl(div, att);
return div;
}
function playTone(idx) {
if (!soundEnabled) return;
var preset = TONE_PRESETS[idx] || TONE_PRESETS[0];
var vol = preset.vol * Math.max(0, volume);
try {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === 'suspended') audioCtx.resume();
var t0 = audioCtx.currentTime;
var t = t0;
preset.notes.forEach(function(note) {
var g = audioCtx.createGain();
g.connect(audioCtx.destination);
g.gain.setValueAtTime(vol, t);
g.gain.linearRampToValueAtTime(vol * 0.6, t + note.d * 0.4);
g.gain.exponentialRampToValueAtTime(0.001, t + note.d);
var o = audioCtx.createOscillator();
o.type = preset.wave;
o.frequency.setValueAtTime(note.f, t);
o.connect(g);
o.start(t);
o.stop(t + note.d + 0.05);
t += note.d;
});
} catch (e) {}
}
function updateSoundBtn() {
var btn = document.getElementById('cw-sound');
if (!btn) return;
if (soundEnabled) {
btn.innerHTML = '&#128276;';
btn.classList.remove('muted');
btn.title = 'Ton an (Klick = aus)';
} else {
btn.innerHTML = '&#128277;';
btn.classList.add('muted');
btn.title = 'Ton aus (Klick = an)';
}
}
function toggleSound() {
soundEnabled = !soundEnabled;
localStorage.setItem('empfang_sound', soundEnabled ? 'on' : 'off');
updateSoundBtn();
if (soundEnabled) playTone(currentToneIdx);
}
function testSound() {
var prev = soundEnabled;
soundEnabled = true;
playTone(currentToneIdx);
soundEnabled = prev;
}
function setVolFromSlider() {
var el = document.getElementById('cw-vol');
if (!el) return;
volume = Math.min(3, Math.max(0, parseInt(el.value, 10) / 100));
document.getElementById('cw-vol-disp').textContent = Math.round(volume * 100) + '%';
try {
localStorage.setItem('empfang_volume', String(volume));
} catch (e) {}
}
function initVolumeUI() {
var el = document.getElementById('cw-vol');
if (!el) return;
el.value = String(Math.round(volume * 100));
document.getElementById('cw-vol-disp').textContent = Math.round(volume * 100) + '%';
el.addEventListener('input', setVolFromSlider);
}
function initToneSelect() {
var sel = document.getElementById('cw-tone');
if (!sel) return;
sel.innerHTML = '';
TONE_PRESETS.forEach(function(t, i) {
var o = document.createElement('option');
o.value = String(i);
o.textContent = t.name;
if (i === currentToneIdx) o.selected = true;
sel.appendChild(o);
});
}
function saveToneChoice() {
var sel = document.getElementById('cw-tone');
currentToneIdx = parseInt(sel.value, 10) || 0;
localStorage.setItem('empfang_tone_idx', String(currentToneIdx));
if (soundEnabled) playTone(currentToneIdx);
}
function toggleSettings() {
var p = document.getElementById('cw-settings');
if (!p) return;
p.classList.toggle('hidden');
var g = document.getElementById('cw-gear');
if (g) g.classList.toggle('on', !p.classList.contains('hidden'));
}
async function loadConversationFromServer() {
if (!currentSession) return;
var me = encodeURIComponent(currentSession.display_name || '');
var aud = encodeURIComponent(conversationAudienceParam());
var qs = 'audience=' + aud + '&me=' + me;
var mu = String((currentSession.user_id || '')).trim();
if (mu) qs += '&me_user_id=' + encodeURIComponent(mu);
if (mode === 'dm' && peerUserId) qs += '&peer_user_id=' + encodeURIComponent(peerUserId);
try {
var r = await fetch(
API_BASE + '/conversation?' + qs,
{ credentials: 'include' }
);
if (r.status === 401) {
document.getElementById('gate').classList.remove('hidden');
return;
}
var d = await r.json();
var msgs = conversationSortChrono(d.messages || []);
lastConvMessages = msgs;
lastServerTick = parseInt(d.tick || 0, 10);
var sig = msgs.map(function(m) { return m.id; }).join(',');
if (lastPollSig && sig !== lastPollSig) {
var myName = currentSession.display_name;
var oldSet = {};
lastPollSig.split(',').forEach(function(id) { if (id) oldSet[id] = true; });
var playOnce = false;
msgs.forEach(function(m) {
if (oldSet[m.id]) return;
var isMe = (m.absender || '').indexOf(myName) === 0 ||
(m.absender || '').split('(')[0].trim() === myName;
if (!isMe) playOnce = true;
});
if (playOnce) playTone(currentToneIdx);
}
lastPollSig = sig;
var log = document.getElementById('log');
log.innerHTML = '';
var myName = currentSession.display_name;
var lastDay = '';
msgs.forEach(function(m) {
var tsFull = m.empfangen || m.zeitstempel || '';
var dk = conversationDayKey(tsFull);
if (dk && dk !== lastDay) {
lastDay = dk;
var sep = document.createElement('div');
sep.className = 'msg-date-sep';
var span = document.createElement('span');
span.textContent = conversationDateLabel(tsFull);
sep.appendChild(span);
log.appendChild(sep);
}
var isMe = (m.absender || '').indexOf(myName) === 0 ||
(m.absender || '').split('(')[0].trim() === myName;
log.appendChild(renderOne(m, isMe));
});
log.scrollTop = log.scrollHeight;
} catch (e) {}
}
function reloadThread() {
loadConversationFromServer();
}
async function init() {
parseQs();
setTitle();
initVolumeUI();
initToneSelect();
updateSoundBtn();
setupDropPaste();
var up = new URLSearchParams(window.location.search);
var invQ = (up.get('invite') || '').trim();
var resolvedMini = null;
if (invQ) {
try {
var ires = await fetch(API_BASE + '/auth/resolve_invite?code=' + encodeURIComponent(invQ));
resolvedMini = await ires.json().catch(function() { return null; });
if (resolvedMini && resolvedMini.valid && resolvedMini.practice_id) {
try { localStorage.setItem('aza_practice_id', resolvedMini.practice_id); } catch(e) {}
}
} catch(e) {}
}
currentSession = await checkAuth();
if (currentSession && invQ && resolvedMini && resolvedMini.valid && resolvedMini.practice_id &&
currentSession.practice_id && currentSession.practice_id !== resolvedMini.practice_id) {
try { await fetch(API_BASE + '/auth/logout', { method: 'POST', credentials: 'include' }); } catch(e) {}
currentSession = null;
try { localStorage.setItem('aza_practice_id', resolvedMini.practice_id); } catch(e) {}
}
if (!currentSession) {
showGate('Melden Sie sich im Empfang an, um zu chatten. Dieses Fenster nutzt dieselbe Anmeldung.');
return;
}
await loadPracticeSubline();
await loadConversationFromServer();
pollTimer = setInterval(async function() {
try {
var r = await fetch(API_BASE + '/pulse', { credentials: 'include' });
if (r.status !== 200) return;
var d = await r.json();
var t = parseInt(d.tick || 0, 10);
if (t !== lastServerTick) {
lastServerTick = t;
await loadConversationFromServer();
}
} catch (e) {}
}, 1000);
}
init();
</script>
</body>
</html>

View File

@@ -0,0 +1,38 @@
body {
font-family: Arial, sans-serif;
margin: 40px;
background: #f5f5f5;
}
header {
margin-bottom: 40px;
}
h1 {
margin-bottom: 5px;
}
main {
background: white;
padding: 30px;
border-radius: 8px;
}
.button {
display: inline-block;
padding: 10px 18px;
background: #2d6cdf;
color: white;
text-decoration: none;
border-radius: 5px;
}
.button:hover {
background: #1b4fad;
}
footer {
margin-top: 40px;
font-size: 12px;
color: #777;
}