1260 lines
51 KiB
Python
1260 lines
51 KiB
Python
|
|
# -*- coding: utf-8 -*-
|
|||
|
|
"""
|
|||
|
|
AzA To-Do Server – Mobile PWA mit Supabase Cloud-Sync
|
|||
|
|
======================================================
|
|||
|
|
Die App synchronisiert sich direkt mit Supabase (kostenlose Cloud-DB).
|
|||
|
|
Das iPhone braucht KEINEN laufenden PC – funktioniert von ueberall.
|
|||
|
|
|
|||
|
|
Der lokale Server wird NUR einmalig benoetigt, um die App per QR-Code
|
|||
|
|
auf das iPhone zu laden. Danach laeuft die App eigenstaendig.
|
|||
|
|
|
|||
|
|
Nutzung:
|
|||
|
|
py todo_server.py Einmalig starten, QR scannen, fertig.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import os
|
|||
|
|
import sys
|
|||
|
|
import json
|
|||
|
|
import socket
|
|||
|
|
import subprocess
|
|||
|
|
import threading
|
|||
|
|
import time
|
|||
|
|
import webbrowser
|
|||
|
|
import base64
|
|||
|
|
from datetime import datetime
|
|||
|
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|||
|
|
from aza_tls import check_tls_or_exit, create_ssl_context, has_tls_config
|
|||
|
|
|
|||
|
|
# ─── Konfiguration ───
|
|||
|
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|||
|
|
TODO_FILE = os.path.join(BASE_DIR, "kg_diktat_todos.json")
|
|||
|
|
PORT = 5111
|
|||
|
|
_APP_VERSION = "1.0.0"
|
|||
|
|
_START_TIME = time.time()
|
|||
|
|
|
|||
|
|
SUPABASE_URL = "https://cqbqkejdlxesxjdtkpfr.supabase.co"
|
|||
|
|
SUPABASE_ANON_KEY = "sb_publishable_r8YMw01FF-fYD9vHLnz7FQ_d8mhpVVQ"
|
|||
|
|
|
|||
|
|
HAS_QRCODE = False
|
|||
|
|
try:
|
|||
|
|
import qrcode
|
|||
|
|
import io as _io
|
|||
|
|
HAS_QRCODE = True
|
|||
|
|
except ImportError:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# ─── Logo ───
|
|||
|
|
LOGO_PNG_FILE = os.path.join(BASE_DIR, "assets", "logo.png")
|
|||
|
|
|
|||
|
|
LOGO_PNG_B64 = (
|
|||
|
|
"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"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _get_logo_bytes():
|
|||
|
|
if os.path.isfile(LOGO_PNG_FILE):
|
|||
|
|
with open(LOGO_PNG_FILE, "rb") as f:
|
|||
|
|
return f.read()
|
|||
|
|
return base64.b64decode(LOGO_PNG_B64)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _load_todos():
|
|||
|
|
try:
|
|||
|
|
if os.path.isfile(TODO_FILE):
|
|||
|
|
with open(TODO_FILE, "r", encoding="utf-8") as f:
|
|||
|
|
return json.load(f)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _save_todos(todos):
|
|||
|
|
try:
|
|||
|
|
with open(TODO_FILE, "w", encoding="utf-8") as f:
|
|||
|
|
json.dump(todos, f, indent=2, ensure_ascii=False)
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"Save error: {e}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _supabase_push(todos=None):
|
|||
|
|
"""Pusht Todos nach Supabase (Server-seitig)."""
|
|||
|
|
import urllib.request
|
|||
|
|
if todos is None:
|
|||
|
|
todos = _load_todos()
|
|||
|
|
payload = json.dumps({"data": todos}).encode("utf-8")
|
|||
|
|
req = urllib.request.Request(
|
|||
|
|
f"{SUPABASE_URL}/rest/v1/todo_sync?id=eq.1",
|
|||
|
|
data=payload, method="PATCH",
|
|||
|
|
headers={
|
|||
|
|
"apikey": SUPABASE_ANON_KEY,
|
|||
|
|
"Authorization": f"Bearer {SUPABASE_ANON_KEY}",
|
|||
|
|
"Content-Type": "application/json",
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
try:
|
|||
|
|
urllib.request.urlopen(req, timeout=10)
|
|||
|
|
return True
|
|||
|
|
except Exception:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _get_local_ip():
|
|||
|
|
try:
|
|||
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|||
|
|
s.settimeout(2)
|
|||
|
|
s.connect(("8.8.8.8", 80))
|
|||
|
|
ip = s.getsockname()[0]
|
|||
|
|
s.close()
|
|||
|
|
return ip
|
|||
|
|
except Exception:
|
|||
|
|
return "127.0.0.1"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _add_firewall_rule():
|
|||
|
|
if sys.platform != "win32":
|
|||
|
|
return
|
|||
|
|
try:
|
|||
|
|
subprocess.run(
|
|||
|
|
["netsh", "advfirewall", "firewall", "add", "rule",
|
|||
|
|
"name=AzA Todo Server (Port)", "dir=in", "action=allow",
|
|||
|
|
"protocol=TCP", f"localport={PORT}"],
|
|||
|
|
capture_output=True, timeout=5
|
|||
|
|
)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─── HTTP-Server (nur fuer Ersteinrichtung + lokale Nutzung) ───
|
|||
|
|
|
|||
|
|
from socketserver import ThreadingMixIn
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TodoServer(ThreadingMixIn, HTTPServer):
|
|||
|
|
allow_reuse_address = True
|
|||
|
|
daemon_threads = True
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TodoHandler(BaseHTTPRequestHandler):
|
|||
|
|
|
|||
|
|
def log_message(self, format, *args):
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
def _send_json(self, data, status=200):
|
|||
|
|
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
|||
|
|
self.send_response(status)
|
|||
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|||
|
|
self.send_header("Access-Control-Allow-Origin", "*")
|
|||
|
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|||
|
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|||
|
|
self.send_header("Content-Length", str(len(body)))
|
|||
|
|
self.end_headers()
|
|||
|
|
self.wfile.write(body)
|
|||
|
|
|
|||
|
|
def _send_html(self, html, status=200):
|
|||
|
|
body = html.encode("utf-8")
|
|||
|
|
self.send_response(status)
|
|||
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|||
|
|
self.send_header("Content-Length", str(len(body)))
|
|||
|
|
self.end_headers()
|
|||
|
|
self.wfile.write(body)
|
|||
|
|
|
|||
|
|
def _send_text(self, text, content_type="text/plain", status=200):
|
|||
|
|
body = text.encode("utf-8")
|
|||
|
|
self.send_response(status)
|
|||
|
|
self.send_header("Content-Type", f"{content_type}; charset=utf-8")
|
|||
|
|
self.send_header("Content-Length", str(len(body)))
|
|||
|
|
self.end_headers()
|
|||
|
|
self.wfile.write(body)
|
|||
|
|
|
|||
|
|
def _send_bytes(self, data, content_type, status=200):
|
|||
|
|
self.send_response(status)
|
|||
|
|
self.send_header("Content-Type", content_type)
|
|||
|
|
self.send_header("Content-Length", str(len(data)))
|
|||
|
|
self.send_header("Cache-Control", "public, max-age=31536000")
|
|||
|
|
self.end_headers()
|
|||
|
|
self.wfile.write(data)
|
|||
|
|
|
|||
|
|
def do_OPTIONS(self):
|
|||
|
|
self.send_response(200)
|
|||
|
|
self.send_header("Access-Control-Allow-Origin", "*")
|
|||
|
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|||
|
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|||
|
|
self.end_headers()
|
|||
|
|
|
|||
|
|
def do_GET(self):
|
|||
|
|
path = self.path.split("?")[0]
|
|||
|
|
if path == "/health":
|
|||
|
|
self._send_json({
|
|||
|
|
"status": "ok",
|
|||
|
|
"version": _APP_VERSION,
|
|||
|
|
"uptime_s": int(time.time() - _START_TIME),
|
|||
|
|
"tls": has_tls_config(),
|
|||
|
|
})
|
|||
|
|
elif path == "/":
|
|||
|
|
self._send_html(_build_pwa_html())
|
|||
|
|
elif path == "/api/todos":
|
|||
|
|
self._send_json(_load_todos())
|
|||
|
|
elif path == "/api/qr":
|
|||
|
|
self._handle_qr()
|
|||
|
|
elif path == "/manifest.json":
|
|||
|
|
self._send_json({
|
|||
|
|
"name": "AzA To-Do", "short_name": "AzA To-Do", "start_url": "/",
|
|||
|
|
"display": "standalone", "background_color": "#E8F4FA",
|
|||
|
|
"theme_color": "#1a4d6d",
|
|||
|
|
"icons": [{"src": "/icon-180.png", "sizes": "180x180", "type": "image/png"}]
|
|||
|
|
})
|
|||
|
|
elif path in ("/icon-180.png", "/apple-touch-icon.png", "/apple-touch-icon-precomposed.png"):
|
|||
|
|
self._send_bytes(_get_logo_bytes(), "image/png")
|
|||
|
|
elif path == "/sw.js":
|
|||
|
|
self._send_text(_build_sw_js(), "application/javascript")
|
|||
|
|
else:
|
|||
|
|
self.send_error(404)
|
|||
|
|
|
|||
|
|
def do_POST(self):
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
def do_PUT(self):
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
def do_DELETE(self):
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
def _handle_qr(self):
|
|||
|
|
if not HAS_QRCODE:
|
|||
|
|
self._send_json({"error": "qrcode nicht installiert"}, 500)
|
|||
|
|
return
|
|||
|
|
scheme = "https" if has_tls_config() else "http"
|
|||
|
|
url = f"{scheme}://{_get_local_ip()}:{PORT}"
|
|||
|
|
img = qrcode.make(url, box_size=8, border=2)
|
|||
|
|
buf = _io.BytesIO()
|
|||
|
|
img.save(buf, format="PNG")
|
|||
|
|
b64 = base64.b64encode(buf.getvalue()).decode()
|
|||
|
|
self._send_json({"qr": f"data:image/png;base64,{b64}", "url": url})
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─── Service Worker ───
|
|||
|
|
|
|||
|
|
def _build_sw_js():
|
|||
|
|
return '''
|
|||
|
|
var CACHE_NAME = 'aza-todo-v5';
|
|||
|
|
var URLS_TO_CACHE = ['/', '/manifest.json', '/icon-180.png'];
|
|||
|
|
|
|||
|
|
self.addEventListener('install', function(event) {
|
|||
|
|
event.waitUntil(
|
|||
|
|
caches.open(CACHE_NAME).then(function(cache) { return cache.addAll(URLS_TO_CACHE); })
|
|||
|
|
);
|
|||
|
|
self.skipWaiting();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
self.addEventListener('activate', function(event) {
|
|||
|
|
event.waitUntil(
|
|||
|
|
caches.keys().then(function(names) {
|
|||
|
|
return Promise.all(
|
|||
|
|
names.filter(function(n) { return n !== CACHE_NAME; })
|
|||
|
|
.map(function(n) { return caches.delete(n); })
|
|||
|
|
);
|
|||
|
|
})
|
|||
|
|
);
|
|||
|
|
self.clients.claim();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
self.addEventListener('fetch', function(event) {
|
|||
|
|
var url = event.request.url;
|
|||
|
|
if (url.indexOf('supabase.co') !== -1) {
|
|||
|
|
event.respondWith(fetch(event.request));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
event.respondWith(
|
|||
|
|
caches.match(event.request).then(function(resp) {
|
|||
|
|
return resp || fetch(event.request).then(function(netResp) {
|
|||
|
|
return caches.open(CACHE_NAME).then(function(cache) {
|
|||
|
|
cache.put(event.request, netResp.clone());
|
|||
|
|
return netResp;
|
|||
|
|
});
|
|||
|
|
}).catch(function() {
|
|||
|
|
return new Response('<h1>Offline</h1>', {headers: {"Content-Type": "text/html"}});
|
|||
|
|
});
|
|||
|
|
})
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
'''
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─── PWA HTML (nutzt Supabase direkt – kein Server noetig!) ───
|
|||
|
|
|
|||
|
|
def _build_pwa_html():
|
|||
|
|
return f'''<!DOCTYPE html>
|
|||
|
|
<html lang="de">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="UTF-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
|||
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|||
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|||
|
|
<meta name="theme-color" content="#1a4d6d">
|
|||
|
|
<link rel="manifest" href="/manifest.json">
|
|||
|
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
|||
|
|
<link rel="icon" type="image/png" href="/icon-180.png">
|
|||
|
|
<title>AzA To-Do</title>
|
|||
|
|
<style>
|
|||
|
|
:root {{
|
|||
|
|
--bg: #E8F4FA; --card: #fff; --header: #1a4d6d; --accent: #5B8DB3;
|
|||
|
|
--light: #B9ECFA; --mid: #7EC8E3; --text: #1a4d6d; --muted: #4a8aaa;
|
|||
|
|
--red: #E87070; --green: #5BDB7B; --yellow: #F0C040;
|
|||
|
|
--prio1: #FDE8E8; --prio2: #FDF5E0; --overdue: #FDECEC; --done: #EDF7ED;
|
|||
|
|
--safe-top: env(safe-area-inset-top, 0px);
|
|||
|
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
|||
|
|
}}
|
|||
|
|
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
|||
|
|
body {{
|
|||
|
|
font-family: -apple-system, "Segoe UI", Roboto, sans-serif;
|
|||
|
|
background: var(--bg); color: var(--text);
|
|||
|
|
min-height: 100vh; min-height: 100dvh;
|
|||
|
|
padding-top: var(--safe-top); padding-bottom: var(--safe-bottom);
|
|||
|
|
-webkit-tap-highlight-color: transparent;
|
|||
|
|
}}
|
|||
|
|
header {{
|
|||
|
|
background: var(--header); color: white; padding: 16px 20px 12px;
|
|||
|
|
position: sticky; top: 0; z-index: 100;
|
|||
|
|
padding-top: calc(12px + var(--safe-top));
|
|||
|
|
box-shadow: 0 2px 12px rgba(0,0,0,.15);
|
|||
|
|
}}
|
|||
|
|
header h1 {{ font-size: 20px; font-weight: 700; display: flex; align-items: center; gap: 8px; }}
|
|||
|
|
header .logo {{ width: 28px; height: 28px; border-radius: 6px; }}
|
|||
|
|
header .sub {{ font-size: 12px; opacity: .7; margin-top: 2px; }}
|
|||
|
|
.sync-dot {{ display: inline-block; width: 8px; height: 8px; border-radius: 50%;
|
|||
|
|
background: var(--green); margin-left: 8px; vertical-align: middle; }}
|
|||
|
|
.sync-dot.off {{ background: var(--red); }}
|
|||
|
|
.sync-dot.syncing {{ background: var(--yellow); animation: pulse .8s infinite; }}
|
|||
|
|
@keyframes pulse {{ 0%,100% {{ opacity:1; }} 50% {{ opacity:.3; }} }}
|
|||
|
|
.input-bar {{
|
|||
|
|
background: #D4EEF7; padding: 12px 16px; display: flex; gap: 8px;
|
|||
|
|
position: sticky; top: 68px; z-index: 90;
|
|||
|
|
}}
|
|||
|
|
.input-bar input {{
|
|||
|
|
flex: 1; border: none; border-radius: 12px; padding: 12px 16px;
|
|||
|
|
font-size: 16px; background: white; color: var(--text);
|
|||
|
|
outline: none; -webkit-appearance: none;
|
|||
|
|
}}
|
|||
|
|
.input-bar input::placeholder {{ color: #aaa; }}
|
|||
|
|
.input-bar button {{
|
|||
|
|
background: var(--accent); color: white; border: none; border-radius: 12px;
|
|||
|
|
padding: 0 18px; font-size: 22px; font-weight: 700; cursor: pointer;
|
|||
|
|
min-width: 48px; transition: background .15s;
|
|||
|
|
}}
|
|||
|
|
.input-bar button:active {{ background: var(--header); }}
|
|||
|
|
.date-bar {{
|
|||
|
|
background: #D4EEF7; padding: 0 16px 10px; display: flex; align-items: center;
|
|||
|
|
gap: 8px; font-size: 13px; color: var(--muted);
|
|||
|
|
}}
|
|||
|
|
.date-bar input[type=date] {{
|
|||
|
|
border: none; border-radius: 8px; padding: 6px 10px; font-size: 14px;
|
|||
|
|
background: white; color: var(--text); -webkit-appearance: none;
|
|||
|
|
}}
|
|||
|
|
.date-bar .prio-btn {{
|
|||
|
|
border: none; border-radius: 8px; padding: 5px 10px; font-size: 13px;
|
|||
|
|
cursor: pointer; transition: all .15s;
|
|||
|
|
}}
|
|||
|
|
.date-bar .prio-btn.p0 {{ background: #E8F4FA; color: var(--muted); }}
|
|||
|
|
.date-bar .prio-btn.p1 {{ background: var(--prio1); color: #A03030; }}
|
|||
|
|
.date-bar .prio-btn.p2 {{ background: var(--prio2); color: #806000; }}
|
|||
|
|
.counter {{ padding: 8px 20px; font-size: 12px; color: var(--muted); background: var(--bg); }}
|
|||
|
|
.counter .overdue-count {{ color: var(--red); font-weight: 600; }}
|
|||
|
|
.todo-list {{ padding: 0 12px 120px; }}
|
|||
|
|
.todo-item {{
|
|||
|
|
background: var(--card); border-radius: 14px; padding: 14px 16px;
|
|||
|
|
margin: 8px 0; display: flex; align-items: flex-start; gap: 12px;
|
|||
|
|
box-shadow: 0 1px 4px rgba(0,0,0,.06); transition: all .2s;
|
|||
|
|
border-left: 4px solid var(--mid); position: relative;
|
|||
|
|
}}
|
|||
|
|
.todo-item.prio1 {{ border-left-color: var(--red); background: var(--prio1); }}
|
|||
|
|
.todo-item.prio2 {{ border-left-color: var(--yellow); background: var(--prio2); }}
|
|||
|
|
.todo-item.overdue {{ border-left-color: var(--red); background: var(--overdue); }}
|
|||
|
|
.todo-item.done {{ border-left-color: #A8D8A8; background: var(--done); opacity: .7; }}
|
|||
|
|
.todo-item .check {{
|
|||
|
|
width: 26px; height: 26px; border-radius: 50%; border: 2px solid var(--mid);
|
|||
|
|
flex-shrink: 0; cursor: pointer; display: flex; align-items: center;
|
|||
|
|
justify-content: center; font-size: 14px; color: transparent;
|
|||
|
|
transition: all .2s; margin-top: 2px;
|
|||
|
|
}}
|
|||
|
|
.todo-item .check:active {{ transform: scale(.9); }}
|
|||
|
|
.todo-item.done .check {{ background: var(--green); border-color: var(--green); color: white; }}
|
|||
|
|
.todo-item .content {{ flex: 1; min-width: 0; }}
|
|||
|
|
.todo-item .task-text {{ font-size: 15px; line-height: 1.4; word-break: break-word; }}
|
|||
|
|
.todo-item.done .task-text {{ text-decoration: line-through; color: #999; }}
|
|||
|
|
.todo-item .meta {{ display: flex; gap: 8px; margin-top: 4px; flex-wrap: wrap; align-items: center; }}
|
|||
|
|
.todo-item .meta span {{ font-size: 11px; padding: 2px 6px; border-radius: 6px; }}
|
|||
|
|
.todo-item .date-tag {{ background: #D4EEF7; color: var(--muted); }}
|
|||
|
|
.todo-item .date-tag.overdue {{ background: #FDE0E0; color: var(--red); font-weight: 600; }}
|
|||
|
|
.todo-item .prio-tag {{ font-weight: 600; }}
|
|||
|
|
.todo-item .prio-tag.p1 {{ background: #F8D0D0; color: #A03030; }}
|
|||
|
|
.todo-item .prio-tag.p2 {{ background: #F8ECC8; color: #806000; }}
|
|||
|
|
.todo-item .notes-indicator {{ color: var(--muted); font-size: 12px; }}
|
|||
|
|
.todo-item .sender-tag {{ background: var(--light); color: var(--text); font-style: italic; }}
|
|||
|
|
.todo-item .delete-btn {{
|
|||
|
|
position: absolute; top: 8px; right: 12px; font-size: 18px; color: #ccc;
|
|||
|
|
cursor: pointer; padding: 4px; line-height: 1; transition: color .15s;
|
|||
|
|
}}
|
|||
|
|
.todo-item .delete-btn:active {{ color: var(--red); }}
|
|||
|
|
.footer-bar {{
|
|||
|
|
position: fixed; bottom: 0; left: 0; right: 0;
|
|||
|
|
background: #D4EEF7; padding: 10px 16px; padding-bottom: calc(10px + var(--safe-bottom));
|
|||
|
|
display: flex; gap: 8px; justify-content: center;
|
|||
|
|
box-shadow: 0 -2px 12px rgba(0,0,0,.08); z-index: 100;
|
|||
|
|
}}
|
|||
|
|
.footer-bar button {{
|
|||
|
|
border: none; border-radius: 10px; padding: 10px 16px; font-size: 13px;
|
|||
|
|
font-weight: 600; cursor: pointer; transition: all .15s;
|
|||
|
|
}}
|
|||
|
|
.footer-bar .btn-del {{ background: #C8DDE6; color: var(--text); }}
|
|||
|
|
.footer-bar .btn-del:active {{ background: #B0CCD6; }}
|
|||
|
|
.footer-bar .btn-sync {{ background: var(--accent); color: white; }}
|
|||
|
|
.footer-bar .btn-sync:active {{ background: var(--header); }}
|
|||
|
|
.modal-overlay {{
|
|||
|
|
display: none; position: fixed; inset: 0; background: rgba(0,0,0,.4);
|
|||
|
|
z-index: 200; align-items: flex-end; justify-content: center;
|
|||
|
|
}}
|
|||
|
|
.modal-overlay.show {{ display: flex; }}
|
|||
|
|
.modal {{
|
|||
|
|
background: var(--bg); border-radius: 20px 20px 0 0; width: 100%; max-width: 500px;
|
|||
|
|
max-height: 85vh; overflow-y: auto; padding: 20px; padding-bottom: calc(20px + var(--safe-bottom));
|
|||
|
|
animation: slideUp .25s ease-out;
|
|||
|
|
}}
|
|||
|
|
@keyframes slideUp {{ from {{ transform: translateY(100%); }} to {{ transform: translateY(0); }} }}
|
|||
|
|
.modal h2 {{ font-size: 17px; color: var(--header); margin-bottom: 16px; }}
|
|||
|
|
.modal label {{ display: block; font-size: 13px; font-weight: 600; color: var(--text); margin: 10px 0 4px; }}
|
|||
|
|
.modal textarea, .modal input[type=text], .modal input[type=date] {{
|
|||
|
|
width: 100%; border: 1px solid #D0E8F0; border-radius: 10px; padding: 10px 12px;
|
|||
|
|
font-size: 15px; font-family: inherit; color: var(--text); background: white;
|
|||
|
|
outline: none; -webkit-appearance: none; resize: vertical;
|
|||
|
|
}}
|
|||
|
|
.modal textarea:focus, .modal input:focus {{ border-color: var(--accent); }}
|
|||
|
|
.modal .prio-group {{ display: flex; gap: 8px; margin-top: 4px; }}
|
|||
|
|
.modal .prio-group button {{
|
|||
|
|
flex: 1; border: 2px solid transparent; border-radius: 10px; padding: 8px;
|
|||
|
|
font-size: 13px; font-weight: 600; cursor: pointer; transition: all .15s;
|
|||
|
|
}}
|
|||
|
|
.modal .prio-group button.active {{ border-color: var(--header); }}
|
|||
|
|
.modal .prio-group .p0 {{ background: #E8F4FA; color: var(--muted); }}
|
|||
|
|
.modal .prio-group .p1 {{ background: var(--prio1); color: #A03030; }}
|
|||
|
|
.modal .prio-group .p2 {{ background: var(--prio2); color: #806000; }}
|
|||
|
|
.modal .btn-row {{ display: flex; gap: 10px; margin-top: 18px; }}
|
|||
|
|
.modal .btn-row button {{
|
|||
|
|
flex: 1; border: none; border-radius: 12px; padding: 14px; font-size: 15px;
|
|||
|
|
font-weight: 700; cursor: pointer; transition: all .15s;
|
|||
|
|
}}
|
|||
|
|
.modal .btn-save {{ background: var(--accent); color: white; }}
|
|||
|
|
.modal .btn-save:active {{ background: var(--header); }}
|
|||
|
|
.modal .btn-cancel {{ background: #C8DDE6; color: var(--text); }}
|
|||
|
|
.empty-state {{ text-align: center; padding: 60px 20px; color: var(--muted); }}
|
|||
|
|
.empty-state .icon {{ font-size: 48px; margin-bottom: 12px; }}
|
|||
|
|
.empty-state p {{ font-size: 15px; }}
|
|||
|
|
/* Tab Bar */
|
|||
|
|
.tab-bar {{
|
|||
|
|
display: flex; background: #A8D8E8; position: sticky; top: 52px; z-index: 95;
|
|||
|
|
}}
|
|||
|
|
.tab-bar .tab {{
|
|||
|
|
flex: 1; text-align: center; padding: 10px 0; font-size: 14px; font-weight: 600;
|
|||
|
|
cursor: pointer; transition: all 0.15s; color: #5A90B0;
|
|||
|
|
background: #A8D8E8; border: none;
|
|||
|
|
}}
|
|||
|
|
.tab-bar .tab.active {{
|
|||
|
|
background: var(--bg); color: var(--text);
|
|||
|
|
}}
|
|||
|
|
/* Notes */
|
|||
|
|
.notes-page {{ display: none; }}
|
|||
|
|
.notes-page.active {{ display: block; }}
|
|||
|
|
.todos-page {{ display: block; }}
|
|||
|
|
.todos-page.hidden {{ display: none; }}
|
|||
|
|
.note-input-bar {{
|
|||
|
|
background: #D4EEF7; padding: 12px 16px;
|
|||
|
|
}}
|
|||
|
|
.note-input-bar input {{
|
|||
|
|
width: 100%; border: none; border-radius: 12px; padding: 12px 16px;
|
|||
|
|
font-size: 16px; background: white; color: var(--text); outline: none;
|
|||
|
|
-webkit-appearance: none; margin-bottom: 8px;
|
|||
|
|
}}
|
|||
|
|
.note-input-bar textarea {{
|
|||
|
|
width: 100%; border: none; border-radius: 12px; padding: 12px 16px;
|
|||
|
|
font-size: 15px; background: white; color: var(--text); outline: none;
|
|||
|
|
-webkit-appearance: none; resize: vertical; font-family: inherit;
|
|||
|
|
min-height: 60px;
|
|||
|
|
}}
|
|||
|
|
.note-input-bar button {{
|
|||
|
|
background: var(--accent); color: white; border: none; border-radius: 12px;
|
|||
|
|
padding: 10px 18px; font-size: 14px; font-weight: 700; cursor: pointer;
|
|||
|
|
margin-top: 8px; float: right; transition: background .15s;
|
|||
|
|
}}
|
|||
|
|
.note-input-bar button:active {{ background: var(--header); }}
|
|||
|
|
.note-item {{
|
|||
|
|
background: var(--card); border-radius: 14px; padding: 14px 16px;
|
|||
|
|
margin: 8px 12px; box-shadow: 0 1px 4px rgba(0,0,0,.06);
|
|||
|
|
border-left: 4px solid var(--mid); position: relative;
|
|||
|
|
}}
|
|||
|
|
.note-item .note-title {{
|
|||
|
|
font-size: 15px; font-weight: 700; color: var(--text); cursor: pointer;
|
|||
|
|
}}
|
|||
|
|
.note-item .note-preview {{
|
|||
|
|
font-size: 13px; color: var(--muted); margin-top: 4px; line-height: 1.4;
|
|||
|
|
}}
|
|||
|
|
.note-item .note-date {{
|
|||
|
|
font-size: 11px; color: #B0C8D8; margin-top: 6px;
|
|||
|
|
}}
|
|||
|
|
.note-item .delete-btn {{
|
|||
|
|
position: absolute; top: 8px; right: 12px; font-size: 18px; color: #ccc;
|
|||
|
|
cursor: pointer; padding: 4px; line-height: 1; transition: color .15s;
|
|||
|
|
}}
|
|||
|
|
.note-item .delete-btn:active {{ color: var(--red); }}
|
|||
|
|
.note-modal .modal {{ max-height: 90vh; }}
|
|||
|
|
.note-modal textarea {{ min-height: 120px; }}
|
|||
|
|
/* Drag & Drop */
|
|||
|
|
.todo-item .drag-handle {{
|
|||
|
|
font-size: 18px; color: #B0C8D8; cursor: grab; touch-action: none;
|
|||
|
|
padding: 4px 2px; margin-right: 4px; flex-shrink: 0; user-select: none;
|
|||
|
|
-webkit-user-select: none; display: flex; align-items: center;
|
|||
|
|
}}
|
|||
|
|
.todo-item .drag-handle:active {{ color: var(--accent); }}
|
|||
|
|
.todo-item.dragging {{
|
|||
|
|
opacity: 0.5; transform: scale(0.97);
|
|||
|
|
box-shadow: 0 4px 16px rgba(0,0,0,.15);
|
|||
|
|
}}
|
|||
|
|
.drop-indicator {{
|
|||
|
|
height: 4px; background: var(--accent); border-radius: 2px;
|
|||
|
|
margin: 2px 12px; transition: all 0.15s;
|
|||
|
|
}}
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<header>
|
|||
|
|
<h1>
|
|||
|
|
<img class="logo" src="/apple-touch-icon.png" alt="AzA">
|
|||
|
|
AzA To-Do
|
|||
|
|
<span class="sync-dot syncing" id="syncDot" title="Sync"></span>
|
|||
|
|
</h1>
|
|||
|
|
<div class="sub" id="syncStatus">Verbinde...</div>
|
|||
|
|
</header>
|
|||
|
|
<div class="tab-bar">
|
|||
|
|
<button class="tab active" id="tabTodo" onclick="switchTab('todo')">📋 Aufgaben</button>
|
|||
|
|
<button class="tab" id="tabNotes" onclick="switchTab('notes')">📝 Notizen</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="todos-page" id="todosPage">
|
|||
|
|
<div class="input-bar">
|
|||
|
|
<input type="text" id="newText" placeholder="Neue Aufgabe..." enterkeyhint="done">
|
|||
|
|
<button onclick="addTodo()" aria-label="Hinzufuegen">+</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="date-bar">
|
|||
|
|
<span>Faellig:</span>
|
|||
|
|
<input type="date" id="newDate">
|
|||
|
|
<button class="prio-btn p0" id="newPrioBtn" onclick="cyclePrio()">Keine</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="counter" id="counter"></div>
|
|||
|
|
<div class="todo-list" id="todoList"></div>
|
|||
|
|
<div class="footer-bar">
|
|||
|
|
<button class="btn-del" onclick="deleteDone()">Erledigte loeschen</button>
|
|||
|
|
<button class="btn-del" onclick="resetSort()">↕ Auto-Sort</button>
|
|||
|
|
<button class="btn-sync" onclick="syncNow()">🔄 Sync</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<!-- Notizen-Seite -->
|
|||
|
|
<div class="notes-page" id="notesPage">
|
|||
|
|
<div class="note-input-bar">
|
|||
|
|
<input type="text" id="noteTitle" placeholder="Notiz-Titel...">
|
|||
|
|
<textarea id="noteText" rows="2" placeholder="Inhalt..."></textarea>
|
|||
|
|
<button onclick="addNote()">+ Notiz</button>
|
|||
|
|
<div style="clear:both"></div>
|
|||
|
|
</div>
|
|||
|
|
<div id="notesList" style="padding-bottom: 80px;"></div>
|
|||
|
|
<div class="footer-bar">
|
|||
|
|
<button class="btn-sync" onclick="syncNotesNow()">🔄 Sync Notizen</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<!-- Notiz-Detail-Modal -->
|
|||
|
|
<div class="modal-overlay note-modal" id="noteDetailModal">
|
|||
|
|
<div class="modal">
|
|||
|
|
<h2>Notiz bearbeiten</h2>
|
|||
|
|
<label>Titel</label>
|
|||
|
|
<input type="text" id="editNoteTitle">
|
|||
|
|
<label>Inhalt</label>
|
|||
|
|
<textarea id="editNoteText" rows="6"></textarea>
|
|||
|
|
<div class="btn-row">
|
|||
|
|
<button class="btn-cancel" onclick="closeNoteDetail()">Abbrechen</button>
|
|||
|
|
<button class="btn-save" onclick="saveNoteDetail()">Speichern</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-overlay" id="detailModal">
|
|||
|
|
<div class="modal">
|
|||
|
|
<h2>Aufgabe bearbeiten</h2>
|
|||
|
|
<label>Aufgabe</label>
|
|||
|
|
<textarea id="editText" rows="2"></textarea>
|
|||
|
|
<label>Notizen</label>
|
|||
|
|
<textarea id="editNotes" rows="3"></textarea>
|
|||
|
|
<label>Faellig</label>
|
|||
|
|
<input type="date" id="editDate">
|
|||
|
|
<label>Prioritaet</label>
|
|||
|
|
<div class="prio-group">
|
|||
|
|
<button class="p1" onclick="setEditPrio(1)">Hoch</button>
|
|||
|
|
<button class="p2" onclick="setEditPrio(2)">Mittel</button>
|
|||
|
|
<button class="p0" onclick="setEditPrio(0)">Keine</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="btn-row">
|
|||
|
|
<button class="btn-cancel" onclick="closeDetail()">Abbrechen</button>
|
|||
|
|
<button class="btn-save" onclick="saveDetail()">Speichern</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<script>
|
|||
|
|
var todos = [];
|
|||
|
|
var newPrio = 0;
|
|||
|
|
var editIdx = -1;
|
|||
|
|
var editPrio = 0;
|
|||
|
|
var SYNC_INTERVAL = 6000;
|
|||
|
|
var LS_KEY = 'aza_todos_v2';
|
|||
|
|
|
|||
|
|
/* ─── Supabase Config (eingebettet vom Server) ─── */
|
|||
|
|
var SUPA_URL = '{SUPABASE_URL}';
|
|||
|
|
var SUPA_KEY = '{SUPABASE_ANON_KEY}';
|
|||
|
|
|
|||
|
|
function setSyncState(state, text) {{
|
|||
|
|
var dot = document.getElementById('syncDot');
|
|||
|
|
var lbl = document.getElementById('syncStatus');
|
|||
|
|
dot.className = 'sync-dot' + (state === 'ok' ? '' : state === 'off' ? ' off' : ' syncing');
|
|||
|
|
lbl.textContent = text || '';
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
/* ─── localStorage Cache ─── */
|
|||
|
|
function saveLocal() {{ try {{ localStorage.setItem(LS_KEY, JSON.stringify(todos)); }} catch(e) {{}} }}
|
|||
|
|
function loadLocal() {{ try {{ var s = localStorage.getItem(LS_KEY); if (s) return JSON.parse(s); }} catch(e) {{}} return null; }}
|
|||
|
|
|
|||
|
|
/* ─── Supabase API ─── */
|
|||
|
|
function supaGet(cb) {{
|
|||
|
|
var xhr = new XMLHttpRequest();
|
|||
|
|
xhr.open('GET', SUPA_URL + '/rest/v1/todo_sync?id=eq.1&select=data', true);
|
|||
|
|
xhr.setRequestHeader('apikey', SUPA_KEY);
|
|||
|
|
xhr.setRequestHeader('Authorization', 'Bearer ' + SUPA_KEY);
|
|||
|
|
xhr.timeout = 10000;
|
|||
|
|
xhr.onload = function() {{
|
|||
|
|
if (xhr.status === 200) {{
|
|||
|
|
try {{
|
|||
|
|
var rows = JSON.parse(xhr.responseText);
|
|||
|
|
if (rows && rows.length > 0 && rows[0].data) {{ cb(rows[0].data, true); return; }}
|
|||
|
|
}} catch(e) {{}}
|
|||
|
|
}}
|
|||
|
|
cb(null, false);
|
|||
|
|
}};
|
|||
|
|
xhr.onerror = function() {{ cb(null, false); }};
|
|||
|
|
xhr.ontimeout = function() {{ cb(null, false); }};
|
|||
|
|
xhr.send();
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
function supaPut(data, cb) {{
|
|||
|
|
var xhr = new XMLHttpRequest();
|
|||
|
|
xhr.open('PATCH', SUPA_URL + '/rest/v1/todo_sync?id=eq.1', true);
|
|||
|
|
xhr.setRequestHeader('apikey', SUPA_KEY);
|
|||
|
|
xhr.setRequestHeader('Authorization', 'Bearer ' + SUPA_KEY);
|
|||
|
|
xhr.setRequestHeader('Content-Type', 'application/json');
|
|||
|
|
xhr.timeout = 10000;
|
|||
|
|
xhr.onload = function() {{ if(cb) cb(xhr.status >= 200 && xhr.status < 300); }};
|
|||
|
|
xhr.onerror = function() {{ if(cb) cb(false); }};
|
|||
|
|
xhr.ontimeout = function() {{ if(cb) cb(false); }};
|
|||
|
|
xhr.send(JSON.stringify({{data: data}}));
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
/* ─── Sync ─── */
|
|||
|
|
function syncNow() {{
|
|||
|
|
setSyncState('syncing', 'Synchronisiere...');
|
|||
|
|
supaGet(function(data, ok) {{
|
|||
|
|
if (ok && data) {{
|
|||
|
|
todos = data;
|
|||
|
|
saveLocal();
|
|||
|
|
setSyncState('ok', 'Cloud verbunden');
|
|||
|
|
render();
|
|||
|
|
}} else {{
|
|||
|
|
var cached = loadLocal();
|
|||
|
|
if (cached && cached.length > 0 && todos.length === 0) {{ todos = cached; }}
|
|||
|
|
setSyncState('off', 'Offline (lokaler Cache)');
|
|||
|
|
render();
|
|||
|
|
}}
|
|||
|
|
}});
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
function pushToCloud() {{
|
|||
|
|
saveLocal();
|
|||
|
|
setSyncState('syncing', 'Speichere...');
|
|||
|
|
supaPut(todos, function(ok) {{
|
|||
|
|
setSyncState(ok ? 'ok' : 'off', ok ? 'Cloud verbunden' : 'Offline (lokal gespeichert)');
|
|||
|
|
}});
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
/* ─── Rendering ─── */
|
|||
|
|
function render() {{
|
|||
|
|
var list = document.getElementById('todoList');
|
|||
|
|
var today = new Date().toISOString().split('T')[0];
|
|||
|
|
var hasManualOrder = todos.some(function(t) {{ return typeof t.manualOrder === 'number'; }});
|
|||
|
|
if (hasManualOrder) {{
|
|||
|
|
todos.sort(function(a,b) {{
|
|||
|
|
var oA = typeof a.manualOrder === 'number' ? a.manualOrder : 9999;
|
|||
|
|
var oB = typeof b.manualOrder === 'number' ? b.manualOrder : 9999;
|
|||
|
|
return oA - oB;
|
|||
|
|
}});
|
|||
|
|
}} else {{
|
|||
|
|
todos.sort(function(a,b) {{
|
|||
|
|
var dA = a.done?1:0, dB = b.done?1:0;
|
|||
|
|
if (dA !== dB) return dA - dB;
|
|||
|
|
var urgA = (!a.done && a.date && a.date < today) ? 0 : (!a.done && a.date) ? 1 : (!a.done) ? 2 : 3;
|
|||
|
|
var urgB = (!b.done && b.date && b.date < today) ? 0 : (!b.done && b.date) ? 1 : (!b.done) ? 2 : 3;
|
|||
|
|
if (urgA !== urgB) return urgA - urgB;
|
|||
|
|
var pMap = {{1:0,2:1,0:2}};
|
|||
|
|
var pA = pMap[a.priority||0]||2, pB = pMap[b.priority||0]||2;
|
|||
|
|
if (pA !== pB) return pA - pB;
|
|||
|
|
return (a.date||'9999') < (b.date||'9999') ? -1 : 1;
|
|||
|
|
}});
|
|||
|
|
}}
|
|||
|
|
var done = 0, overdue = 0;
|
|||
|
|
for (var k=0; k<todos.length; k++) {{
|
|||
|
|
if (todos[k].done) done++;
|
|||
|
|
if (!todos[k].done && todos[k].date && todos[k].date < today) overdue++;
|
|||
|
|
}}
|
|||
|
|
var cText = done + '/' + todos.length + ' erledigt';
|
|||
|
|
if (overdue) cText += ' · <span class="overdue-count">' + overdue + ' ueberfaellig</span>';
|
|||
|
|
document.getElementById('counter').innerHTML = cText;
|
|||
|
|
if (!todos.length) {{
|
|||
|
|
list.innerHTML = '<div class="empty-state"><div class="icon">✅</div><p>Keine Aufgaben</p></div>';
|
|||
|
|
return;
|
|||
|
|
}}
|
|||
|
|
var html = '';
|
|||
|
|
for (var i=0; i<todos.length; i++) {{
|
|||
|
|
var t = todos[i];
|
|||
|
|
var isOverdue = !t.done && t.date && t.date < today;
|
|||
|
|
var cls = '';
|
|||
|
|
if (t.done) cls = 'done';
|
|||
|
|
else if (isOverdue) cls = 'overdue';
|
|||
|
|
else if (t.priority === 1) cls = 'prio1';
|
|||
|
|
else if (t.priority === 2) cls = 'prio2';
|
|||
|
|
var meta = '';
|
|||
|
|
if (t.date) {{
|
|||
|
|
var d = t.date.split('-');
|
|||
|
|
var dtxt = d[2]+'.'+d[1]+'.'+d[0];
|
|||
|
|
meta += '<span class="date-tag'+(isOverdue?' overdue':'')+'">'+(isOverdue?'! ':'')+dtxt+'</span>';
|
|||
|
|
}}
|
|||
|
|
if (t.priority===1) meta += '<span class="prio-tag p1">Hoch</span>';
|
|||
|
|
if (t.priority===2) meta += '<span class="prio-tag p2">Mittel</span>';
|
|||
|
|
if (t.notes && t.notes.trim()) meta += '<span class="notes-indicator">Notiz</span>';
|
|||
|
|
if (t.sender) meta += '<span class="sender-tag">von '+esc(t.sender)+'</span>';
|
|||
|
|
html += '<div class="todo-item '+cls+'" data-i="'+i+'">'
|
|||
|
|
+ '<div class="drag-handle" data-drag="'+i+'">≡</div>'
|
|||
|
|
+ '<div class="check" onclick="toggleDone('+i+')">'+(t.done?'✓':'')+'</div>'
|
|||
|
|
+ '<div class="content" onclick="openDetail('+i+')">'
|
|||
|
|
+ '<div class="task-text">'+esc(t.text)+'</div>'
|
|||
|
|
+ (meta?'<div class="meta">'+meta+'</div>':'')
|
|||
|
|
+ '</div>'
|
|||
|
|
+ '<div class="delete-btn" onclick="deleteTodo('+i+')">✕</div>'
|
|||
|
|
+ '</div>';
|
|||
|
|
}}
|
|||
|
|
list.innerHTML = html;
|
|||
|
|
}}
|
|||
|
|
function esc(s) {{ var d=document.createElement('div'); d.textContent=s||''; return d.innerHTML; }}
|
|||
|
|
|
|||
|
|
/* ─── CRUD ─── */
|
|||
|
|
function addTodo() {{
|
|||
|
|
var inp = document.getElementById('newText');
|
|||
|
|
var text = inp.value.trim();
|
|||
|
|
if (!text) return;
|
|||
|
|
var dateVal = document.getElementById('newDate').value || null;
|
|||
|
|
var maxOrd = -1;
|
|||
|
|
for (var mo=0; mo<todos.length; mo++) {{
|
|||
|
|
if (typeof todos[mo].manualOrder === 'number' && todos[mo].manualOrder > maxOrd) maxOrd = todos[mo].manualOrder;
|
|||
|
|
}}
|
|||
|
|
var newOrd = maxOrd >= 0 ? maxOrd + 1 : undefined;
|
|||
|
|
var newItem = {{ id: Date.now(), text: text, done: false, date: dateVal,
|
|||
|
|
priority: newPrio, notes: '', created: new Date().toISOString() }};
|
|||
|
|
if (typeof newOrd === 'number') newItem.manualOrder = newOrd;
|
|||
|
|
todos.push(newItem);
|
|||
|
|
inp.value = ''; document.getElementById('newDate').value = '';
|
|||
|
|
newPrio = 0; updatePrioBtn(); render(); pushToCloud();
|
|||
|
|
}}
|
|||
|
|
function toggleDone(i) {{ todos[i].done = !todos[i].done; render(); pushToCloud(); }}
|
|||
|
|
function deleteTodo(i) {{
|
|||
|
|
if (!confirm('Loeschen: "'+todos[i].text.substring(0,40)+'"?')) return;
|
|||
|
|
todos.splice(i, 1); render(); pushToCloud();
|
|||
|
|
}}
|
|||
|
|
function deleteDone() {{
|
|||
|
|
if (!confirm('Alle erledigten Aufgaben loeschen?')) return;
|
|||
|
|
todos = todos.filter(function(t) {{ return !t.done; }}); render(); pushToCloud();
|
|||
|
|
}}
|
|||
|
|
function resetSort() {{
|
|||
|
|
for (var rs=0; rs<todos.length; rs++) {{ delete todos[rs].manualOrder; }}
|
|||
|
|
render(); pushToCloud();
|
|||
|
|
}}
|
|||
|
|
function cyclePrio() {{ newPrio = newPrio === 0 ? 1 : newPrio === 1 ? 2 : 0; updatePrioBtn(); }}
|
|||
|
|
function updatePrioBtn() {{
|
|||
|
|
var b = document.getElementById('newPrioBtn');
|
|||
|
|
b.className = 'prio-btn p' + newPrio;
|
|||
|
|
b.textContent = newPrio===1?'Hoch':newPrio===2?'Mittel':'Keine';
|
|||
|
|
}}
|
|||
|
|
function openDetail(i) {{
|
|||
|
|
editIdx = i; var t = todos[i];
|
|||
|
|
document.getElementById('editText').value = t.text||'';
|
|||
|
|
document.getElementById('editNotes').value = t.notes||'';
|
|||
|
|
document.getElementById('editDate').value = t.date||'';
|
|||
|
|
editPrio = t.priority||0; updateEditPrio();
|
|||
|
|
document.getElementById('detailModal').classList.add('show');
|
|||
|
|
}}
|
|||
|
|
function closeDetail() {{ document.getElementById('detailModal').classList.remove('show'); editIdx = -1; }}
|
|||
|
|
function setEditPrio(p) {{ editPrio = p; updateEditPrio(); }}
|
|||
|
|
function updateEditPrio() {{
|
|||
|
|
var btns = document.querySelectorAll('.prio-group button');
|
|||
|
|
for (var j=0; j<btns.length; j++) {{
|
|||
|
|
var bp = parseInt(btns[j].className.replace('p',''));
|
|||
|
|
if (bp === editPrio) btns[j].classList.add('active');
|
|||
|
|
else btns[j].classList.remove('active');
|
|||
|
|
}}
|
|||
|
|
}}
|
|||
|
|
function saveDetail() {{
|
|||
|
|
if (editIdx < 0) return;
|
|||
|
|
var text = document.getElementById('editText').value.trim();
|
|||
|
|
if (!text) {{ alert('Text darf nicht leer sein.'); return; }}
|
|||
|
|
todos[editIdx].text = text;
|
|||
|
|
todos[editIdx].notes = document.getElementById('editNotes').value;
|
|||
|
|
todos[editIdx].date = document.getElementById('editDate').value || null;
|
|||
|
|
todos[editIdx].priority = editPrio;
|
|||
|
|
closeDetail(); render(); pushToCloud();
|
|||
|
|
}}
|
|||
|
|
document.getElementById('newText').addEventListener('keydown', function(e) {{
|
|||
|
|
if (e.key === 'Enter') {{ e.preventDefault(); addTodo(); }}
|
|||
|
|
}});
|
|||
|
|
|
|||
|
|
/* ─── Drag & Drop (Touch + Mouse) ─── */
|
|||
|
|
var dragState = {{ active: false, fromIdx: -1, curIdx: -1, startY: 0, indicator: null }};
|
|||
|
|
|
|||
|
|
function initDragHandlers() {{
|
|||
|
|
var handles = document.querySelectorAll('.drag-handle');
|
|||
|
|
for (var h=0; h<handles.length; h++) {{
|
|||
|
|
handles[h].addEventListener('touchstart', onDragStart, {{passive: false}});
|
|||
|
|
handles[h].addEventListener('mousedown', onDragStartMouse);
|
|||
|
|
}}
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
function onDragStart(e) {{
|
|||
|
|
e.preventDefault();
|
|||
|
|
var idx = parseInt(e.currentTarget.getAttribute('data-drag'));
|
|||
|
|
dragState.active = true;
|
|||
|
|
dragState.fromIdx = idx;
|
|||
|
|
dragState.curIdx = idx;
|
|||
|
|
dragState.startY = e.touches[0].clientY;
|
|||
|
|
var item = e.currentTarget.closest('.todo-item');
|
|||
|
|
if (item) item.classList.add('dragging');
|
|||
|
|
document.addEventListener('touchmove', onDragMove, {{passive: false}});
|
|||
|
|
document.addEventListener('touchend', onDragEnd);
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
function onDragStartMouse(e) {{
|
|||
|
|
e.preventDefault();
|
|||
|
|
var idx = parseInt(e.currentTarget.getAttribute('data-drag'));
|
|||
|
|
dragState.active = true;
|
|||
|
|
dragState.fromIdx = idx;
|
|||
|
|
dragState.curIdx = idx;
|
|||
|
|
dragState.startY = e.clientY;
|
|||
|
|
var item = e.currentTarget.closest('.todo-item');
|
|||
|
|
if (item) item.classList.add('dragging');
|
|||
|
|
document.addEventListener('mousemove', onDragMoveMouse);
|
|||
|
|
document.addEventListener('mouseup', onDragEndMouse);
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
function getDropIndex(clientY) {{
|
|||
|
|
var items = document.querySelectorAll('.todo-item');
|
|||
|
|
for (var j=0; j<items.length; j++) {{
|
|||
|
|
var rect = items[j].getBoundingClientRect();
|
|||
|
|
var mid = rect.top + rect.height / 2;
|
|||
|
|
if (clientY < mid) return j;
|
|||
|
|
}}
|
|||
|
|
return items.length;
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
function showDropIndicator(targetIdx) {{
|
|||
|
|
removeDropIndicator();
|
|||
|
|
var items = document.querySelectorAll('.todo-item');
|
|||
|
|
if (items.length === 0) return;
|
|||
|
|
var ind = document.createElement('div');
|
|||
|
|
ind.className = 'drop-indicator';
|
|||
|
|
dragState.indicator = ind;
|
|||
|
|
if (targetIdx >= items.length) {{
|
|||
|
|
items[items.length-1].parentNode.appendChild(ind);
|
|||
|
|
}} else {{
|
|||
|
|
items[targetIdx].parentNode.insertBefore(ind, items[targetIdx]);
|
|||
|
|
}}
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
function removeDropIndicator() {{
|
|||
|
|
if (dragState.indicator && dragState.indicator.parentNode) {{
|
|||
|
|
dragState.indicator.parentNode.removeChild(dragState.indicator);
|
|||
|
|
}}
|
|||
|
|
dragState.indicator = null;
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
function onDragMove(e) {{
|
|||
|
|
if (!dragState.active) return;
|
|||
|
|
e.preventDefault();
|
|||
|
|
var clientY = e.touches[0].clientY;
|
|||
|
|
var targetIdx = getDropIndex(clientY);
|
|||
|
|
if (targetIdx !== dragState.curIdx) {{
|
|||
|
|
dragState.curIdx = targetIdx;
|
|||
|
|
showDropIndicator(targetIdx);
|
|||
|
|
}}
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
function onDragMoveMouse(e) {{
|
|||
|
|
if (!dragState.active) return;
|
|||
|
|
e.preventDefault();
|
|||
|
|
var targetIdx = getDropIndex(e.clientY);
|
|||
|
|
if (targetIdx !== dragState.curIdx) {{
|
|||
|
|
dragState.curIdx = targetIdx;
|
|||
|
|
showDropIndicator(targetIdx);
|
|||
|
|
}}
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
function finishDrag() {{
|
|||
|
|
if (!dragState.active) return;
|
|||
|
|
dragState.active = false;
|
|||
|
|
removeDropIndicator();
|
|||
|
|
var items = document.querySelectorAll('.todo-item.dragging');
|
|||
|
|
for (var d=0; d<items.length; d++) items[d].classList.remove('dragging');
|
|||
|
|
document.removeEventListener('touchmove', onDragMove);
|
|||
|
|
document.removeEventListener('touchend', onDragEnd);
|
|||
|
|
document.removeEventListener('mousemove', onDragMoveMouse);
|
|||
|
|
document.removeEventListener('mouseup', onDragEndMouse);
|
|||
|
|
var from = dragState.fromIdx;
|
|||
|
|
var to = dragState.curIdx;
|
|||
|
|
if (from < 0 || from >= todos.length) return;
|
|||
|
|
if (to > from) to--;
|
|||
|
|
if (from === to) return;
|
|||
|
|
var moved = todos.splice(from, 1)[0];
|
|||
|
|
todos.splice(to, 0, moved);
|
|||
|
|
for (var mi=0; mi<todos.length; mi++) {{ todos[mi].manualOrder = mi; }}
|
|||
|
|
render();
|
|||
|
|
pushToCloud();
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
function onDragEnd(e) {{ finishDrag(); }}
|
|||
|
|
function onDragEndMouse(e) {{ finishDrag(); }}
|
|||
|
|
|
|||
|
|
/* Hook drag handlers after each render */
|
|||
|
|
var origRender = render;
|
|||
|
|
render = function() {{ origRender(); initDragHandlers(); }};
|
|||
|
|
|
|||
|
|
/* ─── Tab-Switch ─── */
|
|||
|
|
function switchTab(tab) {{
|
|||
|
|
var todoPage = document.getElementById('todosPage');
|
|||
|
|
var notesPage = document.getElementById('notesPage');
|
|||
|
|
var tabTodo = document.getElementById('tabTodo');
|
|||
|
|
var tabNotes = document.getElementById('tabNotes');
|
|||
|
|
if (tab === 'notes') {{
|
|||
|
|
todoPage.classList.add('hidden');
|
|||
|
|
notesPage.classList.add('active');
|
|||
|
|
tabTodo.classList.remove('active');
|
|||
|
|
tabNotes.classList.add('active');
|
|||
|
|
renderNotes();
|
|||
|
|
}} else {{
|
|||
|
|
notesPage.classList.remove('active');
|
|||
|
|
todoPage.classList.remove('hidden');
|
|||
|
|
tabNotes.classList.remove('active');
|
|||
|
|
tabTodo.classList.add('active');
|
|||
|
|
}}
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
/* ─── Notizen ─── */
|
|||
|
|
var userNotes = [];
|
|||
|
|
var editNoteIdx = -1;
|
|||
|
|
var NOTES_LS_KEY = 'aza_notes_v1';
|
|||
|
|
|
|||
|
|
function saveNotesLocal() {{ try {{ localStorage.setItem(NOTES_LS_KEY, JSON.stringify(userNotes)); }} catch(e) {{}} }}
|
|||
|
|
function loadNotesLocal() {{ try {{ var s = localStorage.getItem(NOTES_LS_KEY); if (s) return JSON.parse(s); }} catch(e) {{}} return null; }}
|
|||
|
|
|
|||
|
|
function supaGetNotes(cb) {{
|
|||
|
|
var xhr = new XMLHttpRequest();
|
|||
|
|
xhr.open('GET', SUPA_URL + '/rest/v1/todo_sync?id=eq.2&select=data', true);
|
|||
|
|
xhr.setRequestHeader('apikey', SUPA_KEY);
|
|||
|
|
xhr.setRequestHeader('Authorization', 'Bearer ' + SUPA_KEY);
|
|||
|
|
xhr.timeout = 10000;
|
|||
|
|
xhr.onload = function() {{
|
|||
|
|
if (xhr.status === 200) {{
|
|||
|
|
try {{
|
|||
|
|
var rows = JSON.parse(xhr.responseText);
|
|||
|
|
if (rows && rows.length > 0 && rows[0].data) {{ cb(rows[0].data, true); return; }}
|
|||
|
|
}} catch(e) {{}}
|
|||
|
|
}}
|
|||
|
|
cb(null, false);
|
|||
|
|
}};
|
|||
|
|
xhr.onerror = function() {{ cb(null, false); }};
|
|||
|
|
xhr.ontimeout = function() {{ cb(null, false); }};
|
|||
|
|
xhr.send();
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
function supaPutNotes(data, cb) {{
|
|||
|
|
var xhr = new XMLHttpRequest();
|
|||
|
|
xhr.open('PATCH', SUPA_URL + '/rest/v1/todo_sync?id=eq.2', true);
|
|||
|
|
xhr.setRequestHeader('apikey', SUPA_KEY);
|
|||
|
|
xhr.setRequestHeader('Authorization', 'Bearer ' + SUPA_KEY);
|
|||
|
|
xhr.setRequestHeader('Content-Type', 'application/json');
|
|||
|
|
xhr.timeout = 10000;
|
|||
|
|
xhr.onload = function() {{
|
|||
|
|
if (xhr.status >= 200 && xhr.status < 300) {{ if(cb) cb(true); }}
|
|||
|
|
else {{
|
|||
|
|
var xhr2 = new XMLHttpRequest();
|
|||
|
|
xhr2.open('POST', SUPA_URL + '/rest/v1/todo_sync', true);
|
|||
|
|
xhr2.setRequestHeader('apikey', SUPA_KEY);
|
|||
|
|
xhr2.setRequestHeader('Authorization', 'Bearer ' + SUPA_KEY);
|
|||
|
|
xhr2.setRequestHeader('Content-Type', 'application/json');
|
|||
|
|
xhr2.setRequestHeader('Prefer', 'return=minimal');
|
|||
|
|
xhr2.timeout = 10000;
|
|||
|
|
xhr2.onload = function() {{ if(cb) cb(xhr2.status >= 200 && xhr2.status < 300); }};
|
|||
|
|
xhr2.onerror = function() {{ if(cb) cb(false); }};
|
|||
|
|
xhr2.send(JSON.stringify({{id: 2, data: data}}));
|
|||
|
|
}}
|
|||
|
|
}};
|
|||
|
|
xhr.onerror = function() {{ if(cb) cb(false); }};
|
|||
|
|
xhr.ontimeout = function() {{ if(cb) cb(false); }};
|
|||
|
|
xhr.send(JSON.stringify({{data: data}}));
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
function syncNotesNow() {{
|
|||
|
|
supaGetNotes(function(data, ok) {{
|
|||
|
|
if (ok && data) {{
|
|||
|
|
userNotes = data;
|
|||
|
|
saveNotesLocal();
|
|||
|
|
}} else {{
|
|||
|
|
var cached = loadNotesLocal();
|
|||
|
|
if (cached && cached.length > 0 && userNotes.length === 0) {{ userNotes = cached; }}
|
|||
|
|
}}
|
|||
|
|
renderNotes();
|
|||
|
|
}});
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
function pushNotesToCloud() {{
|
|||
|
|
saveNotesLocal();
|
|||
|
|
supaPutNotes(userNotes, function(ok) {{}});
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
function renderNotes() {{
|
|||
|
|
var list = document.getElementById('notesList');
|
|||
|
|
if (!userNotes || !userNotes.length) {{
|
|||
|
|
list.innerHTML = '<div class="empty-state"><div class="icon">📝</div><p>Keine Notizen</p></div>';
|
|||
|
|
return;
|
|||
|
|
}}
|
|||
|
|
var html = '';
|
|||
|
|
for (var i=0; i<userNotes.length; i++) {{
|
|||
|
|
var n = userNotes[i];
|
|||
|
|
var preview = (n.text||'').substring(0, 120);
|
|||
|
|
if ((n.text||'').length > 120) preview += '...';
|
|||
|
|
var dateStr = '';
|
|||
|
|
try {{
|
|||
|
|
var d = new Date(n.created);
|
|||
|
|
dateStr = d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', {{hour:'2-digit',minute:'2-digit'}});
|
|||
|
|
}} catch(e) {{}}
|
|||
|
|
html += '<div class="note-item" onclick="openNoteDetail('+i+')">'
|
|||
|
|
+ '<div class="note-title">'+esc(n.title||'Notiz')+'</div>'
|
|||
|
|
+ (preview ? '<div class="note-preview">'+esc(preview)+'</div>' : '')
|
|||
|
|
+ (dateStr ? '<div class="note-date">'+dateStr+'</div>' : '')
|
|||
|
|
+ '<div class="delete-btn" onclick="event.stopPropagation(); deleteNote('+i+')">✕</div>'
|
|||
|
|
+ '</div>';
|
|||
|
|
}}
|
|||
|
|
list.innerHTML = html;
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
function addNote() {{
|
|||
|
|
var titleInp = document.getElementById('noteTitle');
|
|||
|
|
var textInp = document.getElementById('noteText');
|
|||
|
|
var title = titleInp.value.trim() || 'Notiz';
|
|||
|
|
var text = textInp.value.trim();
|
|||
|
|
userNotes.push({{ id: Date.now(), title: title, text: text, created: new Date().toISOString() }});
|
|||
|
|
titleInp.value = ''; textInp.value = '';
|
|||
|
|
renderNotes(); pushNotesToCloud();
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
function deleteNote(i) {{
|
|||
|
|
if (!confirm('Notiz "'+((userNotes[i]||{{}}).title||'').substring(0,30)+'" loeschen?')) return;
|
|||
|
|
userNotes.splice(i, 1); renderNotes(); pushNotesToCloud();
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
function openNoteDetail(i) {{
|
|||
|
|
editNoteIdx = i; var n = userNotes[i];
|
|||
|
|
document.getElementById('editNoteTitle').value = n.title||'';
|
|||
|
|
document.getElementById('editNoteText').value = n.text||'';
|
|||
|
|
document.getElementById('noteDetailModal').classList.add('show');
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
function closeNoteDetail() {{
|
|||
|
|
document.getElementById('noteDetailModal').classList.remove('show');
|
|||
|
|
editNoteIdx = -1;
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
function saveNoteDetail() {{
|
|||
|
|
if (editNoteIdx < 0) return;
|
|||
|
|
var title = document.getElementById('editNoteTitle').value.trim();
|
|||
|
|
if (!title) title = 'Notiz';
|
|||
|
|
userNotes[editNoteIdx].title = title;
|
|||
|
|
userNotes[editNoteIdx].text = document.getElementById('editNoteText').value;
|
|||
|
|
closeNoteDetail(); renderNotes(); pushNotesToCloud();
|
|||
|
|
}}
|
|||
|
|
|
|||
|
|
/* ─── Start ─── */
|
|||
|
|
var cached = loadLocal();
|
|||
|
|
if (cached && cached.length > 0) {{ todos = cached; render(); }}
|
|||
|
|
var cachedNotes = loadNotesLocal();
|
|||
|
|
if (cachedNotes && cachedNotes.length > 0) {{ userNotes = cachedNotes; }}
|
|||
|
|
syncNow();
|
|||
|
|
syncNotesNow();
|
|||
|
|
setInterval(syncNow, SYNC_INTERVAL);
|
|||
|
|
setInterval(syncNotesNow, SYNC_INTERVAL);
|
|||
|
|
if ('serviceWorker' in navigator) {{ navigator.serviceWorker.register('/sw.js').catch(function(){{}}); }}
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>
|
|||
|
|
'''
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─── Server starten ───
|
|||
|
|
|
|||
|
|
def start_server(port=PORT, open_browser=True):
|
|||
|
|
check_tls_or_exit()
|
|||
|
|
_add_firewall_rule()
|
|||
|
|
|
|||
|
|
todos = _load_todos()
|
|||
|
|
if todos:
|
|||
|
|
print(f" Pushe {len(todos)} Todos nach Supabase...")
|
|||
|
|
if _supabase_push(todos):
|
|||
|
|
print(" OK!")
|
|||
|
|
else:
|
|||
|
|
print(" Warnung: Push fehlgeschlagen (Todos sind lokal gesichert)")
|
|||
|
|
|
|||
|
|
local_ip = _get_local_ip()
|
|||
|
|
scheme = "https" if has_tls_config() else "http"
|
|||
|
|
url_local = f"{scheme}://{local_ip}:{port}"
|
|||
|
|
|
|||
|
|
print(f"\n{'='*55}")
|
|||
|
|
print(f" AzA To-Do Server (Supabase Cloud-Sync)")
|
|||
|
|
print(f" Lokal: {scheme}://localhost:{port}")
|
|||
|
|
print(f" Netzwerk: {url_local}")
|
|||
|
|
print(f" TLS: {'AKTIV' if has_tls_config() else 'DEAKTIVIERT'}")
|
|||
|
|
print(f"{'='*55}")
|
|||
|
|
print(f"\n EINRICHTUNG iPhone:")
|
|||
|
|
print(f" 1. Safari: {url_local}")
|
|||
|
|
print(f" 2. Teilen -> 'Zum Home-Bildschirm'")
|
|||
|
|
print(f" 3. Fertig! App funktioniert ab jetzt OHNE PC.")
|
|||
|
|
print(f"\n Server laeuft. Ctrl+C zum Beenden.\n")
|
|||
|
|
|
|||
|
|
if open_browser:
|
|||
|
|
threading.Timer(1.0, lambda: webbrowser.open(f"{scheme}://localhost:{port}")).start()
|
|||
|
|
|
|||
|
|
server = TodoServer(("0.0.0.0", port), TodoHandler)
|
|||
|
|
if has_tls_config():
|
|||
|
|
server.socket = create_ssl_context().wrap_socket(server.socket, server_side=True)
|
|||
|
|
try:
|
|||
|
|
server.serve_forever()
|
|||
|
|
except KeyboardInterrupt:
|
|||
|
|
print("\n Server beendet.")
|
|||
|
|
server.server_close()
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
start_server()
|