Kanban-Archiv mit automatischem Löschen, Reise-Karte als Bild herunterladen
Kanban: - Erledigte Aufgaben wandern 2 Tage nach "Erledigt" ins Archiv (Button "Archiv (n)" im Board, dort "Zurückholen") und werden nach 14 Tagen automatisch gelöscht (stündlich im Hintergrund und beim Öffnen) - Neue Spalte done_at; schon erledigte Aufgaben zählen ab dem Update Reisen: - "Karte herunterladen": eigene Städte und besuchte Länder als PNG (4000/8000 px, nur besuchte Hauptstädte beschriftet) oder SVG (kleine Punkte, alle Städte beschriftet, beliebig zoombar); Ausschnitt "Meine Orte" oder ganze Welt; Beschriftungen weichen einander aus - Hauptstadt-Kennzeichen (GeoNames PPLC) wird beim Hinzufügen gespeichert und für ältere Städte im Hintergrund bei Open-Meteo nachgetragen Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f0e17b06b5
commit
dc4ce18593
9 files changed
+489
-12
No files matched your search
@@ -162,6 +162,17 @@ Der Schalter **Städte anderer zeigen** (über der Karte) blendet die Städte de
|
|||||||
einzelne Personen lassen sich über ihre Schaltfläche aus- und einblenden (gemerkt pro Browser). Fremde
|
einzelne Personen lassen sich über ihre Schaltfläche aus- und einblenden (gemerkt pro Browser). Fremde
|
||||||
Städte kann man nur ansehen, nicht ändern. Alle angemeldeten Konten können die Städte der anderen sehen.
|
Städte kann man nur ansehen, nicht ändern. Alle angemeldeten Konten können die Städte der anderen sehen.
|
||||||
|
|
||||||
|
**⤓ Karte herunterladen** speichert die eigenen Städte und besuchten Länder als Bild; Ausschnitt
|
||||||
|
„Meine Orte“ (auf die Städte gezoomt) oder ganze Welt:
|
||||||
|
|
||||||
|
- **PNG** (4000 oder 8000 px breit): gut sichtbare Punkte, beschriftet sind nur besuchte **Hauptstädte**.
|
||||||
|
- **SVG** (beliebig scharf zoombar, z. B. zum Drucken): kleine Punkte und Schrift, **alle** Städte
|
||||||
|
beschriftet – dicht liegende Orte trennen sich beim Hineinzoomen.
|
||||||
|
|
||||||
|
Beschriftungen weichen einander aus, wenn nötig mit Linie zum Punkt. Farben kommen aus dem gewählten
|
||||||
|
Design; erstellt wird das Bild im Browser. Ob eine Stadt Hauptstadt ist, liefert die Städtesuche
|
||||||
|
(GeoNames-Merkmal „PPLC“); bei älteren Einträgen trägt der Server das im Hintergrund nach.
|
||||||
|
|
||||||
### Kanban
|
### Kanban
|
||||||
|
|
||||||
Beliebig viele Projekte mit je einem Board (Offen, In Arbeit, Wartet, Erledigt). Aufgaben
|
Beliebig viele Projekte mit je einem Board (Offen, In Arbeit, Wartet, Erledigt). Aufgaben
|
||||||
@@ -169,6 +180,11 @@ haben Titel, Beschreibung, Status und Bearbeiter; verschieben per Drag & Drop od
|
|||||||
Projekte lassen sich mit Artikeln aus „P42 Projekte“ verknüpfen, abschließen, wieder öffnen
|
Projekte lassen sich mit Artikeln aus „P42 Projekte“ verknüpfen, abschließen, wieder öffnen
|
||||||
und löschen (löschen: wer angelegt hat und Administratoren).
|
und löschen (löschen: wer angelegt hat und Administratoren).
|
||||||
|
|
||||||
|
Erledigte Aufgaben räumen sich selbst auf: **2 Tage** nach „Erledigt“ verschwinden sie ins **Archiv**
|
||||||
|
(Button „Archiv (n)“ im Board, dort auch „Zurückholen“), **14 Tage** nach „Erledigt“ werden sie
|
||||||
|
automatisch gelöscht. Zurück aus „Erledigt“ hält die Uhr an; „Zurückholen“ startet beide Fristen neu.
|
||||||
|
Gelöscht wird stündlich im Hintergrund und beim Öffnen eines Boards.
|
||||||
|
|
||||||
### Designs
|
### Designs
|
||||||
|
|
||||||
Im Menü unter **Design** umschaltbar, gespeichert pro Browser:
|
Im Menü unter **Design** umschaltbar, gespeichert pro Browser:
|
||||||
@@ -213,7 +229,7 @@ backend/
|
|||||||
app/haushalt.py Listen (je Vorrat + Einkauf) mit Sichtbarkeit, Produktkatalog, Einheiten
|
app/haushalt.py Listen (je Vorrat + Einkauf) mit Sichtbarkeit, Produktkatalog, Einheiten
|
||||||
app/rezept.py Rezeptabgleich (Seite abrufen, Zutaten zerlegen, zuordnen)
|
app/rezept.py Rezeptabgleich (Seite abrufen, Zutaten zerlegen, zuordnen)
|
||||||
app/reisen.py Reise-Karte, Städtesuche, Infos aus Wikipedia/Wikidata
|
app/reisen.py Reise-Karte, Städtesuche, Infos aus Wikipedia/Wikidata
|
||||||
app/kanban.py Kanban-Projekte, Aufgaben, Artikel-Verknüpfungen
|
app/kanban.py Kanban-Projekte, Aufgaben, Artikel-Verknüpfungen, Archiv + automatisches Löschen
|
||||||
app/tracking/ Paketdienst-Erkennung, 17TRACK, DHL
|
app/tracking/ Paketdienst-Erkennung, 17TRACK, DHL
|
||||||
|
|
||||||
frontend/
|
frontend/
|
||||||
|
|||||||
+14
-2
@@ -50,6 +50,7 @@ KANBAN_TASKS_TABLE = """(
|
|||||||
assignee INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
assignee INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
priority INTEGER NOT NULL DEFAULT 2 CHECK (priority IN (1, 2, 3)), -- 1 hoch, 2 mittel, 3 niedrig
|
priority INTEGER NOT NULL DEFAULT 2 CHECK (priority IN (1, 2, 3)), -- 1 hoch, 2 mittel, 3 niedrig
|
||||||
position REAL NOT NULL,
|
position REAL NOT NULL,
|
||||||
|
done_at TEXT, -- seit wann "erledigt": nach 2 Tagen im Archiv, nach 14 Tagen gelöscht
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
@@ -164,6 +165,7 @@ CREATE TABLE IF NOT EXISTS travel_cities (
|
|||||||
population INTEGER,
|
population INTEGER,
|
||||||
elevation REAL,
|
elevation REAL,
|
||||||
timezone TEXT,
|
timezone TEXT,
|
||||||
|
capital INTEGER, -- Hauptstadt (GeoNames PPLC); NULL = noch nicht abgefragt
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
UNIQUE (user_id, geoname_id)
|
UNIQUE (user_id, geoname_id)
|
||||||
);
|
);
|
||||||
@@ -303,9 +305,9 @@ def _migrate_checked(conn) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _migrate_travel(conn) -> None:
|
def _migrate_travel(conn) -> None:
|
||||||
# Reise-Städte aus einer Vorversion ohne Höhe/Zeitzone: Spalten ergänzen
|
# Reise-Städte aus einer Vorversion ohne Höhe/Zeitzone/Hauptstadt: Spalten ergänzen (Hauptstadt füllt reisen.py nach)
|
||||||
columns = {r["name"] for r in conn.execute("PRAGMA table_info(travel_cities)")}
|
columns = {r["name"] for r in conn.execute("PRAGMA table_info(travel_cities)")}
|
||||||
for column, kind in (("elevation", "REAL"), ("timezone", "TEXT")):
|
for column, kind in (("elevation", "REAL"), ("timezone", "TEXT"), ("capital", "INTEGER")):
|
||||||
if column not in columns:
|
if column not in columns:
|
||||||
conn.execute(f"ALTER TABLE travel_cities ADD COLUMN {column} {kind}")
|
conn.execute(f"ALTER TABLE travel_cities ADD COLUMN {column} {kind}")
|
||||||
|
|
||||||
@@ -352,6 +354,15 @@ def _migrate_kanban_priority(conn) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_kanban_done(conn) -> None:
|
||||||
|
"""Aufgaben ohne "erledigt seit": Spalte ergänzen. Schon erledigte Aufgaben zählen ab jetzt,
|
||||||
|
damit beim Update nichts sofort archiviert oder gelöscht wird."""
|
||||||
|
columns = {r["name"] for r in conn.execute("PRAGMA table_info(kanban_tasks)")}
|
||||||
|
if "done_at" not in columns:
|
||||||
|
conn.execute("ALTER TABLE kanban_tasks ADD COLUMN done_at TEXT")
|
||||||
|
conn.execute("UPDATE kanban_tasks SET done_at = ? WHERE status = 'erledigt'", (now_iso(),))
|
||||||
|
|
||||||
|
|
||||||
def _migrate_monitor_token(conn) -> None:
|
def _migrate_monitor_token(conn) -> None:
|
||||||
# Monitore aus der Zeit vor P42-S.M.: Spalte für den Token ergänzen
|
# Monitore aus der Zeit vor P42-S.M.: Spalte für den Token ergänzen
|
||||||
columns = {r["name"] for r in conn.execute("PRAGMA table_info(monitors)")}
|
columns = {r["name"] for r in conn.execute("PRAGMA table_info(monitors)")}
|
||||||
@@ -402,6 +413,7 @@ def init() -> None:
|
|||||||
_migrate_kanban_completed(conn)
|
_migrate_kanban_completed(conn)
|
||||||
_migrate_kanban(conn)
|
_migrate_kanban(conn)
|
||||||
_migrate_kanban_priority(conn)
|
_migrate_kanban_priority(conn)
|
||||||
|
_migrate_kanban_done(conn)
|
||||||
_import_legacy(conn)
|
_import_legacy(conn)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+68
-4
@@ -3,9 +3,13 @@
|
|||||||
Alles ist für alle Konten gemeinsam. Löschen dürfen nur, wer etwas angelegt hat, und Administratoren.
|
Alles ist für alle Konten gemeinsam. Löschen dürfen nur, wer etwas angelegt hat, und Administratoren.
|
||||||
Projekte lassen sich als abgeschlossen markieren und mit Artikeln aus "P42 Projekte" verknüpfen.
|
Projekte lassen sich als abgeschlossen markieren und mit Artikeln aus "P42 Projekte" verknüpfen.
|
||||||
`position` (Kommazahl) sortiert Projekte und Aufgaben innerhalb einer Spalte.
|
`position` (Kommazahl) sortiert Projekte und Aufgaben innerhalb einer Spalte.
|
||||||
|
Erledigte Aufgaben wandern nach 2 Tagen ins Archiv und werden nach 14 Tagen gelöscht (gezählt ab "erledigt").
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||||
|
|
||||||
@@ -22,6 +26,38 @@ MAX_DESCRIPTION = 5000
|
|||||||
MAX_TASKS = 1000 # pro Projekt
|
MAX_TASKS = 1000 # pro Projekt
|
||||||
MAX_PROJECTS = 300
|
MAX_PROJECTS = 300
|
||||||
MAX_LINKS = 20
|
MAX_LINKS = 20
|
||||||
|
ARCHIVE_AFTER = timedelta(days=2)
|
||||||
|
DELETE_AFTER = timedelta(days=14)
|
||||||
|
CLEANUP_SECONDS = 3600
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _ago(delta: timedelta) -> str:
|
||||||
|
# Gleiches Format wie now_iso(), damit Textvergleich in SQLite stimmt
|
||||||
|
return (datetime.now(timezone.utc) - delta).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_after(iso: str, delta: timedelta) -> str:
|
||||||
|
return (datetime.fromisoformat(iso.replace("Z", "+00:00")) + delta).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup(conn) -> int:
|
||||||
|
"""Seit 14 Tagen erledigte Aufgaben löschen."""
|
||||||
|
return conn.execute(
|
||||||
|
"DELETE FROM kanban_tasks WHERE status = 'erledigt' AND done_at <= ?", (_ago(DELETE_AFTER),)
|
||||||
|
).rowcount
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_loop() -> None:
|
||||||
|
"""Stündlich aufräumen, auch wenn niemand das Kanban öffnet."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
with transaction() as conn:
|
||||||
|
cleanup(conn)
|
||||||
|
except Exception: # nie die Schleife beenden
|
||||||
|
log.exception("Kanban-Aufräumen fehlgeschlagen")
|
||||||
|
await asyncio.sleep(CLEANUP_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
def _person(user_id, name) -> dict | None:
|
def _person(user_id, name) -> dict | None:
|
||||||
@@ -158,6 +194,7 @@ def _projects(conn, project_id=None) -> list:
|
|||||||
|
|
||||||
|
|
||||||
def _overview(conn) -> dict:
|
def _overview(conn) -> dict:
|
||||||
|
cleanup(conn)
|
||||||
articles = conn.execute("SELECT id, title FROM projects ORDER BY title COLLATE NOCASE").fetchall()
|
articles = conn.execute("SELECT id, title FROM projects ORDER BY title COLLATE NOCASE").fetchall()
|
||||||
return {
|
return {
|
||||||
"projects": _projects(conn),
|
"projects": _projects(conn),
|
||||||
@@ -176,6 +213,7 @@ def _task(row) -> dict:
|
|||||||
"assignee": row["assignee"],
|
"assignee": row["assignee"],
|
||||||
"priority": PRIORITY_NAMES[row["priority"]],
|
"priority": PRIORITY_NAMES[row["priority"]],
|
||||||
"position": row["position"],
|
"position": row["position"],
|
||||||
|
"doneAt": row["done_at"],
|
||||||
"createdAt": row["created_at"],
|
"createdAt": row["created_at"],
|
||||||
"createdBy": _person(row["created_by"], row["created_by_name"]),
|
"createdBy": _person(row["created_by"], row["created_by_name"]),
|
||||||
"updatedAt": row["updated_at"],
|
"updatedAt": row["updated_at"],
|
||||||
@@ -188,6 +226,7 @@ def _board(conn, project_id: int) -> dict:
|
|||||||
if not projects:
|
if not projects:
|
||||||
raise HTTPException(404, "Kanban-Projekt nicht gefunden – vielleicht wurde es gerade gelöscht.")
|
raise HTTPException(404, "Kanban-Projekt nicht gefunden – vielleicht wurde es gerade gelöscht.")
|
||||||
project = projects[0]
|
project = projects[0]
|
||||||
|
cleanup(conn)
|
||||||
tasks = conn.execute(
|
tasks = conn.execute(
|
||||||
"""SELECT t.*, cu.display_name AS created_by_name, uu.display_name AS updated_by_name
|
"""SELECT t.*, cu.display_name AS created_by_name, uu.display_name AS updated_by_name
|
||||||
FROM kanban_tasks t
|
FROM kanban_tasks t
|
||||||
@@ -197,9 +236,18 @@ def _board(conn, project_id: int) -> dict:
|
|||||||
(project_id,),
|
(project_id,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
articles = conn.execute("SELECT id, title FROM projects ORDER BY title COLLATE NOCASE").fetchall()
|
articles = conn.execute("SELECT id, title FROM projects ORDER BY title COLLATE NOCASE").fetchall()
|
||||||
|
archive_before = _ago(ARCHIVE_AFTER)
|
||||||
|
archived = sorted(
|
||||||
|
(r for r in tasks if r["status"] == "erledigt" and r["done_at"] and r["done_at"] <= archive_before),
|
||||||
|
key=lambda r: r["done_at"],
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
archived_ids = {r["id"] for r in archived}
|
||||||
return {
|
return {
|
||||||
"project": project,
|
"project": project,
|
||||||
"tasks": [_task(r) for r in tasks],
|
"tasks": [_task(r) for r in tasks if r["id"] not in archived_ids],
|
||||||
|
# Archiv: nur mit eigenem Button sichtbar; deleteAt = automatisches Löschen
|
||||||
|
"archived": [{**_task(r), "deleteAt": _iso_after(r["done_at"], DELETE_AFTER)} for r in archived],
|
||||||
"users": _users(conn),
|
"users": _users(conn),
|
||||||
"articles": [{"id": a["id"], "title": a["title"]} for a in articles],
|
"articles": [{"id": a["id"], "title": a["title"]} for a in articles],
|
||||||
}
|
}
|
||||||
@@ -310,10 +358,10 @@ def create_task(project_id: int, data: dict = Body(...), user: dict = Depends(cu
|
|||||||
now = now_iso()
|
now = now_iso()
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT INTO kanban_tasks (project_id, title, description, status, assignee, priority, position,
|
"""INSERT INTO kanban_tasks (project_id, title, description, status, assignee, priority, position,
|
||||||
created_at, created_by, updated_at, updated_by)
|
done_at, created_at, created_by, updated_at, updated_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(project_id, title, description, status, _assignee(conn, data.get("assignee")), priority,
|
(project_id, title, description, status, _assignee(conn, data.get("assignee")), priority,
|
||||||
_end_of(conn, project_id, status), now, user["id"], now, user["id"]),
|
_end_of(conn, project_id, status), now if status == "erledigt" else None, now, user["id"], now, user["id"]),
|
||||||
)
|
)
|
||||||
return _board(conn, project_id)
|
return _board(conn, project_id)
|
||||||
|
|
||||||
@@ -332,6 +380,9 @@ def update_task(task_id: int, data: dict = Body(...), user: dict = Depends(curre
|
|||||||
values["assignee"] = _assignee(conn, data["assignee"])
|
values["assignee"] = _assignee(conn, data["assignee"])
|
||||||
if "status" in data:
|
if "status" in data:
|
||||||
values["status"] = _status(data["status"])
|
values["status"] = _status(data["status"])
|
||||||
|
if values["status"] != row["status"]:
|
||||||
|
# Erledigt-Uhr starten bzw. beim Zurückholen aus "erledigt" anhalten
|
||||||
|
values["done_at"] = now_iso() if values["status"] == "erledigt" else None
|
||||||
if "priority" in data:
|
if "priority" in data:
|
||||||
values["priority"] = _priority(data["priority"])
|
values["priority"] = _priority(data["priority"])
|
||||||
if "position" in data:
|
if "position" in data:
|
||||||
@@ -346,6 +397,19 @@ def update_task(task_id: int, data: dict = Body(...), user: dict = Depends(curre
|
|||||||
return _board(conn, row["project_id"])
|
return _board(conn, row["project_id"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/kanban/aufgaben/{task_id}/zurueckholen")
|
||||||
|
def restore_task(task_id: int, user: dict = Depends(current_user)):
|
||||||
|
"""Archivierte Aufgabe zurück in die Spalte "Erledigt"; Archiv- und Lösch-Frist beginnen neu."""
|
||||||
|
with transaction() as conn:
|
||||||
|
row = _get_task(conn, task_id)
|
||||||
|
now = now_iso()
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE kanban_tasks SET status = 'erledigt', done_at = ?, updated_at = ?, updated_by = ? WHERE id = ?",
|
||||||
|
(now, now, user["id"], task_id),
|
||||||
|
)
|
||||||
|
return _board(conn, row["project_id"])
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/kanban/aufgaben/{task_id}")
|
@router.delete("/api/kanban/aufgaben/{task_id}")
|
||||||
def delete_task(task_id: int, user: dict = Depends(current_user)):
|
def delete_task(task_id: int, user: dict = Depends(current_user)):
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
|
|||||||
@@ -22,8 +22,14 @@ from .tracking import track
|
|||||||
async def lifespan(_: FastAPI):
|
async def lifespan(_: FastAPI):
|
||||||
# Verlauf der Server-Auslastung (P42-S.M.) im Hintergrund sammeln
|
# Verlauf der Server-Auslastung (P42-S.M.) im Hintergrund sammeln
|
||||||
sampler = asyncio.create_task(monitoring.sample_loop())
|
sampler = asyncio.create_task(monitoring.sample_loop())
|
||||||
|
# Erledigte Kanban-Aufgaben nach 14 Tagen löschen
|
||||||
|
cleaner = asyncio.create_task(kanban.cleanup_loop())
|
||||||
|
# Hauptstadt-Kennzeichen älterer Reise-Städte nachtragen (für den Karten-Export)
|
||||||
|
capitals = asyncio.create_task(reisen.capitals_loop())
|
||||||
yield
|
yield
|
||||||
sampler.cancel()
|
sampler.cancel()
|
||||||
|
cleaner.cancel()
|
||||||
|
capitals.cancel()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="P42 Dashboard", docs_url="/api/docs", openapi_url="/api/openapi.json", lifespan=lifespan)
|
app = FastAPI(title="P42 Dashboard", docs_url="/api/docs", openapi_url="/api/openapi.json", lifespan=lifespan)
|
||||||
|
|||||||
+43
-3
@@ -6,7 +6,9 @@ Städte werden über die Geocoding-API von Open-Meteo gesucht (Daten: GeoNames,
|
|||||||
Infotexte kommen aus der deutschen Wikipedia, Einwohnerzahlen von Ländern aus Wikidata.
|
Infotexte kommen aus der deutschen Wikipedia, Einwohnerzahlen von Ländern aus Wikidata.
|
||||||
Alle Abrufe laufen über den Server und werden zwischengespeichert; der Browser fragt keine Fremdserver.
|
Alle Abrufe laufen über den Server und werden zwischengespeichert; der Browser fragt keine Fremdserver.
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
@@ -31,7 +33,10 @@ CACHE_OK = 30 * 24 * 3600 # Infos ändern sich selten
|
|||||||
CACHE_MISS = 24 * 3600 # nichts gefunden: morgen noch mal versuchen
|
CACHE_MISS = 24 * 3600 # nichts gefunden: morgen noch mal versuchen
|
||||||
COUNTRY_CODE = re.compile(r"[A-Z]{2}|X-[A-Za-z. ]{1,40}") # X-…: Gebiete ohne ISO-Code auf der Karte
|
COUNTRY_CODE = re.compile(r"[A-Z]{2}|X-[A-Za-z. ]{1,40}") # X-…: Gebiete ohne ISO-Code auf der Karte
|
||||||
|
|
||||||
|
CAPITALS_SECONDS = 6 * 3600
|
||||||
|
|
||||||
_search_cache: dict[str, list] = {}
|
_search_cache: dict[str, list] = {}
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _city(row) -> dict:
|
def _city(row) -> dict:
|
||||||
@@ -39,6 +44,7 @@ def _city(row) -> dict:
|
|||||||
"id": row["id"], "geonameId": row["geoname_id"], "name": row["name"], "admin1": row["admin1"],
|
"id": row["id"], "geonameId": row["geoname_id"], "name": row["name"], "admin1": row["admin1"],
|
||||||
"country": row["country"], "lat": row["lat"], "lon": row["lon"], "population": row["population"],
|
"country": row["country"], "lat": row["lat"], "lon": row["lon"], "population": row["population"],
|
||||||
"elevation": row["elevation"], "timezone": row["timezone"], "createdAt": row["created_at"],
|
"elevation": row["elevation"], "timezone": row["timezone"], "createdAt": row["created_at"],
|
||||||
|
"capital": None if row["capital"] is None else bool(row["capital"]),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -107,18 +113,20 @@ def add_city(data: dict = Body(...), user: dict = Depends(current_user)):
|
|||||||
timezone = data.get("timezone")
|
timezone = data.get("timezone")
|
||||||
if not isinstance(timezone, str) or not re.fullmatch(r"[A-Za-z_]+(?:/[A-Za-z0-9_+-]+){0,2}", timezone):
|
if not isinstance(timezone, str) or not re.fullmatch(r"[A-Za-z_]+(?:/[A-Za-z0-9_+-]+){0,2}", timezone):
|
||||||
timezone = None
|
timezone = None
|
||||||
|
# Hauptstadt kommt aus der Suche; fehlt die Angabe, trägt fill_capitals() sie nach
|
||||||
|
capital = int(data["capital"]) if isinstance(data.get("capital"), bool) else None
|
||||||
values = (
|
values = (
|
||||||
user["id"], geoname_id, _text(data.get("name"), "Name", 100), _text(data.get("admin1"), "Region", 100, False),
|
user["id"], geoname_id, _text(data.get("name"), "Name", 100), _text(data.get("admin1"), "Region", 100, False),
|
||||||
country, _number(data.get("lat"), "Breitengrad", -90, 90), _number(data.get("lon"), "Längengrad", -180, 180),
|
country, _number(data.get("lat"), "Breitengrad", -90, 90), _number(data.get("lon"), "Längengrad", -180, 180),
|
||||||
population, elevation, timezone, now_iso(),
|
population, elevation, timezone, capital, now_iso(),
|
||||||
)
|
)
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
if conn.execute("SELECT COUNT(*) FROM travel_cities WHERE user_id = ?", (user["id"],)).fetchone()[0] >= MAX_CITIES:
|
if conn.execute("SELECT COUNT(*) FROM travel_cities WHERE user_id = ?", (user["id"],)).fetchone()[0] >= MAX_CITIES:
|
||||||
raise ValidationError(f"Maximal {MAX_CITIES} Städte.")
|
raise ValidationError(f"Maximal {MAX_CITIES} Städte.")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT INTO travel_cities
|
"""INSERT INTO travel_cities
|
||||||
(user_id, geoname_id, name, admin1, country, lat, lon, population, elevation, timezone, created_at)
|
(user_id, geoname_id, name, admin1, country, lat, lon, population, elevation, timezone, capital, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (user_id, geoname_id) DO NOTHING""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (user_id, geoname_id) DO NOTHING""",
|
||||||
values,
|
values,
|
||||||
)
|
)
|
||||||
return _state(conn, user["id"])
|
return _state(conn, user["id"])
|
||||||
@@ -180,6 +188,7 @@ async def search_city(q: str = "", _: dict = Depends(current_user)):
|
|||||||
"geonameId": r["id"], "name": r["name"], "admin1": r.get("admin1") or "", "countryName": r.get("country") or "",
|
"geonameId": r["id"], "name": r["name"], "admin1": r.get("admin1") or "", "countryName": r.get("country") or "",
|
||||||
"country": r["country_code"], "lat": r["latitude"], "lon": r["longitude"], "population": r.get("population"),
|
"country": r["country_code"], "lat": r["latitude"], "lon": r["longitude"], "population": r.get("population"),
|
||||||
"elevation": r.get("elevation"), "timezone": r.get("timezone"),
|
"elevation": r.get("elevation"), "timezone": r.get("timezone"),
|
||||||
|
"capital": r.get("feature_code") == "PPLC",
|
||||||
}
|
}
|
||||||
for r in res.json().get("results", [])
|
for r in res.json().get("results", [])
|
||||||
if r.get("country_code") and r.get("id")
|
if r.get("country_code") and r.get("id")
|
||||||
@@ -190,6 +199,37 @@ async def search_city(q: str = "", _: dict = Depends(current_user)):
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Hauptstadt-Kennzeichen für ältere Einträge nachtragen ----------
|
||||||
|
|
||||||
|
|
||||||
|
async def fill_capitals() -> None:
|
||||||
|
"""Städte ohne Hauptstadt-Angabe bei Open-Meteo nachschlagen (GeoNames-Merkmal PPLC)."""
|
||||||
|
with transaction() as conn:
|
||||||
|
ids = [r[0] for r in conn.execute("SELECT DISTINCT geoname_id FROM travel_cities WHERE capital IS NULL")]
|
||||||
|
if not ids:
|
||||||
|
return
|
||||||
|
async with httpx.AsyncClient(timeout=TIMEOUT, headers=HEADERS) as client:
|
||||||
|
for geoname_id in ids:
|
||||||
|
try:
|
||||||
|
res = await client.get("https://geocoding-api.open-meteo.com/v1/get", params={"id": geoname_id})
|
||||||
|
res.raise_for_status()
|
||||||
|
capital = int(res.json().get("feature_code") == "PPLC")
|
||||||
|
except (httpx.HTTPError, ValueError):
|
||||||
|
continue # beim nächsten Durchlauf erneut versuchen
|
||||||
|
with transaction() as conn:
|
||||||
|
conn.execute("UPDATE travel_cities SET capital = ? WHERE geoname_id = ? AND capital IS NULL", (capital, geoname_id))
|
||||||
|
await asyncio.sleep(0.2) # Open-Meteo schonen
|
||||||
|
|
||||||
|
|
||||||
|
async def capitals_loop() -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await fill_capitals()
|
||||||
|
except Exception: # nie die Schleife beenden
|
||||||
|
log.exception("Hauptstädte nachtragen fehlgeschlagen")
|
||||||
|
await asyncio.sleep(CAPITALS_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
# ---------- Infos aus Wikipedia / Wikidata ----------
|
# ---------- Infos aus Wikipedia / Wikidata ----------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
// Reise-Karte als Bild exportieren: eigene Städte und besuchte Länder in hoher Auflösung.
|
||||||
|
// Gezeichnet wird ein eigenes SVG (Farben aus dem aktiven Design); daraus wird bei Bedarf ein PNG.
|
||||||
|
// SVG: kleine Punkte und Schrift, alle Städte beschriftet (man kann beliebig hineinzoomen).
|
||||||
|
// PNG: größere Punkte, beschriftet nur besuchte Hauptstädte (sonst wird es im festen Raster zu voll).
|
||||||
|
import { geoCircle, geoGraticule10, geoNaturalEarth1, geoPath } from 'd3-geo';
|
||||||
|
|
||||||
|
const FONT = 'Arial, Helvetica, sans-serif'; // Systemschrift: Webfonts lädt ein SVG-Bild nicht nach
|
||||||
|
const MAX_PIXELS = 64e6; // Browser-Grenze für Canvas sicher unterschreiten
|
||||||
|
|
||||||
|
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
|
const n = (v) => Math.round(v * 10) / 10;
|
||||||
|
|
||||||
|
function themeColors() {
|
||||||
|
const style = getComputedStyle(document.documentElement);
|
||||||
|
const get = (name, fallback) => style.getPropertyValue(name).trim() || fallback;
|
||||||
|
const rgb = get('--accent-rgb', '255 70 70').split(/[\s,]+/).map(Number);
|
||||||
|
const accent = (a) => `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`;
|
||||||
|
return {
|
||||||
|
ocean: get('--map-ocean', '#050102'),
|
||||||
|
land: get('--map-land', '#1a0506'),
|
||||||
|
bg: get('--bg', '#080203'),
|
||||||
|
bright: get('--bright', '#ffffff'),
|
||||||
|
muted: get('--muted', '#999999'),
|
||||||
|
visited: accent(0.55),
|
||||||
|
visitedLine: accent(0.9),
|
||||||
|
line: accent(0.35),
|
||||||
|
grid: accent(0.1),
|
||||||
|
dot: accent(1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beschriftungen nacheinander setzen: erst direkt neben den Punkt, sonst schrittweise weiter weg (mit Linie),
|
||||||
|
// bis sie weder andere Beschriftungen noch Punkte überdecken. Nur Punkte mit label werden beschriftet,
|
||||||
|
// alle Punkte bleiben aber Hindernisse.
|
||||||
|
function placeLabels(points, s, width, height) {
|
||||||
|
const ctx = document.createElement('canvas').getContext('2d');
|
||||||
|
const fs = 13 * s;
|
||||||
|
ctx.font = `600 ${fs}px ${FONT}`;
|
||||||
|
const r = 4.5 * s;
|
||||||
|
const h = fs * 1.25;
|
||||||
|
const gap = r + 3 * s;
|
||||||
|
const dirs = [[1, 0], [-1, 0], [1, -1], [1, 1], [-1, -1], [-1, 1], [0, -1], [0, 1]];
|
||||||
|
const steps = [1, 2.2, 3.8, 6, 9, 13, 18];
|
||||||
|
const placed = [];
|
||||||
|
const dots = points.map((p) => ({ x0: p.x - r, y0: p.y - r, x1: p.x + r, y1: p.y + r }));
|
||||||
|
const hits = (a, b) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
|
||||||
|
|
||||||
|
// Größere Städte bekommen die besten Plätze
|
||||||
|
const order = points.filter((p) => p.label).sort((a, b) => (b.population || 0) - (a.population || 0));
|
||||||
|
return order.map((p) => {
|
||||||
|
const w = ctx.measureText(p.name).width + 2 * s;
|
||||||
|
let best = null;
|
||||||
|
search: for (const step of steps) {
|
||||||
|
for (const [dx, dy] of dirs) {
|
||||||
|
const len = Math.hypot(dx, dy);
|
||||||
|
const ax = p.x + (dx / len) * gap * step;
|
||||||
|
const ay = p.y + (dy / len) * gap * step;
|
||||||
|
const x0 = dx > 0 ? ax : dx < 0 ? ax - w : ax - w / 2;
|
||||||
|
const y0 = dy > 0 ? ay : dy < 0 ? ay - h : ay - h / 2;
|
||||||
|
const box = { x0, y0, x1: x0 + w, y1: y0 + h };
|
||||||
|
if (box.x0 < 0 || box.y0 < 0 || box.x1 > width || box.y1 > height) continue;
|
||||||
|
if (placed.some((b) => hits(box, b)) || dots.some((d) => hits(box, d))) continue;
|
||||||
|
best = { box, step };
|
||||||
|
break search;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Kein freier Platz: rechts daneben, notfalls überlappend
|
||||||
|
if (!best) best = { box: { x0: p.x + gap, y0: p.y - h / 2, x1: p.x + gap + w, y1: p.y + h / 2 }, step: 1 };
|
||||||
|
placed.push(best.box);
|
||||||
|
return { ...p, ...best };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* area: 'orte' (auf die eigenen Städte zoomen) oder 'welt'. width: Breite in Pixeln.
|
||||||
|
* marks: Größe von Punkten, Linien und Schrift (1 = gut lesbar im Ganzen, kleiner = erst beim Zoomen).
|
||||||
|
* label: 'alle' oder 'hauptstaedte' (welche Städte beschriftet werden). Liefert { svg, width, height }.
|
||||||
|
*/
|
||||||
|
export function buildMapSvg({ shapes, visited, cities, area, width, caption, marks = 1, label = 'alle' }) {
|
||||||
|
const page = width / 2400; // Maßstab für Ränder und Fußzeile
|
||||||
|
const s = page * marks; // Maßstab für Punkte, Linien und Schrift
|
||||||
|
const pad = Math.round(50 * page);
|
||||||
|
const footer = Math.round(46 * page);
|
||||||
|
const focus = area === 'orte' && cities.length
|
||||||
|
? { type: 'GeometryCollection', geometries: cities.map((c) => geoCircle().center([c.lon, c.lat]).radius(1.2)()) }
|
||||||
|
: { type: 'Sphere' };
|
||||||
|
|
||||||
|
// Höhe aus dem Seitenverhältnis des Ausschnitts, in vernünftigen Grenzen
|
||||||
|
const probe = geoNaturalEarth1().fitWidth(width - 2 * pad, focus);
|
||||||
|
const [[, py0], [, py1]] = geoPath(probe).bounds(focus);
|
||||||
|
let height = Math.round(Math.min(Math.max(py1 - py0, width * 0.3), width * 1.3) + 2 * pad + footer);
|
||||||
|
const projection = geoNaturalEarth1().fitExtent([[pad, pad], [width - pad, height - pad - footer]], focus);
|
||||||
|
const path = geoPath(projection);
|
||||||
|
const c = themeColors();
|
||||||
|
|
||||||
|
const points = cities
|
||||||
|
.map((city) => {
|
||||||
|
const p = projection([city.lon, city.lat]);
|
||||||
|
return p && {
|
||||||
|
name: city.name, population: city.population, x: p[0], y: p[1],
|
||||||
|
label: label === 'alle' || city.capital === true,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((p) => p && p.x >= 0 && p.x <= width && p.y >= 0 && p.y <= height - footer);
|
||||||
|
const labels = placeLabels(points, s, width, height - footer);
|
||||||
|
|
||||||
|
const lw = n(0.6 * s);
|
||||||
|
const countries = shapes.map((shape) => {
|
||||||
|
const d = path(shape.feature);
|
||||||
|
if (!d) return '';
|
||||||
|
const on = visited.has(shape.key);
|
||||||
|
return `<path d="${d}" fill="${on ? c.visited : c.land}" stroke="${on ? c.visitedLine : c.line}" stroke-width="${lw}"/>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const r = n(4.5 * s);
|
||||||
|
const fs = n(13 * s);
|
||||||
|
const leaders = labels.filter((l) => l.step > 1).map((l) => {
|
||||||
|
// Linie vom Punkt zur nächsten Kante der Beschriftung
|
||||||
|
const tx = Math.max(l.box.x0, Math.min(l.x, l.box.x1));
|
||||||
|
const ty = Math.max(l.box.y0, Math.min(l.y, l.box.y1));
|
||||||
|
return `<line x1="${n(l.x)}" y1="${n(l.y)}" x2="${n(tx)}" y2="${n(ty)}" stroke="${c.bright}" stroke-opacity="0.7" stroke-width="${n(1 * s)}"/>`;
|
||||||
|
}).join('');
|
||||||
|
const dots = points.map((p) =>
|
||||||
|
`<circle cx="${n(p.x)}" cy="${n(p.y)}" r="${r}" fill="${c.bright}" stroke="${c.dot}" stroke-width="${n(2 * s)}"/>`).join('');
|
||||||
|
const texts = labels.map((l) =>
|
||||||
|
`<text x="${n(l.box.x0 + s)}" y="${n(l.box.y0 + fs * 0.98)}">${esc(l.name)}</text>`).join('');
|
||||||
|
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||||
|
<rect width="100%" height="100%" fill="${c.bg}"/>
|
||||||
|
<path d="${path({ type: 'Sphere' })}" fill="${c.ocean}"/>
|
||||||
|
<path d="${path(geoGraticule10())}" fill="none" stroke="${c.grid}" stroke-width="${lw}"/>
|
||||||
|
<g stroke-linejoin="round">${countries}</g>
|
||||||
|
<g>${leaders}</g>
|
||||||
|
<g>${dots}</g>
|
||||||
|
<g font-family="${FONT}" font-size="${fs}" font-weight="600" fill="${c.bright}" stroke="${c.bg}" stroke-width="${n(3.2 * s)}" stroke-linejoin="round" paint-order="stroke">${texts}</g>
|
||||||
|
<text x="${pad}" y="${height - Math.round(18 * page)}" font-family="${FONT}" font-size="${n(15 * page)}" fill="${c.muted}">${esc(caption)}</text>
|
||||||
|
</svg>`;
|
||||||
|
return { svg, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wie buildMapSvg, aber fürs PNG so verkleinert, dass der Browser das Bild noch zeichnen kann. */
|
||||||
|
export function buildMapImage(options) {
|
||||||
|
const out = buildMapSvg(options);
|
||||||
|
if (out.width * out.height <= MAX_PIXELS) return out;
|
||||||
|
return buildMapSvg({ ...options, width: Math.floor(Math.sqrt(MAX_PIXELS * (out.width / out.height))) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function svgToPng(svg, width, height) {
|
||||||
|
const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
|
||||||
|
try {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = url;
|
||||||
|
await img.decode();
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('Bild zu groß für diesen Browser.'))), 'image/png');
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function download(blob, filename) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.append(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ export default function KanbanBoard() {
|
|||||||
const [filter, setFilter] = useState(loadFilter);
|
const [filter, setFilter] = useState(loadFilter);
|
||||||
const [editing, setEditing] = useState(null); // null | { task } | { status } (neu)
|
const [editing, setEditing] = useState(null); // null | { task } | { status } (neu)
|
||||||
const [drag, setDrag] = useState(null); // { id, status, index } während Drag & Drop
|
const [drag, setDrag] = useState(null); // { id, status, index } während Drag & Drop
|
||||||
|
const [showArchive, setShowArchive] = useState(false);
|
||||||
const dialog = useRef(null);
|
const dialog = useRef(null);
|
||||||
const projectDialog = useRef(null);
|
const projectDialog = useRef(null);
|
||||||
const [projectForm, setProjectForm] = useState(null);
|
const [projectForm, setProjectForm] = useState(null);
|
||||||
@@ -128,7 +129,7 @@ export default function KanbanBoard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteProject() {
|
async function deleteProject() {
|
||||||
const n = data.tasks.length;
|
const n = data.tasks.length + data.archived.length;
|
||||||
if (!window.confirm(`Kanban-Projekt „${project.name}“${n ? ` mit ${n} ${n === 1 ? 'Aufgabe' : 'Aufgaben'}` : ''} endgültig löschen?`)) return;
|
if (!window.confirm(`Kanban-Projekt „${project.name}“${n ? ` mit ${n} ${n === 1 ? 'Aufgabe' : 'Aufgaben'}` : ''} endgültig löschen?`)) return;
|
||||||
try {
|
try {
|
||||||
await api('DELETE', `/api/kanban/projekte/${project.id}`);
|
await api('DELETE', `/api/kanban/projekte/${project.id}`);
|
||||||
@@ -141,11 +142,13 @@ export default function KanbanBoard() {
|
|||||||
if (!data) {
|
if (!data) {
|
||||||
return <><BackLink />{error ? <p className="error">{error}</p> : <p className="hint">Lade …</p>}</>;
|
return <><BackLink />{error ? <p className="error">{error}</p> : <p className="hint">Lade …</p>}</>;
|
||||||
}
|
}
|
||||||
const visible = (data?.tasks || []).filter((t) =>
|
const matches = (t) => (
|
||||||
filter === 'alle' ? true
|
filter === 'alle' ? true
|
||||||
: filter === 'meine' ? t.assignee === user.id
|
: filter === 'meine' ? t.assignee === user.id
|
||||||
: filter === 'niemand' ? t.assignee == null
|
: filter === 'niemand' ? t.assignee == null
|
||||||
: t.assignee === Number(filter));
|
: t.assignee === Number(filter));
|
||||||
|
const visible = (data?.tasks || []).filter(matches);
|
||||||
|
const archived = (data?.archived || []).filter(matches);
|
||||||
const byColumn = Object.fromEntries(COLUMNS.map((c) => [c.key, visible.filter((t) => t.status === c.key).sort(byPriority)]));
|
const byColumn = Object.fromEntries(COLUMNS.map((c) => [c.key, visible.filter((t) => t.status === c.key).sort(byPriority)]));
|
||||||
|
|
||||||
// ---------- Drag & Drop (Desktop) ----------
|
// ---------- Drag & Drop (Desktop) ----------
|
||||||
@@ -214,6 +217,15 @@ export default function KanbanBoard() {
|
|||||||
{users.filter((u) => u.id !== user.id).map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
{users.filter((u) => u.id !== user.id).map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<button
|
||||||
|
className="btn ghost"
|
||||||
|
type="button"
|
||||||
|
aria-expanded={showArchive}
|
||||||
|
aria-controls="kanban-archive"
|
||||||
|
onClick={() => setShowArchive((v) => !v)}
|
||||||
|
>
|
||||||
|
Archiv ({data.archived.length})
|
||||||
|
</button>
|
||||||
<button className="btn" type="button" onClick={() => open({ status: 'offen' })}>+ Neue Aufgabe</button>
|
<button className="btn" type="button" onClick={() => open({ status: 'offen' })}>+ Neue Aufgabe</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -267,6 +279,35 @@ export default function KanbanBoard() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showArchive && (
|
||||||
|
<section className="card kanban-archive" id="kanban-archive" aria-labelledby="archive-title">
|
||||||
|
<header className="household-head">
|
||||||
|
<h2 id="archive-title">Archiv</h2>
|
||||||
|
<span className="hint">Erledigtes kommt nach 2 Tagen hierher und wird nach 14 Tagen automatisch gelöscht.</span>
|
||||||
|
</header>
|
||||||
|
{archived.length === 0 ? (
|
||||||
|
<p className="hint">
|
||||||
|
{data.archived.length ? 'Keine archivierten Aufgaben für diesen Bearbeiter-Filter.' : 'Noch nichts archiviert.'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="archive-list">
|
||||||
|
{archived.map((task) => (
|
||||||
|
<li key={task.id} className="archive-item">
|
||||||
|
<button type="button" className="archive-title" onClick={() => open({ task })}>{task.title}</button>
|
||||||
|
<Assignee id={task.assignee} users={users} />
|
||||||
|
<small className="archive-dates">
|
||||||
|
erledigt {fmtDate(task.doneAt)} · wird am {fmtDate(task.deleteAt)} gelöscht
|
||||||
|
</small>
|
||||||
|
<button className="link-btn" type="button" onClick={() => run('POST', `/api/kanban/aufgaben/${task.id}/zurueckholen`)}>
|
||||||
|
Zurückholen
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<TaskDialog
|
<TaskDialog
|
||||||
key={`aufgabe-${editing?.opened ?? 'zu'}`}
|
key={`aufgabe-${editing?.opened ?? 'zu'}`}
|
||||||
ref={dialog}
|
ref={dialog}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { feature } from 'topojson-client';
|
import { feature } from 'topojson-client';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
|
import { useAuth } from '../auth.jsx';
|
||||||
|
import { buildMapImage, download, svgToPng } from '../components/mapExport.js';
|
||||||
import WorldMap from '../components/WorldMap.jsx';
|
import WorldMap from '../components/WorldMap.jsx';
|
||||||
import LAENDER from '../data/laender.json';
|
import LAENDER from '../data/laender.json';
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ export default function Reisen() {
|
|||||||
const [showOthers, setShowOthers] = useState(() => loadFlag(OTHERS_KEY));
|
const [showOthers, setShowOthers] = useState(() => loadFlag(OTHERS_KEY));
|
||||||
const [hiddenPeople, setHiddenPeople] = useState(() => loadSet(HIDDEN_KEY));
|
const [hiddenPeople, setHiddenPeople] = useState(() => loadSet(HIDDEN_KEY));
|
||||||
const map = useRef(null);
|
const map = useRef(null);
|
||||||
|
const exportDialog = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api('GET', '/api/reisen').then(setData).catch((e) => setError(e.message));
|
api('GET', '/api/reisen').then(setData).catch((e) => setError(e.message));
|
||||||
@@ -161,7 +164,13 @@ export default function Reisen() {
|
|||||||
{' '}· Klick auf ein Land oder einen Punkt zeigt Infos.
|
{' '}· Klick auf ein Land oder einen Punkt zeigt Infos.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{shapes && visited.size > 0 && (
|
||||||
|
<button className="link-btn" type="button" onClick={() => exportDialog.current.showModal()}>⤓ Karte herunterladen</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{shapes && data && (
|
||||||
|
<ExportDialog ref={exportDialog} shapes={shapes} visited={visited} cities={cities} />
|
||||||
|
)}
|
||||||
|
|
||||||
<CitySearch cities={cities} onAdd={addCity} busy={busy} />
|
<CitySearch cities={cities} onAdd={addCity} busy={busy} />
|
||||||
{error && <p className="error travel-error" role="alert">{error}</p>}
|
{error && <p className="error travel-error" role="alert">{error}</p>}
|
||||||
@@ -256,6 +265,112 @@ export default function Reisen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Karte als Bild herunterladen (nur eigene Orte) ----------
|
||||||
|
|
||||||
|
const SIZES = [
|
||||||
|
{ width: 4000, title: 'Mittel (4000 px breit)' },
|
||||||
|
{ width: 8000, title: 'Groß (8000 px breit) – zum Hineinzoomen' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function ExportDialog({ ref, shapes, visited, cities }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [area, setArea] = useState(cities.length ? 'orte' : 'welt');
|
||||||
|
const [format, setFormat] = useState('png');
|
||||||
|
const [width, setWidth] = useState(8000);
|
||||||
|
const [state, setState] = useState(null); // null | 'busy' | Fehlermeldung
|
||||||
|
|
||||||
|
async function save(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setState('busy');
|
||||||
|
// Kurz Luft lassen, damit "Erstelle Bild …" erscheint, bevor der Browser rechnet
|
||||||
|
await new Promise((resolve) => { setTimeout(resolve, 30); });
|
||||||
|
try {
|
||||||
|
const date = new Date();
|
||||||
|
const caption = `Reisen · ${user.displayName} · ${visited.size} ${visited.size === 1 ? 'Land' : 'Länder'} · `
|
||||||
|
+ `${cities.length} ${cities.length === 1 ? 'Stadt' : 'Städte'} · Stand ${date.toLocaleDateString('de-DE')}`
|
||||||
|
+ ' · Karte: Natural Earth';
|
||||||
|
const name = `reisekarte-${user.username.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${date.toISOString().slice(0, 10)}`;
|
||||||
|
// SVG: klein und vollständig beschriftet (zoombar); PNG: nur Hauptstädte beschriftet
|
||||||
|
const options = format === 'svg'
|
||||||
|
? { shapes, visited, cities, area, caption, width: 2400, marks: 0.4, label: 'alle' }
|
||||||
|
: { shapes, visited, cities, area, caption, width, marks: 1, label: 'hauptstaedte' };
|
||||||
|
if (format === 'svg') {
|
||||||
|
const { svg } = buildMapImage(options);
|
||||||
|
download(new Blob([svg], { type: 'image/svg+xml' }), `${name}.svg`);
|
||||||
|
} else {
|
||||||
|
const out = buildMapImage(options);
|
||||||
|
download(await svgToPng(out.svg, out.width, out.height), `${name}.png`);
|
||||||
|
}
|
||||||
|
setState(null);
|
||||||
|
ref.current.close();
|
||||||
|
} catch (err) {
|
||||||
|
setState(err.message || 'Das Bild konnte nicht erstellt werden.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const busy = state === 'busy';
|
||||||
|
const capitals = cities.filter((c) => c.capital === true).length;
|
||||||
|
const unknown = cities.filter((c) => c.capital == null).length;
|
||||||
|
return (
|
||||||
|
<dialog className="modal" ref={ref} onClose={() => setState(null)}>
|
||||||
|
<form className="form" onSubmit={save}>
|
||||||
|
<h2>Karte herunterladen</h2>
|
||||||
|
<p className="hint">Deine Städte und besuchten Länder als Bild – Städte anderer Konten sind nicht dabei.</p>
|
||||||
|
<fieldset className="link-picker">
|
||||||
|
<legend>Ausschnitt</legend>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input type="radio" name="area" checked={area === 'orte'} disabled={!cities.length} onChange={() => setArea('orte')} />
|
||||||
|
Meine Orte (auf die Städte gezoomt)
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input type="radio" name="area" checked={area === 'welt'} onChange={() => setArea('welt')} />
|
||||||
|
Ganze Welt
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset className="link-picker">
|
||||||
|
<legend>Format</legend>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input type="radio" name="format" checked={format === 'png'} onChange={() => setFormat('png')} />
|
||||||
|
PNG-Bild – beschriftet sind nur besuchte Hauptstädte ({capitals})
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input type="radio" name="format" checked={format === 'svg'} onChange={() => setFormat('svg')} />
|
||||||
|
SVG – alle Städte beschriftet, beliebig scharf zoombar (z. B. zum Drucken)
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</fieldset>
|
||||||
|
{format === 'png' && (
|
||||||
|
<label>
|
||||||
|
Größe
|
||||||
|
<select value={width} onChange={(e) => setWidth(Number(e.target.value))}>
|
||||||
|
{SIZES.map((s) => <option key={s.width} value={s.width}>{s.title}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{format === 'png' && unknown > 0 && (
|
||||||
|
<p className="hint">Bei {unknown} {unknown === 1 ? 'Stadt' : 'Städten'} wird gerade noch nachgeschlagen, ob es eine Hauptstadt ist – kurz warten und die Seite neu laden.</p>
|
||||||
|
)}
|
||||||
|
{state && !busy && <p className="error">{state}</p>}
|
||||||
|
<div className="form-actions">
|
||||||
|
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Abbrechen</button>
|
||||||
|
<button className="btn" type="submit" disabled={busy}>{busy ? 'Erstelle Bild …' : 'Herunterladen'}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Besuchte Orte: Kontinent → Land (ausklappbar) → Städte ----------
|
// ---------- Besuchte Orte: Kontinent → Land (ausklappbar) → Städte ----------
|
||||||
|
|
||||||
const CONTINENTS = ['Europa', 'Asien', 'Afrika', 'Nordamerika', 'Südamerika', 'Ozeanien', 'Antarktis'];
|
const CONTINENTS = ['Europa', 'Asien', 'Afrika', 'Nordamerika', 'Südamerika', 'Ozeanien', 'Antarktis'];
|
||||||
|
|||||||
@@ -1745,5 +1745,13 @@ html.vt-items .household-item { view-transition-name: var(--vt); }
|
|||||||
}
|
}
|
||||||
html.calm .tile:hover, html.calm .project-card:hover, html.calm .kanban-project-card:hover, html.calm .task-card:hover { transform: none; }
|
html.calm .tile:hover, html.calm .project-card:hover, html.calm .kanban-project-card:hover, html.calm .task-card:hover { transform: none; }
|
||||||
|
|
||||||
|
/* Kanban-Archiv */
|
||||||
|
.kanban-archive { margin-top: clamp(16px, 2vw, 28px); display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.archive-list { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.archive-item { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 14px; padding: 10px 0; border-top: 1px dashed var(--border); }
|
||||||
|
.archive-title { flex: 1 1 220px; min-width: 0; text-align: left; font: inherit; font-weight: 600; color: var(--bright); background: none; border: 0; padding: 0; cursor: pointer; text-decoration: line-through; text-decoration-color: var(--muted); }
|
||||||
|
.archive-title:hover { color: var(--accent); }
|
||||||
|
.archive-dates { color: var(--muted); font-size: 0.78rem; }
|
||||||
|
|
||||||
/* Auswahlknöpfe (z. B. Sichtbarkeit einer Einkaufsliste) nicht wie Eingabefelder gestalten */
|
/* Auswahlknöpfe (z. B. Sichtbarkeit einer Einkaufsliste) nicht wie Eingabefelder gestalten */
|
||||||
.form label.checkbox input[type='radio'] { width: 18px; height: 18px; min-height: 0; padding: 0; margin: 0; border: 0; background: none; box-shadow: none; accent-color: var(--accent); flex: none; }
|
.form label.checkbox input[type='radio'] { width: 18px; height: 18px; min-height: 0; padding: 0; margin: 0; border: 0; background: none; box-shadow: none; accent-color: var(--accent); flex: none; }
|
||||||
Reference in new issue
Block a user