Sprint 19: Social, UX-Verbesserungen, Nerd2Noob-Hilfe
This commit is contained in:
parent
10d30bf565
commit
89d87030a2
18 changed files with 930 additions and 74 deletions
|
|
@ -675,6 +675,20 @@ def _migrate(conn_factory):
|
||||||
""")
|
""")
|
||||||
logger.info("Migration: dog_shares Tabelle bereit.")
|
logger.info("Migration: dog_shares Tabelle bereit.")
|
||||||
|
|
||||||
|
# Event-RSVP
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS event_rsvp (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
event_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
status TEXT DEFAULT 'going',
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(event_id, user_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_event_rsvp_event ON event_rsvp(event_id);
|
||||||
|
""")
|
||||||
|
logger.info("Migration: event_rsvp Tabelle bereit.")
|
||||||
|
|
||||||
# Events: user_id NOT NULL Constraint entfernen (für Scheduler-Imports ohne User)
|
# Events: user_id NOT NULL Constraint entfernen (für Scheduler-Imports ohne User)
|
||||||
_ev_cols = {r[1]: r[3] for r in conn.execute("PRAGMA table_info(events)").fetchall()}
|
_ev_cols = {r[1]: r[3] for r in conn.execute("PRAGMA table_info(events)").fetchall()}
|
||||||
if _ev_cols.get("user_id") == 1:
|
if _ev_cols.get("user_id") == 1:
|
||||||
|
|
@ -704,3 +718,21 @@ def _migrate(conn_factory):
|
||||||
ON events(external_id) WHERE external_id IS NOT NULL;
|
ON events(external_id) WHERE external_id IS NOT NULL;
|
||||||
""")
|
""")
|
||||||
logger.info("Migration: events.user_id NOT NULL Constraint entfernt.")
|
logger.info("Migration: events.user_id NOT NULL Constraint entfernt.")
|
||||||
|
|
||||||
|
# Service-Angebote: Sitting + Walks Matching
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS service_offers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
type TEXT NOT NULL, -- 'sitting' oder 'walks'
|
||||||
|
beschreibung TEXT,
|
||||||
|
preis_pro_tag REAL,
|
||||||
|
lat REAL,
|
||||||
|
lon REAL,
|
||||||
|
radius_km INTEGER DEFAULT 10,
|
||||||
|
aktiv INTEGER DEFAULT 1,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_service_offers_type ON service_offers(type, aktiv);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_service_offers_user ON service_offers(user_id, type);
|
||||||
|
""")
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ from routes.import_data import router as import_router
|
||||||
from routes.sharing import dog_router as sharing_dog_router, share_router as sharing_share_router
|
from routes.sharing import dog_router as sharing_dog_router, share_router as sharing_share_router
|
||||||
from routes.widget import router as widget_router
|
from routes.widget import router as widget_router
|
||||||
from routes.notifications import router as notifications_router
|
from routes.notifications import router as notifications_router
|
||||||
|
from routes.services import router as services_router
|
||||||
|
|
||||||
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
||||||
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
||||||
|
|
@ -107,6 +108,7 @@ app.include_router(sharing_dog_router, prefix="/api/dogs", tags=["Teilen"
|
||||||
app.include_router(sharing_share_router, prefix="/api/share", tags=["Teilen"])
|
app.include_router(sharing_share_router, prefix="/api/share", tags=["Teilen"])
|
||||||
app.include_router(widget_router, prefix="/api/widget", tags=["Widget"])
|
app.include_router(widget_router, prefix="/api/widget", tags=["Widget"])
|
||||||
app.include_router(notifications_router, prefix="/api/notifications", tags=["Notifications"])
|
app.include_router(notifications_router, prefix="/api/notifications", tags=["Notifications"])
|
||||||
|
app.include_router(services_router, prefix="/api/services", tags=["Services"])
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,9 @@ def _haversine(lat1, lon1, lat2, lon2):
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Schemas
|
# Schemas
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
class RsvpCreate(BaseModel):
|
||||||
|
status: str = 'going' # 'going' | 'maybe'
|
||||||
|
|
||||||
class EventCreate(BaseModel):
|
class EventCreate(BaseModel):
|
||||||
titel: str
|
titel: str
|
||||||
datum: str # YYYY-MM-DD
|
datum: str # YYYY-MM-DD
|
||||||
|
|
@ -65,7 +68,8 @@ async def list_events(
|
||||||
q = """
|
q = """
|
||||||
SELECT e.*,
|
SELECT e.*,
|
||||||
CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name,
|
CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name,
|
||||||
e.quelle
|
e.quelle,
|
||||||
|
(SELECT COUNT(*) FROM event_rsvp r WHERE r.event_id = e.id AND r.status = 'going') AS rsvp_count
|
||||||
FROM events e
|
FROM events e
|
||||||
LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0
|
LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0
|
||||||
WHERE e.status = 'aktiv'
|
WHERE e.status = 'aktiv'
|
||||||
|
|
@ -101,7 +105,8 @@ async def create_event(data: EventCreate, user=Depends(get_current_user)):
|
||||||
data.lat, data.lon, data.ort_name,
|
data.lat, data.lon, data.ort_name,
|
||||||
data.typ, data.beschreibung, data.link))
|
data.typ, data.beschreibung, data.link))
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, e.quelle "
|
"SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, e.quelle, "
|
||||||
|
"0 AS rsvp_count "
|
||||||
"FROM events e LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0 WHERE e.id = ?",
|
"FROM events e LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0 WHERE e.id = ?",
|
||||||
(cur.lastrowid,)
|
(cur.lastrowid,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
@ -115,7 +120,8 @@ async def create_event(data: EventCreate, user=Depends(get_current_user)):
|
||||||
async def get_event(event_id: int):
|
async def get_event(event_id: int):
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, e.quelle "
|
"SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, e.quelle, "
|
||||||
|
"(SELECT COUNT(*) FROM event_rsvp r WHERE r.event_id = e.id AND r.status = 'going') AS rsvp_count "
|
||||||
"FROM events e LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0 WHERE e.id = ?",
|
"FROM events e LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0 WHERE e.id = ?",
|
||||||
(event_id,)
|
(event_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
@ -142,7 +148,8 @@ async def update_event(event_id: int, data: EventUpdate, user=Depends(get_curren
|
||||||
cols = ', '.join(f"{k} = ?" for k in updates)
|
cols = ', '.join(f"{k} = ?" for k in updates)
|
||||||
conn.execute(f"UPDATE events SET {cols} WHERE id = ?", [*updates.values(), event_id])
|
conn.execute(f"UPDATE events SET {cols} WHERE id = ?", [*updates.values(), event_id])
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, e.quelle "
|
"SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, e.quelle, "
|
||||||
|
"(SELECT COUNT(*) FROM event_rsvp r WHERE r.event_id = e.id AND r.status = 'going') AS rsvp_count "
|
||||||
"FROM events e LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0 WHERE e.id = ?",
|
"FROM events e LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0 WHERE e.id = ?",
|
||||||
(event_id,)
|
(event_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
@ -161,3 +168,53 @@ async def delete_event(event_id: int, user=Depends(get_current_user)):
|
||||||
if ev['user_id'] == 0 or ev['user_id'] != user['id']:
|
if ev['user_id'] == 0 or ev['user_id'] != user['id']:
|
||||||
raise HTTPException(403, "Nur der Veranstalter kann das Event löschen.")
|
raise HTTPException(403, "Nur der Veranstalter kann das Event löschen.")
|
||||||
conn.execute("UPDATE events SET status = 'geloescht' WHERE id = ?", (event_id,))
|
conn.execute("UPDATE events SET status = 'geloescht' WHERE id = ?", (event_id,))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/events/{id}/rsvp
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.post("/{event_id}/rsvp", status_code=201)
|
||||||
|
async def rsvp_event(event_id: int, data: RsvpCreate, user=Depends(get_current_user)):
|
||||||
|
if data.status not in ('going', 'maybe'):
|
||||||
|
raise HTTPException(400, "Status muss 'going' oder 'maybe' sein.")
|
||||||
|
with db() as conn:
|
||||||
|
ev = conn.execute("SELECT id FROM events WHERE id = ? AND status = 'aktiv'", (event_id,)).fetchone()
|
||||||
|
if not ev:
|
||||||
|
raise HTTPException(404, "Event nicht gefunden.")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO event_rsvp (event_id, user_id, status) VALUES (?, ?, ?)",
|
||||||
|
(event_id, user['id'], data.status)
|
||||||
|
)
|
||||||
|
count = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM event_rsvp WHERE event_id = ? AND status = 'going'", (event_id,)
|
||||||
|
).fetchone()[0]
|
||||||
|
return {"event_id": event_id, "status": data.status, "rsvp_count": count}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# DELETE /api/events/{id}/rsvp
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.delete("/{event_id}/rsvp", status_code=204)
|
||||||
|
async def cancel_rsvp(event_id: int, user=Depends(get_current_user)):
|
||||||
|
with db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM event_rsvp WHERE event_id = ? AND user_id = ?",
|
||||||
|
(event_id, user['id'])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/events/{id}/rsvp
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.get("/{event_id}/rsvp")
|
||||||
|
async def list_rsvp(event_id: int):
|
||||||
|
with db() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT r.user_id, u.name, r.status "
|
||||||
|
"FROM event_rsvp r "
|
||||||
|
"JOIN users u ON u.id = r.user_id "
|
||||||
|
"WHERE r.event_id = ? "
|
||||||
|
"ORDER BY r.created_at ASC",
|
||||||
|
(event_id,)
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
"""BAN YARO — Forum (Sprint 11)"""
|
"""BAN YARO — Forum (Sprint 11)"""
|
||||||
|
|
||||||
import os, uuid, json
|
import os, uuid, json, logging
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from database import db
|
from database import db
|
||||||
from auth import get_current_user, get_current_user_optional
|
from auth import get_current_user, get_current_user_optional
|
||||||
|
from routes.push import send_push_to_user
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -295,9 +298,30 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current
|
||||||
WHERE p.id = ?""",
|
WHERE p.id = ?""",
|
||||||
(cur.lastrowid,)
|
(cur.lastrowid,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
# Thread-Owner ermitteln für Push-Notification
|
||||||
|
owner_row = conn.execute(
|
||||||
|
"SELECT user_id FROM forum_threads WHERE id = ?", (thread_id,)
|
||||||
|
).fetchone()
|
||||||
|
owner_id = owner_row['user_id'] if owner_row else None
|
||||||
|
|
||||||
pd = dict(row)
|
pd = dict(row)
|
||||||
pd['foto_urls'] = []
|
pd['foto_urls'] = []
|
||||||
pd['user_liked'] = False
|
pd['user_liked'] = False
|
||||||
|
|
||||||
|
# Push-Notification an Thread-Owner (nicht an sich selbst)
|
||||||
|
if owner_id and owner_id != user['id']:
|
||||||
|
try:
|
||||||
|
commenter_name = pd.get('autor_name') or 'Jemand'
|
||||||
|
send_push_to_user(owner_id, {
|
||||||
|
"type": "forum_reply",
|
||||||
|
"title": "Neue Antwort auf deinen Beitrag",
|
||||||
|
"body": f"{commenter_name} hat auf deinen Beitrag geantwortet",
|
||||||
|
"tag": f"forum-{thread_id}",
|
||||||
|
"data": {"page": "forum", "id": thread_id},
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Push-Notification für Forum-Reply fehlgeschlagen (nicht kritisch)")
|
||||||
|
|
||||||
return pd
|
return pd
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -243,6 +243,22 @@ async def upload_dokument(
|
||||||
return {"datei_url": datei_url, "datei_typ": datei_typ}
|
return {"datei_url": datei_url, "datei_typ": datei_typ}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/dogs/{dog_id}/health/gewicht — Gewichtsverlauf
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.get("/{dog_id}/health/gewicht")
|
||||||
|
async def list_gewicht(dog_id: int, user=Depends(get_current_user)):
|
||||||
|
with db() as conn:
|
||||||
|
_check_dog_owner(conn, dog_id, user["id"])
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT datum, wert AS gewicht FROM health
|
||||||
|
WHERE dog_id=? AND typ='gewicht' AND wert IS NOT NULL
|
||||||
|
ORDER BY datum ASC""",
|
||||||
|
(dog_id,)
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# POST /api/dogs/{dog_id}/health/symptom-check — KI-Symptomprüfung
|
# POST /api/dogs/{dog_id}/health/symptom-check — KI-Symptomprüfung
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -5044,3 +5044,93 @@ textarea.form-control {
|
||||||
color: var(--c-text-muted);
|
color: var(--c-text-muted);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* health chart extras (weight chart) */
|
||||||
|
.health-chart-title { font-size: var(--text-sm); font-weight: var(--weight-semibold); color: var(--c-text); margin-bottom: var(--space-2); }
|
||||||
|
.health-chart-svg { width: 100%; height: auto; display: block; }
|
||||||
|
.health-chart-labels { display: flex; justify-content: space-between; font-size: var(--text-xs); color: var(--c-text-secondary); margin-top: var(--space-1); }
|
||||||
|
.health-chart-empty { font-size: var(--text-sm); color: var(--c-text-muted); text-align: center; padding: var(--space-4) 0; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
RSVP — Event-Teilnahme
|
||||||
|
============================================================ */
|
||||||
|
.event-rsvp-bar { display:flex; gap:var(--space-2); align-items:center; margin:var(--space-3) 0 var(--space-2); flex-wrap:wrap; }
|
||||||
|
.event-rsvp-btn { display:inline-flex; align-items:center; gap:var(--space-1); padding:6px 14px; border-radius:var(--radius); border:1.5px solid var(--c-border); background:var(--c-surface); color:var(--c-text-secondary); font-size:var(--text-sm); font-weight:500; cursor:pointer; transition:background .15s,color .15s,border-color .15s; }
|
||||||
|
.event-rsvp-btn:hover { border-color:var(--c-primary); color:var(--c-primary); }
|
||||||
|
.event-rsvp-btn.active { background:var(--c-primary); border-color:var(--c-primary); color:#fff; }
|
||||||
|
.event-attendees { font-size:var(--text-sm); color:var(--c-text-secondary); cursor:pointer; display:inline-flex; align-items:center; gap:var(--space-1); }
|
||||||
|
.event-attendees:hover { color:var(--c-primary); }
|
||||||
|
.ev-attendees-list { display:flex; flex-wrap:wrap; gap:var(--space-1); margin-top:var(--space-2); }
|
||||||
|
.ev-attendee-chip { display:inline-flex; align-items:center; gap:4px; padding:3px 10px; border-radius:999px; background:var(--c-surface-2); font-size:var(--text-xs); color:var(--c-text-secondary); }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
SERVICES / MATCHING (Sitting & Walks Anbieter-Suche)
|
||||||
|
============================================================ */
|
||||||
|
.svc-matching-layout { display:flex; flex-direction:column; gap:var(--space-4); padding:var(--space-3) 0; }
|
||||||
|
.svc-own-offer { padding:var(--space-4); }
|
||||||
|
.svc-own-offer-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-3); }
|
||||||
|
.svc-own-offer-title { font-weight:var(--weight-semibold); font-size:var(--text-base); }
|
||||||
|
.svc-login-hint { font-size:var(--text-sm); color:var(--c-text-muted); }
|
||||||
|
.svc-toggle { position:relative; display:inline-block; width:44px; height:24px; cursor:pointer; }
|
||||||
|
.svc-toggle input { opacity:0; width:0; height:0; }
|
||||||
|
.svc-toggle-slider { position:absolute; inset:0; background:var(--c-border); border-radius:var(--radius-full); transition:background var(--transition-fast); }
|
||||||
|
.svc-toggle-slider::before { content:''; position:absolute; width:18px; height:18px; left:3px; top:3px; background:#fff; border-radius:50%; transition:transform var(--transition-fast); box-shadow:0 1px 3px rgba(0,0,0,.2); }
|
||||||
|
.svc-toggle input:checked + .svc-toggle-slider { background:var(--c-primary); }
|
||||||
|
.svc-toggle input:checked + .svc-toggle-slider::before { transform:translateX(20px); }
|
||||||
|
.svc-offer-form { display:flex; flex-direction:column; gap:var(--space-3); }
|
||||||
|
.svc-offer-form--hidden { display:none; }
|
||||||
|
.svc-hint { color:var(--c-text-secondary); font-size:var(--text-sm); text-align:center; padding:var(--space-6) 0; }
|
||||||
|
.svc-results-list { display:flex; flex-direction:column; gap:var(--space-3); }
|
||||||
|
.svc-card { display:flex; align-items:flex-start; gap:var(--space-3); padding:var(--space-4); background:var(--c-surface); border-radius:var(--radius-lg); border:1px solid var(--c-border-light); box-shadow:var(--shadow-xs); }
|
||||||
|
.svc-card-avatar { width:44px; height:44px; border-radius:var(--radius-full); background:var(--c-primary-subtle); color:var(--c-primary); display:flex; align-items:center; justify-content:center; font-size:1.4rem; flex-shrink:0; }
|
||||||
|
.svc-card-body { flex:1; min-width:0; }
|
||||||
|
.svc-card-name { font-weight:var(--weight-semibold); margin-bottom:var(--space-1); }
|
||||||
|
.svc-card-dist { font-size:var(--text-xs); color:var(--c-text-muted); margin-bottom:var(--space-1); }
|
||||||
|
.svc-card-desc { font-size:var(--text-sm); color:var(--c-text-secondary); overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; }
|
||||||
|
.svc-card-side { display:flex; flex-direction:column; align-items:flex-end; gap:var(--space-2); flex-shrink:0; }
|
||||||
|
.svc-card-price { font-weight:var(--weight-bold); color:var(--c-primary); font-size:var(--text-sm); }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
HELP TOOLTIP
|
||||||
|
============================================================ */
|
||||||
|
.by-help-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px; height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--c-surface-2);
|
||||||
|
color: var(--c-text-secondary);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-left: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
.by-help-btn:hover { background: var(--c-primary-subtle, #e8f0fe); color: var(--c-primary); }
|
||||||
|
|
||||||
|
.by-help-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 9000;
|
||||||
|
background: var(--c-text);
|
||||||
|
color: var(--c-bg);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
max-width: 240px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,.15);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
/* SVG-Icon-Variante (Phosphor) */
|
||||||
|
.empty-state-icon > svg.ph-icon,
|
||||||
|
svg.empty-state-icon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
color: var(--c-text-muted);
|
||||||
|
opacity: .5;
|
||||||
|
}
|
||||||
|
.empty-state-cta {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,9 @@ const API = (() => {
|
||||||
symptomCheck(dogId, symptoms) {
|
symptomCheck(dogId, symptoms) {
|
||||||
return post(`/dogs/${dogId}/health/symptom-check`, { symptoms });
|
return post(`/dogs/${dogId}/health/symptom-check`, { symptoms });
|
||||||
},
|
},
|
||||||
|
gewichtVerlauf(dogId) {
|
||||||
|
return get(`/dogs/${dogId}/health/gewicht`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -230,10 +233,13 @@ const API = (() => {
|
||||||
const q = new URLSearchParams(params).toString();
|
const q = new URLSearchParams(params).toString();
|
||||||
return get(`/events${q ? '?' + q : ''}`);
|
return get(`/events${q ? '?' + q : ''}`);
|
||||||
},
|
},
|
||||||
get(id) { return get(`/events/${id}`); },
|
get(id) { return get(`/events/${id}`); },
|
||||||
create(data) { return post('/events', data); },
|
create(data) { return post('/events', data); },
|
||||||
update(id, data) { return patch(`/events/${id}`, data); },
|
update(id, data) { return patch(`/events/${id}`, data); },
|
||||||
delete(id) { return del(`/events/${id}`); },
|
delete(id) { return del(`/events/${id}`); },
|
||||||
|
rsvp(id, status) { return post(`/events/${id}/rsvp`, { status }); },
|
||||||
|
cancelRsvp(id) { return del(`/events/${id}/rsvp`); },
|
||||||
|
listRsvp(id) { return get(`/events/${id}/rsvp`); },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -445,6 +451,20 @@ const API = (() => {
|
||||||
delete(id) { return del(`/notifications/${id}`); },
|
delete(id) { return del(`/notifications/${id}`); },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// SERVICE-ANGEBOTE (Sitting & Walks Matching)
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
const services = {
|
||||||
|
list(type, lat = null, lon = null, radius = 20) {
|
||||||
|
const p = new URLSearchParams({ type, radius });
|
||||||
|
if (lat !== null) { p.set('lat', lat); p.set('lon', lon); }
|
||||||
|
return get(`/services?${p}`);
|
||||||
|
},
|
||||||
|
me() { return get('/services/me'); },
|
||||||
|
upsert(data) { return post('/services', data); },
|
||||||
|
deactivate(id) { return del(`/services/${id}`); },
|
||||||
|
};
|
||||||
|
|
||||||
const importData = {
|
const importData = {
|
||||||
notestation(dogId, file) {
|
notestation(dogId, file) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
|
|
@ -477,7 +497,7 @@ const API = (() => {
|
||||||
get, post, put, patch, del, upload,
|
get, post, put, patch, del, upload,
|
||||||
auth, dogs, diary, health, tieraerzte, poison,
|
auth, dogs, diary, health, tieraerzte, poison,
|
||||||
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
|
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
|
||||||
friends, chat, webcal, importData, sharing, widget, notifications,
|
friends, chat, webcal, importData, sharing, widget, notifications, services,
|
||||||
subscribeToPush, getLocation,
|
subscribeToPush, getLocation,
|
||||||
APIError,
|
APIError,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
Router, State-Management, Navigation, Initialisierung.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const APP_VER = '128'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
const APP_VER = '129'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||||
|
|
||||||
const App = (() => {
|
const App = (() => {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,20 @@ window.Page_diary = (() => {
|
||||||
UI.setLoading(btn, false);
|
UI.setLoading(btn, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// EMPTY-STATE HELPER
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
function _emptyState(icon, title, text, cta = '') {
|
||||||
|
return `<div class="empty-state">
|
||||||
|
<svg class="ph-icon empty-state-icon" aria-hidden="true">
|
||||||
|
<use href="/icons/phosphor.svg#${icon}"></use>
|
||||||
|
</svg>
|
||||||
|
<div class="empty-state-title">${title}</div>
|
||||||
|
${text ? `<p class="empty-state-text">${text}</p>` : ''}
|
||||||
|
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// LISTE RENDERN — Timeline gruppiert nach Monat
|
// LISTE RENDERN — Timeline gruppiert nach Monat
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -214,12 +228,12 @@ window.Page_diary = (() => {
|
||||||
if (!listEl) return;
|
if (!listEl) return;
|
||||||
|
|
||||||
if (_entries.length === 0) {
|
if (_entries.length === 0) {
|
||||||
listEl.innerHTML = UI.emptyState({
|
listEl.innerHTML = _emptyState(
|
||||||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>',
|
'book-open',
|
||||||
title: 'Noch keine Einträge',
|
'Noch keine Tagebucheinträge',
|
||||||
text: 'Halte besondere Momente mit deinem Hund fest.',
|
'Halte besondere Momente mit deinem Hund fest — Spaziergänge, Erlebnisse, Erinnerungen.',
|
||||||
action: `<button class="btn btn-primary" id="diary-first-entry">Ersten Eintrag erstellen</button>`,
|
`<button class="btn btn-primary" id="diary-first-entry">Ersten Eintrag schreiben</button>`
|
||||||
});
|
);
|
||||||
listEl.querySelector('#diary-first-entry')
|
listEl.querySelector('#diary-first-entry')
|
||||||
?.addEventListener('click', () => _showForm(null));
|
?.addEventListener('click', () => _showForm(null));
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -582,6 +582,7 @@ window.Page_dog_profile = (() => {
|
||||||
<label class="form-label">
|
<label class="form-label">
|
||||||
Rasse
|
Rasse
|
||||||
<span style="color:var(--c-text-secondary)">(optional)</span>
|
<span style="color:var(--c-text-secondary)">(optional)</span>
|
||||||
|
${UI.help('Die Rasse wird für Rasseninformationen und Statistiken verwendet.')}
|
||||||
</label>
|
</label>
|
||||||
<input class="form-control" type="text" name="rasse"
|
<input class="form-control" type="text" name="rasse"
|
||||||
value="${_esc(dog?.rasse || '')}"
|
value="${_esc(dog?.rasse || '')}"
|
||||||
|
|
@ -612,7 +613,10 @@ window.Page_dog_profile = (() => {
|
||||||
min="0.1" max="120" step="0.1" placeholder="z. B. 28.5">
|
min="0.1" max="120" step="0.1" placeholder="z. B. 28.5">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Chip-Nummer</label>
|
<label class="form-label">
|
||||||
|
Chip-Nummer
|
||||||
|
${UI.help('Die 15-stellige Chip-Nummer findest du im Heimtierausweis oder beim Tierarzt.')}
|
||||||
|
</label>
|
||||||
<input class="form-control" type="text" name="chip_nr"
|
<input class="form-control" type="text" name="chip_nr"
|
||||||
value="${_esc(dog?.chip_nr || '')}" placeholder="15-stellig">
|
value="${_esc(dog?.chip_nr || '')}" placeholder="15-stellig">
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ window.Page_events = (() => {
|
||||||
let _map = null;
|
let _map = null;
|
||||||
let _markers = [];
|
let _markers = [];
|
||||||
let _clusterGroup = null;
|
let _clusterGroup = null;
|
||||||
|
let _myRsvp = {}; // { [event_id]: 'going'|'maybe'|null }
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// Phosphor-Icon-Helper
|
// Phosphor-Icon-Helper
|
||||||
|
|
@ -47,6 +48,17 @@ window.Page_events = (() => {
|
||||||
return `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${name}"></use></svg>`;
|
return `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${name}"></use></svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _emptyState(icon, title, text, cta = '') {
|
||||||
|
return `<div class="empty-state">
|
||||||
|
<svg class="ph-icon empty-state-icon" aria-hidden="true">
|
||||||
|
<use href="/icons/phosphor.svg#${icon}"></use>
|
||||||
|
</svg>
|
||||||
|
<div class="empty-state-title">${title}</div>
|
||||||
|
${text ? `<p class="empty-state-text">${text}</p>` : ''}
|
||||||
|
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// init
|
// init
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -133,7 +145,11 @@ window.Page_events = (() => {
|
||||||
|
|
||||||
const filtered = _filtered();
|
const filtered = _filtered();
|
||||||
if (!filtered.length) {
|
if (!filtered.length) {
|
||||||
listEl.innerHTML = UI.emptyState({ icon: '🎪', title: 'Keine Events', text: 'Noch keine Veranstaltungen geplant.' });
|
listEl.innerHTML = _emptyState(
|
||||||
|
'calendar-blank',
|
||||||
|
'Keine Events in der Nähe',
|
||||||
|
'Hier erscheinen Hundeveranstaltungen, Treffen und Aktivitäten in deiner Umgebung.'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -185,6 +201,7 @@ window.Page_events = (() => {
|
||||||
${ev.uhrzeit ? `· ${_icon('clock')} ${ev.uhrzeit} Uhr` : ''}
|
${ev.uhrzeit ? `· ${_icon('clock')} ${ev.uhrzeit} Uhr` : ''}
|
||||||
${ev.ort_name ? `· ${_icon('map-pin')} ${UI.escHtml(ev.ort_name)}` : ''}
|
${ev.ort_name ? `· ${_icon('map-pin')} ${UI.escHtml(ev.ort_name)}` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
${ev.rsvp_count ? `<span class="event-attendees" data-ev-attendees="${ev.id}">${_icon('users')} ${ev.rsvp_count} nehmen teil</span>` : ''}
|
||||||
${ev.link ? `<div class="events-card-actions">
|
${ev.link ? `<div class="events-card-actions">
|
||||||
<a class="btn btn-ghost btn-xs ev-ext-link" href="${UI.escHtml(ev.link)}" target="_blank" rel="noopener" onclick="event.stopPropagation()">
|
<a class="btn btn-ghost btn-xs ev-ext-link" href="${UI.escHtml(ev.link)}" target="_blank" rel="noopener" onclick="event.stopPropagation()">
|
||||||
${_icon('arrow-square-out')} Details
|
${_icon('arrow-square-out')} Details
|
||||||
|
|
@ -326,12 +343,26 @@ window.Page_events = (() => {
|
||||||
let ev;
|
let ev;
|
||||||
try { ev = await API.events.get(id); } catch { return; }
|
try { ev = await API.events.get(id); } catch { return; }
|
||||||
|
|
||||||
const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1];
|
const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1];
|
||||||
const color = TYP_COLOR[ev.typ] || '#6b7280';
|
const color = TYP_COLOR[ev.typ] || '#6b7280';
|
||||||
const d = new Date(ev.datum + 'T00:00:00');
|
const d = new Date(ev.datum + 'T00:00:00');
|
||||||
const datum = d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
const datum = d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
const isOwn = _state.user?.id === ev.user_id;
|
const isOwn = _state.user?.id === ev.user_id;
|
||||||
const isVdh = ev.quelle === 'vdh';
|
const isVdh = ev.quelle === 'vdh';
|
||||||
|
const myRsvp = _myRsvp[id] ?? null;
|
||||||
|
|
||||||
|
// RSVP-Bar (nur für eingeloggte User)
|
||||||
|
const rsvpBar = _state.user ? `
|
||||||
|
<div class="event-rsvp-bar" id="ev-rsvp-bar-${id}">
|
||||||
|
<button class="btn event-rsvp-btn ${myRsvp === 'going' ? 'active' : ''}" data-rsvp-id="${id}" data-rsvp-status="going">
|
||||||
|
${_icon('check-circle')} Ich komme
|
||||||
|
</button>
|
||||||
|
<button class="btn event-rsvp-btn ${myRsvp === 'maybe' ? 'active' : ''}" data-rsvp-id="${id}" data-rsvp-status="maybe">
|
||||||
|
${_icon('question')} Vielleicht
|
||||||
|
</button>
|
||||||
|
${ev.rsvp_count ? `<span class="event-attendees" id="ev-attendees-${id}" data-ev-attendees="${id}" style="margin-left:auto">${_icon('users')} ${ev.rsvp_count} nehmen teil</span>` : `<span class="event-attendees" id="ev-attendees-${id}" data-ev-attendees="${id}" style="margin-left:auto;display:none">${_icon('users')} 0 nehmen teil</span>`}
|
||||||
|
</div>
|
||||||
|
` : (ev.rsvp_count ? `<div class="event-rsvp-bar"><span class="event-attendees" data-ev-attendees="${id}">${_icon('users')} ${ev.rsvp_count} nehmen teil</span></div>` : '');
|
||||||
|
|
||||||
const body = `
|
const body = `
|
||||||
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
|
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
|
||||||
|
|
@ -348,6 +379,8 @@ window.Page_events = (() => {
|
||||||
<div class="events-detail-row" style="color:var(--c-text-muted);font-size:var(--text-xs)">
|
<div class="events-detail-row" style="color:var(--c-text-muted);font-size:var(--text-xs)">
|
||||||
${_icon('user')} Veranstalter: ${UI.escHtml(ev.veranstalter_name || '–')}
|
${_icon('user')} Veranstalter: ${UI.escHtml(ev.veranstalter_name || '–')}
|
||||||
</div>
|
</div>
|
||||||
|
${rsvpBar}
|
||||||
|
<div id="ev-attendees-panel-${id}"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const footer = isOwn ? `
|
const footer = isOwn ? `
|
||||||
|
|
@ -365,6 +398,80 @@ window.Page_events = (() => {
|
||||||
UI.modal.close(); setTimeout(() => _openForm(ev), 50);
|
UI.modal.close(); setTimeout(() => _openForm(ev), 50);
|
||||||
});
|
});
|
||||||
document.getElementById('ev-detail-del')?.addEventListener('click', () => _deleteEvent(ev));
|
document.getElementById('ev-detail-del')?.addEventListener('click', () => _deleteEvent(ev));
|
||||||
|
|
||||||
|
// RSVP-Buttons
|
||||||
|
document.querySelectorAll(`[data-rsvp-id="${id}"]`).forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => _handleRsvp(id, btn.dataset.rsvpStatus));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _handleRsvp(eventId, status) {
|
||||||
|
const current = _myRsvp[eventId] ?? null;
|
||||||
|
try {
|
||||||
|
if (current === status) {
|
||||||
|
// Toggle off → absagen
|
||||||
|
await API.events.cancelRsvp(eventId);
|
||||||
|
_myRsvp[eventId] = null;
|
||||||
|
} else {
|
||||||
|
const res = await API.events.rsvp(eventId, status);
|
||||||
|
_myRsvp[eventId] = status;
|
||||||
|
// Teilnehmerzähler aktualisieren
|
||||||
|
_updateAttendeeCount(eventId, res.rsvp_count);
|
||||||
|
}
|
||||||
|
// Button-Styles aktualisieren
|
||||||
|
document.querySelectorAll(`[data-rsvp-id="${eventId}"]`).forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.rsvpStatus === (_myRsvp[eventId] ?? ''));
|
||||||
|
});
|
||||||
|
// Bei Absage Zähler neu laden
|
||||||
|
if (current === status) {
|
||||||
|
const attendees = await API.events.listRsvp(eventId);
|
||||||
|
const goingCount = attendees.filter(a => a.status === 'going').length;
|
||||||
|
_updateAttendeeCount(eventId, goingCount);
|
||||||
|
}
|
||||||
|
} catch (e) { UI.toast(e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateAttendeeCount(eventId, count) {
|
||||||
|
// Im Modal
|
||||||
|
const span = document.getElementById(`ev-attendees-${eventId}`);
|
||||||
|
if (span) {
|
||||||
|
if (count > 0) {
|
||||||
|
span.innerHTML = `${_icon('users')} ${count} nehmen teil`;
|
||||||
|
span.style.display = '';
|
||||||
|
} else {
|
||||||
|
span.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// In der Listenansicht (Event-Objekt aktualisieren)
|
||||||
|
const ev = _events.find(x => x.id === eventId);
|
||||||
|
if (ev) {
|
||||||
|
ev.rsvp_count = count;
|
||||||
|
// Karte neu rendern falls sichtbar
|
||||||
|
const card = document.querySelector(`[data-ev-id="${eventId}"]`);
|
||||||
|
if (card) card.outerHTML = _cardHTML(ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _showAttendees(eventId) {
|
||||||
|
const panel = document.getElementById(`ev-attendees-panel-${eventId}`);
|
||||||
|
if (!panel) return;
|
||||||
|
if (panel.dataset.loaded) { panel.innerHTML = ''; delete panel.dataset.loaded; return; }
|
||||||
|
try {
|
||||||
|
const attendees = await API.events.listRsvp(eventId);
|
||||||
|
if (!attendees.length) { panel.innerHTML = '<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:var(--space-2)">Noch keine Zusagen.</p>'; }
|
||||||
|
else {
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="ev-attendees-list">
|
||||||
|
${attendees.map(a => `
|
||||||
|
<span class="ev-attendee-chip">
|
||||||
|
${a.status === 'going' ? _icon('check-circle') : _icon('question')}
|
||||||
|
${UI.escHtml(a.name)}
|
||||||
|
</span>
|
||||||
|
`).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
panel.dataset.loaded = '1';
|
||||||
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _deleteEvent(ev) {
|
async function _deleteEvent(ev) {
|
||||||
|
|
@ -548,6 +655,14 @@ window.Page_events = (() => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Teilnehmer-Liste anzeigen (Karten-Ansicht oder Modal)
|
||||||
|
const attendeesBtn = e.target.closest('[data-ev-attendees]');
|
||||||
|
if (attendeesBtn) {
|
||||||
|
e.stopPropagation();
|
||||||
|
_showAttendees(parseInt(attendeesBtn.dataset.evAttendees));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Karten-Klick → Detail
|
// Karten-Klick → Detail
|
||||||
const card = e.target.closest('[data-ev-id]');
|
const card = e.target.closest('[data-ev-id]');
|
||||||
if (card) { _showDetail(parseInt(card.dataset.evId)); }
|
if (card) { _showDetail(parseInt(card.dataset.evId)); }
|
||||||
|
|
|
||||||
|
|
@ -366,19 +366,15 @@ window.Page_friends = (() => {
|
||||||
const el = _container.querySelector('#fr-list');
|
const el = _container.querySelector('#fr-list');
|
||||||
|
|
||||||
if (!list.length) {
|
if (!list.length) {
|
||||||
el.innerHTML = `
|
el.innerHTML = _emptyState(
|
||||||
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
|
'users-three',
|
||||||
<svg class="ph-icon" style="width:48px;height:48px;color:var(--c-border);
|
'Noch keine Freunde',
|
||||||
margin-bottom:var(--space-3)" aria-hidden="true">
|
'Verbinde dich mit anderen Hundebesitzern. Teile Routen, sieh Aktivitäten und schreib Nachrichten.',
|
||||||
<use href="/icons/phosphor.svg#paw-print"></use>
|
`<button class="btn btn-primary" id="fr-empty-search">Freunde suchen</button>`
|
||||||
</svg>
|
);
|
||||||
<p style="font-size:var(--text-base);font-weight:var(--weight-semibold);
|
el.querySelector('#fr-empty-search')?.addEventListener('click', () => {
|
||||||
color:var(--c-text);margin:0 0 var(--space-2)">Noch keine Hundefreunde</p>
|
_container.querySelector('#fr-search')?.focus();
|
||||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0">
|
});
|
||||||
Suche oben nach anderen Hundebesitzern und schick ihnen eine Anfrage.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -774,6 +770,17 @@ window.Page_friends = (() => {
|
||||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _emptyState(icon, title, text, cta = '') {
|
||||||
|
return `<div class="empty-state">
|
||||||
|
<svg class="ph-icon empty-state-icon" aria-hidden="true">
|
||||||
|
<use href="/icons/phosphor.svg#${icon}"></use>
|
||||||
|
</svg>
|
||||||
|
<div class="empty-state-title">${title}</div>
|
||||||
|
${text ? `<p class="empty-state-text">${text}</p>` : ''}
|
||||||
|
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
return { init, refresh, onDogChange, _accept, _decline, _cancel, _removeFriend, _openChat };
|
return { init, refresh, onDogChange, _accept, _decline, _cancel, _removeFriend, _openChat };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -321,6 +321,11 @@ window.Page_health = (() => {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// silent fail
|
// silent fail
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
_data['gewicht_chart'] = await API.health.gewichtVerlauf(dogId);
|
||||||
|
} catch (err) {
|
||||||
|
_data['gewicht_chart'] = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -347,15 +352,33 @@ window.Page_health = (() => {
|
||||||
_bindTabEvents(content);
|
_bindTabEvents(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// EMPTY-STATE HELPER
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
function _emptyState(icon, title, text, cta = '') {
|
||||||
|
return `<div class="empty-state">
|
||||||
|
<svg class="ph-icon empty-state-icon" aria-hidden="true">
|
||||||
|
<use href="/icons/phosphor.svg#${icon}"></use>
|
||||||
|
</svg>
|
||||||
|
<div class="empty-state-title">${title}</div>
|
||||||
|
${text ? `<p class="empty-state-text">${text}</p>` : ''}
|
||||||
|
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// IMPFUNGEN — mit Ampel-Status
|
// IMPFUNGEN — mit Ampel-Status
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
function _renderImpfungen(entries) {
|
function _renderImpfungen(entries) {
|
||||||
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Impfung eintragen</button>`;
|
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Impfung eintragen</button>`;
|
||||||
|
const helpIcon = UI.help('Trage Impfdatum und nächsten Termin ein — wir erinnern dich rechtzeitig.');
|
||||||
|
|
||||||
if (!entries.length) return UI.emptyState({
|
if (!entries.length) return _emptyState(
|
||||||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>', title: 'Noch keine Impfungen', text: 'Trage alle Impfungen ein, um nichts zu verpassen.', action: addBtn
|
'syringe',
|
||||||
});
|
'Noch keine Impfungen',
|
||||||
|
`Trage alle Impfungen ein, um nichts zu verpassen. ${helpIcon}`,
|
||||||
|
addBtn
|
||||||
|
);
|
||||||
|
|
||||||
const items = entries.map(e => {
|
const items = entries.map(e => {
|
||||||
const ampel = _impfAmpel(e.naechstes);
|
const ampel = _impfAmpel(e.naechstes);
|
||||||
|
|
@ -453,7 +476,8 @@ window.Page_health = (() => {
|
||||||
</div>`;
|
</div>`;
|
||||||
})() : '';
|
})() : '';
|
||||||
|
|
||||||
const chart = sorted.length >= 2 ? _weightChart(sorted) : '';
|
const chartEntries = _data['gewicht_chart'] || [];
|
||||||
|
const chart = _renderWeightChart(chartEntries);
|
||||||
|
|
||||||
const items = sorted.slice().reverse().map(e => `
|
const items = sorted.slice().reverse().map(e => `
|
||||||
<div class="health-card" data-id="${e.id}" data-action="open-entry"
|
<div class="health-card" data-id="${e.id}" data-action="open-entry"
|
||||||
|
|
@ -478,7 +502,13 @@ window.Page_health = (() => {
|
||||||
</div>
|
</div>
|
||||||
${deltaHtml}
|
${deltaHtml}
|
||||||
</div>
|
</div>
|
||||||
${chart ? `<div class="health-chart-wrap">${chart}</div>` : ''}
|
${chart ? `<div class="health-chart-wrap">
|
||||||
|
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||||||
|
padding:var(--space-2) var(--space-3) 0;display:flex;align-items:center;gap:var(--space-1)">
|
||||||
|
Gewichtsverlauf ${UI.help('Wird aus allen Einträgen mit Gewichtsangabe berechnet.')}
|
||||||
|
</div>
|
||||||
|
${chart}
|
||||||
|
</div>` : ''}
|
||||||
<div class="health-list" style="margin-top:var(--space-2)">${items}</div>
|
<div class="health-list" style="margin-top:var(--space-2)">${items}</div>
|
||||||
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>
|
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -556,6 +586,62 @@ window.Page_health = (() => {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// GEWICHTSVERLAUF-CHART (dedizierter Endpoint /health/gewicht)
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
function _renderWeightChart(entries) {
|
||||||
|
// entries: [{datum, gewicht}, ...]
|
||||||
|
if (!entries || entries.length < 2) {
|
||||||
|
return '<p class="health-chart-empty">Mindestens 2 Gewichtseinträge für den Verlauf nötig.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const W = 300, H = 120, PAD = 24;
|
||||||
|
const weights = entries.map(e => e.gewicht);
|
||||||
|
const min = Math.min(...weights), max = Math.max(...weights);
|
||||||
|
const range = max - min || 1;
|
||||||
|
|
||||||
|
// x: gleichmäßig verteilt, y: normalisiert
|
||||||
|
const pts = entries.map((e, i) => {
|
||||||
|
const x = PAD + (i / (entries.length - 1)) * (W - 2 * PAD);
|
||||||
|
const y = H - PAD - ((e.gewicht - min) / range) * (H - 2 * PAD);
|
||||||
|
return { x, y, ...e };
|
||||||
|
});
|
||||||
|
|
||||||
|
const polyline = pts.map(p => `${p.x},${p.y}`).join(' ');
|
||||||
|
const area = `${pts[0].x},${H - PAD} ` + polyline + ` ${pts[pts.length - 1].x},${H - PAD}`;
|
||||||
|
|
||||||
|
// Datenpunkte + Tooltips als title-Elemente
|
||||||
|
const circles = pts.map(p =>
|
||||||
|
`<circle cx="${p.x}" cy="${p.y}" r="4" fill="var(--c-primary)">
|
||||||
|
<title>${p.datum}: ${p.gewicht} kg</title>
|
||||||
|
</circle>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const gId = `wg${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
return `
|
||||||
|
<div class="health-chart-wrap">
|
||||||
|
<div class="health-chart-title">Gewichtsverlauf</div>
|
||||||
|
<svg viewBox="0 0 ${W} ${H}" class="health-chart-svg" aria-label="Gewichtsverlauf">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="${gId}" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="var(--c-primary)" stop-opacity=".25"/>
|
||||||
|
<stop offset="100%" stop-color="var(--c-primary)" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<polygon points="${area}" fill="url(#${gId})"/>
|
||||||
|
<polyline points="${polyline}" fill="none" stroke="var(--c-primary)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||||
|
${circles}
|
||||||
|
<text x="${PAD - 2}" y="${H - PAD + 4}" font-size="9" fill="var(--c-text-secondary)" text-anchor="middle">${min}</text>
|
||||||
|
<text x="${PAD - 2}" y="${PAD + 4}" font-size="9" fill="var(--c-text-secondary)" text-anchor="middle">${max}</text>
|
||||||
|
</svg>
|
||||||
|
<div class="health-chart-labels">
|
||||||
|
<span>${entries[0].datum}</span>
|
||||||
|
<span>${entries[entries.length - 1].datum}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// LÄUFIGKEIT — Timeline + Vorhersage
|
// LÄUFIGKEIT — Timeline + Vorhersage
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -927,7 +1013,10 @@ window.Page_health = (() => {
|
||||||
|
|
||||||
const uploadField = t === 'dokument' ? `
|
const uploadField = t === 'dokument' ? `
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Datei (JPG, PNG, PDF)</label>
|
<label class="form-label">
|
||||||
|
Datei (JPG, PNG, PDF)
|
||||||
|
${UI.help('PDF oder Foto — z.B. Impfpass, Röntgenbild, Befund.')}
|
||||||
|
</label>
|
||||||
<input class="form-control" type="file" name="datei" accept="image/*,.pdf">
|
<input class="form-control" type="file" name="datei" accept="image/*,.pdf">
|
||||||
</div>
|
</div>
|
||||||
` : '';
|
` : '';
|
||||||
|
|
|
||||||
|
|
@ -257,12 +257,12 @@ window.Page_lost = (() => {
|
||||||
if (!listEl) return;
|
if (!listEl) return;
|
||||||
|
|
||||||
if (_reports.length === 0) {
|
if (_reports.length === 0) {
|
||||||
listEl.innerHTML = UI.emptyState({
|
listEl.innerHTML = _emptyState(
|
||||||
icon : '🐾',
|
'magnifying-glass',
|
||||||
title : 'Keine vermissten Hunde',
|
'Aktuell kein vermisster Hund gemeldet',
|
||||||
text : 'In deiner Nähe (25 km) werden aktuell keine Hunde vermisst.',
|
'Wenn ein Hund vermisst wird, erscheint die Meldung hier. Du kannst auch selbst eine Meldung erstellen.',
|
||||||
action: `<button class="btn btn-primary" id="lost-empty-report">🔍 Hund melden</button>`,
|
`<button class="btn btn-primary" id="lost-empty-report">Vermissten melden</button>`
|
||||||
});
|
);
|
||||||
listEl.querySelector('#lost-empty-report')
|
listEl.querySelector('#lost-empty-report')
|
||||||
?.addEventListener('click', _showReportForm);
|
?.addEventListener('click', _showReportForm);
|
||||||
return;
|
return;
|
||||||
|
|
@ -680,6 +680,17 @@ window.Page_lost = (() => {
|
||||||
.replace(/"/g, '"');
|
.replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _emptyState(icon, title, text, cta = '') {
|
||||||
|
return `<div class="empty-state">
|
||||||
|
<svg class="ph-icon empty-state-icon" aria-hidden="true">
|
||||||
|
<use href="/icons/phosphor.svg#${icon}"></use>
|
||||||
|
</svg>
|
||||||
|
<div class="empty-state-title">${title}</div>
|
||||||
|
${text ? `<p class="empty-state-text">${text}</p>` : ''}
|
||||||
|
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// PUBLIC
|
// PUBLIC
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -45,13 +45,31 @@ window.Page_routes = (() => {
|
||||||
return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _emptyState(icon, title, text, cta = '') {
|
||||||
|
return `<div class="empty-state">
|
||||||
|
<svg class="ph-icon empty-state-icon" aria-hidden="true">
|
||||||
|
<use href="/icons/phosphor.svg#${icon}"></use>
|
||||||
|
</svg>
|
||||||
|
<div class="empty-state-title">${title}</div>
|
||||||
|
${text ? `<p class="empty-state-text">${text}</p>` : ''}
|
||||||
|
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
async function init(container, appState) {
|
async function init(container, appState) {
|
||||||
_container = container;
|
_container = container;
|
||||||
_appState = appState;
|
_appState = appState;
|
||||||
_render();
|
_render();
|
||||||
_loadLeaflet(); // fire & forget — bereit wenn Cards gerendert werden
|
_loadLeaflet(); // fire & forget — bereit wenn Cards gerendert werden
|
||||||
try { _userPos = await API.getLocation(); } catch {}
|
try { _userPos = await API.getLocation(); } catch {}
|
||||||
_loadData();
|
await _loadData();
|
||||||
|
|
||||||
|
// Deep-Link: /#routes?id=123 → direkt Route-Detail öffnen
|
||||||
|
const params = new URLSearchParams((location.hash.split('?')[1] || ''));
|
||||||
|
const deepId = params.get('id');
|
||||||
|
if (deepId) {
|
||||||
|
_openDetail(parseInt(deepId, 10));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _loadLeaflet() {
|
async function _loadLeaflet() {
|
||||||
|
|
@ -460,20 +478,12 @@ window.Page_routes = (() => {
|
||||||
</div>`;
|
</div>`;
|
||||||
} else {
|
} else {
|
||||||
// Noch gar keine eigenen Routen
|
// Noch gar keine eigenen Routen
|
||||||
grid.innerHTML = `<div class="rk-empty rk-empty--onboarding">
|
grid.innerHTML = _emptyState(
|
||||||
<div class="rk-empty-icon">🥾</div>
|
'map-trifold',
|
||||||
<h3 class="rk-empty-title">Deine erste Gassi-Route</h3>
|
'Noch keine Routen',
|
||||||
<p class="rk-empty-text">Zeichne deine Lieblingsstrecken auf — mit Streckendaten, Fotos und Hundetauglichkeit.</p>
|
'Zeichne Lieblingsrouten auf oder importiere GPX-Dateien. Teile Routen mit Freunden.',
|
||||||
<div class="rk-empty-features">
|
`<button class="btn btn-primary" id="rk-empty-rec">${UI.icon('path')} Route aufzeichnen</button>`
|
||||||
<div class="rk-empty-feature">${UI.icon('map-trifold')}<span>GPS-Aufzeichnung</span></div>
|
);
|
||||||
<div class="rk-empty-feature">${UI.icon('camera')}<span>Fotos entlang der Strecke</span></div>
|
|
||||||
<div class="rk-empty-feature"><span>🐾</span><span>Hundetauglichkeit bewerten</span></div>
|
|
||||||
<div class="rk-empty-feature">${UI.icon('download-simple')}<span>GPX-Download für Navi</span></div>
|
|
||||||
<div class="rk-empty-feature">${UI.icon('map-pin')}<span>Restaurants & Parkplätze</span></div>
|
|
||||||
<div class="rk-empty-feature">${UI.icon('lock')}<span>Privat oder öffentlich</span></div>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary btn-lg" id="rk-empty-rec">${UI.icon('path')} Erste Route aufzeichnen</button>
|
|
||||||
</div>`;
|
|
||||||
document.getElementById('rk-empty-rec')?.addEventListener('click', () => {
|
document.getElementById('rk-empty-rec')?.addEventListener('click', () => {
|
||||||
App.navigate('map');
|
App.navigate('map');
|
||||||
setTimeout(() => window.Page_map?.startRecording?.(), 600);
|
setTimeout(() => window.Page_map?.startRecording?.(), 600);
|
||||||
|
|
@ -688,6 +698,8 @@ window.Page_routes = (() => {
|
||||||
|
|
||||||
const footer = `
|
const footer = `
|
||||||
<button type="button" class="btn btn-secondary" id="rd-gpx">${UI.icon('download-simple')} GPX</button>
|
<button type="button" class="btn btn-secondary" id="rd-gpx">${UI.icon('download-simple')} GPX</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="rd-share" title="Route teilen">${UI.icon('share')}</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="rd-send-friend" title="An Freund senden">${UI.icon('chat-circle-dots')} An Freund senden</button>
|
||||||
${isOwn ? `<button type="button" class="btn btn-ghost" id="rd-vis" title="${route.is_public?'Privat machen':'Öffentlich machen'}">
|
${isOwn ? `<button type="button" class="btn btn-ghost" id="rd-vis" title="${route.is_public?'Privat machen':'Öffentlich machen'}">
|
||||||
${route.is_public ? UI.icon('lock')+' Privat' : UI.icon('globe')+' Öffentlich'}
|
${route.is_public ? UI.icon('lock')+' Privat' : UI.icon('globe')+' Öffentlich'}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -700,6 +712,23 @@ window.Page_routes = (() => {
|
||||||
document.getElementById('rd-close')?.addEventListener('click', UI.modal.close);
|
document.getElementById('rd-close')?.addEventListener('click', UI.modal.close);
|
||||||
document.getElementById('rd-gpx')?.addEventListener('click', () => _downloadGpxDirect(route));
|
document.getElementById('rd-gpx')?.addEventListener('click', () => _downloadGpxDirect(route));
|
||||||
|
|
||||||
|
// Teilen-Button
|
||||||
|
document.getElementById('rd-share')?.addEventListener('click', () => {
|
||||||
|
const shareUrl = location.origin + '/#routes?id=' + route.id;
|
||||||
|
if (navigator.share) {
|
||||||
|
navigator.share({ title: route.name, url: shareUrl }).catch(() => {});
|
||||||
|
} else {
|
||||||
|
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||||
|
UI.toast.success('Link kopiert!');
|
||||||
|
}).catch(() => {
|
||||||
|
UI.toast.error('Link konnte nicht kopiert werden.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// An Freund senden
|
||||||
|
document.getElementById('rd-send-friend')?.addEventListener('click', () => _openSendToFriendModal(route));
|
||||||
|
|
||||||
// Sichtbarkeit toggle
|
// Sichtbarkeit toggle
|
||||||
document.getElementById('rd-vis')?.addEventListener('click', async () => {
|
document.getElementById('rd-vis')?.addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1108,6 +1137,7 @@ window.Page_routes = (() => {
|
||||||
</label>
|
</label>
|
||||||
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
|
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
|
||||||
<input type="checkbox" id="ri-public"> Öffentlich
|
<input type="checkbox" id="ri-public"> Öffentlich
|
||||||
|
${UI.help('Öffentliche Routen können von allen Nutzern in der Entdecken-Ansicht gefunden werden.')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -1173,6 +1203,71 @@ window.Page_routes = (() => {
|
||||||
return Array.from({ length: maxPts }, (_, i) => track[Math.round(i * step)]);
|
return Array.from({ length: maxPts }, (_, i) => track[Math.round(i * step)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// An Freund senden
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
async function _openSendToFriendModal(route) {
|
||||||
|
const shareUrl = location.origin + '/#routes?id=' + route.id;
|
||||||
|
|
||||||
|
// Freunde laden
|
||||||
|
let friends = [];
|
||||||
|
try {
|
||||||
|
friends = await API.friends.list();
|
||||||
|
} catch (err) {
|
||||||
|
UI.toast.error('Freunde konnten nicht geladen werden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!friends.length) {
|
||||||
|
UI.toast.info('Du hast noch keine Freunde hinzugefügt.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const friendRows = friends.map(f => {
|
||||||
|
const initial = (f.name || '?')[0].toUpperCase();
|
||||||
|
return `<div class="rk-friend-row" data-id="${f.id}" data-name="${_esc(f.name || 'Anonym')}"
|
||||||
|
style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3);
|
||||||
|
cursor:pointer;border-radius:var(--radius-md);transition:background .15s"
|
||||||
|
onmouseover="this.style.background='var(--c-surface-2)'"
|
||||||
|
onmouseout="this.style.background=''">
|
||||||
|
<div style="width:36px;height:36px;border-radius:50%;background:var(--c-primary);
|
||||||
|
color:#fff;display:flex;align-items:center;justify-content:center;
|
||||||
|
font-weight:600;flex-shrink:0">${_esc(initial)}</div>
|
||||||
|
<span>${_esc(f.name || 'Anonym')}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const body = `<div id="rk-friend-list">${friendRows}</div>`;
|
||||||
|
const footer = `<button type="button" class="btn btn-ghost" id="rsf-cancel">Abbrechen</button>`;
|
||||||
|
|
||||||
|
UI.modal.open({
|
||||||
|
title: `${UI.icon('chat-circle-dots')} An Freund senden`,
|
||||||
|
body,
|
||||||
|
footer,
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('rsf-cancel')?.addEventListener('click', UI.modal.close);
|
||||||
|
|
||||||
|
document.getElementById('rk-friend-list')?.addEventListener('click', async e => {
|
||||||
|
const row = e.target.closest('.rk-friend-row');
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
const partnerId = parseInt(row.dataset.id, 10);
|
||||||
|
const partnerName = row.dataset.name;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const conv = await API.chat.start(partnerId);
|
||||||
|
const convId = conv.id;
|
||||||
|
const text = `Ich habe eine Route für dich: ${route.name}\n${shareUrl}`;
|
||||||
|
await API.chat.send(convId, text);
|
||||||
|
UI.modal.close();
|
||||||
|
UI.toast.success(`Gesendet an ${partnerName}`);
|
||||||
|
} catch (err) {
|
||||||
|
UI.toast.error('Senden fehlgeschlagen: ' + (err.message || 'Unbekannter Fehler'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { init, refresh, onDogChange };
|
return { init, refresh, onDogChange };
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,17 @@ window.Page_sitting = (() => {
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// State
|
// State
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
let _container = null;
|
let _container = null;
|
||||||
let _state = null;
|
let _state = null;
|
||||||
let _tab = 'suchen'; // suchen | profil | anfragen
|
let _tab = 'suchen'; // suchen | profil | anfragen | matching
|
||||||
let _sitters = [];
|
let _sitters = [];
|
||||||
let _mySitter = null;
|
let _mySitter = null;
|
||||||
let _myRequests = [];
|
let _myRequests = [];
|
||||||
let _inbox = [];
|
let _inbox = [];
|
||||||
|
// Matching-State
|
||||||
|
let _matchResults = null; // null = noch nicht geladen
|
||||||
|
let _matchLoading = false;
|
||||||
|
let _myServiceOffer = null; // eigenes Angebot (type='sitting')
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// init
|
// init
|
||||||
|
|
@ -46,6 +50,7 @@ window.Page_sitting = (() => {
|
||||||
<div class="sitting-layout">
|
<div class="sitting-layout">
|
||||||
<div class="sitting-tabs by-tabs" id="sit-tabs">
|
<div class="sitting-tabs by-tabs" id="sit-tabs">
|
||||||
<button class="by-tab active" data-sit-tab="suchen">${UI.icon('magnifying-glass')} Sitter finden</button>
|
<button class="by-tab active" data-sit-tab="suchen">${UI.icon('magnifying-glass')} Sitter finden</button>
|
||||||
|
<button class="by-tab" data-sit-tab="matching">${UI.icon('users')} Anbieter</button>
|
||||||
${_state.user ? `
|
${_state.user ? `
|
||||||
<button class="by-tab" data-sit-tab="profil">${UI.icon('user')} Mein Profil</button>
|
<button class="by-tab" data-sit-tab="profil">${UI.icon('user')} Mein Profil</button>
|
||||||
<button class="by-tab" data-sit-tab="anfragen">${UI.icon('bell')} Anfragen</button>
|
<button class="by-tab" data-sit-tab="anfragen">${UI.icon('bell')} Anfragen</button>
|
||||||
|
|
@ -70,6 +75,7 @@ window.Page_sitting = (() => {
|
||||||
tasks.push(API.sitting.me());
|
tasks.push(API.sitting.me());
|
||||||
tasks.push(API.sitting.requests());
|
tasks.push(API.sitting.requests());
|
||||||
tasks.push(API.sitting.inbox());
|
tasks.push(API.sitting.inbox());
|
||||||
|
tasks.push(API.services.me());
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -78,6 +84,8 @@ window.Page_sitting = (() => {
|
||||||
_mySitter = results[1]?.status === 'fulfilled' ? results[1].value : null;
|
_mySitter = results[1]?.status === 'fulfilled' ? results[1].value : null;
|
||||||
_myRequests = results[2]?.status === 'fulfilled' ? results[2].value : [];
|
_myRequests = results[2]?.status === 'fulfilled' ? results[2].value : [];
|
||||||
_inbox = results[3]?.status === 'fulfilled' ? results[3].value : [];
|
_inbox = results[3]?.status === 'fulfilled' ? results[3].value : [];
|
||||||
|
const myOffers = results[4]?.status === 'fulfilled' ? results[4].value : [];
|
||||||
|
_myServiceOffer = myOffers?.find(o => o.type === 'sitting') || null;
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
_renderTab();
|
_renderTab();
|
||||||
|
|
@ -92,6 +100,7 @@ window.Page_sitting = (() => {
|
||||||
if (_tab === 'suchen') _renderSuchen(content);
|
if (_tab === 'suchen') _renderSuchen(content);
|
||||||
if (_tab === 'profil') _renderProfil(content);
|
if (_tab === 'profil') _renderProfil(content);
|
||||||
if (_tab === 'anfragen') _renderAnfragen(content);
|
if (_tab === 'anfragen') _renderAnfragen(content);
|
||||||
|
if (_tab === 'matching') _renderMatching(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Tab: Sitter suchen ----
|
// ---- Tab: Sitter suchen ----
|
||||||
|
|
@ -441,6 +450,239 @@ window.Page_sitting = (() => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Tab: Anbieter in deiner Nähe (service_offers Matching)
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
function _renderMatching(el) {
|
||||||
|
const offerActive = _myServiceOffer?.aktiv;
|
||||||
|
const offerDesc = _myServiceOffer?.beschreibung || '';
|
||||||
|
const offerPreis = _myServiceOffer?.preis_pro_tag ?? '';
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="svc-matching-layout">
|
||||||
|
|
||||||
|
<!-- Eigenes Angebot -->
|
||||||
|
<div class="svc-own-offer by-card">
|
||||||
|
<div class="svc-own-offer-header">
|
||||||
|
<span class="svc-own-offer-title">${UI.icon('handshake')} Mein Angebot</span>
|
||||||
|
${_state.user ? `
|
||||||
|
<label class="svc-toggle" title="${offerActive ? 'Angebot deaktivieren' : 'Angebot aktivieren'}">
|
||||||
|
<input type="checkbox" id="svc-offer-toggle" ${offerActive ? 'checked' : ''}>
|
||||||
|
<span class="svc-toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
` : `<span class="svc-login-hint">Zum Anbieten bitte anmelden</span>`}
|
||||||
|
</div>
|
||||||
|
${_state.user ? `
|
||||||
|
<form id="svc-offer-form" class="svc-offer-form ${offerActive ? '' : 'svc-offer-form--hidden'}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Beschreibung</label>
|
||||||
|
<textarea class="form-control" name="beschreibung" rows="2"
|
||||||
|
placeholder="Was bietest du an? Erfahrungen, besondere Stärken…">${UI.escape(offerDesc)}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-row-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Preis/Tag (€, optional)</label>
|
||||||
|
<input class="form-control" type="number" step="1" min="0" name="preis_pro_tag"
|
||||||
|
value="${offerPreis}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Umkreis (km)</label>
|
||||||
|
<input class="form-control" type="number" min="1" max="100" name="radius_km"
|
||||||
|
value="${_myServiceOffer?.radius_km ?? 10}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="svc-offer-actions">
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="svc-gps-btn">
|
||||||
|
${UI.icon('map-pin')} Position
|
||||||
|
</button>
|
||||||
|
<input type="hidden" name="lat" id="svc-lat" value="${_myServiceOffer?.lat || ''}">
|
||||||
|
<input type="hidden" name="lon" id="svc-lon" value="${_myServiceOffer?.lon || ''}">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">
|
||||||
|
${UI.icon('floppy-disk')} Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Anbieter finden -->
|
||||||
|
<div class="svc-search-section">
|
||||||
|
<div class="svc-search-header">
|
||||||
|
<span class="by-section-label" style="margin:0">${UI.icon('magnifying-glass')} Anbieter in deiner Nähe</span>
|
||||||
|
<button class="btn btn-primary btn-sm" id="svc-find-btn">
|
||||||
|
${UI.icon('map-pin')} Suchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="svc-results">
|
||||||
|
<p class="svc-hint">Klicke auf "Suchen" um Hundesitting-Anbieter in deiner Nähe zu finden.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Toggle
|
||||||
|
document.getElementById('svc-offer-toggle')?.addEventListener('change', async e => {
|
||||||
|
const form = document.getElementById('svc-offer-form');
|
||||||
|
if (e.target.checked) {
|
||||||
|
form?.classList.remove('svc-offer-form--hidden');
|
||||||
|
} else {
|
||||||
|
form?.classList.add('svc-offer-form--hidden');
|
||||||
|
if (_myServiceOffer) {
|
||||||
|
try {
|
||||||
|
await API.services.deactivate(_myServiceOffer.id);
|
||||||
|
_myServiceOffer = { ..._myServiceOffer, aktiv: 0 };
|
||||||
|
UI.toast('Angebot deaktiviert.');
|
||||||
|
} catch (err) { UI.toast(err.message, 'error'); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GPS
|
||||||
|
document.getElementById('svc-gps-btn')?.addEventListener('click', async () => {
|
||||||
|
const btn = document.getElementById('svc-gps-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const pos = await API.getLocation();
|
||||||
|
document.getElementById('svc-lat').value = pos.lat.toFixed(6);
|
||||||
|
document.getElementById('svc-lon').value = pos.lon.toFixed(6);
|
||||||
|
UI.toast('Position gespeichert.');
|
||||||
|
} catch { UI.toast('GPS nicht verfügbar.', 'error'); }
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Formular speichern
|
||||||
|
document.getElementById('svc-offer-form')?.addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.target;
|
||||||
|
const fd = new FormData(form);
|
||||||
|
const submitBtn = form.querySelector('[type="submit"]');
|
||||||
|
|
||||||
|
// Wenn noch keine Position gespeichert, GPS holen
|
||||||
|
if (!fd.get('lat') || !fd.get('lon')) {
|
||||||
|
try {
|
||||||
|
const pos = await API.getLocation();
|
||||||
|
document.getElementById('svc-lat').value = pos.lat.toFixed(6);
|
||||||
|
document.getElementById('svc-lon').value = pos.lon.toFixed(6);
|
||||||
|
fd.set('lat', pos.lat.toFixed(6));
|
||||||
|
fd.set('lon', pos.lon.toFixed(6));
|
||||||
|
} catch {
|
||||||
|
UI.toast('Bitte GPS-Position ermitteln.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = '…'; }
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
type: 'sitting',
|
||||||
|
beschreibung: fd.get('beschreibung') || null,
|
||||||
|
preis_pro_tag: fd.get('preis_pro_tag') ? parseFloat(fd.get('preis_pro_tag')) : null,
|
||||||
|
lat: parseFloat(fd.get('lat')),
|
||||||
|
lon: parseFloat(fd.get('lon')),
|
||||||
|
radius_km: parseInt(fd.get('radius_km')) || 10,
|
||||||
|
};
|
||||||
|
_myServiceOffer = await API.services.upsert(payload);
|
||||||
|
UI.toast('Angebot gespeichert!');
|
||||||
|
} catch (err) {
|
||||||
|
UI.toast(err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = `${UI.icon('floppy-disk')} Speichern`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Suche
|
||||||
|
document.getElementById('svc-find-btn')?.addEventListener('click', _searchProviders);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _searchProviders() {
|
||||||
|
if (_matchLoading) return;
|
||||||
|
_matchLoading = true;
|
||||||
|
const btn = document.getElementById('svc-find-btn');
|
||||||
|
if (btn) { btn.disabled = true; btn.innerHTML = `${UI.icon('spinner')} Suche…`; }
|
||||||
|
|
||||||
|
const resultsEl = document.getElementById('svc-results');
|
||||||
|
|
||||||
|
let pos = null;
|
||||||
|
try {
|
||||||
|
pos = await API.getLocation();
|
||||||
|
} catch {
|
||||||
|
UI.toast('GPS nicht verfügbar — Suche ohne Entfernung.', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const offers = await API.services.list('sitting', pos?.lat ?? null, pos?.lon ?? null, 50);
|
||||||
|
_matchResults = offers;
|
||||||
|
_renderMatchResults(resultsEl, pos);
|
||||||
|
} catch (err) {
|
||||||
|
UI.toast(err.message, 'error');
|
||||||
|
if (resultsEl) resultsEl.innerHTML = `<p class="svc-hint">Fehler beim Laden.</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_matchLoading = false;
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = `${UI.icon('map-pin')} Erneut suchen`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderMatchResults(el, pos) {
|
||||||
|
if (!el) return;
|
||||||
|
const list = _matchResults || [];
|
||||||
|
|
||||||
|
// Eigene Angebote ausblenden
|
||||||
|
const filtered = list.filter(o => o.user_id !== _state.user?.id);
|
||||||
|
|
||||||
|
if (!filtered.length) {
|
||||||
|
el.innerHTML = `<p class="svc-hint">Keine Anbieter in deiner Nähe gefunden.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="svc-results-list">
|
||||||
|
${filtered.map(o => _serviceCardHTML(o)).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _serviceCardHTML(o) {
|
||||||
|
const dist = o.distanz_km != null ? `${o.distanz_km} km entfernt` : '';
|
||||||
|
const preis = o.preis_pro_tag != null ? `${o.preis_pro_tag.toFixed(0)} €/Tag` : 'Preis anfragen';
|
||||||
|
return `
|
||||||
|
<div class="svc-card">
|
||||||
|
<div class="svc-card-avatar">${UI.icon('paw-print')}</div>
|
||||||
|
<div class="svc-card-body">
|
||||||
|
<div class="svc-card-name">${UI.escape(o.anbieter_name || `Nutzer #${o.user_id}`)}</div>
|
||||||
|
${dist ? `<div class="svc-card-dist">${UI.icon('map-pin')} ${dist}</div>` : ''}
|
||||||
|
${o.beschreibung ? `<div class="svc-card-desc">${UI.escape(o.beschreibung)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="svc-card-side">
|
||||||
|
<div class="svc-card-price">${preis}</div>
|
||||||
|
<button class="btn btn-primary btn-sm" data-svc-chat="${o.user_id}">
|
||||||
|
${UI.icon('chat-circle')} Kontakt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _openChatWithProvider(userId) {
|
||||||
|
if (!_state.user) {
|
||||||
|
UI.toast('Bitte zuerst anmelden.', 'error');
|
||||||
|
App.navigate('settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { conversation_id } = await API.chat.start(userId);
|
||||||
|
App.navigate('chat', true, { conversation_id });
|
||||||
|
} catch (err) {
|
||||||
|
UI.toast(err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// Click-Handler
|
// Click-Handler
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -467,6 +709,13 @@ window.Page_sitting = (() => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Anbieter kontaktieren
|
||||||
|
const chatBtn = e.target.closest('[data-svc-chat]');
|
||||||
|
if (chatBtn) {
|
||||||
|
_openChatWithProvider(parseInt(chatBtn.dataset.svcChat));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Anfragen-Aktionen
|
// Anfragen-Aktionen
|
||||||
const acceptBtn = e.target.closest('[data-sit-accept]');
|
const acceptBtn = e.target.closest('[data-sit-accept]');
|
||||||
const declineBtn = e.target.closest('[data-sit-decline]');
|
const declineBtn = e.target.closest('[data-sit-decline]');
|
||||||
|
|
|
||||||
|
|
@ -268,6 +268,37 @@ const UI = (() => {
|
||||||
.replace(/"/g, '"');
|
.replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// HELP TOOLTIP — inline ? Badge mit Klick-Tooltip
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
function help(text) {
|
||||||
|
return `<button class="by-help-btn" data-help="${escape(text)}" aria-label="Hilfe" type="button">
|
||||||
|
<svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px">
|
||||||
|
<use href="/icons/phosphor.svg#question"></use>
|
||||||
|
</svg>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event-Delegation für Help-Tooltips — einmalig registrieren
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const btn = e.target.closest('.by-help-btn');
|
||||||
|
if (!btn) {
|
||||||
|
document.querySelectorAll('.by-help-tooltip').forEach(t => t.remove());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.stopPropagation();
|
||||||
|
document.querySelectorAll('.by-help-tooltip').forEach(t => t.remove());
|
||||||
|
const tip = document.createElement('div');
|
||||||
|
tip.className = 'by-help-tooltip';
|
||||||
|
tip.textContent = btn.dataset.help;
|
||||||
|
document.body.appendChild(tip);
|
||||||
|
const r = btn.getBoundingClientRect();
|
||||||
|
tip.style.top = (r.bottom + window.scrollY + 6) + 'px';
|
||||||
|
tip.style.left = Math.max(8, r.left + window.scrollX - tip.offsetWidth / 2 + r.width / 2) + 'px';
|
||||||
|
const maxL = window.innerWidth - tip.offsetWidth - 8;
|
||||||
|
if (parseFloat(tip.style.left) > maxL) tip.style.left = maxL + 'px';
|
||||||
|
});
|
||||||
|
|
||||||
// Öffentliche API
|
// Öffentliche API
|
||||||
return {
|
return {
|
||||||
toast, modal,
|
toast, modal,
|
||||||
|
|
@ -276,7 +307,7 @@ const UI = (() => {
|
||||||
emptyState, time,
|
emptyState, time,
|
||||||
setupPhotoPreview, scrollTop, skeleton,
|
setupPhotoPreview, scrollTop, skeleton,
|
||||||
icon: _svgIcon,
|
icon: _svgIcon,
|
||||||
escape,
|
escape, help,
|
||||||
};
|
};
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v156';
|
const CACHE_VERSION = 'by-v157';
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue