Sprint 11: Freunde & Chat + Phosphor-Icon-Vollmigration

- Freundschaften (pending/accepted), Nutzersuche, Anfragen per Push
- Direktnachrichten mit Polling, iMessage-Stil, Deep-Links aus Push
- Alle Seiten (map, places, diary, health, dog-profile, sitting, knigge,
  forum, wiki, walks) vollständig auf Phosphor-Icons migriert
- Wikidata-Rassen-Scraper (~833 neue Rassen, lokal gespiegelte Fotos)
- TheDogAPI lokal gespiegelt (169 Rassen + Fotos)
- Quiz-Result-Cards horizontal (korrekte Bildproportionen)
- SW by-v89
This commit is contained in:
rene 2026-04-15 21:33:53 +02:00
parent 96bd57f0ad
commit 097295c628
44 changed files with 9980 additions and 300 deletions

File diff suppressed because it is too large Load diff

View file

@ -475,3 +475,18 @@
@media (max-width: 400px) {
.grid-3 { grid-template-columns: 1fr 1fr; }
}
/* ============================================================
Phosphor Icons SVG Sprite
============================================================ */
.ph-icon {
width: 20px;
height: 20px;
display: inline-block;
vertical-align: middle;
fill: currentColor;
flex-shrink: 0;
}
.nav-item .ph-icon { width: 24px; height: 24px; }
.nav-item-center .ph-icon { width: 22px; height: 22px; }
.header-menu-btn .ph-icon { width: 22px; height: 22px; }

View file

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<symbol id="arrow-left" viewBox="0 0 256 256"><path d="M224,128a8,8,0,0,1-8,8H59.31l58.35,58.34a8,8,0,0,1-11.32,11.32l-72-72a8,8,0,0,1,0-11.32l72-72a8,8,0,0,1,11.32,11.32L59.31,120H216A8,8,0,0,1,224,128Z"/></symbol>
<symbol id="arrow-right" viewBox="0 0 256 256"><path d="M221.66,133.66l-72,72a8,8,0,0,1-11.32-11.32L196.69,136H40a8,8,0,0,1,0-16H196.69L138.34,61.66a8,8,0,0,1,11.32-11.32l72,72A8,8,0,0,1,221.66,133.66Z"/></symbol>
<symbol id="bell" viewBox="0 0 256 256"><path d="M221.8,175.94C216.25,166.38,208,139.33,208,104a80,80,0,1,0-160,0c0,35.34-8.26,62.38-13.81,71.94A16,16,0,0,0,48,200H88.81a40,40,0,0,0,78.38,0H208a16,16,0,0,0,13.8-24.06ZM128,216a24,24,0,0,1-22.62-16h45.24A24,24,0,0,1,128,216ZM48,184c7.7-13.24,16-43.92,16-80a64,64,0,1,1,128,0c0,36.05,8.28,66.73,16,80Z"/></symbol>
<symbol id="book-open" viewBox="0 0 256 256"><path d="M232,48H160a40,40,0,0,0-32,16A40,40,0,0,0,96,48H24a8,8,0,0,0-8,8V200a8,8,0,0,0,8,8H96a24,24,0,0,1,24,24,8,8,0,0,0,16,0,24,24,0,0,1,24-24h72a8,8,0,0,0,8-8V56A8,8,0,0,0,232,48ZM96,192H32V64H96a24,24,0,0,1,24,24V200A39.81,39.81,0,0,0,96,192Zm128,0H160a39.81,39.81,0,0,0-24,8V88a24,24,0,0,1,24-24h64Z"/></symbol>
<symbol id="books" viewBox="0 0 256 256"><path d="M231.65,194.55,198.46,36.75a16,16,0,0,0-19-12.39L132.65,34.42a16.08,16.08,0,0,0-12.3,19l33.19,157.8A16,16,0,0,0,169.16,224a16.25,16.25,0,0,0,3.38-.36l46.81-10.06A16.09,16.09,0,0,0,231.65,194.55ZM136,50.15c0-.06,0-.09,0-.09l46.8-10,3.33,15.87L139.33,66Zm6.62,31.47,46.82-10.05,3.34,15.9L146,97.53Zm6.64,31.57,46.82-10.06,13.3,63.24-46.82,10.06ZM216,197.94l-46.8,10-3.33-15.87L212.67,182,216,197.85C216,197.91,216,197.94,216,197.94ZM104,32H56A16,16,0,0,0,40,48V208a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V48A16,16,0,0,0,104,32ZM56,48h48V64H56Zm0,32h48v96H56Zm48,128H56V192h48v16Z"/></symbol>
<symbol id="calendar-dots" viewBox="0 0 256 256"><path d="M208,32H184V24a8,8,0,0,0-16,0v8H88V24a8,8,0,0,0-16,0v8H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32ZM72,48v8a8,8,0,0,0,16,0V48h80v8a8,8,0,0,0,16,0V48h24V80H48V48ZM208,208H48V96H208V208Zm-68-76a12,12,0,1,1-12-12A12,12,0,0,1,140,132Zm44,0a12,12,0,1,1-12-12A12,12,0,0,1,184,132ZM96,172a12,12,0,1,1-12-12A12,12,0,0,1,96,172Zm44,0a12,12,0,1,1-12-12A12,12,0,0,1,140,172Zm44,0a12,12,0,1,1-12-12A12,12,0,0,1,184,172Z"/></symbol>
<symbol id="camera" viewBox="0 0 256 256"><path d="M208,56H180.28L166.65,35.56A8,8,0,0,0,160,32H96a8,8,0,0,0-6.65,3.56L75.71,56H48A24,24,0,0,0,24,80V192a24,24,0,0,0,24,24H208a24,24,0,0,0,24-24V80A24,24,0,0,0,208,56Zm8,136a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V80a8,8,0,0,1,8-8H80a8,8,0,0,0,6.66-3.56L100.28,48h55.43l13.63,20.44A8,8,0,0,0,176,72h32a8,8,0,0,1,8,8ZM128,88a44,44,0,1,0,44,44A44.05,44.05,0,0,0,128,88Zm0,72a28,28,0,1,1,28-28A28,28,0,0,1,128,160Z"/></symbol>
<symbol id="caret-down" viewBox="0 0 256 256"><path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z"/></symbol>
<symbol id="caret-up" viewBox="0 0 256 256"><path d="M213.66,165.66a8,8,0,0,1-11.32,0L128,91.31,53.66,165.66a8,8,0,0,1-11.32-11.32l80-80a8,8,0,0,1,11.32,0l80,80A8,8,0,0,1,213.66,165.66Z"/></symbol>
<symbol id="chat-circle-dots" viewBox="0 0 256 256"><path d="M140,128a12,12,0,1,1-12-12A12,12,0,0,1,140,128ZM84,116a12,12,0,1,0,12,12A12,12,0,0,0,84,116Zm88,0a12,12,0,1,0,12,12A12,12,0,0,0,172,116Zm60,12A104,104,0,0,1,79.12,219.82L45.07,231.17a16,16,0,0,1-20.24-20.24l11.35-34.05A104,104,0,1,1,232,128Zm-16,0A88,88,0,1,0,51.81,172.06a8,8,0,0,1,.66,6.54L40,216,77.4,203.53a7.85,7.85,0,0,1,2.53-.42,8,8,0,0,1,4,1.08A88,88,0,0,0,216,128Z"/></symbol>
<symbol id="check" viewBox="0 0 256 256"><path d="M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z"/></symbol>
<symbol id="dog" viewBox="0 0 256 256"><path d="M239.71,125l-16.42-88a16,16,0,0,0-19.61-12.58l-.31.09L150.85,40h-45.7L52.63,24.56l-.31-.09A16,16,0,0,0,32.71,37.05L16.29,125a15.77,15.77,0,0,0,9.12,17.52A16.26,16.26,0,0,0,32.12,144,15.48,15.48,0,0,0,40,141.84V184a40,40,0,0,0,40,40h96a40,40,0,0,0,40-40V141.85a15.5,15.5,0,0,0,7.87,2.16,16.31,16.31,0,0,0,6.72-1.47A15.77,15.77,0,0,0,239.71,125ZM32,128h0L48.43,40,90.5,52.37Zm144,80H136V195.31l13.66-13.65a8,8,0,0,0-11.32-11.32L128,180.69l-10.34-10.35a8,8,0,0,0-11.32,11.32L120,195.31V208H80a24,24,0,0,1-24-24V123.11L107.92,56h40.15L200,123.11V184A24,24,0,0,1,176,208Zm48-80L165.5,52.37,207.57,40,224,128ZM104,140a12,12,0,1,1-12-12A12,12,0,0,1,104,140Zm72,0a12,12,0,1,1-12-12A12,12,0,0,1,176,140Z"/></symbol>
<symbol id="eye-slash" viewBox="0 0 256 256"><path d="M53.92,34.62A8,8,0,1,0,42.08,45.38L61.32,66.55C25,88.84,9.38,123.2,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208a127.11,127.11,0,0,0,52.07-10.83l22,24.21a8,8,0,1,0,11.84-10.76Zm47.33,75.84,41.67,45.85a32,32,0,0,1-41.67-45.85ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.16,133.16,0,0,1,25,128c4.69-8.79,19.66-33.39,47.35-49.38l18,19.75a48,48,0,0,0,63.66,70l14.73,16.2A112,112,0,0,1,128,192Zm6-95.43a8,8,0,0,1,3-15.72,48.16,48.16,0,0,1,38.77,42.64,8,8,0,0,1-7.22,8.71,6.39,6.39,0,0,1-.75,0,8,8,0,0,1-8-7.26A32.09,32.09,0,0,0,134,96.57Zm113.28,34.69c-.42.94-10.55,23.37-33.36,43.8a8,8,0,1,1-10.67-11.92A132.77,132.77,0,0,0,231.05,128a133.15,133.15,0,0,0-23.12-30.77C185.67,75.19,158.78,64,128,64a118.37,118.37,0,0,0-19.36,1.57A8,8,0,1,1,106,49.79,134,134,0,0,1,128,48c34.88,0,66.57,13.26,91.66,38.35,18.83,18.83,27.3,37.62,27.65,38.41A8,8,0,0,1,247.31,131.26Z"/></symbol>
<symbol id="eye" viewBox="0 0 256 256"><path d="M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.47,133.47,0,0,1,25,128,133.33,133.33,0,0,1,48.07,97.25C70.33,75.19,97.22,64,128,64s57.67,11.19,79.93,33.25A133.46,133.46,0,0,1,231.05,128C223.84,141.46,192.43,192,128,192Zm0-112a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Z"/></symbol>
<symbol id="film-slate" viewBox="0 0 256 256"><path d="M216,104H102.09L210,75.51a8,8,0,0,0,5.68-9.84l-8.16-30a15.93,15.93,0,0,0-19.42-11.13L35.81,64.74a15.75,15.75,0,0,0-9.7,7.4,15.51,15.51,0,0,0-1.55,12L32,111.56c0,.14,0,.29,0,.44v88a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V112A8,8,0,0,0,216,104ZM192.16,40l6,22.07-22.62,6L147.42,51.83Zm-66.69,17.6,28.12,16.24-36.94,9.75L88.53,67.37Zm-79.4,44.62-6-22.08,26.5-7L94.69,89.4ZM208,200H48V120H208v80Z"/></symbol>
<symbol id="fire" viewBox="0 0 256 256"><path d="M183.89,153.34a57.6,57.6,0,0,1-46.56,46.55A8.75,8.75,0,0,1,136,200a8,8,0,0,1-1.32-15.89c16.57-2.79,30.63-16.85,33.44-33.45a8,8,0,0,1,15.78,2.68ZM216,144a88,88,0,0,1-176,0c0-27.92,11-56.47,32.66-84.85a8,8,0,0,1,11.93-.89l24.12,23.41,22-60.41a8,8,0,0,1,12.63-3.41C165.21,36,216,84.55,216,144Zm-16,0c0-46.09-35.79-85.92-58.21-106.33L119.52,98.74a8,8,0,0,1-13.09,3L80.06,76.16C64.09,99.21,56,122,56,144a72,72,0,0,0,144,0Z"/></symbol>
<symbol id="gear" viewBox="0 0 256 256"><path d="M128,80a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Zm88-29.84q.06-2.16,0-4.32l14.92-18.64a8,8,0,0,0,1.48-7.06,107.21,107.21,0,0,0-10.88-26.25,8,8,0,0,0-6-3.93l-23.72-2.64q-1.48-1.56-3-3L186,40.54a8,8,0,0,0-3.94-6,107.71,107.71,0,0,0-26.25-10.87,8,8,0,0,0-7.06,1.49L130.16,40Q128,40,125.84,40L107.2,25.11a8,8,0,0,0-7.06-1.48A107.6,107.6,0,0,0,73.89,34.51a8,8,0,0,0-3.93,6L67.32,64.27q-1.56,1.49-3,3L40.54,70a8,8,0,0,0-6,3.94,107.71,107.71,0,0,0-10.87,26.25,8,8,0,0,0,1.49,7.06L40,125.84Q40,128,40,130.16L25.11,148.8a8,8,0,0,0-1.48,7.06,107.21,107.21,0,0,0,10.88,26.25,8,8,0,0,0,6,3.93l23.72,2.64q1.49,1.56,3,3L70,215.46a8,8,0,0,0,3.94,6,107.71,107.71,0,0,0,26.25,10.87,8,8,0,0,0,7.06-1.49L125.84,216q2.16.06,4.32,0l18.64,14.92a8,8,0,0,0,7.06,1.48,107.21,107.21,0,0,0,26.25-10.88,8,8,0,0,0,3.93-6l2.64-23.72q1.56-1.48,3-3L215.46,186a8,8,0,0,0,6-3.94,107.71,107.71,0,0,0,10.87-26.25,8,8,0,0,0-1.49-7.06Zm-16.1-6.5a73.93,73.93,0,0,1,0,8.68,8,8,0,0,0,1.74,5.48l14.19,17.73a91.57,91.57,0,0,1-6.23,15L187,173.11a8,8,0,0,0-5.1,2.64,74.11,74.11,0,0,1-6.14,6.14,8,8,0,0,0-2.64,5.1l-2.51,22.58a91.32,91.32,0,0,1-15,6.23l-17.74-14.19a8,8,0,0,0-5-1.75h-.48a73.93,73.93,0,0,1-8.68,0,8,8,0,0,0-5.48,1.74L100.45,215.8a91.57,91.57,0,0,1-15-6.23L82.89,187a8,8,0,0,0-2.64-5.1,74.11,74.11,0,0,1-6.14-6.14,8,8,0,0,0-5.1-2.64L46.43,170.6a91.32,91.32,0,0,1-6.23-15l14.19-17.74a8,8,0,0,0,1.74-5.48,73.93,73.93,0,0,1,0-8.68,8,8,0,0,0-1.74-5.48L40.2,100.45a91.57,91.57,0,0,1,6.23-15L69,82.89a8,8,0,0,0,5.1-2.64,74.11,74.11,0,0,1,6.14-6.14A8,8,0,0,0,82.89,69L85.4,46.43a91.32,91.32,0,0,1,15-6.23l17.74,14.19a8,8,0,0,0,5.48,1.74,73.93,73.93,0,0,1,8.68,0,8,8,0,0,0,5.48-1.74L155.55,40.2a91.57,91.57,0,0,1,15,6.23L173.11,69a8,8,0,0,0,2.64,5.1,74.11,74.11,0,0,1,6.14,6.14,8,8,0,0,0,5.1,2.64l22.58,2.51a91.32,91.32,0,0,1,6.23,15l-14.19,17.74A8,8,0,0,0,199.87,123.66Z"/></symbol>
<symbol id="handshake" viewBox="0 0 256 256"><path d="M254.3,107.91,228.78,56.85a16,16,0,0,0-21.47-7.15L182.44,62.13,130.05,48.27a8.14,8.14,0,0,0-4.1,0L73.56,62.13,48.69,49.7a16,16,0,0,0-21.47,7.15L1.7,107.9a16,16,0,0,0,7.15,21.47l27,13.51,55.49,39.63a8.06,8.06,0,0,0,2.71,1.25l64,16a8,8,0,0,0,7.6-2.1l55.07-55.08,26.42-13.21a16,16,0,0,0,7.15-21.46Zm-54.89,33.37L165,113.72a8,8,0,0,0-10.68.61C136.51,132.27,116.66,130,104,122L147.24,80h31.81l27.21,54.41ZM41.53,64,62,74.22,36.43,125.27,16,115.06Zm116,119.13L99.42,168.61l-49.2-35.14,28-56L128,64.28l9.8,2.59-45,43.68-.08.09a16,16,0,0,0,2.72,24.81c20.56,13.13,45.37,11,64.91-5L188,152.66Zm62-57.87-25.52-51L214.47,64,240,115.06Zm-87.75,92.67a8,8,0,0,1-7.75,6.06,8.13,8.13,0,0,1-1.95-.24L80.41,213.33a7.89,7.89,0,0,1-2.71-1.25L51.35,193.26a8,8,0,0,1,9.3-13l25.11,17.94L126,208.24A8,8,0,0,1,131.82,217.94Z"/></symbol>
<symbol id="heart" viewBox="0 0 256 256"><path d="M178,40c-20.65,0-38.73,8.88-50,23.89C116.73,48.88,98.65,40,78,40a62.07,62.07,0,0,0-62,62c0,70,103.79,126.66,108.21,129a8,8,0,0,0,7.58,0C136.21,228.66,240,172,240,102A62.07,62.07,0,0,0,178,40ZM128,214.8C109.74,204.16,32,155.69,32,102A46.06,46.06,0,0,1,78,56c19.45,0,35.78,10.36,42.6,27a8,8,0,0,0,14.8,0c6.82-16.67,23.15-27,42.6-27a46.06,46.06,0,0,1,46,46C224,155.61,146.24,204.15,128,214.8Z"/></symbol>
<symbol id="house-line" viewBox="0 0 256 256"><path d="M240,208H224V136l2.34,2.34A8,8,0,0,0,237.66,127L139.31,28.68a16,16,0,0,0-22.62,0L18.34,127a8,8,0,0,0,11.32,11.31L32,136v72H16a8,8,0,0,0,0,16H240a8,8,0,0,0,0-16ZM48,120l80-80,80,80v88H160V152a8,8,0,0,0-8-8H104a8,8,0,0,0-8,8v56H48Zm96,88H112V160h32Z"/></symbol>
<symbol id="image" viewBox="0 0 256 256"><path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm0,16V158.75l-26.07-26.06a16,16,0,0,0-22.63,0l-20,20-44-44a16,16,0,0,0-22.62,0L40,149.37V56ZM40,172l52-52,80,80H40Zm176,28H194.63l-36-36,20-20L216,181.38V200ZM144,100a12,12,0,1,1,12,12A12,12,0,0,1,144,100Z"/></symbol>
<symbol id="list" viewBox="0 0 256 256"><path d="M224,128a8,8,0,0,1-8,8H40a8,8,0,0,1,0-16H216A8,8,0,0,1,224,128ZM40,72H216a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16ZM216,184H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Z"/></symbol>
<symbol id="lock-open" viewBox="0 0 256 256"><path d="M208,80H96V56a32,32,0,0,1,32-32c15.37,0,29.2,11,32.16,25.59a8,8,0,0,0,15.68-3.18C171.32,24.15,151.2,8,128,8A48.05,48.05,0,0,0,80,56V80H48A16,16,0,0,0,32,96V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V96A16,16,0,0,0,208,80Zm0,128H48V96H208V208Zm-68-56a12,12,0,1,1-12-12A12,12,0,0,1,140,152Z"/></symbol>
<symbol id="magnifying-glass" viewBox="0 0 256 256"><path d="M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/></symbol>
<symbol id="map-pin" viewBox="0 0 256 256"><path d="M128,64a40,40,0,1,0,40,40A40,40,0,0,0,128,64Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,128Zm0-112a88.1,88.1,0,0,0-88,88c0,31.4,14.51,64.68,42,96.25a254.19,254.19,0,0,0,41.45,38.3,8,8,0,0,0,9.18,0A254.19,254.19,0,0,0,174,200.25c27.45-31.57,42-64.85,42-96.25A88.1,88.1,0,0,0,128,16Zm0,206c-16.53-13-72-60.75-72-118a72,72,0,0,1,144,0C200,161.23,144.53,209,128,222Z"/></symbol>
<symbol id="map-trifold" viewBox="0 0 256 256"><path d="M228.92,49.69a8,8,0,0,0-6.86-1.45L160.93,63.52,99.58,32.84a8,8,0,0,0-5.52-.6l-64,16A8,8,0,0,0,24,56V200a8,8,0,0,0,9.94,7.76l61.13-15.28,61.35,30.68A8.15,8.15,0,0,0,160,224a8,8,0,0,0,1.94-.24l64-16A8,8,0,0,0,232,200V56A8,8,0,0,0,228.92,49.69ZM104,52.94l48,24V203.06l-48-24ZM40,62.25l48-12v127.5l-48,12Zm176,131.5-48,12V78.25l48-12Z"/></symbol>
<symbol id="path" viewBox="0 0 256 256"><path d="M200,168a32.06,32.06,0,0,0-31,24H72a32,32,0,0,1,0-64h96a40,40,0,0,0,0-80H72a8,8,0,0,0,0,16h96a24,24,0,0,1,0,48H72a48,48,0,0,0,0,96h97a32,32,0,1,0,31-40Zm0,48a16,16,0,1,1,16-16A16,16,0,0,1,200,216Z"/></symbol>
<symbol id="paw-print" viewBox="0 0 256 256"><path d="M212,80a28,28,0,1,0,28,28A28,28,0,0,0,212,80Zm0,40a12,12,0,1,1,12-12A12,12,0,0,1,212,120ZM72,108a28,28,0,1,0-28,28A28,28,0,0,0,72,108ZM44,120a12,12,0,1,1,12-12A12,12,0,0,1,44,120ZM92,88A28,28,0,1,0,64,60,28,28,0,0,0,92,88Zm0-40A12,12,0,1,1,80,60,12,12,0,0,1,92,48Zm72,40a28,28,0,1,0-28-28A28,28,0,0,0,164,88Zm0-40a12,12,0,1,1-12,12A12,12,0,0,1,164,48Zm23.12,100.86a35.3,35.3,0,0,1-16.87-21.14,44,44,0,0,0-84.5,0A35.25,35.25,0,0,1,69,148.82,40,40,0,0,0,88,224a39.48,39.48,0,0,0,15.52-3.13,64.09,64.09,0,0,1,48.87,0,40,40,0,0,0,34.73-72ZM168,208a24,24,0,0,1-9.45-1.93,80.14,80.14,0,0,0-61.19,0,24,24,0,0,1-20.71-43.26,51.22,51.22,0,0,0,24.46-30.67,28,28,0,0,1,53.78,0,51.27,51.27,0,0,0,24.53,30.71A24,24,0,0,1,168,208Z"/></symbol>
<symbol id="pencil-simple" viewBox="0 0 256 256"><path d="M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM92.69,208H48V163.31l88-88L180.69,120ZM192,108.68,147.31,64l24-24L216,84.68Z"/></symbol>
<symbol id="plus" viewBox="0 0 256 256"><path d="M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z"/></symbol>
<symbol id="spinner" viewBox="0 0 256 256"><path d="M136,32V64a8,8,0,0,1-16,0V32a8,8,0,0,1,16,0Zm37.25,58.75a8,8,0,0,0,5.66-2.35l22.63-22.62a8,8,0,0,0-11.32-11.32L167.6,77.09a8,8,0,0,0,5.65,13.66ZM224,120H192a8,8,0,0,0,0,16h32a8,8,0,0,0,0-16Zm-45.09,47.6a8,8,0,0,0-11.31,11.31l22.62,22.63a8,8,0,0,0,11.32-11.32ZM128,184a8,8,0,0,0-8,8v32a8,8,0,0,0,16,0V192A8,8,0,0,0,128,184ZM77.09,167.6,54.46,190.22a8,8,0,0,0,11.32,11.32L88.4,178.91A8,8,0,0,0,77.09,167.6ZM72,128a8,8,0,0,0-8-8H32a8,8,0,0,0,0,16H64A8,8,0,0,0,72,128ZM65.78,54.46A8,8,0,0,0,54.46,65.78L77.09,88.4A8,8,0,0,0,88.4,77.09Z"/></symbol>
<symbol id="star" viewBox="0 0 256 256"><path d="M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Zm-15.34,5.47-48.7,42a8,8,0,0,0-2.56,7.91l14.88,62.8a.37.37,0,0,1-.17.48c-.18.14-.23.11-.38,0l-54.72-33.65a8,8,0,0,0-8.38,0L69.09,215.94c-.15.09-.19.12-.38,0a.37.37,0,0,1-.17-.48l14.88-62.8a8,8,0,0,0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16A8,8,0,0,0,103,91.86l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153,91.86a8,8,0,0,0,6.75,4.92l63.92,5.16c.15,0,.24,0,.33.29S224,102.63,223.84,102.73Z"/></symbol>
<symbol id="syringe" viewBox="0 0 256 256"><path d="M237.66,66.34l-48-48a8,8,0,0,0-11.32,11.32L196.69,48,168,76.69,133.66,42.34a8,8,0,0,0-11.32,11.32L128.69,60l-84,84A15.86,15.86,0,0,0,40,155.31v49.38L18.34,226.34a8,8,0,0,0,11.32,11.32L51.31,216h49.38A15.86,15.86,0,0,0,112,211.31l84-84,6.34,6.35a8,8,0,0,0,11.32-11.32L179.31,88,208,59.31l18.34,18.35a8,8,0,0,0,11.32-11.32ZM100.69,200H56V155.31l18-18,20.34,20.35a8,8,0,0,0,11.32-11.32L85.31,126,98,113.31l20.34,20.35a8,8,0,0,0,11.32-11.32L109.31,102,140,71.31,184.69,116Z"/></symbol>
<symbol id="trash" viewBox="0 0 256 256"><path d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z"/></symbol>
<symbol id="upload" viewBox="0 0 256 256"><path d="M240,136v64a16,16,0,0,1-16,16H32a16,16,0,0,1-16-16V136a16,16,0,0,1,16-16H80a8,8,0,0,1,0,16H32v64H224V136H176a8,8,0,0,1,0-16h48A16,16,0,0,1,240,136ZM85.66,77.66,120,43.31V128a8,8,0,0,0,16,0V43.31l34.34,34.35a8,8,0,0,0,11.32-11.32l-48-48a8,8,0,0,0-11.32,0l-48,48A8,8,0,0,0,85.66,77.66ZM200,168a12,12,0,1,0-12,12A12,12,0,0,0,200,168Z"/></symbol>
<symbol id="user" viewBox="0 0 256 256"><path d="M230.92,212c-15.23-26.33-38.7-45.21-66.09-54.16a72,72,0,1,0-73.66,0C63.78,166.78,40.31,185.66,25.08,212a8,8,0,1,0,13.85,8c18.84-32.56,52.14-52,89.07-52s70.23,19.44,89.07,52a8,8,0,1,0,13.85-8ZM72,96a56,56,0,1,1,56,56A56.06,56.06,0,0,1,72,96Z"/></symbol>
<symbol id="warning-octagon" viewBox="0 0 256 256"><path d="M120,136V80a8,8,0,0,1,16,0v56a8,8,0,0,1-16,0ZM232,91.55v72.9a15.86,15.86,0,0,1-4.69,11.31l-51.55,51.55A15.86,15.86,0,0,1,164.45,232H91.55a15.86,15.86,0,0,1-11.31-4.69L28.69,175.76A15.86,15.86,0,0,1,24,164.45V91.55a15.86,15.86,0,0,1,4.69-11.31L80.24,28.69A15.86,15.86,0,0,1,91.55,24h72.9a15.86,15.86,0,0,1,11.31,4.69l51.55,51.55A15.86,15.86,0,0,1,232,91.55Zm-16,0L164.45,40H91.55L40,91.55v72.9L91.55,216h72.9L216,164.45ZM128,160a12,12,0,1,0,12,12A12,12,0,0,0,128,160Z"/></symbol>
<symbol id="warning" viewBox="0 0 256 256"><path d="M236.8,188.09,149.35,36.22h0a24.76,24.76,0,0,0-42.7,0L19.2,188.09a23.51,23.51,0,0,0,0,23.72A24.35,24.35,0,0,0,40.55,224h174.9a24.35,24.35,0,0,0,21.33-12.19A23.51,23.51,0,0,0,236.8,188.09ZM222.93,203.8a8.5,8.5,0,0,1-7.48,4.2H40.55a8.5,8.5,0,0,1-7.48-4.2,7.59,7.59,0,0,1,0-7.72L120.52,44.21a8.75,8.75,0,0,1,15,0l87.45,151.87A7.59,7.59,0,0,1,222.93,203.8ZM120,144V104a8,8,0,0,1,16,0v40a8,8,0,0,1-16,0Zm20,36a12,12,0,1,1-12-12A12,12,0,0,1,140,180Z"/></symbol>
<symbol id="x" viewBox="0 0 256 256"><path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"/></symbol>
<symbol id="info" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/></symbol>
<symbol id="download-simple" viewBox="0 0 256 256"><path d="M224,144v64a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V144a8,8,0,0,1,16,0v56H208V144a8,8,0,0,1,16,0Zm-101.66,5.66a8,8,0,0,0,11.32,0l40-40a8,8,0,0,0-11.32-11.32L136,124.69V32a8,8,0,0,0-16,0v92.69L93.66,98.34a8,8,0,0,0-11.32,11.32Z"/></symbol>
<symbol id="floppy-disk" viewBox="0 0 256 256"><path d="M219.31,72,184,36.69A15.86,15.86,0,0,0,172.69,32H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V83.31A15.86,15.86,0,0,0,219.31,72ZM168,208H88V152h80Zm40,0H184V152a16,16,0,0,0-16-16H88a16,16,0,0,0-16,16v56H48V48H172.69L208,83.31ZM160,72a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h56A8,8,0,0,1,160,72Z"/></symbol>
<symbol id="arrow-square-out" viewBox="0 0 256 256"><path d="M224,104a8,8,0,0,1-16,0V59.32l-66.33,66.34a8,8,0,0,1-11.32-11.32L196.68,48H152a8,8,0,0,1,0-16h64a8,8,0,0,1,8,8Zm-40,24a8,8,0,0,0-8,8v72H48V80h72a8,8,0,0,0,0-16H48A16,16,0,0,0,32,80V208a16,16,0,0,0,16,16H176a16,16,0,0,0,16-16V136A8,8,0,0,0,184,128Z"/></symbol>
<symbol id="sign-out" viewBox="0 0 256 256"><path d="M120,216a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V40a8,8,0,0,1,8-8h64a8,8,0,0,1,0,16H56V208h56A8,8,0,0,1,120,216Zm109.66-93.66-40-40a8,8,0,0,0-11.32,11.32L204.69,120H112a8,8,0,0,0,0,16h92.69l-26.35,26.34a8,8,0,0,0,11.32,11.32l40-40A8,8,0,0,0,229.66,122.34Z"/></symbol>
<symbol id="lock" viewBox="0 0 256 256"><path d="M208,80H176V56a48,48,0,0,0-96,0V80H48A16,16,0,0,0,32,96V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V96A16,16,0,0,0,208,80ZM96,56a32,32,0,0,1,64,0V80H96ZM208,208H48V96H208V208Zm-68-56a12,12,0,1,1-12-12A12,12,0,0,1,140,152Z"/></symbol>
<symbol id="users" viewBox="0 0 256 256"><path d="M117.25,157.92a60,60,0,1,0-66.5,0A95.83,95.83,0,0,0,3.53,195.63a8,8,0,1,0,13.4,8.74,80,80,0,0,1,134.14,0,8,8,0,0,0,13.4-8.74A95.83,95.83,0,0,0,117.25,157.92ZM40,108a44,44,0,1,1,44,44A44.05,44.05,0,0,1,40,108Zm210.14,98.7a8,8,0,0,1-11.07-2.33A79.83,79.83,0,0,0,172,168a8,8,0,0,1,0-16,44,44,0,1,0-16.34-84.87,8,8,0,1,1-5.94-14.85,60,60,0,0,1,55.53,105.64,95.83,95.83,0,0,1,47.22,37.71A8,8,0,0,1,250.14,206.7Z"/></symbol>
<symbol id="push-pin" viewBox="0 0 256 256"><path d="M235.32,81.37,174.63,20.69a16,16,0,0,0-22.63,0L98.37,74.49c-10.66-3.34-35-7.37-60.4,13.14a16,16,0,0,0-1.29,23.78L85,159.71,42.34,202.34a8,8,0,0,0,11.32,11.32L96.29,171l48.29,48.29A16,16,0,0,0,155.9,224c.38,0,.75,0,1.13,0a15.93,15.93,0,0,0,11.64-6.33c19.64-26.1,17.75-47.32,13.19-60L235.33,104A16,16,0,0,0,235.32,81.37ZM224,92.69h0l-57.27,57.46a8,8,0,0,0-1.49,9.22c9.46,18.93-1.8,38.59-9.34,48.62L48,100.08c12.08-9.74,23.64-12.31,32.48-12.31A40.13,40.13,0,0,1,96.81,91a8,8,0,0,0,9.25-1.51L163.32,32,224,92.68Z"/></symbol>
<symbol id="clock" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm64-88a8,8,0,0,1-8,8H128a8,8,0,0,1-8-8V72a8,8,0,0,1,16,0v48h48A8,8,0,0,1,192,128Z"/></symbol>
<symbol id="fork-knife" viewBox="0 0 256 256"><path d="M72,88V40a8,8,0,0,1,16,0V88a8,8,0,0,1-16,0ZM216,40V224a8,8,0,0,1-16,0V176H152a8,8,0,0,1-8-8,268.75,268.75,0,0,1,7.22-56.88c9.78-40.49,28.32-67.63,53.63-78.47A8,8,0,0,1,216,40ZM200,53.9c-32.17,24.57-38.47,84.42-39.7,106.1H200ZM119.89,38.69a8,8,0,1,0-15.78,2.63L112,88.63a32,32,0,0,1-64,0l7.88-47.31a8,8,0,1,0-15.78-2.63l-8,48A8.17,8.17,0,0,0,32,88a48.07,48.07,0,0,0,40,47.32V224a8,8,0,0,0,16,0V135.32A48.07,48.07,0,0,0,128,88a8.17,8.17,0,0,0-.11-1.31Z"/></symbol>
<symbol id="shopping-cart" viewBox="0 0 256 256"><path d="M230.14,58.87A8,8,0,0,0,224,56H62.68L56.6,22.57A8,8,0,0,0,48.73,16H24a8,8,0,0,0,0,16h18L67.56,172.29a24,24,0,0,0,5.33,11.27,28,28,0,1,0,44.4,8.44h45.42A27.75,27.75,0,0,0,160,204a28,28,0,1,0,28-28H91.17a8,8,0,0,1-7.87-6.57L80.13,152h116a24,24,0,0,0,23.61-19.71l12.16-66.86A8,8,0,0,0,230.14,58.87ZM104,204a12,12,0,1,1-12-12A12,12,0,0,1,104,204Zm96,0a12,12,0,1,1-12-12A12,12,0,0,1,200,204Zm4-74.57A8,8,0,0,1,196.1,136H77.22L65.59,72H214.41Z"/></symbol>
<symbol id="first-aid" viewBox="0 0 256 256"><path d="M216,88H168V40a16,16,0,0,0-16-16H104A16,16,0,0,0,88,40V88H40a16,16,0,0,0-16,16v48a16,16,0,0,0,16,16H88v48a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V168h48a16,16,0,0,0,16-16V104A16,16,0,0,0,216,88Zm0,64H160a8,8,0,0,0-8,8v56H104V160a8,8,0,0,0-8-8H40V104H96a8,8,0,0,0,8-8V40h48V96a8,8,0,0,0,8,8h56Z"/></symbol>
<symbol id="graduation-cap" viewBox="0 0 256 256"><path d="M251.76,88.94l-120-64a8,8,0,0,0-7.52,0l-120,64a8,8,0,0,0,0,14.12L32,117.87v48.42a15.91,15.91,0,0,0,4.06,10.65C49.16,191.53,78.51,216,128,216a130,130,0,0,0,48-8.76V240a8,8,0,0,0,16,0V199.51a115.63,115.63,0,0,0,27.94-22.57A15.91,15.91,0,0,0,224,166.29V117.87l27.76-14.81a8,8,0,0,0,0-14.12ZM128,200c-43.27,0-68.72-21.14-80-33.71V126.4l76.24,40.66a8,8,0,0,0,7.52,0L176,143.47v46.34C163.4,195.69,147.52,200,128,200Zm80-33.75a97.83,97.83,0,0,1-16,14.25V134.93l16-8.53ZM188,118.94l-.22-.13-56-29.87a8,8,0,0,0-7.52,14.12L171,128l-43,22.93L25,96,128,41.07,231,96Z"/></symbol>
<symbol id="flag" viewBox="0 0 256 256"><path d="M42.76,50A8,8,0,0,0,40,56V224a8,8,0,0,0,16,0V179.77c26.79-21.16,49.87-9.75,76.45,3.41,16.4,8.11,34.06,16.85,53,16.85,13.93,0,28.54-4.75,43.82-18a8,8,0,0,0,2.76-6V56A8,8,0,0,0,218.76,50c-28,24.23-51.72,12.49-79.21-1.12C111.07,34.76,78.78,18.79,42.76,50ZM216,172.25c-26.79,21.16-49.87,9.74-76.45-3.41-25-12.35-52.81-26.13-83.55-8.4V59.79c26.79-21.16,49.87-9.75,76.45,3.4,25,12.35,52.82,26.13,83.55,8.4Z"/></symbol>
<symbol id="trophy" viewBox="0 0 256 256"><path d="M232,64H208V48a8,8,0,0,0-8-8H56a8,8,0,0,0-8,8V64H24A16,16,0,0,0,8,80V96a40,40,0,0,0,40,40h3.65A80.13,80.13,0,0,0,120,191.61V216H96a8,8,0,0,0,0,16h64a8,8,0,0,0,0-16H136V191.58c31.94-3.23,58.44-25.64,68.08-55.58H208a40,40,0,0,0,40-40V80A16,16,0,0,0,232,64ZM48,120A24,24,0,0,1,24,96V80H48v32q0,4,.39,8Zm144-8.9c0,35.52-29,64.64-64,64.9a64,64,0,0,1-64-64V56H192ZM232,96a24,24,0,0,1-24,24h-.5a81.81,81.81,0,0,0,.5-8.9V80h24Z"/></symbol>
<symbol id="scales" viewBox="0 0 256 256"><path d="M239.43,133l-32-80h0a8,8,0,0,0-9.16-4.84L136,62V40a8,8,0,0,0-16,0V65.58L54.26,80.19A8,8,0,0,0,48.57,85h0v.06L16.57,165a7.92,7.92,0,0,0-.57,3c0,23.31,24.54,32,40,32s40-8.69,40-32a7.92,7.92,0,0,0-.57-3L66.92,93.77,120,82V208H104a8,8,0,0,0,0,16h48a8,8,0,0,0,0-16H136V78.42L187,67.1,160.57,133a7.92,7.92,0,0,0-.57,3c0,23.31,24.54,32,40,32s40-8.69,40-32A7.92,7.92,0,0,0,239.43,133ZM56,184c-7.53,0-22.76-3.61-23.93-14.64L56,109.54l23.93,59.82C78.76,180.39,63.53,184,56,184Zm144-32c-7.53,0-22.76-3.61-23.93-14.64L200,77.54l23.93,59.82C222.76,148.39,207.53,152,200,152Z"/></symbol>
<symbol id="leaf" viewBox="0 0 256 256"><path d="M223.45,40.07a8,8,0,0,0-7.52-7.52C139.8,28.08,78.82,51,52.82,94a87.09,87.09,0,0,0-12.76,49c.57,15.92,5.21,32,13.79,47.85l-19.51,19.5a8,8,0,0,0,11.32,11.32l19.5-19.51C81,210.73,97.09,215.37,113,215.94q1.67.06,3.33.06A86.93,86.93,0,0,0,162,203.18C205,177.18,227.93,116.21,223.45,40.07ZM153.75,189.5c-22.75,13.78-49.68,14-76.71.77l88.63-88.62a8,8,0,0,0-11.32-11.32L65.73,179c-13.19-27-13-54,.77-76.71,22.09-36.47,74.6-56.44,141.31-54.06C210.2,114.89,190.22,167.41,153.75,189.5Z"/></symbol>
<symbol id="file-text" viewBox="0 0 256 256"><path d="M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM200,216H56V40h88V88a8,8,0,0,0,8,8h48V216Zm-32-80a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,136Zm0,32a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,168Z"/></symbol>
<symbol id="sun" viewBox="0 0 256 256"><path d="M120,40V16a8,8,0,0,1,16,0V40a8,8,0,0,1-16,0Zm72,88a64,64,0,1,1-64-64A64.07,64.07,0,0,1,192,128Zm-16,0a48,48,0,1,0-48,48A48.05,48.05,0,0,0,176,128ZM58.34,69.66A8,8,0,0,0,69.66,58.34l-16-16A8,8,0,0,0,42.34,53.66Zm0,116.68-16,16a8,8,0,0,0,11.32,11.32l16-16a8,8,0,0,0-11.32-11.32ZM192,72a8,8,0,0,0,5.66-2.34l16-16a8,8,0,0,0-11.32-11.32l-16,16A8,8,0,0,0,192,72Zm5.66,114.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32-11.32ZM48,128a8,8,0,0,0-8-8H16a8,8,0,0,0,0,16H40A8,8,0,0,0,48,128Zm80,80a8,8,0,0,0-8,8v24a8,8,0,0,0,16,0V216A8,8,0,0,0,128,208Zm112-88H216a8,8,0,0,0,0,16h24a8,8,0,0,0,0-16Z"/></symbol>
<symbol id="moon" viewBox="0 0 256 256"><path d="M233.54,142.23a8,8,0,0,0-8-2,88.08,88.08,0,0,1-109.8-109.8,8,8,0,0,0-10-10,104.84,104.84,0,0,0-52.91,37A104,104,0,0,0,136,224a103.09,103.09,0,0,0,62.52-20.88,104.84,104.84,0,0,0,37-52.91A8,8,0,0,0,233.54,142.23ZM188.9,190.34A88,88,0,0,1,65.66,67.11a89,89,0,0,1,31.4-26A106,106,0,0,0,96,56,104.11,104.11,0,0,0,200,160a106,106,0,0,0,14.92-1.06A89,89,0,0,1,188.9,190.34Z"/></symbol>
<symbol id="shield" viewBox="0 0 256 256"><path d="M208,40H48A16,16,0,0,0,32,56v56c0,52.72,25.52,84.67,46.93,102.19,23.06,18.86,46,25.27,47,25.53a8,8,0,0,0,4.2,0c1-.26,23.91-6.67,47-25.53C198.48,196.67,224,164.72,224,112V56A16,16,0,0,0,208,40Zm0,72c0,37.07-13.66,67.16-40.6,89.42A129.3,129.3,0,0,1,128,223.62a128.25,128.25,0,0,1-38.92-21.81C61.82,179.51,48,149.3,48,112l0-56,160,0Z"/></symbol>
<symbol id="robot" viewBox="0 0 256 256"><path d="M200,48H136V16a8,8,0,0,0-16,0V48H56A32,32,0,0,0,24,80V192a32,32,0,0,0,32,32H200a32,32,0,0,0,32-32V80A32,32,0,0,0,200,48Zm16,144a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V80A16,16,0,0,1,56,64H200a16,16,0,0,1,16,16Zm-52-56H92a28,28,0,0,0,0,56h72a28,28,0,0,0,0-56Zm-24,16v24H116V152ZM80,164a12,12,0,0,1,12-12h8v24H92A12,12,0,0,1,80,164Zm84,12h-8V152h8a12,12,0,0,1,0,24ZM72,108a12,12,0,1,1,12,12A12,12,0,0,1,72,108Zm88,0a12,12,0,1,1,12,12A12,12,0,0,1,160,108Z"/></symbol>
<symbol id="tag" viewBox="0 0 256 256"><path d="M243.31,136,144,36.69A15.86,15.86,0,0,0,132.69,32H40a8,8,0,0,0-8,8v92.69A15.86,15.86,0,0,0,36.69,144L136,243.31a16,16,0,0,0,22.63,0l84.68-84.68a16,16,0,0,0,0-22.63Zm-96,96L48,132.69V48h84.69L232,147.31ZM96,84A12,12,0,1,1,84,72,12,12,0,0,1,96,84Z"/></symbol>
<symbol id="clipboard-text" viewBox="0 0 256 256"><path d="M168,152a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,152Zm-8-40H96a8,8,0,0,0,0,16h64a8,8,0,0,0,0-16Zm56-64V216a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V48A16,16,0,0,1,56,32H92.26a47.92,47.92,0,0,1,71.48,0H200A16,16,0,0,1,216,48ZM96,64h64a32,32,0,0,0-64,0ZM200,48H173.25A47.93,47.93,0,0,1,176,64v8a8,8,0,0,1-8,8H88a8,8,0,0,1-8-8V64a47.93,47.93,0,0,1,2.75-16H56V216H200Z"/></symbol>
<symbol id="drop" viewBox="0 0 256 256"><path d="M174,47.75a254.19,254.19,0,0,0-41.45-38.3,8,8,0,0,0-9.18,0A254.19,254.19,0,0,0,82,47.75C54.51,79.32,40,112.6,40,144a88,88,0,0,0,176,0C216,112.6,201.49,79.32,174,47.75ZM128,216a72.08,72.08,0,0,1-72-72c0-57.23,55.47-105,72-118,16.53,13,72,60.75,72,118A72.08,72.08,0,0,1,128,216Zm55.89-62.66a57.6,57.6,0,0,1-46.56,46.55A8.75,8.75,0,0,1,136,200a8,8,0,0,1-1.32-15.89c16.57-2.79,30.63-16.85,33.44-33.45a8,8,0,0,1,15.78,2.68Z"/></symbol>
<symbol id="target" viewBox="0 0 256 256"><path d="M221.87,83.16A104.1,104.1,0,1,1,195.67,49l22.67-22.68a8,8,0,0,1,11.32,11.32l-96,96a8,8,0,0,1-11.32-11.32l27.72-27.72a40,40,0,1,0,17.87,31.09,8,8,0,1,1,16-.9,56,56,0,1,1-22.38-41.65L184.3,60.39a87.88,87.88,0,1,0,23.13,29.67,8,8,0,0,1,14.44-6.9Z"/></symbol>
<symbol id="car" viewBox="0 0 256 256"><path d="M240,104H229.2L201.42,41.5A16,16,0,0,0,186.8,32H69.2a16,16,0,0,0-14.62,9.5L26.8,104H16a8,8,0,0,0,0,16h8v80a16,16,0,0,0,16,16H64a16,16,0,0,0,16-16V184h96v16a16,16,0,0,0,16,16h24a16,16,0,0,0,16-16V120h8a8,8,0,0,0,0-16ZM69.2,48H186.8l24.89,56H44.31ZM64,200H40V184H64Zm128,0V184h24v16Zm24-32H40V120H216ZM56,144a8,8,0,0,1,8-8H80a8,8,0,0,1,0,16H64A8,8,0,0,1,56,144Zm112,0a8,8,0,0,1,8-8h16a8,8,0,0,1,0,16H176A8,8,0,0,1,168,144Z"/></symbol>
<symbol id="skull" viewBox="0 0 256 256"><path d="M92,104a28,28,0,1,0,28,28A28,28,0,0,0,92,104Zm0,40a12,12,0,1,1,12-12A12,12,0,0,1,92,144Zm72-40a28,28,0,1,0,28,28A28,28,0,0,0,164,104Zm0,40a12,12,0,1,1,12-12A12,12,0,0,1,164,144ZM128,16C70.65,16,24,60.86,24,116c0,34.1,18.27,66,48,84.28V216a16,16,0,0,0,16,16h80a16,16,0,0,0,16-16V200.28C213.73,182,232,150.1,232,116,232,60.86,185.35,16,128,16Zm44.12,172.69a8,8,0,0,0-4.12,7V216H152V192a8,8,0,0,0-16,0v24H120V192a8,8,0,0,0-16,0v24H88V195.69a8,8,0,0,0-4.12-7C56.81,173.69,40,145.84,40,116c0-46.32,39.48-84,88-84s88,37.68,88,84C216,145.83,199.19,173.69,172.12,188.69Z"/></symbol>
<symbol id="gender-male" viewBox="0 0 256 256"><path d="M216,32H168a8,8,0,0,0,0,16h28.69L154.62,90.07a80,80,0,1,0,11.31,11.31L208,59.32V88a8,8,0,0,0,16,0V40A8,8,0,0,0,216,32ZM149.24,197.29a64,64,0,1,1,0-90.53A64.1,64.1,0,0,1,149.24,197.29Z"/></symbol>
<symbol id="gender-female" viewBox="0 0 256 256"><path d="M208,96a80,80,0,1,0-88,79.6V200H88a8,8,0,0,0,0,16h32v24a8,8,0,0,0,16,0V216h32a8,8,0,0,0,0-16H136V175.6A80.11,80.11,0,0,0,208,96ZM64,96a64,64,0,1,1,64,64A64.07,64.07,0,0,1,64,96Z"/></symbol>
<symbol id="chair" viewBox="0 0 256 256"><path d="M208,136H176V104h16a16,16,0,0,0,16-16V40a16,16,0,0,0-16-16H64A16,16,0,0,0,48,40V88a16,16,0,0,0,16,16H80v32H48a16,16,0,0,0-16,16v16a16,16,0,0,0,16,16h8v40a8,8,0,0,0,16,0V184H184v40a8,8,0,0,0,16,0V184h8a16,16,0,0,0,16-16V152A16,16,0,0,0,208,136ZM64,40H192V88H64Zm32,64h64v32H96Zm112,64H48V152H208v16Z"/></symbol>
<symbol id="person-simple-run" viewBox="0 0 256 256"><path d="M152,88a32,32,0,1,0-32-32A32,32,0,0,0,152,88Zm0-48a16,16,0,1,1-16,16A16,16,0,0,1,152,40Zm67.31,100.68c-.61.28-7.49,3.28-19.67,3.28-13.85,0-34.55-3.88-60.69-20a169.31,169.31,0,0,1-15.41,32.34,104.29,104.29,0,0,1,31.31,15.81C173.92,186.65,184,207.35,184,232a8,8,0,0,1-16,0c0-41.7-34.69-56.71-54.14-61.85-.55.7-1.12,1.41-1.69,2.1-19.64,23.8-44.25,36.18-71.63,36.18A92.29,92.29,0,0,1,31.2,208,8,8,0,0,1,32.8,192c25.92,2.58,48.47-7.49,67-30,12.49-15.14,21-33.61,25.25-47C86.13,92.35,61.27,111.63,61,111.84A8,8,0,1,1,51,99.36c1.5-1.2,37.22-29,89.51,6.57,45.47,30.91,71.93,20.31,72.18,20.19a8,8,0,1,1,6.63,14.56Z"/></symbol>
<symbol id="pencil" viewBox="0 0 256 256"><path d="M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM51.31,160,136,75.31,152.69,92,68,176.68ZM48,179.31,76.69,208H48Zm48,25.38L79.31,188,164,103.31,180.69,120Zm96-96L147.31,64l24-24L216,84.68Z"/></symbol>
<symbol id="user-minus" viewBox="0 0 256 256"><path d="M256,136a8,8,0,0,1-8,8H200a8,8,0,0,1,0-16h48A8,8,0,0,1,256,136ZM152,80a48,48,0,1,0-48,48A48.05,48.05,0,0,0,152,80Zm-80,0a32,32,0,1,1,32,32A32,32,0,0,1,72,80Zm112,96H16a8,8,0,0,0-8,8c0,44.18,39.4,80,88,80s88-35.82,88-80A8,8,0,0,0,184,176Zm-88,72c-38.46,0-70.27-26.54-71.87-60H167.87C166.27,221.46,134.46,248,96,248Z"/></symbol>
<symbol id="user-plus" viewBox="0 0 256 256"><path d="M256,136a8,8,0,0,1-8,8H232v16a8,8,0,0,1-16,0V144H200a8,8,0,0,1,0-16h16V112a8,8,0,0,1,16,0v16h16A8,8,0,0,1,256,136ZM152,80a48,48,0,1,0-48,48A48.05,48.05,0,0,0,152,80Zm-80,0a32,32,0,1,1,32,32A32,32,0,0,1,72,80Zm112,96H16a8,8,0,0,0-8,8c0,44.18,39.4,80,88,80s88-35.82,88-80A8,8,0,0,0,184,176Zm-88,72c-38.46,0-70.27-26.54-71.87-60H167.87C166.27,221.46,134.46,248,96,248Z"/></symbol>
<symbol id="paper-plane-tilt" viewBox="0 0 256 256"><path d="M231.4,44.34a8,8,0,0,0-7.69-2L28.16,108a8,8,0,0,0-.53,15.09l73.5,24.5,24.5,73.5A8,8,0,0,0,133.23,227c.27,0,.53,0,.8,0a8,8,0,0,0,6.92-4L231.82,52A8,8,0,0,0,231.4,44.34Zm-97.19,154.2L115,143.61l46.2-46.2-11.32-11.31-46.2,46.2L48.47,113.84l155.7-51.72Z"/></symbol>
</svg>

After

Width:  |  Height:  |  Size: 33 KiB

View file

@ -22,8 +22,8 @@
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/layout.css?v=62">
<link rel="stylesheet" href="/css/components.css?v=62">
<link rel="stylesheet" href="/css/layout.css?v=65">
<link rel="stylesheet" href="/css/components.css?v=65">
</head>
<body>
@ -43,50 +43,64 @@
<div class="sidebar-nav">
<span class="sidebar-section-label">Mein Hund</span>
<div class="sidebar-item active" data-page="diary">
<span class="sidebar-item-icon">📖</span> Tagebuch
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg> Tagebuch
</div>
<div class="sidebar-item" data-page="health">
<span class="sidebar-item-icon">💉</span> Gesundheit
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg> Gesundheit
</div>
<span class="sidebar-section-label">Entdecken</span>
<div class="sidebar-item" data-page="map">
<span class="sidebar-item-icon">🗺️</span> Karte
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-trifold"></use></svg> Karte
</div>
<div class="sidebar-item" data-page="routes">
<span class="sidebar-item-icon">🥾</span> Routen
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg> Routen
</div>
<div class="sidebar-item" data-page="events">
<span class="sidebar-item-icon">🎯</span> Events
<div class="sidebar-item" data-page="events">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-dots"></use></svg> Events
</div>
<span class="sidebar-section-label">Soziales</span>
<div class="sidebar-item" data-page="friends">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#users"></use></svg> Freunde
<span class="sidebar-item-badge" id="friends-badge" style="display:none">0</span>
</div>
<div class="sidebar-item" data-page="chat">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#chat-circle-dots"></use></svg> Nachrichten
<span class="sidebar-item-badge" id="chat-badge" style="display:none">0</span>
</div>
<span class="sidebar-section-label">Community</span>
<div class="sidebar-item" data-page="poison">
<span class="sidebar-item-icon">⚠️</span> Giftköder
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning-octagon"></use></svg> Giftköder
<span class="sidebar-item-badge" id="poison-badge" style="display:none">0</span>
</div>
<div class="sidebar-item" data-page="walks">
<span class="sidebar-item-icon">🦮</span> Gassi-Treffen
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Gassi-Treffen
</div>
<div class="sidebar-item" data-page="sitting">
<span class="sidebar-item-icon">🏠</span> Sitting
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#house-line"></use></svg> Sitting
</div>
<div class="sidebar-item" data-page="forum">
<span class="sidebar-item-icon">💬</span> Forum
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#chat-circle-dots"></use></svg> Forum
</div>
<div class="sidebar-item" data-page="lost">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg> Verlorener Hund
<span class="sidebar-item-badge" id="lost-badge" style="display:none">0</span>
</div>
<span class="sidebar-section-label">Wissen</span>
<div class="sidebar-item" data-page="wiki">
<span class="sidebar-item-icon">📚</span> Wiki
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#books"></use></svg> Wiki
</div>
<div class="sidebar-item" data-page="knigge">
<span class="sidebar-item-icon">🤝</span> Knigge
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg> Knigge
</div>
<div class="sidebar-item" data-page="movies">
<span class="sidebar-item-icon">🎬</span> Filme
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#film-slate"></use></svg> Filme
</div>
<div class="sidebar-item sidebar-item--user" id="sidebar-user">
<span class="sidebar-item-icon">👤</span>
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user"></use></svg>
<span id="sidebar-username">Anmelden</span>
</div>
</div>
@ -99,12 +113,12 @@
<!-- MOBILE HEADER -->
<header id="app-header">
<button class="header-back hidden" id="header-back" aria-label="Zurück">&#8592;</button>
<button class="header-back hidden" id="header-back" aria-label="Zurück"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#arrow-left"></use></svg></button>
<div id="header-dog-switcher" class="dog-switcher">
<span class="header-title" id="header-title">Ban Yaro</span>
</div>
<div id="header-actions"></div>
<button class="header-menu-btn" id="header-menu-btn" aria-label="Menü"></button>
<button class="header-menu-btn" id="header-menu-btn" aria-label="Menü"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#list"></use></svg></button>
</header>
<!-- HAUPT-INHALTSBEREICH -->
@ -165,35 +179,47 @@
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-lost">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-settings">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-friends">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-chat">
<div class="page-body page-container-chat"></div>
</section>
</main>
<!-- MOBILE BOTTOM NAVIGATION -->
<nav id="bottom-nav" role="navigation" aria-label="Hauptnavigation">
<div class="nav-item active" data-page="diary">
<span class="nav-item-icon">📖</span>
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>
<span class="nav-item-label">Tagebuch</span>
</div>
<div class="nav-item" data-page="health">
<span class="nav-item-icon">💉</span>
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>
<span class="nav-item-label">Gesundheit</span>
</div>
<!-- Mittlerer + Button -->
<div class="nav-item nav-item-center" id="nav-add">
<span class="nav-item-icon">+</span>
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#plus"></use></svg>
</div>
<div class="nav-item" data-page="poison">
<span class="nav-item-icon">
⚠️
<span style="position:relative;display:inline-flex">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning-octagon"></use></svg>
<span class="nav-badge hidden" id="poison-nav-badge">0</span>
</span>
<span class="nav-item-label">Alarm</span>
</div>
<div class="nav-item" data-page="settings">
<span class="nav-item-icon">👤</span>
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gear"></use></svg>
<span class="nav-item-label">Ich</span>
</div>
</nav>
@ -207,9 +233,9 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=62"></script>
<script src="/js/ui.js?v=62"></script>
<script src="/js/app.js?v=62"></script>
<script src="/js/api.js?v=65"></script>
<script src="/js/ui.js?v=65"></script>
<script src="/js/app.js?v=65"></script>
<!-- Feature-Seiten werden lazy geladen -->

View file

@ -248,6 +248,73 @@ const API = (() => {
updateRequest(id, status) { return patch(`/sitting/requests/${id}`, { status }); },
};
// ----------------------------------------------------------
// FORUM
// ----------------------------------------------------------
const forum = {
threads(params = {}) {
const q = new URLSearchParams(params).toString();
return get(`/forum/threads${q ? '?' + q : ''}`);
},
thread(id) { return get(`/forum/threads/${id}`); },
create(data) { return post('/forum/threads', data); },
deleteThread(id) { return del(`/forum/threads/${id}`); },
patchThread(id, data) { return patch(`/forum/threads/${id}`, data); },
addPost(threadId, data){ return post(`/forum/threads/${threadId}/posts`, data); },
deletePost(id) { return del(`/forum/posts/${id}`); },
uploadThreadFoto(id, file) {
const fd = new FormData(); fd.append('file', file);
return upload(`/forum/threads/${id}/fotos`, fd);
},
uploadPostFoto(id, file) {
const fd = new FormData(); fd.append('file', file);
return upload(`/forum/posts/${id}/fotos`, fd);
},
like(targetType, targetId) {
return post('/forum/like', { target_type: targetType, target_id: targetId });
},
report(targetType, targetId, grund) {
return post('/forum/report', { target_type: targetType, target_id: targetId, grund });
},
reports() { return get('/forum/reports'); },
resolveReport(id) { return patch(`/forum/reports/${id}`, { resolved: 1 }); },
membersMap() { return get('/forum/members/map'); },
setLocation(lat, lon, show) {
return patch('/forum/members/location', { lat, lon, show });
},
search(q) { return get(`/forum/search?q=${encodeURIComponent(q)}`); },
// Legacy aliases (keep old names working)
listThreads(params = {}) { return forum.threads(params); },
getThread(id) { return forum.thread(id); },
createThread(data) { return forum.create(data); },
createPost(tid, data) { return forum.addPost(tid, data); },
};
// ----------------------------------------------------------
// VERLORENER HUND
// ----------------------------------------------------------
const lost = {
list(lat = null, lon = null, radius_km = 25) {
const params = new URLSearchParams({ radius_km });
if (lat !== null) { params.set('lat', lat); params.set('lon', lon); }
return get(`/lost?${params}`);
},
report(data) { return post('/lost', data); },
uploadFoto(id, form) { return upload(`/lost/${id}/foto`, form); },
markFound(id) { return post(`/lost/${id}/found`); },
delete(id) { return del(`/lost/${id}`); },
};
// ----------------------------------------------------------
// KNIGGE
// ----------------------------------------------------------
const knigge = {
vote: (szenario_id, answer) => post('/knigge/vote', { szenario_id, answer }),
votes: (szenario_id) => get(`/knigge/votes?szenario_id=${encodeURIComponent(szenario_id)}`),
kiRat: (situation) => post('/knigge/ki-rat', { situation }),
};
// ----------------------------------------------------------
// WETTER
// ----------------------------------------------------------
@ -255,6 +322,30 @@ const API = (() => {
alerts(lat, lon) { return get(`/weather/alerts?lat=${lat}&lon=${lon}`); },
};
// ----------------------------------------------------------
// FREUNDE
// ----------------------------------------------------------
const friends = {
list() { return get('/friends/'); },
search(q) { return get(`/friends/search?q=${encodeURIComponent(q)}`); },
sendRequest(userId) { return post(`/friends/request/${userId}`, {}); },
accept(friendshipId) { return post(`/friends/${friendshipId}/accept`, {}); },
decline(friendshipId) { return post(`/friends/${friendshipId}/decline`, {}); },
remove(friendUserId) { return del(`/friends/${friendUserId}`); },
};
// ----------------------------------------------------------
// DIREKTNACHRICHTEN
// ----------------------------------------------------------
const chat = {
conversations() { return get('/chat/conversations'); },
start(partnerId) { return post('/chat/conversations', { partner_id: partnerId }); },
messages(convId, offset=0) { return get(`/chat/conversations/${convId}?offset=${offset}`); },
send(convId, text) { return post(`/chat/conversations/${convId}/messages`, { text }); },
markRead(convId) { return post(`/chat/conversations/${convId}/read`, {}); },
deleteMessage(msgId) { return del(`/chat/messages/${msgId}`); },
};
// ----------------------------------------------------------
// PUSH NOTIFICATIONS
// ----------------------------------------------------------
@ -325,7 +416,8 @@ const API = (() => {
return {
get, post, put, patch, del, upload,
auth, dogs, diary, health, tieraerzte, poison,
places, routes, walks, events, sitting, weather, push,
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
friends, chat,
subscribeToPush, getLocation,
APIError,
};

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '62'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '66'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => {
@ -37,12 +37,15 @@ events: { title: 'Events', module: null },
knigge: { title: 'Knigge', module: null },
movies: { title: 'Filme', module: null },
settings: { title: 'Einstellungen', module: null },
lost: { title: 'Verlorener Hund', module: null },
friends: { title: 'Freunde', module: null },
chat: { title: 'Nachrichten', module: null },
};
// ----------------------------------------------------------
// ROUTER
// ----------------------------------------------------------
function navigate(pageId, pushHistory = true) {
function navigate(pageId, pushHistory = true, params = {}) {
if (!pages[pageId]) return;
// Aktive Seite ausblenden
@ -70,14 +73,20 @@ events: { title: 'Events', module: null },
UI.scrollTop();
// Seiten-Modul lazy laden (einmalig)
_loadPage(pageId);
_loadPage(pageId, params);
}
async function _loadPage(pageId) {
async function _loadPage(pageId, params = {}) {
const page = pages[pageId];
if (page.module) {
// Bereits geladen → nur refresh aufrufen wenn vorhanden
page.module.refresh?.();
const hasParams = params && Object.keys(params).length > 0;
if (hasParams) {
// Re-init mit neuen Params (z.B. Chat mit bestimmter Konversation)
const container = document.querySelector(`#page-${pageId} .page-body`);
page.module.init?.(container, state, params);
} else {
page.module.refresh?.();
}
return;
}
@ -96,7 +105,7 @@ events: { title: 'Events', module: null },
await _loadScript(`/js/pages/${pageId}.js`);
const mod = window[`Page_${pageId.replace(/-/g, '_')}`];
if (mod?.init) {
await mod.init(container, state);
await mod.init(container, state, params);
page.module = mod;
} else {
// Platzhalter wenn Seite noch nicht gebaut
@ -214,16 +223,16 @@ events: { title: 'Events', module: null },
body: `
<div class="flex flex-col gap-3">
<button class="btn btn-secondary w-full" data-quick="diary">
📖 Tagebuch-Eintrag
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg> Tagebuch-Eintrag
</button>
<button class="btn btn-secondary w-full" data-quick="health">
💉 Gesundheits-Eintrag
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg> Gesundheits-Eintrag
</button>
<button class="btn btn-danger w-full" data-quick="poison">
Giftköder melden
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning-octagon"></use></svg> Giftköder melden
</button>
<button class="btn btn-nature w-full" data-quick="walk">
🦮 Gassi-Treffen erstellen
<button class="btn btn-nature w-full" data-quick="walk">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Gassi-Treffen erstellen
</button>
</div>
`,
@ -423,9 +432,16 @@ if (action === 'walk') { navigate('walks'); pages['walks'].module?.openNew?.(
// Erste Seite laden: Hash aus URL oder Standard 'diary'.
// Bewusst NACH _checkAuth(), damit _loadPage() nur einmal aufgerufen wird
// (vorher war Hash-Navigation auch in _bindNavigation() → doppelter Aufruf).
const hash = location.hash.replace('#', '');
const startPage = (hash && pages[hash]) ? hash : 'diary';
navigate(startPage, false);
const rawHash = location.hash.replace('#', '');
const [hashPage, hashQuery] = rawHash.split('?');
const hashParams = {};
if (hashQuery) {
new URLSearchParams(hashQuery).forEach((v, k) => {
hashParams[k] = isNaN(v) ? v : Number(v);
});
}
const startPage = (hashPage && pages[hashPage]) ? hashPage : 'diary';
navigate(startPage, false, hashParams);
}
// ----------------------------------------------------------

View file

@ -0,0 +1,344 @@
/* ============================================================
BAN YARO Chat-Seite
============================================================ */
window.Page_chat = (() => {
let _container = null;
let _view = 'list'; // 'list' | 'thread'
let _convId = null;
let _partnerName = '';
let _myId = null;
let _pollTimer = null;
let _lastMsgId = 0;
// ----------------------------------------------------------
async function init(container, appState, params = {}) {
_container = container;
_myId = appState?.user?.id || null;
if (params.conversation_id) {
await _openThread(params.conversation_id);
} else {
await _showList();
}
}
// ----------------------------------------------------------
// Conversation list
// ----------------------------------------------------------
async function _showList() {
_view = 'list';
_stopPolling();
_convId = null;
_container.innerHTML = `
<div style="background:var(--c-surface)">
<div style="padding:var(--space-4) var(--space-4) var(--space-2)">
<h2 style="font-size:var(--text-xl);font-weight:var(--weight-bold)">Nachrichten</h2>
</div>
<div id="chat-list-body"></div>
</div>
`;
await _loadList();
await _updateChatBadge();
}
async function _loadList() {
const el = document.getElementById('chat-list-body');
if (!el) return;
try {
const convs = await API.chat.conversations();
if (!convs.length) {
el.innerHTML = `
<div class="empty-state" style="padding:var(--space-12) var(--space-4)">
<svg class="ph-icon" style="font-size:3rem;opacity:0.3">
<use href="/icons/phosphor.svg#chat-circle-dots"></use>
</svg>
<p style="margin-top:var(--space-3);color:var(--c-text-muted)">
Noch keine Nachrichten.<br>
Schreibe einem Freund über die Freunde-Seite!
</p>
</div>
`;
return;
}
el.innerHTML = convs.map(c => {
const initials = (c.partner_name || '?')[0].toUpperCase();
const preview = c.last_text
? _esc(c.last_text.substring(0, 60)) + (c.last_text.length > 60 ? '…' : '')
: '<em style="opacity:0.6">Noch keine Nachrichten</em>';
const timeStr = c.last_msg_at ? _fmtTime(c.last_msg_at) : '';
const badge = c.unread_count > 0
? `<span class="chat-unread-badge">${c.unread_count}</span>`
: '';
return `
<div class="chat-conv-item" onclick="Page_chat._openThread(${c.id})">
<div class="chat-conv-avatar">${initials}</div>
<div class="chat-conv-info">
<div class="chat-conv-name">${_esc(c.partner_name)}</div>
<div class="chat-conv-preview">${preview}</div>
</div>
<div class="chat-conv-meta">
<span class="chat-conv-time">${timeStr}</span>
${badge}
</div>
</div>
`;
}).join('');
} catch (e) {
if (e.status === 401) {
document.getElementById('chat-list-body').innerHTML =
`<div class="empty-state"><p>Bitte melde dich an.</p></div>`;
}
}
}
// ----------------------------------------------------------
// Thread (message view)
// ----------------------------------------------------------
async function _openThread(convId) {
_convId = convId;
_view = 'thread';
_stopPolling();
_container.innerHTML = `
<div class="chat-thread" id="chat-thread">
<div class="chat-thread-header">
<button class="btn btn-ghost btn-sm" onclick="Page_chat._showList()" style="padding:var(--space-1)">
<svg class="ph-icon"><use href="/icons/phosphor.svg#arrow-left"></use></svg>
</button>
<div class="chat-conv-avatar" id="chat-partner-av" style="width:32px;height:32px;font-size:var(--text-sm)">?</div>
<span class="chat-thread-partner" id="chat-partner-name"></span>
</div>
<div class="chat-messages" id="chat-messages">
<div style="text-align:center;padding:var(--space-6);color:var(--c-text-muted);font-size:var(--text-sm)">
Laden
</div>
</div>
<div class="chat-input-bar">
<textarea id="chat-input" class="chat-input" rows="1"
placeholder="Nachricht…" maxlength="2000"></textarea>
<button class="chat-send-btn" id="chat-send-btn" onclick="Page_chat._send()">
<svg class="ph-icon"><use href="/icons/phosphor.svg#paper-plane-tilt"></use></svg>
</button>
</div>
</div>
`;
// Auto-resize textarea
const input = document.getElementById('chat-input');
input.addEventListener('input', () => {
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 100) + 'px';
});
input.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
Page_chat._send();
}
});
await _loadMessages(true);
await API.chat.markRead(_convId).catch(() => {});
await _updateChatBadge();
// Poll every 4s while thread is open
_pollTimer = setInterval(async () => {
if (_view === 'thread' && _convId === convId) {
await _pollNew();
}
}, 4000);
}
async function _loadMessages(scroll = false) {
const el = document.getElementById('chat-messages');
if (!el) return;
try {
const data = await API.chat.messages(_convId);
_partnerName = data.partner_name;
const nameEl = document.getElementById('chat-partner-name');
const avEl = document.getElementById('chat-partner-av');
if (nameEl) nameEl.textContent = data.partner_name;
if (avEl) avEl.textContent = (data.partner_name || '?')[0].toUpperCase();
if (!data.messages.length) {
el.innerHTML = `
<div style="text-align:center;padding:var(--space-8);color:var(--c-text-muted);font-size:var(--text-sm)">
Schreibe die erste Nachricht!
</div>
`;
_lastMsgId = 0;
return;
}
_lastMsgId = data.messages[data.messages.length - 1].id;
el.innerHTML = _renderMessages(data.messages);
if (scroll) _scrollToBottom(el);
} catch (e) {
if (el) el.innerHTML = `<div class="empty-state"><p>Fehler beim Laden.</p></div>`;
}
}
async function _pollNew() {
const el = document.getElementById('chat-messages');
if (!el) return;
try {
const data = await API.chat.messages(_convId);
if (!data.messages.length) return;
const newest = data.messages[data.messages.length - 1];
if (newest.id <= _lastMsgId) return;
// New messages arrived
_lastMsgId = newest.id;
const wasAtBottom = _isScrolledToBottom(el);
el.innerHTML = _renderMessages(data.messages);
if (wasAtBottom) _scrollToBottom(el);
await API.chat.markRead(_convId).catch(() => {});
await _updateChatBadge();
} catch (e) {
// silent
}
}
function _renderMessages(msgs) {
let html = '';
let lastDate = null;
for (const m of msgs) {
const dateStr = m.created_at.substring(0, 10);
if (dateStr !== lastDate) {
html += `<div class="chat-date-divider">${_fmtDate(m.created_at)}</div>`;
lastDate = dateStr;
}
const isMine = m.sender_id === _myId;
const rowClass = isMine ? 'chat-bubble-row--mine' : 'chat-bubble-row--theirs';
const bubClass = isMine ? 'chat-bubble--mine' : 'chat-bubble--theirs';
const delClass = m.is_deleted ? ' chat-bubble--deleted' : '';
const timeStr = _fmtTime(m.created_at);
const deleteBtn = isMine && !m.is_deleted
? `<button class="btn btn-ghost" style="padding:2px;opacity:0.4;font-size:var(--text-xs)"
onclick="Page_chat._deleteMsg(${m.id})" title="Löschen">
<svg class="ph-icon" style="width:12px;height:12px"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>`
: '';
html += `
<div class="chat-bubble-row ${rowClass}">
<div>
<div class="chat-bubble ${bubClass}${delClass}">${_esc(m.text)}</div>
<div class="chat-bubble-time" style="display:flex;align-items:center;gap:2px;justify-content:${isMine ? 'flex-end' : 'flex-start'}">
${timeStr} ${deleteBtn}
</div>
</div>
</div>
`;
}
return html;
}
// ----------------------------------------------------------
async function _send() {
const input = document.getElementById('chat-input');
const btn = document.getElementById('chat-send-btn');
if (!input) return;
const text = input.value.trim();
if (!text || !_convId) return;
btn.disabled = true;
try {
await API.chat.send(_convId, text);
input.value = '';
input.style.height = 'auto';
await _loadMessages(true);
} catch (e) {
UI.toast(e.message, 'danger');
} finally {
btn.disabled = false;
input.focus();
}
}
// ----------------------------------------------------------
async function _deleteMsg(msgId) {
try {
await API.chat.deleteMessage(msgId);
await _loadMessages(false);
} catch (e) {
UI.toast(e.message, 'danger');
}
}
// ----------------------------------------------------------
async function _updateChatBadge() {
try {
const convs = await API.chat.conversations();
const total = convs.reduce((s, c) => s + (c.unread_count || 0), 0);
const badge = document.getElementById('chat-badge');
if (badge) {
badge.textContent = total;
badge.style.display = total > 0 ? '' : 'none';
}
} catch (e) {
// silent
}
}
// ----------------------------------------------------------
// Helpers
// ----------------------------------------------------------
function _stopPolling() {
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
}
function _scrollToBottom(el) {
el.scrollTop = el.scrollHeight;
}
function _isScrolledToBottom(el) {
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
}
function _fmtTime(iso) {
if (!iso) return '';
const d = new Date(iso.replace(' ', 'T') + 'Z');
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
if (isToday) return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
}
function _fmtDate(iso) {
if (!iso) return '';
const d = new Date(iso.replace(' ', 'T') + 'Z');
const now = new Date();
const diff = Math.floor((now - d) / 86400000);
if (diff === 0) return 'Heute';
if (diff === 1) return 'Gestern';
return d.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' });
}
function _esc(s) {
if (!s) return '';
return String(s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')
.replace(/\n/g, '<br>');
}
// ----------------------------------------------------------
return {
init,
_showList,
_openThread,
_send,
_deleteMsg,
};
})();

View file

@ -16,12 +16,12 @@ window.Page_diary = (() => {
const LIMIT = 20;
const TYPEN = {
eintrag: { label: 'Eintrag', icon: '📖' },
foto: { label: 'Foto', icon: '📷' },
meilenstein:{ label: 'Meilenstein',icon: '🏆' },
training: { label: 'Training', icon: '🎯' },
gesundheit: { label: 'Gesundheit', icon: '💉' },
ausflug: { label: 'Ausflug', icon: '🚗' },
eintrag: { label: 'Eintrag', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>' },
foto: { label: 'Foto', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg>' },
meilenstein:{ label: 'Meilenstein',icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trophy"></use></svg>' },
training: { label: 'Training', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#target"></use></svg>' },
gesundheit: { label: 'Gesundheit', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>' },
ausflug: { label: 'Ausflug', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>' },
};
// ----------------------------------------------------------
@ -72,7 +72,7 @@ window.Page_diary = (() => {
async function _render() {
if (!_appState.activeDog) {
_container.innerHTML = UI.emptyState({
icon: '🐕',
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>',
title: 'Noch kein Hund angelegt',
text: 'Erstelle zuerst ein Hundeprofil, um das Tagebuch zu nutzen.',
action: `<button class="btn btn-primary" id="diary-goto-profile">Profil erstellen</button>`,
@ -99,7 +99,7 @@ window.Page_diary = (() => {
const isActive = dog.id === activeDogId;
const av = dog.foto_url
? `<img src="${_escape(dog.foto_url)}" alt="${_escape(dog.name)}">`
: `<span style="font-size:2.5rem">🐕</span>`;
: `<span>${UI.icon('dog')}</span>`;
return `
<div class="diary-picker-card${isActive ? ' diary-picker-card--active' : ''}"
data-dog-id="${dog.id}">
@ -186,7 +186,7 @@ window.Page_diary = (() => {
if (_entries.length === 0) {
listEl.innerHTML = UI.emptyState({
icon: '📖',
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>',
title: 'Noch keine Einträge',
text: 'Halte besondere Momente mit deinem Hund fest.',
action: `<button class="btn btn-primary" id="diary-first-entry">Ersten Eintrag erstellen</button>`,
@ -243,6 +243,11 @@ window.Page_diary = (() => {
? `<p class="diary-card-text">${_escape(e.text.slice(0, 140))}${e.text.length > 140 ? '…' : ''}</p>`
: '';
// Meilenstein-Badge (nur bei is_milestone=1, nicht bei manuell gewähltem Typ 'meilenstein')
const milestoneBadge = e.is_milestone
? `<div class="diary-card-milestone-badge">${UI.icon('calendar-dots')} Meilenstein</div>`
: '';
// Mehrere Hunde: kleine Avatare in der Karte
const dogAvatars = _dogAvatarRow(e.dog_ids || []);
@ -250,6 +255,7 @@ window.Page_diary = (() => {
<div class="diary-card${isMile ? ' diary-card--milestone' : ''}" data-entry-id="${e.id}">
${photo}
<div class="diary-card-body">
${milestoneBadge}
<div class="diary-card-meta">
<span class="diary-card-type">${typ.icon} ${typ.label}</span>
<span class="diary-card-date">${dateStr}</span>
@ -269,7 +275,7 @@ window.Page_diary = (() => {
const dog = _appState.dogs.find(d => d.id === did);
if (!dog) return '';
return `<div class="diary-dog-av" title="${_escape(dog.name)}">
${dog.foto_url ? `<img src="${_escape(dog.foto_url)}" alt="">` : '<span>🐕</span>'}
${dog.foto_url ? `<img src="${_escape(dog.foto_url)}" alt="">` : `<span>${UI.icon('dog')}</span>`}
</div>`;
}).join('');
return `<div class="diary-dog-row">${avatars}</div>`;
@ -299,7 +305,7 @@ window.Page_diary = (() => {
const dog = _appState.dogs.find(d => d.id === did);
return dog ? `<div class="diary-dog-chip">
<div class="diary-dog-av">
${dog.foto_url ? `<img src="${_escape(dog.foto_url)}" alt="">` : '<span>🐕</span>'}
${dog.foto_url ? `<img src="${_escape(dog.foto_url)}" alt="">` : `<span>${UI.icon('dog')}</span>`}
</div>
<span>${_escape(dog.name)}</span>
</div>` : '';
@ -308,7 +314,7 @@ window.Page_diary = (() => {
: '';
const body = `
${isMile ? '<div class="diary-detail-milestone-badge">🏆 Meilenstein</div>' : ''}
${isMile ? `<div class="diary-detail-milestone-badge">${UI.icon('trophy')} Meilenstein</div>` : ''}
${photo}
<div style="display:flex;gap:var(--space-2);align-items:center;margin-bottom:var(--space-3)">
<span class="badge badge-primary">${typ.icon} ${typ.label}</span>
@ -374,7 +380,7 @@ window.Page_diary = (() => {
<input type="checkbox" name="extra_dog" value="${d.id}"
${entryDogIds.includes(d.id) ? 'checked' : ''}>
<div class="diary-dog-av">
${d.foto_url ? `<img src="${_escape(d.foto_url)}" alt="">` : '<span>🐕</span>'}
${d.foto_url ? `<img src="${_escape(d.foto_url)}" alt="">` : `<span>${UI.icon('dog')}</span>`}
</div>
<span>${_escape(d.name)}</span>
</label>`).join('')}

View file

@ -47,7 +47,7 @@ window.Page_dog_profile = (() => {
async function _render() {
if (!_appState.user) {
_container.innerHTML = UI.emptyState({
icon : '🐕',
icon : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>',
title : 'Anmelden erforderlich',
text : 'Melde dich an, um ein Hundeprofil anzulegen.',
action: `<button class="btn btn-primary" id="profile-goto-login">Anmelden</button>`,
@ -85,13 +85,13 @@ window.Page_dog_profile = (() => {
: `<div style="width:120px;height:120px;border-radius:50%;
background:var(--c-surface-2);display:flex;
align-items:center;justify-content:center;
font-size:3.5rem;border:3px solid var(--c-border)">🐕</div>`}
font-size:3.5rem;border:3px solid var(--c-border)">${UI.icon('dog')}</div>`}
<label style="position:absolute;bottom:4px;right:4px;
background:var(--c-primary);color:#fff;border-radius:50%;
width:30px;height:30px;display:flex;align-items:center;
justify-content:center;cursor:pointer;font-size:14px"
title="Foto ändern">
📷
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg>
<input type="file" id="dp-photo-input" accept="image/*"
style="display:none">
</label>
@ -110,7 +110,7 @@ window.Page_dog_profile = (() => {
${geburtstag ? `
<div class="card" style="padding:var(--space-3)">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-bottom:2px">🎂 Geburtstag</div>
margin-bottom:2px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-dots"></use></svg> Geburtstag</div>
<div style="font-weight:500;font-size:var(--text-sm)">${geburtstag}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${_calcAlter(dog.geburtstag)}
@ -120,7 +120,7 @@ window.Page_dog_profile = (() => {
${dog.geschlecht ? `
<div class="card" style="padding:var(--space-3)">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-bottom:2px">${dog.geschlecht === 'm' ? '♂' : '♀'} Geschlecht</div>
margin-bottom:2px">${dog.geschlecht === 'm' ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-male"></use></svg>' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-female"></use></svg>'} Geschlecht</div>
<div style="font-weight:500;font-size:var(--text-sm)">
${dog.geschlecht === 'm' ? 'Rüde' : 'Hündin'}
</div>
@ -129,14 +129,14 @@ window.Page_dog_profile = (() => {
${dog.gewicht_kg ? `
<div class="card" style="padding:var(--space-3)">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-bottom:2px"> Gewicht</div>
margin-bottom:2px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scales"></use></svg> Gewicht</div>
<div style="font-weight:500;font-size:var(--text-sm)">${dog.gewicht_kg} kg</div>
</div>
` : ''}
${dog.chip_nr ? `
<div class="card" style="padding:var(--space-3)">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-bottom:2px">💾 Chip-Nr.</div>
margin-bottom:2px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#tag"></use></svg> Chip-Nr.</div>
<div style="font-size:var(--text-xs);font-weight:500;
word-break:break-all">${_esc(dog.chip_nr)}</div>
</div>
@ -151,6 +151,34 @@ window.Page_dog_profile = (() => {
</div>
` : ''}
${dog.is_public ? `
<div style="background:var(--c-primary-subtle);border:1px solid var(--c-primary-light);
border-radius:var(--radius-md);padding:var(--space-4);
margin-bottom:var(--space-5);text-align:left">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-bottom:var(--space-2);font-weight:var(--weight-medium)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#tag"></use></svg> NFC-Link
</div>
<div style="display:flex;align-items:center;gap:var(--space-2);
flex-wrap:wrap">
<code id="dp-nfc-link"
style="flex:1;font-size:var(--text-sm);background:var(--c-surface);
border:1px solid var(--c-border);border-radius:var(--radius-sm);
padding:var(--space-2) var(--space-3);color:var(--c-text);
word-break:break-all">banyaro.app/hund/${dog.id}</code>
<button class="btn btn-secondary btn-sm" id="dp-copy-link-btn"
style="flex-shrink:0;padding:var(--space-2) var(--space-3);
font-size:var(--text-sm);min-height:36px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg> Kopieren
</button>
</div>
<p style="margin:var(--space-2) 0 0;font-size:var(--text-xs);
color:var(--c-text-muted)">
Dieser Link kann auf ein NFC-Tag gebrannt werden
</p>
</div>
` : ''}
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button class="btn btn-primary w-full" id="dp-edit-btn">
Profil bearbeiten
@ -182,6 +210,26 @@ window.Page_dog_profile = (() => {
UI.toast.error(err.message || 'Fehler beim Hochladen.');
}
});
// NFC-Link kopieren
document.getElementById('dp-copy-link-btn')?.addEventListener('click', async () => {
const url = `https://banyaro.app/hund/${dog.id}`;
try {
await navigator.clipboard.writeText(url);
UI.toast.success('Link kopiert!');
} catch {
// Fallback für ältere Browser
const el = document.getElementById('dp-nfc-link');
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
document.execCommand('copy');
sel.removeAllRanges();
UI.toast.success('Link kopiert!');
}
});
// Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig.
}
@ -192,7 +240,7 @@ window.Page_dog_profile = (() => {
_container.innerHTML = `
<div style="padding:var(--space-4) 0 var(--space-2)">
<div style="text-align:center;margin-bottom:var(--space-5)">
<div style="font-size:3rem;margin-bottom:var(--space-2)">🐕</div>
<div style="font-size:3rem;margin-bottom:var(--space-2)">${UI.icon('dog')}</div>
<h2 style="font-size:var(--text-xl);font-weight:700;margin:0 0 var(--space-2)">
Hund anlegen
</h2>
@ -215,7 +263,7 @@ window.Page_dog_profile = (() => {
body: _formHTML(null, true),
footer: `
<button type="button" class="btn btn-secondary flex-1" id="dp-form-cancel">Abbrechen</button>
<button type="submit" form="dp-form" class="btn btn-primary flex-1">🐕 Hund anlegen</button>
<button type="submit" form="dp-form" class="btn btn-primary flex-1">${UI.icon('dog')} Hund anlegen</button>
`,
});
_bindForm(null, true);
@ -317,7 +365,7 @@ window.Page_dog_profile = (() => {
background:var(--c-surface-2);border:2px solid var(--c-border);
display:${dog?.foto_url ? 'block' : 'none'}">
<label class="btn btn-secondary btn-sm" style="cursor:pointer;margin:0">
📷 Foto auswählen
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg> Foto auswählen
<input type="file" name="foto" accept="image/*" style="display:none"
id="dp-form-foto">
</label>
@ -329,7 +377,7 @@ window.Page_dog_profile = (() => {
${dog ? `<button type="button" class="btn btn-secondary flex-1"
id="dp-form-cancel">Abbrechen</button>` : ''}
<button type="submit" class="btn btn-primary flex-1">
${dog ? 'Speichern' : '🐕 Hund anlegen'}
${dog ? 'Speichern' : `${UI.icon('dog')} Hund anlegen`}
</button>
</div>` : ''}

View file

@ -34,10 +34,18 @@ window.Page_events = (() => {
let _state = null;
let _events = [];
let _filter = 'alle';
let _quellFilter = 'alle'; // 'alle' | 'vdh' | 'nutzer'
let _view = 'liste'; // liste | karte
let _map = null;
let _markers = [];
// ----------------------------------------------------------
// Phosphor-Icon-Helper
// ----------------------------------------------------------
function _icon(name) {
return `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${name}"></use></svg>`;
}
// ----------------------------------------------------------
// init
// ----------------------------------------------------------
@ -59,11 +67,11 @@ window.Page_events = (() => {
_container.innerHTML = `
<div class="events-toolbar">
<div class="events-view-toggle">
<button class="events-view-btn active" data-ev-view="liste"> Liste</button>
<button class="events-view-btn" data-ev-view="karte">🗺 Karte</button>
<button class="events-view-btn active" data-ev-view="liste">${UI.icon('list')} Liste</button>
<button class="events-view-btn" data-ev-view="karte">${UI.icon('map-trifold')} Karte</button>
</div>
<div style="flex:1"></div>
${_state.user ? `<button class="btn btn-primary btn-sm" id="ev-new-btn">+ Event</button>` : ''}
${_state.user ? `<button class="btn btn-primary btn-sm" id="ev-new-btn">${UI.icon('plus')} Event</button>` : ''}
</div>
<div class="events-filter-bar" id="ev-filter-bar">
@ -74,6 +82,14 @@ window.Page_events = (() => {
`).join('')}
</div>
<div class="events-source-bar" id="ev-source-bar">
<button class="events-source-btn active" data-ev-quelle="alle">Alle Quellen</button>
<button class="events-source-btn events-source-vdh" data-ev-quelle="vdh">
<span class="ev-vdh-badge">VDH</span> VDH-Events
</button>
<button class="events-source-btn" data-ev-quelle="nutzer">Von Nutzern</button>
</div>
<div class="events-list" id="ev-list"></div>
<div class="events-map" id="ev-map" style="display:none"></div>
`;
@ -96,6 +112,17 @@ window.Page_events = (() => {
}
}
// ----------------------------------------------------------
// Gefilterte Events ermitteln
// ----------------------------------------------------------
function _filtered() {
let evs = _filter === 'alle' ? _events : _events.filter(e => e.typ === _filter);
if (_quellFilter !== 'alle') {
evs = evs.filter(e => (e.quelle || 'nutzer') === _quellFilter);
}
return evs;
}
// ----------------------------------------------------------
// Liste rendern
// ----------------------------------------------------------
@ -103,7 +130,7 @@ window.Page_events = (() => {
const listEl = document.getElementById('ev-list');
if (!listEl) return;
const filtered = _filter === 'alle' ? _events : _events.filter(e => e.typ === _filter);
const filtered = _filtered();
if (!filtered.length) {
listEl.innerHTML = UI.emptyState({ icon: '🎪', title: 'Keine Events', text: 'Noch keine Veranstaltungen geplant.' });
return;
@ -140,6 +167,8 @@ window.Page_events = (() => {
const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1];
const color = TYP_COLOR[ev.typ] || '#6b7280';
const isOwn = _state.user?.id === ev.user_id;
const isVdh = ev.quelle === 'vdh';
return `
<div class="events-card" data-ev-id="${ev.id}" style="border-left-color:${color}">
<div class="events-date-badge">
@ -148,14 +177,22 @@ window.Page_events = (() => {
<span class="month">${mon}</span>
</div>
<div class="events-card-body">
<div class="events-card-title">${UI.escHtml(ev.titel)}</div>
<div class="events-card-title">
${UI.escHtml(ev.titel)}
${isVdh ? `<span class="ev-vdh-badge" title="Vom VDH importiert">VDH</span>` : ''}
</div>
<div class="events-card-meta">
<span class="events-badge" style="background:${color}20;color:${color}">${typ.icon} ${typ.label}</span>
${ev.uhrzeit ? `· ${ev.uhrzeit} Uhr` : ''}
${ev.ort_name ? `· 📍 ${UI.escHtml(ev.ort_name)}` : ''}
${ev.uhrzeit ? `· ${_icon('clock')} ${ev.uhrzeit} Uhr` : ''}
${ev.ort_name ? `· ${_icon('map-pin')} ${UI.escHtml(ev.ort_name)}` : ''}
</div>
${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()">
${_icon('arrow-square-out')} Details
</a>
</div>` : ''}
</div>
${isOwn ? `<button class="btn-icon" data-ev-edit="${ev.id}" title="Bearbeiten">✏️</button>` : ''}
${isOwn ? `<button class="btn-icon" data-ev-edit="${ev.id}" title="Bearbeiten">${_icon('pencil-simple')}</button>` : ''}
</div>
`;
}
@ -223,22 +260,33 @@ window.Page_events = (() => {
const d = new Date(ev.datum + 'T00:00:00');
const datum = d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
const isOwn = _state.user?.id === ev.user_id;
const isVdh = ev.quelle === 'vdh';
const body = `
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
<span class="events-badge" style="background:${color}20;color:${color};font-size:var(--text-sm)">${typ.icon} ${typ.label}</span>
${isVdh ? `<span class="ev-vdh-badge">VDH</span>` : ''}
</div>
<div class="events-detail-row">📅 ${datum}${ev.uhrzeit ? ' · ' + ev.uhrzeit + ' Uhr' : ''}</div>
${ev.ort_name ? `<div class="events-detail-row">📍 ${UI.escHtml(ev.ort_name)}</div>` : ''}
<div class="events-detail-row">${_icon('calendar-dots')} ${datum}${ev.uhrzeit ? ' · ' + ev.uhrzeit + ' Uhr' : ''}</div>
${ev.ort_name ? `<div class="events-detail-row">${_icon('map-pin')} ${UI.escHtml(ev.ort_name)}</div>` : ''}
${ev.beschreibung ? `<div class="events-detail-desc">${UI.escHtml(ev.beschreibung)}</div>` : ''}
${ev.link ? `<div class="events-detail-row">🔗 <a href="${UI.escHtml(ev.link)}" target="_blank" rel="noopener">Mehr Infos</a></div>` : ''}
<div class="events-detail-row" style="color:var(--c-text-muted);font-size:var(--text-xs)">Veranstalter: ${UI.escHtml(ev.veranstalter_name || '')}</div>
${ev.link ? `<div class="events-detail-row">
${_icon('arrow-square-out')}
<a href="${UI.escHtml(ev.link)}" target="_blank" rel="noopener">Mehr Infos</a>
</div>` : ''}
<div class="events-detail-row" style="color:var(--c-text-muted);font-size:var(--text-xs)">
${_icon('user')} Veranstalter: ${UI.escHtml(ev.veranstalter_name || '')}
</div>
`;
const footer = isOwn ? `
<button class="btn btn-secondary" id="ev-detail-edit"> Bearbeiten</button>
<button class="btn btn-danger" id="ev-detail-del">🗑 Löschen</button>
` : '';
<button class="btn btn-secondary" id="ev-detail-edit">${_icon('pencil-simple')} Bearbeiten</button>
<button class="btn btn-danger" id="ev-detail-del">${_icon('trash')} Löschen</button>
` : (ev.link ? `
<a class="btn btn-primary" href="${UI.escHtml(ev.link)}" target="_blank" rel="noopener">
${_icon('arrow-square-out')} Zur Veranstaltung
</a>
` : '');
UI.modal.open({ title: UI.escHtml(ev.titel), body, footer });
@ -368,7 +416,16 @@ window.Page_events = (() => {
// Click-Handler
// ----------------------------------------------------------
function _onClick(e) {
// Filter
// Quelle-Filter
const sourceBtn = e.target.closest('[data-ev-quelle]');
if (sourceBtn) {
_quellFilter = sourceBtn.dataset.evQuelle;
document.querySelectorAll('[data-ev-quelle]').forEach(b => b.classList.toggle('active', b.dataset.evQuelle === _quellFilter));
_renderList();
return;
}
// Typ-Filter
const filterBtn = e.target.closest('[data-ev-typ]');
if (filterBtn) {
_filter = filterBtn.dataset.evTyp;
@ -387,8 +444,7 @@ window.Page_events = (() => {
if (_view === 'karte') {
listEl.style.display = 'none';
mapEl.style.display = 'block';
const filtered = _filter === 'alle' ? _events : _events.filter(ev => ev.typ === _filter);
_renderMap(filtered);
_renderMap(_filtered());
} else {
listEl.style.display = '';
mapEl.style.display = 'none';
@ -399,6 +455,9 @@ window.Page_events = (() => {
// Neu-Button
if (e.target.closest('#ev-new-btn')) { openNew(); return; }
// Externer Link — nicht als Karten-Klick behandeln
if (e.target.closest('.ev-ext-link')) return;
// Bearbeiten-Icon auf Karte
const editBtn = e.target.closest('[data-ev-edit]');
if (editBtn) {

View file

@ -0,0 +1,890 @@
/* ============================================================
BAN YARO Forum (Sprint 11)
Kategorien, Threads, Antworten, Likes, Reports,
Foto-Upload, Mitgliederkarte, Moderations-Panel
============================================================ */
window.Page_forum = (() => {
let _container = null;
let _appState = null;
let _threads = [];
let _aktivKat = 'alle';
let _offset = 0;
let _searchTimer = null;
let _searching = false;
let _mapLoaded = false;
let _leafletLoaded = false;
let _map = null;
let _activeSection = 'list'; // 'list' | 'map'
const LIMIT = 30;
const KATEGORIEN = [
{ key: 'alle', label: 'Alle' },
{ key: 'allgemein', label: 'Allgemein' },
{ key: 'rasse', label: 'Rasse' },
{ key: 'region', label: 'Region' },
{ key: 'gesundheit', label: 'Gesundheit' },
{ key: 'erziehung', label: 'Erziehung' },
{ key: 'tauschboerse', label: 'Tauschbörse' },
];
// ----------------------------------------------------------
// Helpers
// ----------------------------------------------------------
function _esc(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function _fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
const now = new Date();
const diff = (now - d) / 1000;
if (diff < 60) return 'gerade eben';
if (diff < 3600) return `vor ${Math.floor(diff / 60)} Min`;
if (diff < 86400) return `vor ${Math.floor(diff / 3600)} Std`;
return d.toLocaleDateString('de-DE');
}
function _initial(name) {
return (name || '?').charAt(0).toUpperCase();
}
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_render();
_loadThreads(true);
}
function refresh() {
_loadThreads(true);
}
function onDogChange() {}
// ----------------------------------------------------------
// RENDER — Grundstruktur
// ----------------------------------------------------------
function _render() {
const isMod = !!_appState.user?.is_moderator;
_container.innerHTML = `
<div class="forum-layout">
<!-- Header -->
<div class="forum-header">
<h2 class="forum-header-title">Forum</h2>
<div class="forum-header-actions">
${isMod ? `<button class="btn btn-ghost btn-sm" id="forum-mod-btn" title="Moderationsberichte">${UI.icon('warning')}</button>` : ''}
<button class="btn btn-primary btn-sm" id="forum-new-btn">${UI.icon('plus')} Neues Thema</button>
</div>
</div>
<!-- Kategorie-Tabs -->
<div class="forum-category-tabs" id="forum-tabs">
${KATEGORIEN.map(k => `
<button class="forum-tab ${k.key === _aktivKat ? 'active' : ''}"
data-kat="${k.key}">${_esc(k.label)}</button>
`).join('')}
<button class="forum-tab ${_activeSection === 'map' ? 'active' : ''}"
data-section="map">${UI.icon('users')} Mitgliederkarte</button>
</div>
<!-- Suchleiste -->
<div class="forum-search-wrap">
<input type="search" class="forum-search" id="forum-search"
placeholder="Forum durchsuchen…" autocomplete="off">
</div>
<!-- Thread-Liste / Karte / Suche -->
<div id="forum-main">
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt</p>
</div>
</div>
`;
// Tab-Klicks
document.getElementById('forum-tabs').addEventListener('click', e => {
const btn = e.target.closest('[data-kat], [data-section]');
if (!btn) return;
if (btn.dataset.section === 'map') {
_aktivKat = 'alle';
_activeSection = 'map';
document.querySelectorAll('.forum-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_renderMembersMap();
return;
}
_aktivKat = btn.dataset.kat;
_activeSection = 'list';
document.querySelectorAll('.forum-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_offset = 0;
_threads = [];
_loadThreads(true);
});
// Suche
document.getElementById('forum-search').addEventListener('input', e => {
clearTimeout(_searchTimer);
const q = e.target.value.trim();
if (!q) {
_searching = false;
_renderList();
return;
}
_searchTimer = setTimeout(() => _doSearch(q), 400);
});
// Neues Thema
document.getElementById('forum-new-btn').addEventListener('click', () => {
if (!_appState.user) { UI.toast.info('Bitte erst anmelden.'); return; }
_showCreateForm();
});
// Moderations-Panel
document.getElementById('forum-mod-btn')?.addEventListener('click', _showModPanel);
}
// ----------------------------------------------------------
// Threads laden
// ----------------------------------------------------------
async function _loadThreads(reset = false) {
if (reset) { _offset = 0; _threads = []; }
const mainEl = document.getElementById('forum-main');
if (mainEl && reset) {
mainEl.innerHTML = `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt…</p>`;
}
try {
const params = { limit: LIMIT, offset: _offset };
if (_aktivKat !== 'alle') params.kategorie = _aktivKat;
const rows = await API.forum.threads(params);
_threads = reset ? rows : [..._threads, ...rows];
_offset += rows.length;
_renderList(rows.length < LIMIT);
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Laden.');
}
}
// ----------------------------------------------------------
// Thread-Liste rendern
// ----------------------------------------------------------
function _renderList(noMore = false) {
if (_searching) return;
const el = document.getElementById('forum-main');
if (!el) return;
if (!_threads.length) {
el.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon('chat-circle-dots')}</div>
<p style="color:var(--c-text-secondary)">Noch keine Beiträge in dieser Kategorie.</p>
<button class="btn btn-primary" style="margin-top:var(--space-4)" id="forum-first-btn">
Ersten Beitrag erstellen
</button>
</div>`;
document.getElementById('forum-first-btn')?.addEventListener('click', () => {
if (!_appState.user) { UI.toast.info('Bitte erst anmelden.'); return; }
_showCreateForm();
});
return;
}
el.innerHTML = `
<div class="forum-list-inner" id="forum-thread-list">
${_threads.map(_threadCardHTML).join('')}
</div>
${!noMore ? `<div style="text-align:center;padding:var(--space-4)">
<button class="btn btn-secondary btn-sm" id="forum-loadmore">Mehr laden</button>
</div>` : ''}
`;
el.querySelectorAll('.forum-thread-card').forEach(card => {
card.addEventListener('click', () => _openThread(parseInt(card.dataset.id)));
});
document.getElementById('forum-loadmore')?.addEventListener('click', () => {
_loadThreads(false);
});
}
function _threadCardHTML(t) {
const preview = t.text_preview
? _esc(t.text_preview.slice(0, 120)) + (t.text_preview.length >= 120 ? '…' : '')
: '';
const pinBadge = t.is_pinned ? `<span class="forum-pin-badge" title="Angepinnt">${UI.icon('push-pin')}</span>` : '';
const lockBadge = t.is_locked ? `<span class="forum-lock-badge" title="Gesperrt">${UI.icon('lock')}</span>` : '';
const fotoHtml = t.foto_preview
? `<img class="forum-card-thumb" src="${_esc(t.foto_preview)}" alt="" loading="lazy">`
: '';
return `
<div class="forum-thread-card" data-id="${t.id}">
<div class="forum-card-top">
<span class="forum-category-badge forum-category-badge--${_esc(t.kategorie)}">${_esc(t.kategorie)}</span>
${pinBadge}${lockBadge}
</div>
<div class="forum-card-content">
<div class="forum-card-main">
<div class="forum-card-title">${_esc(t.titel)}</div>
${preview ? `<div class="forum-card-preview">${preview}</div>` : ''}
<div class="forum-card-meta">
<span>${UI.icon('user')} ${_esc(t.autor_name || 'Unbekannt')}</span>
<span>${UI.icon('calendar-dots')} ${_fmtDate(t.created_at)}</span>
<span>${UI.icon('chat-circle-dots')} ${t.antworten || 0}</span>
<span class="${t.user_liked ? 'forum-liked' : ''}">${UI.icon('heart')} ${t.likes || 0}</span>
</div>
</div>
${fotoHtml}
</div>
</div>`;
}
// ----------------------------------------------------------
// Suche
// ----------------------------------------------------------
async function _doSearch(q) {
_searching = true;
const el = document.getElementById('forum-main');
if (el) el.innerHTML = `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-6)">Suche…</p>`;
try {
const results = await API.forum.search(q);
if (!document.getElementById('forum-main')) return;
if (!results.length) {
document.getElementById('forum-main').innerHTML = `
<div style="text-align:center;padding:var(--space-8)">
<div style="font-size:2rem;margin-bottom:var(--space-2)">${UI.icon('magnifying-glass')}</div>
<p style="color:var(--c-text-secondary)">Keine Ergebnisse für ${_esc(q)}"</p>
</div>`;
return;
}
document.getElementById('forum-main').innerHTML = `
<div class="forum-list-inner">
${results.map(t => _threadCardHTML({ ...t, foto_preview: null, is_pinned: 0, is_locked: 0, user_liked: false })).join('')}
</div>`;
document.getElementById('forum-main').querySelectorAll('.forum-thread-card').forEach(card => {
card.addEventListener('click', () => _openThread(parseInt(card.dataset.id)));
});
} catch (err) {
UI.toast.error(err.message || 'Suchfehler.');
}
}
// ----------------------------------------------------------
// Thread-Detail-Modal
// ----------------------------------------------------------
async function _openThread(threadId) {
let thread;
try {
thread = await API.forum.thread(threadId);
} catch (err) {
UI.toast.error(err.message);
return;
}
const uid = _appState.user?.id;
const isMod = !!_appState.user?.is_moderator;
const isOwn = uid && uid === thread.user_id;
const modToolbar = (isMod) ? `
<div class="forum-mod-toolbar">
<button class="btn btn-ghost btn-sm forum-mod-pin" title="${thread.is_pinned ? 'Unpin' : 'Anpinnen'}">
${UI.icon('push-pin')} ${thread.is_pinned ? 'Unpin' : 'Pin'}
</button>
<button class="btn btn-ghost btn-sm forum-mod-lock" title="${thread.is_locked ? 'Entsperren' : 'Sperren'}">
${UI.icon('lock')} ${thread.is_locked ? 'Entsperren' : 'Sperren'}
</button>
<button class="btn btn-ghost btn-sm forum-mod-delete-thread" style="color:var(--c-danger)">${UI.icon('trash')} Thread</button>
</div>` : '';
const fotoGallery = (thread.foto_urls?.length)
? `<div class="forum-foto-grid">${thread.foto_urls.map(u =>
`<img src="${_esc(u)}" class="forum-foto-img" data-src="${_esc(u)}" alt="" loading="lazy">`
).join('')}</div>`
: '';
const likeClass = thread.user_liked ? 'forum-like-btn active' : 'forum-like-btn';
const postsHtml = (thread.posts?.length)
? thread.posts.map(p => _postHTML(p, uid, isMod)).join('')
: `<p style="color:var(--c-text-muted);font-style:italic;padding:var(--space-3) 0">Noch keine Antworten.</p>`;
const replySection = _appState.user && !thread.is_locked ? `
<div class="forum-reply-form">
<label class="form-label">Antwort schreiben</label>
<textarea class="form-control" id="forum-reply-text" rows="3"
placeholder="Deine Antwort…"></textarea>
<div class="forum-reply-actions">
<label class="btn btn-ghost btn-sm forum-upload-label" title="Foto anhängen">
${UI.icon('camera')}
<input type="file" accept="image/*" id="forum-reply-file" style="display:none">
</label>
<div id="forum-reply-previews" class="forum-upload-previews"></div>
</div>
</div>` : (!_appState.user
? `<p style="color:var(--c-text-muted);font-size:0.85rem;margin-top:var(--space-3)">Bitte anmelden um zu antworten.</p>`
: `<p style="color:var(--c-text-muted);font-size:0.85rem;margin-top:var(--space-3)">${UI.icon('lock')} Dieser Thread ist gesperrt.</p>`
);
const body = `
<div class="forum-thread-detail">
${modToolbar}
<div class="forum-thread-header-row">
<span class="forum-category-badge forum-category-badge--${_esc(thread.kategorie)}">${_esc(thread.kategorie)}</span>
<span style="color:var(--c-text-muted);font-size:0.8rem">${_fmtDate(thread.created_at)}</span>
${thread.is_pinned ? `<span>${UI.icon('push-pin')}</span>` : ''}
${thread.is_locked ? `<span>${UI.icon('lock')}</span>` : ''}
</div>
<div class="forum-thread-body">
<p style="white-space:pre-wrap;word-break:break-word">${_esc(thread.text)}</p>
${fotoGallery}
</div>
<div class="forum-thread-author-row">
<div class="forum-avatar">${_esc(_initial(thread.autor_name))}</div>
<span style="font-size:0.85rem;color:var(--c-text-secondary)">${_esc(thread.autor_name || 'Unbekannt')}</span>
<div style="margin-left:auto;display:flex;gap:var(--space-2);align-items:center">
<button class="${likeClass}" id="thread-like-btn" data-count="${thread.likes || 0}">
${UI.icon('heart')} <span id="thread-like-count">${thread.likes || 0}</span>
</button>
${_appState.user && !isOwn ? `<button class="forum-report-btn" id="thread-report-btn">${UI.icon('flag')}</button>` : ''}
</div>
</div>
<div class="forum-posts-section">
<div class="forum-posts-divider">${thread.antworten || 0} Antworten</div>
<div id="forum-posts-list">${postsHtml}</div>
</div>
${replySection}
</div>
`;
const footer = _appState.user ? `
${(isOwn || isMod) ? `<button type="button" class="btn btn-ghost btn-sm" id="ft-delete-thread" style="color:var(--c-danger)">${UI.icon('trash')} Löschen</button>` : ''}
<button type="button" class="btn btn-secondary" id="ft-close">Schließen</button>
${(!thread.is_locked && _appState.user) ? `<button type="button" class="btn btn-primary" id="ft-reply">Antworten</button>` : ''}
` : `<button type="button" class="btn btn-primary" id="ft-close">Schließen</button>`;
UI.modal.open({ title: `${UI.icon('chat-circle-dots')} ${_esc(thread.titel)}`, body, footer });
// Close
document.getElementById('ft-close')?.addEventListener('click', UI.modal.close);
// Like thread
document.getElementById('thread-like-btn')?.addEventListener('click', async () => {
if (!_appState.user) { UI.toast.info('Bitte erst anmelden.'); return; }
const btn = document.getElementById('thread-like-btn');
try {
const res = await API.forum.like('thread', thread.id);
thread.user_liked = res.liked;
thread.likes = res.count;
btn.classList.toggle('active', res.liked);
const countEl = document.getElementById('thread-like-count');
if (countEl) countEl.textContent = res.count;
// Update local state
const idx = _threads.findIndex(t => t.id === thread.id);
if (idx !== -1) { _threads[idx].likes = res.count; _threads[idx].user_liked = res.liked; }
} catch (err) { UI.toast.error(err.message); }
});
// Report thread
document.getElementById('thread-report-btn')?.addEventListener('click', () => {
_showReportForm('thread', thread.id);
});
// Delete thread
document.getElementById('ft-delete-thread')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title: 'Thread löschen?',
message: 'Der Thread wird unwiderruflich entfernt.',
confirmText: 'Löschen', danger: true,
});
if (!ok) return;
try {
await API.forum.deleteThread(thread.id);
_threads = _threads.filter(t => t.id !== thread.id);
UI.modal.close();
_renderList(true);
UI.toast.success('Thread gelöscht.');
} catch (err) { UI.toast.error(err.message); }
});
// Moderator: pin/lock/delete
document.querySelector('.forum-mod-pin')?.addEventListener('click', async () => {
try {
await API.forum.patchThread(thread.id, { is_pinned: thread.is_pinned ? 0 : 1 });
UI.toast.success('Gespeichert.');
UI.modal.close();
_loadThreads(true);
} catch (err) { UI.toast.error(err.message); }
});
document.querySelector('.forum-mod-lock')?.addEventListener('click', async () => {
try {
await API.forum.patchThread(thread.id, { is_locked: thread.is_locked ? 0 : 1 });
UI.toast.success('Gespeichert.');
UI.modal.close();
_loadThreads(true);
} catch (err) { UI.toast.error(err.message); }
});
document.querySelector('.forum-mod-delete-thread')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({ title: 'Thread löschen?', message: 'Moderator-Löschung.', confirmText: 'Löschen', danger: true });
if (!ok) return;
try {
await API.forum.deleteThread(thread.id);
_threads = _threads.filter(t => t.id !== thread.id);
UI.modal.close();
_renderList(true);
UI.toast.success('Thread gelöscht.');
} catch (err) { UI.toast.error(err.message); }
});
// Foto-Vollbild
document.getElementById('modal-container')?.querySelectorAll('.forum-foto-img').forEach(img => {
img.addEventListener('click', () => {
window.open(img.dataset.src || img.src, '_blank');
});
});
// Reply file preview
const replyFileInput = document.getElementById('forum-reply-file');
replyFileInput?.addEventListener('change', () => {
const previews = document.getElementById('forum-reply-previews');
if (!previews) return;
previews.innerHTML = '';
const files = Array.from(replyFileInput.files || []);
files.slice(0, 5).forEach(file => {
const url = URL.createObjectURL(file);
const img = document.createElement('img');
img.src = url;
img.className = 'forum-upload-thumb';
previews.appendChild(img);
});
});
// Post-Löschen-Buttons binden
const postsListEl = document.getElementById('forum-posts-list');
if (postsListEl) _bindPostActions(postsListEl, thread.id, uid, isMod);
// Reply abschicken
document.getElementById('ft-reply')?.addEventListener('click', async () => {
const btn = document.getElementById('ft-reply');
const text = document.getElementById('forum-reply-text')?.value?.trim();
if (!text) { UI.toast.warning('Bitte Text eingeben.'); return; }
await UI.asyncButton(btn, async () => {
const post = await API.forum.addPost(thread.id, { text });
// Foto hochladen falls vorhanden
const files = Array.from(document.getElementById('forum-reply-file')?.files || []);
for (const file of files.slice(0, 5)) {
try {
await API.forum.uploadPostFoto(post.id, file);
} catch (e) { /* Foto-Upload-Fehler ignorieren */ }
}
thread.antworten = (thread.antworten || 0) + 1;
const idx = _threads.findIndex(t => t.id === thread.id);
if (idx !== -1) _threads[idx].antworten = thread.antworten;
const listEl = document.getElementById('forum-posts-list');
if (listEl) {
const placeholder = listEl.querySelector('p[style*="italic"]');
if (placeholder) listEl.innerHTML = '';
listEl.insertAdjacentHTML('beforeend', _postHTML(post, uid, isMod));
_bindPostActions(listEl, thread.id, uid, isMod);
}
document.getElementById('forum-reply-text').value = '';
const previews = document.getElementById('forum-reply-previews');
if (previews) previews.innerHTML = '';
UI.toast.success('Antwort gesendet.');
});
});
}
// ----------------------------------------------------------
// Post HTML
// ----------------------------------------------------------
function _postHTML(p, uid, isMod) {
if (p.is_deleted) {
return `<div class="forum-post forum-post--deleted" data-post-id="${p.id}">
<em>Beitrag wurde entfernt</em>
</div>`;
}
const isOwn = uid && uid === p.user_id;
const fotoHtml = (p.foto_urls?.length)
? `<div class="forum-foto-grid">${p.foto_urls.map(u =>
`<img src="${_esc(u)}" class="forum-foto-img" data-src="${_esc(u)}" alt="" loading="lazy">`
).join('')}</div>`
: '';
const likeClass = p.user_liked ? 'forum-like-btn active' : 'forum-like-btn';
const canDelete = isOwn || isMod;
return `
<div class="forum-post" data-post-id="${p.id}" data-user-id="${p.user_id || ''}">
<div class="forum-post-header">
<div class="forum-avatar forum-avatar--sm">${_esc(_initial(p.autor_name))}</div>
<span class="forum-post-author">${_esc(p.autor_name || 'Unbekannt')}</span>
<span class="forum-post-date">${_fmtDate(p.created_at)}</span>
</div>
<div class="forum-post-body">
<div class="forum-post-text">${_esc(p.text)}</div>
${fotoHtml}
</div>
<div class="forum-post-actions">
<button class="${likeClass} forum-post-like" data-post-id="${p.id}">
${UI.icon('heart')} <span class="forum-post-like-count">${p.likes || 0}</span>
</button>
${(!isOwn && uid) ? `<button class="forum-report-btn forum-post-report" data-post-id="${p.id}">${UI.icon('flag')}</button>` : ''}
${canDelete ? `<button class="btn btn-ghost btn-sm forum-post-delete" data-post-id="${p.id}" style="color:var(--c-danger);margin-left:auto">${UI.icon('trash')}</button>` : ''}
</div>
</div>`;
}
// ----------------------------------------------------------
// Post-Aktionen binden
// ----------------------------------------------------------
function _bindPostActions(container, threadId, uid, isMod) {
// Like
container.querySelectorAll('.forum-post-like:not([data-bound])').forEach(btn => {
btn.dataset.bound = '1';
btn.addEventListener('click', async () => {
if (!uid) { UI.toast.info('Bitte erst anmelden.'); return; }
const postId = parseInt(btn.dataset.postId);
try {
const res = await API.forum.like('post', postId);
btn.classList.toggle('active', res.liked);
const countEl = btn.querySelector('.forum-post-like-count');
if (countEl) countEl.textContent = res.count;
} catch (err) { UI.toast.error(err.message); }
});
});
// Report
container.querySelectorAll('.forum-post-report:not([data-bound])').forEach(btn => {
btn.dataset.bound = '1';
btn.addEventListener('click', () => {
_showReportForm('post', parseInt(btn.dataset.postId));
});
});
// Delete
container.querySelectorAll('.forum-post-delete:not([data-bound])').forEach(btn => {
btn.dataset.bound = '1';
btn.addEventListener('click', async () => {
const postId = parseInt(btn.dataset.postId);
const postEl = container.querySelector(`[data-post-id="${postId}"]`);
const ok = await UI.modal.confirm({
title: 'Antwort löschen?',
message: 'Dieser Beitrag wird entfernt.',
confirmText: 'Löschen', danger: true,
});
if (!ok) return;
try {
await API.forum.deletePost(postId);
if (postEl) {
postEl.innerHTML = '<em style="color:var(--c-text-muted)">Beitrag wurde entfernt</em>';
postEl.className = 'forum-post forum-post--deleted';
}
const idx = _threads.findIndex(t => t.id === threadId);
if (idx !== -1 && _threads[idx].antworten > 0) _threads[idx].antworten--;
UI.toast.success('Beitrag gelöscht.');
} catch (err) { UI.toast.error(err.message); }
});
});
// Foto-Fullscreen
container.querySelectorAll('.forum-foto-img:not([data-bound])').forEach(img => {
img.dataset.bound = '1';
img.addEventListener('click', () => window.open(img.dataset.src || img.src, '_blank'));
});
}
// ----------------------------------------------------------
// Report-Formular
// ----------------------------------------------------------
function _showReportForm(targetType, targetId) {
const body = `
<form id="forum-report-form">
<div class="form-group">
<label class="form-label">Grund der Meldung</label>
<select class="form-control" name="grund">
<option value="spam">Spam</option>
<option value="beleidigung">Beleidigung / Hassrede</option>
<option value="falschinfo">Falsche Informationen</option>
<option value="sonstiges">Sonstiges</option>
</select>
</div>
</form>`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="rep-cancel">Abbrechen</button>
<button type="submit" form="forum-report-form" class="btn btn-danger flex-1">Melden</button>`;
UI.modal.open({ title: `${UI.icon('flag')} Inhalt melden`, body, footer });
document.getElementById('rep-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('forum-report-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector('[form="forum-report-form"][type="submit"]');
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
await API.forum.report(targetType, targetId, fd.grund);
UI.modal.close();
UI.toast.success('Gemeldet. Danke!');
});
});
}
// ----------------------------------------------------------
// Neues Thema
// ----------------------------------------------------------
function _showCreateForm() {
const katOptions = KATEGORIEN.filter(k => k.key !== 'alle').map(k =>
`<option value="${k.key}" ${k.key === _aktivKat ? 'selected' : ''}>${_esc(k.label)}</option>`
).join('');
const body = `
<form id="forum-thread-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Kategorie</label>
<select class="form-control" name="kategorie">${katOptions}</select>
</div>
<div class="form-group">
<label class="form-label">Titel *</label>
<input class="form-control" type="text" name="titel"
placeholder="Worum geht es?" required maxlength="200">
</div>
<div class="form-group">
<label class="form-label">Text * (min. 20 Zeichen)</label>
<textarea class="form-control" name="text" rows="5"
placeholder="Beschreibe dein Thema ausführlich…" required></textarea>
</div>
<div class="form-group">
<label class="form-label">Fotos (max. 5)</label>
<div class="forum-upload-area">
<label class="btn btn-secondary btn-sm" for="forum-thread-files">${UI.icon('camera')} Fotos auswählen</label>
<input type="file" id="forum-thread-files" accept="image/*" multiple style="display:none">
</div>
<div id="forum-thread-previews" class="forum-upload-previews" style="margin-top:var(--space-2)"></div>
</div>
</form>`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="ff-cancel">Abbrechen</button>
<button type="submit" form="forum-thread-form" class="btn btn-primary flex-1">${UI.icon('chat-circle-dots')} Erstellen</button>`;
UI.modal.open({ title: '+ Neues Thema', body, footer });
document.getElementById('ff-cancel')?.addEventListener('click', UI.modal.close);
// Foto-Vorschau
document.getElementById('forum-thread-files')?.addEventListener('change', e => {
const previews = document.getElementById('forum-thread-previews');
if (!previews) return;
previews.innerHTML = '';
Array.from(e.target.files || []).slice(0, 5).forEach(file => {
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
img.className = 'forum-upload-thumb';
previews.appendChild(img);
});
});
document.getElementById('forum-thread-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector('[form="forum-thread-form"][type="submit"]');
const fd = UI.formData(e.target);
if ((fd.text || '').trim().length < 20) {
UI.toast.warning('Text muss mindestens 20 Zeichen lang sein.');
return;
}
await UI.asyncButton(btn, async () => {
const created = await API.forum.create({
kategorie: fd.kategorie,
titel: (fd.titel || '').trim(),
text: (fd.text || '').trim(),
});
// Fotos hochladen
const files = Array.from(document.getElementById('forum-thread-files')?.files || []);
for (const file of files.slice(0, 5)) {
try {
await API.forum.uploadThreadFoto(created.id, file);
} catch (e) { /* ignorieren */ }
}
_threads.unshift({
...created,
text_preview: created.text?.slice(0, 120) || '',
foto_preview: null,
});
UI.modal.close();
_renderList();
UI.toast.success('Thema erstellt!');
});
});
}
// ----------------------------------------------------------
// Mitgliederkarte
// ----------------------------------------------------------
async function _renderMembersMap() {
const el = document.getElementById('forum-main');
if (!el) return;
el.innerHTML = `
<div class="forum-members-section">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
<strong>Mitglieder auf der Karte</strong>
${_appState.user ? `
<label class="forum-location-toggle">
<input type="checkbox" id="forum-loc-toggle" ${_appState.user.forum_show_location ? 'checked' : ''}>
<span>Meinen (ungefähren) Standort teilen</span>
</label>` : ''}
</div>
<div id="forum-map" class="forum-map-container"></div>
</div>`;
// Location-Toggle
document.getElementById('forum-loc-toggle')?.addEventListener('change', async e => {
const show = e.target.checked;
if (show) {
try {
const pos = await API.getLocation();
await API.forum.setLocation(pos.lat, pos.lon, true);
UI.toast.success('Standort geteilt.');
_loadMembersOnMap();
} catch (err) {
e.target.checked = false;
UI.toast.error('Standort konnte nicht ermittelt werden.');
}
} else {
try {
await API.forum.setLocation(null, null, false);
UI.toast.success('Standort versteckt.');
} catch (err) { UI.toast.error(err.message); }
}
});
await _loadLeaflet();
const mapEl = document.getElementById('forum-map');
if (!mapEl) return;
_map = L.map(mapEl, { zoomControl: true }).setView([51.0, 10.0], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap',
maxZoom: 18,
}).addTo(_map);
_loadMembersOnMap();
}
async function _loadMembersOnMap() {
if (!_map) return;
try {
const members = await API.forum.membersMap();
members.forEach(m => {
L.marker([m.lat, m.lon])
.bindPopup(`<strong>${_esc(m.vorname || '?')}</strong>`)
.addTo(_map);
});
} catch (err) {
console.error('Mitgliederkarte Fehler:', err);
}
}
async function _loadLeaflet() {
if (_leafletLoaded || window.L) { _leafletLoaded = true; return; }
// CSS
if (!document.querySelector('link[href*="leaflet.css"]')) {
const lCss = document.createElement('link');
lCss.rel = 'stylesheet';
lCss.href = '/css/leaflet.css';
document.head.appendChild(lCss);
}
// JS
await new Promise((resolve, reject) => {
if (window.L) { resolve(); return; }
const s = document.createElement('script');
s.src = '/js/leaflet.js';
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
_leafletLoaded = true;
}
// ----------------------------------------------------------
// Moderations-Panel
// ----------------------------------------------------------
async function _showModPanel() {
let reports;
try {
reports = await API.forum.reports();
} catch (err) {
UI.toast.error(err.message);
return;
}
const body = reports.length
? `<div class="forum-mod-reports">
${reports.map(r => `
<div class="forum-mod-report-item" data-id="${r.id}">
<div style="font-size:var(--text-sm)">
<strong>${_esc(r.target_type)} #${r.target_id}</strong>
${_esc(r.grund)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
von ${_esc(r.melder_name || '?')} · ${_fmtDate(r.created_at)}
</div>
<button class="btn btn-sm btn-secondary forum-resolve-btn" data-id="${r.id}" style="margin-top:var(--space-2)">
${UI.icon('check')} Erledigt
</button>
</div>`).join('')}
</div>`
: `<p style="color:var(--c-text-muted);text-align:center;padding:var(--space-6)">Keine offenen Berichte.</p>`;
const footer = `<button type="button" class="btn btn-primary flex-1" id="mod-close">Schließen</button>`;
UI.modal.open({ title: `${UI.icon('scales')} Moderationsberichte`, body, footer });
document.getElementById('mod-close')?.addEventListener('click', UI.modal.close);
document.querySelectorAll('.forum-resolve-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = parseInt(btn.dataset.id);
try {
await API.forum.resolveReport(id);
btn.closest('.forum-mod-report-item')?.remove();
UI.toast.success('Erledigt.');
} catch (err) { UI.toast.error(err.message); }
});
});
}
return { init, refresh, onDogChange };
})();

View file

@ -0,0 +1,282 @@
/* ============================================================
BAN YARO Freunde-Seite
============================================================ */
window.Page_friends = (() => {
let _container = null;
let _searchTimer = null;
// ----------------------------------------------------------
function init(container) {
_container = container;
render();
}
// ----------------------------------------------------------
async function render() {
_container.innerHTML = `
<div style="padding:var(--space-4)">
<h2 style="font-size:var(--text-xl);font-weight:var(--weight-bold);margin-bottom:var(--space-4)">
Freunde
</h2>
<!-- Suche -->
<div class="friends-search-row">
<input id="fr-search" class="form-input" type="search"
placeholder="Namen suchen…" autocomplete="off" style="flex:1">
</div>
<div id="fr-search-results"></div>
<!-- Incoming requests -->
<div id="fr-incoming"></div>
<!-- Outgoing -->
<div id="fr-outgoing"></div>
<!-- Friends list -->
<div id="fr-list"></div>
</div>
`;
document.getElementById('fr-search').addEventListener('input', e => {
clearTimeout(_searchTimer);
_searchTimer = setTimeout(() => _doSearch(e.target.value.trim()), 400);
});
await _loadFriends();
}
// ----------------------------------------------------------
async function _loadFriends() {
try {
const data = await API.friends.list();
_renderIncoming(data.incoming);
_renderOutgoing(data.outgoing);
_renderFriends(data.friends);
_updateBadge(data.incoming.length);
} catch (e) {
if (e.status === 401) {
document.getElementById('fr-list').innerHTML =
`<div class="empty-state"><p>Bitte melde dich an, um Freunde zu verwalten.</p></div>`;
}
}
}
// ----------------------------------------------------------
function _updateBadge(count) {
const el = document.getElementById('friends-badge');
if (!el) return;
el.textContent = count;
el.style.display = count > 0 ? '' : 'none';
}
// ----------------------------------------------------------
function _renderIncoming(list) {
const el = document.getElementById('fr-incoming');
if (!list.length) { el.innerHTML = ''; return; }
el.innerHTML = `
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);margin-bottom:var(--space-2)">
Anfragen (${list.length})
</h3>
${list.map(r => `
<div class="friend-request-card">
<div class="friend-avatar">${_initial(r.requester_name)}</div>
<div style="flex:1">
<div style="font-weight:var(--weight-semibold)">${_esc(r.requester_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">möchte mit dir befreundet sein</div>
</div>
<div class="friend-item-actions">
<button class="btn btn-primary btn-sm"
onclick="Page_friends._accept(${r.id})">
<svg class="ph-icon"><use href="/icons/phosphor.svg#check"></use></svg>
</button>
<button class="btn btn-ghost btn-sm"
onclick="Page_friends._decline(${r.id})">
<svg class="ph-icon"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
</div>
`).join('')}
`;
}
// ----------------------------------------------------------
function _renderOutgoing(list) {
const el = document.getElementById('fr-outgoing');
if (!list.length) { el.innerHTML = ''; return; }
el.innerHTML = `
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);margin:var(--space-4) 0 var(--space-2)">
Gesendete Anfragen
</h3>
${list.map(r => `
<div class="friend-item">
<div class="friend-avatar">${_initial(r.addressee_name)}</div>
<div class="friend-item-name">${_esc(r.addressee_name)}</div>
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">ausstehend</span>
<button class="btn btn-ghost btn-sm"
onclick="Page_friends._cancel(${r.id})"
title="Anfrage zurückziehen">
<svg class="ph-icon"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
`).join('')}
`;
}
// ----------------------------------------------------------
function _renderFriends(list) {
const el = document.getElementById('fr-list');
if (!list.length) {
el.innerHTML = `
<div class="empty-state" style="margin-top:var(--space-6)">
<svg class="ph-icon" style="font-size:3rem;opacity:0.3"><use href="/icons/phosphor.svg#users"></use></svg>
<p style="margin-top:var(--space-3);color:var(--c-text-muted)">
Noch keine Freunde. Suche oben nach Nutzern!
</p>
</div>
`;
return;
}
el.innerHTML = `
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);margin:var(--space-4) 0 var(--space-2)">
Freunde (${list.length})
</h3>
${list.map(f => `
<div class="friend-item">
<div class="friend-avatar">${_initial(f.friend_name)}</div>
<div class="friend-item-name">${_esc(f.friend_name)}</div>
<div class="friend-item-actions">
<button class="btn btn-ghost btn-sm"
onclick="Page_friends._openChat(${f.friend_id})"
title="Nachricht senden">
<svg class="ph-icon"><use href="/icons/phosphor.svg#chat-circle-dots"></use></svg>
</button>
<button class="btn btn-ghost btn-sm"
onclick="Page_friends._removeFriend(${f.friend_id}, '${_esc(f.friend_name)}')"
title="Freund entfernen">
<svg class="ph-icon"><use href="/icons/phosphor.svg#user-minus"></use></svg>
</button>
</div>
</div>
`).join('')}
`;
}
// ----------------------------------------------------------
async function _doSearch(q) {
const el = document.getElementById('fr-search-results');
if (q.length < 2) { el.innerHTML = ''; return; }
try {
const results = await API.friends.search(q);
if (!results.length) {
el.innerHTML = `<div class="friends-search-results">
<div style="padding:var(--space-3) var(--space-4);color:var(--c-text-muted);font-size:var(--text-sm)">
Keine Nutzer gefunden.
</div>
</div>`;
return;
}
el.innerHTML = `
<div class="friends-search-results">
${results.map(u => `
<div class="friend-result-item">
<div class="friend-avatar">${_initial(u.name)}</div>
<div style="flex:1;font-size:var(--text-sm)">${_esc(u.name)}</div>
<button class="btn btn-primary btn-sm"
onclick="Page_friends._sendRequest(${u.id}, this)">
<svg class="ph-icon"><use href="/icons/phosphor.svg#user-plus"></use></svg>
Anfrage
</button>
</div>
`).join('')}
</div>
`;
} catch (e) {
el.innerHTML = '';
}
}
// ----------------------------------------------------------
async function _sendRequest(userId, btn) {
btn.disabled = true;
try {
await API.friends.sendRequest(userId);
UI.toast('Freundschaftsanfrage gesendet!', 'success');
document.getElementById('fr-search').value = '';
document.getElementById('fr-search-results').innerHTML = '';
await _loadFriends();
} catch (e) {
UI.toast(e.message, 'danger');
btn.disabled = false;
}
}
async function _accept(id) {
try {
await API.friends.accept(id);
UI.toast('Freundschaft angenommen!', 'success');
await _loadFriends();
} catch (e) {
UI.toast(e.message, 'danger');
}
}
async function _decline(id) {
try {
await API.friends.decline(id);
await _loadFriends();
} catch (e) {
UI.toast(e.message, 'danger');
}
}
async function _cancel(id) {
try {
await API.friends.decline(id);
await _loadFriends();
} catch (e) {
UI.toast(e.message, 'danger');
}
}
async function _removeFriend(userId, name) {
if (!confirm(`${name} als Freund entfernen?`)) return;
try {
await API.friends.remove(userId);
UI.toast('Freund entfernt.', 'info');
await _loadFriends();
} catch (e) {
UI.toast(e.message, 'danger');
}
}
async function _openChat(userId) {
try {
const { conversation_id } = await API.chat.start(userId);
App.navigate('chat', true, { conversation_id });
} catch (e) {
UI.toast(e.message, 'danger');
}
}
// ----------------------------------------------------------
function _initial(name) {
return (name || '?')[0].toUpperCase();
}
function _esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ----------------------------------------------------------
return {
init,
_sendRequest, _accept, _decline, _cancel, _removeFriend, _openChat,
};
})();

View file

@ -13,13 +13,14 @@ window.Page_health = (() => {
let _activeTab = 'impfung';
const TABS = [
{ key: 'impfung', label: 'Impfpass', icon: '💉' },
{ key: 'tierarzt', label: 'Besuche', icon: '🩺' },
{ key: 'gewicht', label: 'Gewicht', icon: '⚖️' },
{ key: 'medikament', label: 'Medikamente',icon: '💊' },
{ key: 'allergie', label: 'Allergien', icon: '🌿' },
{ key: 'dokument', label: 'Dokumente', icon: '📄' },
{ key: 'praxen', label: 'Praxen', icon: '🏥' },
{ key: 'impfung', label: 'Impfpass', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>' },
{ key: 'tierarzt', label: 'Besuche', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
{ key: 'gewicht', label: 'Gewicht', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scales"></use></svg>' },
{ key: 'medikament', label: 'Medikamente', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
{ key: 'allergie', label: 'Allergien', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>' },
{ key: 'dokument', label: 'Dokumente', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>' },
{ key: 'praxen', label: 'Praxen', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
{ key: 'symptomcheck', label: 'Symptom-Check', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>' },
];
// ----------------------------------------------------------
@ -56,7 +57,7 @@ window.Page_health = (() => {
async function _render() {
if (!_appState.activeDog) {
_container.innerHTML = UI.emptyState({
icon: '💉',
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>',
title: 'Noch kein Hund angelegt',
text: 'Erstelle zuerst ein Hundeprofil.',
action: `<button class="btn btn-primary" onclick="App.navigate('dog-profile')">Profil erstellen</button>`,
@ -81,7 +82,7 @@ window.Page_health = (() => {
const isActive = dog.id === activeDogId;
const av = dog.foto_url
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}">`
: `<span style="font-size:2.5rem">🐕</span>`;
: `<span>${UI.icon('dog')}</span>`;
return `
<div class="diary-picker-card${isActive ? ' diary-picker-card--active' : ''}"
data-dog-id="${dog.id}">
@ -119,7 +120,7 @@ window.Page_health = (() => {
_container.innerHTML = `
<div class="health-header">
<button class="btn btn-secondary btn-sm" id="health-ki-btn">
KI-Zusammenfassung
${UI.icon('star')} KI-Zusammenfassung
</button>
</div>
<div id="health-reminders"></div>
@ -165,13 +166,17 @@ window.Page_health = (() => {
if (!items.length) { el.innerHTML = ''; return; }
const ICONS = { impfung: '💉', entwurmung: '🪱', medikament: '💊' };
const ICONS = {
impfung: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>',
entwurmung: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg>',
medikament: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>',
};
el.innerHTML = `
<div style="padding:var(--space-3) var(--space-4) 0">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);margin-bottom:var(--space-2)">
📅 Anstehende Erinnerungen
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-dots"></use></svg> Anstehende Erinnerungen
</div>
${items.map(e => {
const ampel = _impfAmpel(e.naechstes);
@ -185,7 +190,7 @@ window.Page_health = (() => {
padding:var(--space-2) var(--space-3);margin-bottom:var(--space-1);
background:var(--c-surface);border-radius:var(--radius-md);
border-left:3px solid ${ampel.color === 'red' ? '#ef4444' : ampel.color === 'yellow' ? '#f59e0b' : '#22c55e'}">
<span style="font-size:1.2rem">${ICONS[e._typ] || '📋'}</span>
<span style="font-size:1.2rem">${ICONS[e._typ] || '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg>'}</span>
<div style="flex:1;min-width:0">
<div style="font-size:var(--text-sm);font-weight:var(--weight-medium);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
@ -199,7 +204,7 @@ window.Page_health = (() => {
data-action="reminder-erledigt"
data-entry-id="${e.id}" data-entry-typ="${e._typ}"
style="flex-shrink:0;white-space:nowrap">
Erledigt
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> Erledigt
</button>
</div>`;
}).join('')}
@ -302,13 +307,14 @@ window.Page_health = (() => {
const entries = _data[_activeTab] || [];
switch (_activeTab) {
case 'impfung': content.innerHTML = _renderImpfungen(entries); break;
case 'tierarzt': content.innerHTML = _renderTierarzt(entries); break;
case 'gewicht': content.innerHTML = _renderGewicht(entries); break;
case 'medikament': content.innerHTML = _renderMedikamente(entries); break;
case 'allergie': content.innerHTML = _renderAllergien(entries); break;
case 'dokument': content.innerHTML = _renderDokumente(entries); break;
case 'praxen': content.innerHTML = _renderPraxen(); break;
case 'impfung': content.innerHTML = _renderImpfungen(entries); break;
case 'tierarzt': content.innerHTML = _renderTierarzt(entries); break;
case 'gewicht': content.innerHTML = _renderGewicht(entries); break;
case 'medikament': content.innerHTML = _renderMedikamente(entries); break;
case 'allergie': content.innerHTML = _renderAllergien(entries); break;
case 'dokument': content.innerHTML = _renderDokumente(entries); break;
case 'praxen': content.innerHTML = _renderPraxen(); break;
case 'symptomcheck': _renderSymptomCheck(content); break;
}
_bindTabEvents(content);
@ -321,7 +327,7 @@ window.Page_health = (() => {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Impfung eintragen</button>`;
if (!entries.length) return UI.emptyState({
icon: '💉', title: 'Noch keine Impfungen', text: 'Trage alle Impfungen ein, um nichts zu verpassen.', action: addBtn
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
});
const items = entries.map(e => {
@ -337,7 +343,7 @@ window.Page_health = (() => {
${UI.time.format(e.datum + 'T00:00:00')}
${e.charge_nr ? ` · Ch.-Nr: ${_esc(e.charge_nr)}` : ''}
</div>
${vetName ? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-top:var(--space-1)">🏥 ${_esc(vetName)}</div>` : ''}
${vetName ? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-top:var(--space-1)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> ${_esc(vetName)}</div>` : ''}
${e.naechstes ? `<div class="health-card-next ampel-text-${ampel.color}">
Nächste Impfung: ${UI.time.format(e.naechstes + 'T00:00:00')} ${ampel.icon}
</div>` : ''}
@ -365,7 +371,7 @@ window.Page_health = (() => {
function _renderTierarzt(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Besuch eintragen</button>`;
if (!entries.length) return UI.emptyState({
icon: '🩺', title: 'Noch keine Tierarztbesuche', text: 'Halte alle Tierarztbesuche fest.', action: addBtn
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', title: 'Noch keine Tierarztbesuche', text: 'Halte alle Tierarztbesuche fest.', action: addBtn
});
const items = entries.map(e => {
@ -383,7 +389,7 @@ window.Page_health = (() => {
${praxisName ? `
<div style="display:flex;align-items:center;gap:var(--space-1);
margin-top:var(--space-1);font-size:var(--text-sm);color:var(--c-text-secondary)">
🏥 ${_esc(praxisName)}${praxisOrt ? ` · ${_esc(praxisOrt)}` : ''}
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> ${_esc(praxisName)}${praxisOrt ? ` · ${_esc(praxisOrt)}` : ''}
</div>` : ''}
${e.diagnose ? `<div class="health-card-note"><b>Diagnose:</b> ${_esc(e.diagnose)}</div>` : ''}
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
@ -403,7 +409,7 @@ window.Page_health = (() => {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Gewicht eintragen</button>`;
if (!entries.length) return UI.emptyState({
icon: '⚖️', title: 'Noch keine Gewichtseinträge', action: addBtn
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scales"></use></svg>', title: 'Noch keine Gewichtseinträge', action: addBtn
});
const sorted = [...entries].sort((a, b) => a.datum.localeCompare(b.datum));
@ -529,7 +535,7 @@ window.Page_health = (() => {
function _renderMedikamente(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Medikament eintragen</button>`;
if (!entries.length) return UI.emptyState({
icon: '💊', title: 'Noch keine Medikamente', action: addBtn
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', title: 'Noch keine Medikamente', action: addBtn
});
const aktive = entries.filter(e => e.aktiv);
@ -554,7 +560,7 @@ window.Page_health = (() => {
return `
<div class="health-list">
${renderGroup(aktive, '💊 Aktuelle Medikamente')}
${renderGroup(aktive, '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> Aktuelle Medikamente')}
${renderGroup(inaktive, 'Vergangene Medikamente')}
</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>
@ -567,7 +573,7 @@ window.Page_health = (() => {
function _renderAllergien(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Allergie eintragen</button>`;
if (!entries.length) return UI.emptyState({
icon: '🌿', title: 'Noch keine Allergien eingetragen', action: addBtn
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>', title: 'Noch keine Allergien eingetragen', action: addBtn
});
const SCHWEREGRAD = { leicht: '🟡', mittel: '🟠', schwer: '🔴' };
@ -598,7 +604,7 @@ window.Page_health = (() => {
function _renderDokumente(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Dokument hinzufügen</button>`;
if (!entries.length) return UI.emptyState({
icon: '📄', title: 'Noch keine Dokumente', text: 'Lade Impfpässe, Befunde und mehr hoch.', action: addBtn
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>', title: 'Noch keine Dokumente', text: 'Lade Impfpässe, Befunde und mehr hoch.', action: addBtn
});
const items = entries.map(e => {
@ -610,7 +616,7 @@ window.Page_health = (() => {
? `<img src="${e.datei_url}" class="health-doc-thumb" alt="Vorschau"
style="width:64px;height:64px;object-fit:cover;border-radius:var(--radius-md);flex-shrink:0">`
: `<div style="width:48px;height:48px;display:flex;align-items:center;justify-content:center;
font-size:2rem;flex-shrink:0">${isPdf ? '📄' : '📎'}</div>`}
font-size:2rem;flex-shrink:0"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg></div>`}
<div class="health-card-body">
<div class="health-card-title">${_esc(e.bezeichnung)}</div>
<div class="health-card-meta">${UI.time.format(e.datum + 'T00:00:00')}</div>
@ -619,7 +625,7 @@ window.Page_health = (() => {
? `<a href="${e.datei_url}" target="_blank" rel="noopener"
class="btn btn-secondary btn-sm" style="margin-top:var(--space-2);display:inline-flex"
onclick="event.stopPropagation()">
${isPdf ? '📄 PDF öffnen' : '🖼️ Bild öffnen'}
${isPdf ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF öffnen' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#image"></use></svg> Bild öffnen'}
</a>`
: `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">Noch keine Datei hochgeladen</span>`}
</div>
@ -668,7 +674,7 @@ window.Page_health = (() => {
${fields}
${entry.datei_url
? (entry.datei_typ === 'pdf'
? `<a href="${entry.datei_url}" target="_blank" class="btn btn-secondary btn-sm" style="margin-top:var(--space-3)">📄 PDF öffnen</a>`
? `<a href="${entry.datei_url}" target="_blank" class="btn btn-secondary btn-sm" style="margin-top:var(--space-3)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF öffnen</a>`
: `<img src="${entry.datei_url}" style="width:100%;border-radius:var(--radius-md);margin-top:var(--space-3)" alt="Dokument">`)
: ''}
</div>
@ -717,7 +723,7 @@ window.Page_health = (() => {
if (praxis) {
const adresse = [praxis.strasse, [praxis.plz, praxis.ort].filter(Boolean).join(' ')].filter(Boolean).join(', ');
const tel = praxis.telefon ? ` · <a href="tel:${_esc(praxis.telefon)}">${_esc(praxis.telefon)}</a>` : '';
rows.push(['Praxis', `🏥 ${_esc(praxis.name)}${adresse ? `<br><small style="color:var(--c-text-secondary)">${_esc(adresse)}${tel}</small>` : tel}`]);
rows.push(['Praxis', `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> ${_esc(praxis.name)}${adresse ? `<br><small style="color:var(--c-text-secondary)">${_esc(adresse)}${tel}</small>` : tel}`]);
}
} else if (e.tierarzt_name) {
rows.push(['Tierarzt', _esc(e.tierarzt_name)]);
@ -940,7 +946,7 @@ window.Page_health = (() => {
: `<div class="form-group">
<div style="padding:var(--space-3);background:var(--c-bg);border-radius:var(--radius-md);
font-size:var(--text-sm);color:var(--c-text-secondary)">
🏥 Noch keine Praxis angelegt
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> Noch keine Praxis angelegt
<button type="button" class="btn btn-ghost btn-sm" style="padding:0;font-size:inherit"
data-action="goto-praxen">Praxis im Tab Praxen anlegen</button>
</div>
@ -1062,7 +1068,7 @@ window.Page_health = (() => {
const inaktive = _praxen.filter(p => !p.aktiv);
if (!_praxen.length) return UI.emptyState({
icon: '🏥', title: 'Noch keine Praxis eingetragen',
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', title: 'Noch keine Praxis eingetragen',
text: 'Trage deine Tierarztpraxis ein für schnellen Zugriff.',
action: addBtn
});
@ -1070,7 +1076,7 @@ window.Page_health = (() => {
const renderCard = p => `
<div class="health-card praxis-card${!p.aktiv ? ' health-card--inactive' : ''}"
data-praxis-id="${p.id}" data-action="open-praxis">
<div style="font-size:1.5rem">${p.ist_notfallpraxis ? '🚨' : '🏥'}</div>
<div style="font-size:1.5rem">${p.ist_notfallpraxis ? '🚨' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>'}</div>
<div class="health-card-body">
<div class="health-card-title">
${_esc(p.name)}
@ -1225,6 +1231,111 @@ window.Page_health = (() => {
});
}
// ----------------------------------------------------------
// SYMPTOM-CHECK
// ----------------------------------------------------------
function _renderSymptomCheck(content) {
content.innerHTML = `
<div style="padding:var(--space-4)">
<div class="card" style="padding:var(--space-4)">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-4)">
Beschreibe die Symptome deines Hundes. Die KI gibt eine erste Einschätzung kein Ersatz für den Tierarzt.
</p>
<div class="form-group">
<label class="form-label" for="symptom-input">Symptome</label>
<textarea id="symptom-input" class="form-control" rows="4"
placeholder="z.B. frisst nicht, trinkt viel, schläft mehr als sonst..."></textarea>
</div>
<button id="symptom-submit-btn" class="btn btn-primary" style="width:100%">
Symptome analysieren <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
</button>
<div id="symptom-result" style="display:none;margin-top:var(--space-5)"></div>
</div>
</div>
`;
content.querySelector('#symptom-submit-btn').addEventListener('click', async function () {
const btn = this;
const textarea = content.querySelector('#symptom-input');
const resultEl = content.querySelector('#symptom-result');
const symptoms = textarea.value.trim();
if (!symptoms) {
UI.toast.warning('Bitte Symptome eingeben.');
return;
}
await UI.asyncButton(btn, async () => {
resultEl.style.display = 'none';
resultEl.innerHTML = '';
let result;
try {
result = await API.post(
`/dogs/${_appState.activeDog.id}/health/symptom-check`,
{ symptoms }
);
} catch (err) {
if (err.status === 402) {
resultEl.innerHTML = `
<div class="card" style="padding:var(--space-4);border:1px solid var(--c-warning,#f59e0b)">
<p style="margin:0;font-size:var(--text-sm)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg> Dieses Feature benötigt Ban Yaro Plus oder einen laufenden KI-Server.
</p>
</div>`;
} else if (err.status === 503) {
resultEl.innerHTML = `
<div class="card" style="padding:var(--space-4);border:1px solid var(--c-danger,#ef4444)">
<p style="margin:0;font-size:var(--text-sm)">
KI-Server nicht erreichbar. Bitte später versuchen.
</p>
</div>`;
} else {
UI.toast.error(err.message || 'Fehler bei der Symptomanalyse.');
return;
}
resultEl.style.display = '';
return;
}
const DRINGLICHKEIT = {
beobachten: { badgeClass: 'badge-success', label: '🟢 Beobachten' },
tierarzt: { badgeClass: 'badge-warning', label: '🟡 Zum Tierarzt' },
notfall: { badgeClass: 'badge-danger', label: '🔴 Notfall — sofort zum Tierarzt!' },
};
const d = DRINGLICHKEIT[result.dringlichkeit] || { badgeClass: 'badge-primary', label: _esc(result.dringlichkeit) };
const hinweiseHtml = (result.hinweise || []).length
? `<ul style="margin:var(--space-2) 0 0;padding-left:var(--space-5);font-size:var(--text-sm)">
${result.hinweise.map(h => `<li style="margin-bottom:var(--space-1)">${_esc(h)}</li>`).join('')}
</ul>`
: '';
const zumTierarztHtml = result.zum_tierarzt_wenn
? `<div style="margin-top:var(--space-3);padding:var(--space-3);
background:var(--c-surface-alt,var(--c-surface));
border-radius:var(--radius-md);font-size:var(--text-sm)">
<strong>Zum Tierarzt wenn:</strong> ${_esc(result.zum_tierarzt_wenn)}
</div>`
: '';
resultEl.innerHTML = `
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
<span class="badge ${d.badgeClass}" style="font-size:var(--text-sm);padding:var(--space-1) var(--space-3)">
${d.label}
</span>
</div>
${result.einschaetzung
? `<p style="font-size:var(--text-sm);line-height:1.6;margin:0">${_esc(result.einschaetzung)}</p>`
: ''}
${hinweiseHtml}
${zumTierarztHtml}
`;
resultEl.style.display = '';
});
});
}
// ----------------------------------------------------------
// KI-ZUSAMMENFASSUNG
// ----------------------------------------------------------
@ -1235,7 +1346,7 @@ window.Page_health = (() => {
try {
const { zusammenfassung } = await API.health.kiZusammenfassung(_appState.activeDog.id);
UI.modal.open({
title: '✨ KI-Gesundheitsbericht',
title: `${UI.icon('star')} KI-Gesundheitsbericht`,
body: `<div style="white-space:pre-wrap;line-height:1.7;font-size:var(--text-sm)">${_esc(zusammenfassung)}</div>`,
});
} catch (err) {

View file

@ -0,0 +1,413 @@
/* ============================================================
BAN YARO Hunde-Knigge
Seiten-Modul: Begegnungen, Community-Voting, KI-Rat, Haftpflicht.
============================================================ */
window.Page_knigge = (() => {
// ----------------------------------------------------------
// MODUL-STATE
// ----------------------------------------------------------
let _container = null;
let _appState = null;
// Voting-State: { szenario_id: { counts: {}, user_answer: null } }
const _voteState = {};
// ----------------------------------------------------------
// HARDCODED INHALTE
// ----------------------------------------------------------
const BEGEGNUNGEN = [
{
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>',
titel: 'Fremder Hund',
tipps: 'Kurze Leine, ruhig bleiben, Hunde schnüffeln lassen wenn beide entspannt. Eskalation: weglenken, Richtung wechseln.',
},
{
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#users"></use></svg>',
titel: 'Kinder',
tipps: 'Hund nie unbeaufsichtigt mit Kindern. Kind fragen ob es streicheln darf. Hund dahinter positionieren, nicht zwischen Kind und Weg.',
},
{
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#person-simple-run"></use></svg>',
titel: 'Radfahrer',
tipps: 'Hund an die Seite nehmen. Fahrrad = potentielle Bedrohung für manche Hunde. Frühzeitig weglenken.',
},
{
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#person-simple-run"></use></svg>',
titel: 'Jogger',
tipps: 'Kurze Leine, Abstand halten, Hund nicht anspringen lassen.',
},
{
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>',
titel: 'ÖPNV',
tipps: 'Maulkorbpflicht gilt im ÖPNV (Deutschland-weit). Kleine Hunde in Transportbox kostenlos, große Hunde brauchen Fahrschein. Regeln je Stadt unterschiedlich.',
},
{
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shopping-cart"></use></svg>',
titel: 'Supermarkt / Geschäfte',
tipps: 'Grundsätzlich Hausrecht des Betreibers. "Hunde willkommen"-Schild = explizite Einladung. Im Zweifel fragen. Außen anbinden nur kurz und beaufsichtigt.',
},
];
const SZENARIEN = [
{
id: 'begegnung_leine',
frage: 'Dein Hund ist gut sozialisiert und läuft frei. Ein angeleinten Hund kommt entgegen. Was tust du?',
antworten: [
{ key: 'a', text: 'Hund weiterlaufen lassen — er ist ja friedlich' },
{ key: 'b', text: 'Hund anleinen und Abstand halten' },
{ key: 'c', text: 'Besitzer fragen ob ein Treffen ok ist' },
],
richtig: 'b',
erklaerung: 'Freilaufende Hunde auf angeleine Hunde zulaufen zu lassen ist unhöflich und kann den angeleinten Hund in Stress versetzen ("Leinenfrust"). Immer erst anleinen und Abstand halten.',
},
{
id: 'gassi_kot',
frage: 'Dein Hund macht sein Geschäft im Park abseits des Weges im Gebüsch. Was machst du?',
antworten: [
{ key: 'a', text: 'Liegenlassen — im Gebüsch stört es niemanden' },
{ key: 'b', text: 'Aufsammeln, auch wenn es versteckt liegt' },
{ key: 'c', text: 'Nur aufsammeln wenn jemand zuschaut' },
],
richtig: 'b',
erklaerung: 'Kot grundsätzlich immer aufsammeln — auch im Gebüsch. Kinder spielen überall, und Parasiten (z.B. Spulwurm) können für Menschen gefährlich sein.',
},
{
id: 'restaurant_hund',
frage: 'Im Restaurant-Außenbereich sitzt du mit deinem Hund. Ein anderer Gast bittet dich deinen Hund wegzunehmen weil er Angst hat. Was tust du?',
antworten: [
{ key: 'a', text: 'Ablehnen — Außenbereich ist hundefreundlich' },
{ key: 'b', text: 'Hund wegsetzen oder selbst weiter hinten platzieren' },
{ key: 'c', text: 'Personal entscheiden lassen' },
],
richtig: 'c',
erklaerung: 'Das Personal / der Betreiber entscheidet über das Hausrecht. Gut wäre es, selbst Kompromissbereitschaft zu zeigen und den Hund etwas wegzurücken — das deeskaliert und signalisiert Rücksicht.',
},
{
id: 'anleine_pflicht',
frage: 'Im Park gibt es keine Schilder. Muss dein Hund an die Leine?',
antworten: [
{ key: 'a', text: 'Nein — kein Schild bedeutet keine Pflicht' },
{ key: 'b', text: 'Kommt auf die Gemeindeordnung an' },
{ key: 'c', text: 'Ja — immer Leinenpflicht in öffentlichen Parks' },
],
richtig: 'b',
erklaerung: 'Leinenpflicht ist Ländersache und variiert stark. Viele Bundesländer haben eine allgemeine Anleinpflicht in Ortschaften oder Parks. Im Zweifel Hund anleinen oder Gemeindewebsite prüfen.',
},
];
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_render();
_loadAllVotes();
}
function refresh() {
// statische Seite — kein Reload nötig
}
// ----------------------------------------------------------
// HAUPT-RENDER
// ----------------------------------------------------------
function _render() {
_container.innerHTML = `
<div id="knigge-wrap">
${_renderBegegnungen()}
${_renderVoting()}
${_renderKiRat()}
${_renderHaftpflicht()}
</div>
`;
_bindAccordion();
_bindVoting();
_bindKiRat();
}
// ----------------------------------------------------------
// SECTION 1: BEGEGNUNGEN — Accordion-Karten
// ----------------------------------------------------------
function _renderBegegnungen() {
const cards = BEGEGNUNGEN.map((b, i) => `
<div class="knigge-accordion" id="acc-${i}">
<button class="knigge-accordion-head" data-acc="${i}" aria-expanded="false">
<span>${b.icon} <strong>${_esc(b.titel)}</strong></span>
<span class="knigge-accordion-arrow">${UI.icon('caret-down')}</span>
</button>
<div class="knigge-accordion-body" id="acc-body-${i}" hidden>
<p style="color:var(--c-text);line-height:1.6">${_esc(b.tipps)}</p>
</div>
</div>
`).join('');
return `
<h2 style="font-size:var(--text-lg);font-weight:700;margin:var(--space-6) 0 var(--space-3)">
${UI.icon('paw-print')} Begegnungen
</h2>
<div class="card" style="padding:0;overflow:hidden">
${cards}
</div>
`;
}
function _bindAccordion() {
_container.querySelectorAll('.knigge-accordion-head').forEach(btn => {
btn.addEventListener('click', () => {
const i = btn.dataset.acc;
const body = document.getElementById(`acc-body-${i}`);
const arrow = btn.querySelector('.knigge-accordion-arrow');
const open = !body.hidden;
body.hidden = open;
btn.setAttribute('aria-expanded', String(!open));
arrow.innerHTML = open ? UI.icon('caret-down') : UI.icon('caret-up');
});
});
}
// ----------------------------------------------------------
// SECTION 2: COMMUNITY VOTING
// ----------------------------------------------------------
function _renderVoting() {
const cards = SZENARIEN.map(s => `
<div class="card" style="margin-bottom:var(--space-4)" id="sz-${s.id}">
<p style="font-weight:var(--weight-semibold);margin-bottom:var(--space-3);line-height:1.5">
${_esc(s.frage)}
</p>
<div class="knigge-vote-options" id="opts-${s.id}">
${s.antworten.map(a => `
<button class="knigge-vote-btn btn btn-secondary"
data-sz="${s.id}" data-key="${a.key}"
style="width:100%;margin-bottom:var(--space-2);justify-content:flex-start;text-align:left">
${_esc(a.text)}
</button>
`).join('')}
</div>
<div class="knigge-vote-result hidden" id="res-${s.id}"></div>
</div>
`).join('');
return `
<h2 style="font-size:var(--text-lg);font-weight:700;margin:var(--space-6) 0 var(--space-3)">
${UI.icon('star')} Was wäre richtig?
</h2>
${cards}
`;
}
function _bindVoting() {
_container.querySelectorAll('.knigge-vote-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const szId = btn.dataset.sz;
const key = btn.dataset.key;
if (!_appState.user) {
UI.toast.warning('Bitte melde dich an um abzustimmen.');
return;
}
try {
const result = await API.knigge.vote(szId, key);
_voteState[szId] = { counts: result.counts, user_answer: result.user_answer };
_renderVoteResult(szId);
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Abstimmen.');
}
});
});
}
async function _loadAllVotes() {
for (const s of SZENARIEN) {
try {
const result = await API.knigge.votes(s.id);
_voteState[s.id] = { counts: result.counts, user_answer: result.user_answer };
if (result.user_answer) {
_renderVoteResult(s.id);
}
} catch {
// ignorieren — Votes werden on-demand geladen
}
}
}
function _renderVoteResult(szId) {
const szenario = SZENARIEN.find(s => s.id === szId);
if (!szenario) return;
const state = _voteState[szId];
if (!state) return;
const optsEl = document.getElementById(`opts-${szId}`);
const resEl = document.getElementById(`res-${szId}`);
if (!optsEl || !resEl) return;
// Optionen ausblenden
optsEl.classList.add('hidden');
resEl.classList.remove('hidden');
const counts = state.counts || {};
const userAnswer = state.user_answer;
const total = Object.values(counts).reduce((s, c) => s + c, 0) || 1;
const isCorrect = userAnswer === szenario.richtig;
const bars = szenario.antworten.map(a => {
const cnt = counts[a.key] || 0;
const pct = Math.round((cnt / total) * 100);
const isU = a.key === userAnswer;
const isR = a.key === szenario.richtig;
const color = isR
? 'var(--c-success, #22c55e)'
: (isU && !isR ? 'var(--c-danger, #ef4444)' : 'var(--c-border)');
return `
<div style="margin-bottom:var(--space-3)">
<div style="display:flex;justify-content:space-between;margin-bottom:4px;font-size:var(--text-sm)">
<span style="color:${isU ? 'var(--c-text)' : 'var(--c-text-secondary)'};font-weight:${isU ? 'var(--weight-semibold)' : 'normal'}">
${isU ? UI.icon('arrow-right') + ' ' : ''}${_esc(a.text)}${isR ? ' ' + UI.icon('check') : ''}
</span>
<span style="color:var(--c-text-secondary)">${pct}% (${cnt})</span>
</div>
<div style="background:var(--c-surface-2);border-radius:4px;height:8px;overflow:hidden">
<div style="width:${pct}%;background:${color};height:8px;border-radius:4px;transition:width 0.4s"></div>
</div>
</div>
`;
}).join('');
const badge = isCorrect
? `<span style="color:var(--c-success,#22c55e);font-weight:var(--weight-semibold)">${UI.icon('check')} Richtig!</span>`
: `<span style="color:var(--c-danger,#ef4444);font-weight:var(--weight-semibold)">${UI.icon('x')} Nicht ganz — </span>`;
resEl.innerHTML = `
<div style="margin-bottom:var(--space-4)">${bars}</div>
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-3);font-size:var(--text-sm);line-height:1.5">
${badge}
<span style="color:var(--c-text-secondary)">${_esc(szenario.erklaerung)}</span>
</div>
`;
}
// ----------------------------------------------------------
// SECTION 3: KI-SITUATIONSBERATER
// ----------------------------------------------------------
function _renderKiRat() {
return `
<h2 style="font-size:var(--text-lg);font-weight:700;margin:var(--space-6) 0 var(--space-3)">
${UI.icon('robot')} KI-Situationsberater
</h2>
<div class="card">
<textarea id="ki-situation-input" class="form-control"
rows="3"
placeholder="Beschreibe deine Situation…"
style="margin-bottom:var(--space-3)"></textarea>
<button class="btn btn-primary" id="ki-rat-btn" style="width:100%">
Rat holen ${UI.icon('robot')}
</button>
<div id="ki-rat-result" style="margin-top:var(--space-4);display:none"></div>
</div>
`;
}
function _bindKiRat() {
const btn = _container.querySelector('#ki-rat-btn');
const input = _container.querySelector('#ki-situation-input');
const result = _container.querySelector('#ki-rat-result');
btn?.addEventListener('click', async () => {
const situation = input?.value?.trim();
if (!situation) {
UI.toast.warning('Bitte beschreibe zuerst deine Situation.');
return;
}
if (!_appState.user) {
UI.toast.warning('Bitte melde dich an um den KI-Rat zu nutzen.');
return;
}
UI.setLoading(btn, true);
result.style.display = 'none';
try {
const data = await API.knigge.kiRat(situation);
result.innerHTML = `
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-4);line-height:1.6;color:var(--c-text)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);margin-bottom:var(--space-2)">${UI.icon('robot')} KI-Rat</div>
${_esc(data.rat)}
</div>
`;
result.style.display = 'block';
} catch (err) {
const is402 = err.status === 402 || err.status === 503;
result.innerHTML = `
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-4);color:var(--c-text-secondary);font-size:var(--text-sm)">
${is402
? 'Für KI-Rat wird Ban Yaro Plus oder ein laufender KI-Server benötigt.'
: _esc(err.message || 'Fehler beim KI-Abruf.')}
</div>
`;
result.style.display = 'block';
} finally {
UI.setLoading(btn, false);
}
});
}
// ----------------------------------------------------------
// SECTION 4: HAFTPFLICHT-HINWEISE
// ----------------------------------------------------------
function _renderHaftpflicht() {
return `
<h2 style="font-size:var(--text-lg);font-weight:700;margin:var(--space-6) 0 var(--space-3)">
${UI.icon('shield')} Haftpflicht-Hinweise
</h2>
<div class="card" style="margin-bottom:var(--space-8)">
<ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:var(--space-3)">
<li style="display:flex;gap:var(--space-3);align-items:flex-start">
<span style="font-size:1.2rem;flex-shrink:0">${UI.icon('scales')}</span>
<span style="color:var(--c-text);line-height:1.5">
Hundehalter haften unbegrenzt für Schäden die ihr Hund verursacht
(§ 833 BGB) auch ohne Verschulden.
</span>
</li>
<li style="display:flex;gap:var(--space-3);align-items:flex-start">
<span style="font-size:1.2rem;flex-shrink:0">${UI.icon('map-trifold')}</span>
<span style="color:var(--c-text);line-height:1.5">
Eine Hundehaftpflichtversicherung ist in einigen Bundesländern
(Bayern, Hamburg, Berlin u.a.) Pflicht.
</span>
</li>
<li style="display:flex;gap:var(--space-3);align-items:flex-start">
<span style="font-size:1.2rem;flex-shrink:0">${UI.icon('info')}</span>
<span style="color:var(--c-text);line-height:1.5">
Empfehlung: Absicherung ab ~50 /Jahr.
</span>
</li>
</ul>
<p style="margin-top:var(--space-4);font-size:var(--text-xs);color:var(--c-text-muted)">
Dies ist keine Rechtsberatung.
</p>
</div>
`;
}
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------
function _esc(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
return { init, refresh };
})();

View file

@ -0,0 +1,688 @@
/* ============================================================
BAN YARO Verlorener Hund (Sprint 11)
Seiten-Modul: Leaflet-Karte + Meldungsliste + Melden-Formular.
============================================================ */
window.Page_lost = (() => {
// ----------------------------------------------------------
// MODUL-STATE
// ----------------------------------------------------------
let _container = null;
let _appState = null;
let _map = null;
let _markers = [];
let _userMarker = null;
let _reports = [];
let _userPos = null;
let _leafletLoaded = false;
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
await _render();
}
// ----------------------------------------------------------
// REFRESH — Navigation zur bereits geladenen Seite
// ----------------------------------------------------------
async function refresh() {
if (_userPos) await _loadReports();
}
// ----------------------------------------------------------
// OPEN NEW — vom + Button
// ----------------------------------------------------------
function openNew() {
_showReportForm();
}
// ----------------------------------------------------------
// RENDER — Grundstruktur aufbauen
// ----------------------------------------------------------
async function _render() {
_container.innerHTML = `
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-3);flex-wrap:wrap">
<button class="btn btn-secondary" id="lost-btn-locate">${UI.icon('map-pin')} Mein Standort</button>
<button class="btn btn-primary" id="lost-btn-report">${UI.icon('magnifying-glass')} Hund vermisst melden</button>
</div>
<div id="lost-map"
style="height:280px;border-radius:var(--radius-md);overflow:hidden;
background:var(--c-surface-2)">
</div>
<div style="font-size:10px;color:var(--c-text-secondary);
text-align:right;margin-bottom:var(--space-4);
padding:2px var(--space-2) 0">
© OpenStreetMap-Mitwirkende
</div>
<p id="lost-info"
style="font-size:var(--text-sm);color:var(--c-text-secondary);
margin-bottom:var(--space-3)">
Standort wird ermittelt
</p>
<div id="lost-held"></div>
<div id="lost-list"></div>
`;
document.getElementById('lost-btn-locate')
?.addEventListener('click', _locateUser);
document.getElementById('lost-btn-report')
?.addEventListener('click', _showReportForm);
await _loadLeaflet();
_initMap();
setTimeout(() => _map?.invalidateSize(), 100);
await _locateAndLoad();
}
// ----------------------------------------------------------
// LEAFLET DYNAMISCH LADEN
// ----------------------------------------------------------
async function _loadLeaflet() {
if (_leafletLoaded || window.L) { _leafletLoaded = true; return; }
await new Promise(resolve => {
if (document.querySelector('link[href*="leaflet"]')) { resolve(); return; }
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/css/leaflet.css';
link.onload = resolve;
link.onerror = resolve;
document.head.appendChild(link);
});
await new Promise((resolve, reject) => {
if (document.querySelector('script[src*="leaflet"]')) { resolve(); return; }
const s = document.createElement('script');
s.src = '/js/leaflet.js';
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
_leafletLoaded = true;
}
// ----------------------------------------------------------
// KARTE INITIALISIEREN
// ----------------------------------------------------------
function _initMap() {
const mapEl = document.getElementById('lost-map');
if (!mapEl || !window.L || _map) return;
_map = L.map('lost-map', { zoomControl: true, attributionControl: false })
.setView([51.1657, 10.4515], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
}).addTo(_map);
}
// ----------------------------------------------------------
// STANDORT ERMITTELN + LADEN
// ----------------------------------------------------------
async function _locateAndLoad() {
try {
_userPos = await API.getLocation({ timeout: 8000 });
_showUserOnMap();
} catch {
_userPos = null;
}
await _loadReports();
}
async function _locateUser() {
const btn = document.getElementById('lost-btn-locate');
UI.setLoading(btn, true);
try {
_userPos = await API.getLocation({ timeout: 8000 });
_showUserOnMap();
if (_map) _map.setView([_userPos.lat, _userPos.lon], 13);
await _loadReports();
} catch {
UI.toast.warning('Standort konnte nicht ermittelt werden.');
}
UI.setLoading(btn, false);
}
function _showUserOnMap() {
if (!_map || !window.L || !_userPos) return;
if (_userMarker) _map.removeLayer(_userMarker);
_userMarker = L.circleMarker([_userPos.lat, _userPos.lon], {
radius : 9,
fillColor : '#3498db',
color : '#fff',
weight : 2,
fillOpacity : 0.9,
}).addTo(_map).bindPopup('<b>Du bist hier</b>');
_map.setView([_userPos.lat, _userPos.lon], 13);
}
// ----------------------------------------------------------
// MELDUNGEN LADEN
// ----------------------------------------------------------
async function _loadReports() {
const infoEl = document.getElementById('lost-info');
if (!_userPos) {
_reports = [];
_renderHeld();
_renderList();
if (infoEl) infoEl.textContent =
'Standort unbekannt — bitte Standort freigeben (📍 Mein Standort).';
return;
}
try {
_reports = await API.lost.list(_userPos.lat, _userPos.lon, 25);
_renderMarkers();
_renderHeld();
_renderList();
_updateBadge(_reports.length);
if (infoEl) {
infoEl.textContent = _reports.length > 0
? `${_reports.length} vermisste${_reports.length !== 1 ? 'r Hund' : 'r Hund'} im Umkreis von 25 km`
: 'Keine vermissten Hunde in deiner Nähe (25 km Radius). 🐾';
}
} catch {
UI.toast.error('Meldungen konnten nicht geladen werden.');
}
}
// ----------------------------------------------------------
// KARTEN-MARKER
// ----------------------------------------------------------
function _renderMarkers() {
if (!_map || !window.L) return;
_markers.forEach(m => _map.removeLayer(m));
_markers = [];
_reports.forEach(r => {
const icon = L.divIcon({
className : '',
html : `<div style="
background:#e74c3c;color:#fff;border-radius:50%;
width:34px;height:34px;
display:flex;align-items:center;justify-content:center;
font-size:17px;box-shadow:0 2px 6px rgba(0,0,0,.35);
border:2px solid #fff">🐕</div>`,
iconSize : [34, 34],
iconAnchor : [17, 17],
});
const distStr = r.distanz_m !== undefined
? (r.distanz_m < 1000 ? `${r.distanz_m} m` : `${(r.distanz_m / 1000).toFixed(1)} km`)
: '';
const marker = L.marker([r.lat, r.lon], { icon })
.addTo(_map)
.bindPopup(`
<b>🔍 ${_escape(r.name)}</b><br>
${r.rasse ? _escape(r.rasse) + '<br>' : ''}
${distStr ? `<small>📍 ${distStr} entfernt</small><br>` : ''}
<small>📅 ${_fmtDate(r.created_at)}</small>
`);
marker.on('click', () => _openDetail(r));
_markers.push(marker);
});
}
// ----------------------------------------------------------
// HELD DES TAGES
// ----------------------------------------------------------
function _renderHeld() {
const heldEl = document.getElementById('lost-held');
if (!heldEl) return;
// Letzter gefundener Hund (is_active=0, gefunden_at gesetzt) — wir laden
// sie nicht separat, daher nutzen wir die aktiven; für "Held" einen eigenen
// API-Call wäre übertrieben. Stattdessen zeigen wir es nur wenn die Liste
// kommt und wir einen kürzlich-gefundenen kennen. Wir überspringen hier
// den separaten Endpunkt und blenden die Sektion aus wenn leer.
heldEl.innerHTML = '';
}
// ----------------------------------------------------------
// LISTE
// ----------------------------------------------------------
function _renderList() {
const listEl = document.getElementById('lost-list');
if (!listEl) return;
if (_reports.length === 0) {
listEl.innerHTML = UI.emptyState({
icon : '🐾',
title : 'Keine vermissten Hunde',
text : 'In deiner Nähe (25 km) werden aktuell keine Hunde vermisst.',
action: `<button class="btn btn-primary" id="lost-empty-report">🔍 Hund melden</button>`,
});
listEl.querySelector('#lost-empty-report')
?.addEventListener('click', _showReportForm);
return;
}
listEl.innerHTML = _reports.map(r => _reportCard(r)).join('');
listEl.querySelectorAll('[data-lost-id]').forEach(card => {
card.addEventListener('click', () => {
const r = _reports.find(x => x.id === parseInt(card.dataset.lostId));
if (r) _openDetail(r);
});
});
}
function _reportCard(r) {
const isOwn = _appState.user && _appState.user.id === r.user_id;
const distStr = r.distanz_m !== undefined
? (r.distanz_m < 1000 ? `${r.distanz_m} m` : `${(r.distanz_m / 1000).toFixed(1)} km`)
: '';
return `
<div class="card" data-lost-id="${r.id}"
style="cursor:pointer;margin-bottom:var(--space-3);
border-left:4px solid #e74c3c">
<div style="display:flex;gap:var(--space-3);align-items:flex-start">
${r.foto_url
? `<img src="${r.foto_url}" alt="Foto"
loading="lazy"
style="width:72px;height:72px;object-fit:cover;
border-radius:var(--radius-md);flex-shrink:0">`
: `<div style="width:72px;height:72px;background:var(--c-surface-2);
border-radius:var(--radius-md);flex-shrink:0;
display:flex;align-items:center;justify-content:center;
font-size:2rem">🐕</div>`}
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:var(--space-2);
margin-bottom:var(--space-1);flex-wrap:wrap">
<span style="font-weight:var(--weight-semibold);font-size:var(--text-base)">
${_escape(r.name)}
</span>
${r.rasse
? `<span class="badge">${_escape(r.rasse)}</span>`
: ''}
${isOwn
? '<span class="badge badge-warning">Meine Meldung</span>'
: ''}
${distStr
? `<span style="margin-left:auto;color:var(--c-text-secondary);
font-size:var(--text-sm);white-space:nowrap">
📍 ${distStr}
</span>`
: ''}
</div>
<p style="margin:0 0 var(--space-1);font-size:var(--text-sm);
color:var(--c-text)">
${_escape(r.beschreibung.slice(0, 120))}${r.beschreibung.length > 120 ? '…' : ''}
</p>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
Gemeldet ${_fmtDate(r.created_at)}
${r.melder_name ? '· ' + _escape(r.melder_name.split(' ')[0]) : ''}
</div>
</div>
</div>
</div>
`;
}
// ----------------------------------------------------------
// DETAIL-MODAL
// ----------------------------------------------------------
function _openDetail(r) {
const isOwn = _appState.user && _appState.user.id === r.user_id;
const isAdmin = _appState.user?.rolle === 'admin';
const distStr = r.distanz_m !== undefined
? (r.distanz_m < 1000 ? `${r.distanz_m} m` : `${(r.distanz_m / 1000).toFixed(1)} km`)
: '';
const body = `
${r.foto_url
? `<img src="${r.foto_url}" alt="Foto"
style="width:100%;border-radius:var(--radius-md);margin-bottom:var(--space-4)">`
: ''}
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3)">
<span class="badge badge-danger">🐕 ${_escape(r.name)}</span>
${r.rasse ? `<span class="badge">${_escape(r.rasse)}</span>` : ''}
</div>
<p style="white-space:pre-wrap;margin-bottom:var(--space-3)">
${_escape(r.beschreibung)}
</p>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);
margin-bottom:var(--space-4);line-height:1.8">
<div>📍 ${r.lat.toFixed(5)}, ${r.lon.toFixed(5)}${distStr ? ' (' + distStr + ' entfernt)' : ''}</div>
<div>📅 Gemeldet: ${_fmtDate(r.created_at)}</div>
${r.melder_name ? `<div>👤 Gemeldet von: ${_escape(r.melder_name.split(' ')[0])}</div>` : ''}
</div>
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
<button class="btn btn-secondary flex-1" id="detail-lost-map">🗺 Auf Karte</button>
${isOwn || isAdmin
? `<button class="btn btn-nature flex-1" id="detail-lost-found">🎉 Gefunden!</button>`
: ''}
${isOwn || isAdmin
? `<button class="btn btn-danger flex-1" id="detail-lost-delete">🗑 Löschen</button>`
: ''}
</div>
`;
UI.modal.open({ title: `🔍 ${_escape(r.name)} wird vermisst`, body });
document.getElementById('detail-lost-map')?.addEventListener('click', () => {
UI.modal.close();
if (_map) {
_map.setView([r.lat, r.lon], 16);
document.getElementById('lost-map')
?.scrollIntoView({ behavior: 'smooth', block: 'start' });
const marker = _markers[_reports.findIndex(x => x.id === r.id)];
marker?.openPopup();
}
});
document.getElementById('detail-lost-found')?.addEventListener('click', () => {
_showFoundDialog(r);
});
document.getElementById('detail-lost-delete')?.addEventListener('click', async () => {
if (!confirm(`Meldung für ${r.name} wirklich löschen?`)) return;
try {
await API.lost.delete(r.id);
_reports = _reports.filter(x => x.id !== r.id);
_renderMarkers();
_renderList();
_updateBadge(_reports.length);
UI.modal.close();
UI.toast.success('Meldung gelöscht.');
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Löschen.');
}
});
}
// ----------------------------------------------------------
// GEFUNDEN-DIALOG
// ----------------------------------------------------------
function _showFoundDialog(r) {
UI.modal.open({
title: `🎉 ${_escape(r.name)} gefunden?`,
body: `
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
Wurde ${_escape(r.name)} wiedergefunden? Die Meldung wird als
abgeschlossen markiert und aus der Liste entfernt.
</p>
`,
footer: `
<button class="btn btn-secondary" id="found-cancel">Abbrechen</button>
<button class="btn btn-nature" id="found-confirm">🎉 Ja, gefunden!</button>
`,
});
document.getElementById('found-cancel')
?.addEventListener('click', UI.modal.close);
document.getElementById('found-confirm')?.addEventListener('click', async () => {
const btn = document.getElementById('found-confirm');
await UI.asyncButton(btn, async () => {
await API.lost.markFound(r.id);
_reports = _reports.filter(x => x.id !== r.id);
_renderMarkers();
_renderList();
_updateBadge(_reports.length);
UI.modal.close();
UI.toast.success(`${r.name} ist wieder da! 🎉`);
});
});
}
// ----------------------------------------------------------
// MELDE-FORMULAR
// ----------------------------------------------------------
function _showReportForm() {
if (!_appState.user) {
UI.toast.warning('Bitte zuerst anmelden, um eine Meldung abzuschicken.');
App.navigate('settings');
return;
}
// Eigene registrierte Hunde für Dropdown
const dogs = _appState.dogs || [];
const dogOpts = dogs.length > 0
? `<option value="">— kein registrierter Hund —</option>` +
dogs.map(d => `<option value="${d.id}">${_escape(d.name)}${d.rasse ? ' (' + _escape(d.rasse) + ')' : ''}</option>`).join('')
: '';
const body = `
<form id="lost-form" autocomplete="off">
${dogs.length > 0 ? `
<div class="form-group">
<label class="form-label">
Registrierter Hund
<span style="color:var(--c-text-secondary)">(optional)</span>
</label>
<select class="form-control" name="dog_id" id="lf-dog-select">
${dogOpts}
</select>
</div>` : ''}
<div class="form-group">
<label class="form-label">Name des Hundes *</label>
<input class="form-control" type="text" name="name" id="lf-name"
placeholder="z. B. Bello" required>
</div>
<div class="form-group">
<label class="form-label">
Rasse
<span style="color:var(--c-text-secondary)">(optional)</span>
</label>
<input class="form-control" type="text" name="rasse"
placeholder="z. B. Labrador">
</div>
<div class="form-group">
<label class="form-label">Beschreibung *</label>
<textarea class="form-control" name="beschreibung" rows="3"
placeholder="Farbe, Merkmale, wo zuletzt gesehen, Halsband, …"
required></textarea>
</div>
<div class="form-group">
<label class="form-label">Standort (letzter bekannter Ort)</label>
<div style="display:flex;gap:var(--space-2);align-items:center">
<input class="form-control" type="text" id="lf-lat-disp"
placeholder="Breite" readonly style="flex:1">
<input class="form-control" type="text" id="lf-lon-disp"
placeholder="Länge" readonly style="flex:1">
<button type="button" class="btn btn-secondary" id="lf-gps-btn"
title="GPS-Standort ermitteln">📍</button>
</div>
<input type="hidden" name="lat" id="lf-lat">
<input type="hidden" name="lon" id="lf-lon">
<small id="lf-gps-hint" style="color:var(--c-text-secondary)">
${_userPos
? '✅ Aktueller Standort vorausgefüllt'
: 'GPS-Button drücken um Standort zu ermitteln'}
</small>
</div>
<div class="form-group">
<label class="form-label">
Foto
<span style="color:var(--c-text-secondary)">(optional)</span>
</label>
<input class="form-control" type="file" name="photo"
accept="image/*" capture="environment">
<img id="lf-photo-preview"
style="display:none;width:100%;max-height:200px;object-fit:cover;
border-radius:var(--radius-md);margin-top:var(--space-2)">
</div>
</form>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="lf-cancel">Abbrechen</button>
<button type="submit" form="lost-form" class="btn btn-primary flex-1">
🔍 Meldung abschicken
</button>
`;
UI.modal.open({ title: '🔍 Hund vermisst melden', body, footer });
// Standort vorausfüllen
if (_userPos) {
document.getElementById('lf-lat').value = _userPos.lat;
document.getElementById('lf-lon').value = _userPos.lon;
document.getElementById('lf-lat-disp').value = _userPos.lat.toFixed(6);
document.getElementById('lf-lon-disp').value = _userPos.lon.toFixed(6);
}
// Wenn registrierter Hund gewählt → Name+Rasse vorausfüllen
document.getElementById('lf-dog-select')?.addEventListener('change', e => {
const dogId = parseInt(e.target.value);
const dog = dogs.find(d => d.id === dogId);
if (dog) {
document.getElementById('lf-name').value = dog.name;
const rasseInput = document.querySelector('#lost-form [name="rasse"]');
if (rasseInput && dog.rasse) rasseInput.value = dog.rasse;
}
});
// GPS-Button
document.getElementById('lf-gps-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('lf-gps-btn');
UI.setLoading(btn, true);
try {
const pos = await API.getLocation({ timeout: 10000, enableHighAccuracy: true });
document.getElementById('lf-lat').value = pos.lat;
document.getElementById('lf-lon').value = pos.lon;
document.getElementById('lf-lat-disp').value = pos.lat.toFixed(6);
document.getElementById('lf-lon-disp').value = pos.lon.toFixed(6);
document.getElementById('lf-gps-hint').textContent = '✅ Standort aktualisiert';
_userPos = pos;
} catch {
UI.toast.error('GPS-Standort konnte nicht ermittelt werden.');
}
UI.setLoading(btn, false);
});
// Foto-Vorschau
const photoInput = document.querySelector('#lost-form [name="photo"]');
const photoPreview = document.getElementById('lf-photo-preview');
if (photoInput && photoPreview) {
UI.setupPhotoPreview(photoInput, photoPreview);
photoInput.addEventListener('change', () => {
photoPreview.style.display = photoInput.files[0] ? 'block' : 'none';
});
}
document.getElementById('lf-cancel')
?.addEventListener('click', UI.modal.close);
// Formular absenden
document.getElementById('lost-form')?.addEventListener('submit', async e => {
e.preventDefault();
const submitBtn = document.querySelector('[form="lost-form"][type="submit"]') ||
e.target.querySelector('[type="submit"]');
const fd = UI.formData(e.target);
if (!fd.lat || !fd.lon) {
UI.toast.warning('Bitte zuerst den GPS-Standort ermitteln (📍).');
return;
}
if (!fd.name?.trim()) {
UI.toast.warning('Bitte den Namen des Hundes eingeben.');
return;
}
await UI.asyncButton(submitBtn, async () => {
const payload = {
name : fd.name.trim(),
rasse : fd.rasse?.trim() || null,
beschreibung : fd.beschreibung?.trim() || '',
lat : parseFloat(fd.lat),
lon : parseFloat(fd.lon),
dog_id : fd.dog_id ? parseInt(fd.dog_id) : null,
};
const created = await API.lost.report(payload);
// Foto hochladen
if (photoInput?.files[0]) {
try {
const formData = new FormData();
formData.append('file', photoInput.files[0]);
const media = await API.lost.uploadFoto(created.id, formData);
created.foto_url = media.foto_url;
} catch {
UI.toast.warning('Meldung erstellt — Foto konnte nicht hochgeladen werden.');
}
}
// Distanz client-seitig berechnen
created.distanz_m = _userPos
? Math.round(_haversine(_userPos.lat, _userPos.lon, created.lat, created.lon))
: 0;
_reports.unshift(created);
_renderMarkers();
_renderList();
_updateBadge(_reports.length);
UI.toast.success('Hund als vermisst gemeldet. Wir drücken die Daumen!');
UI.modal.close();
});
});
}
// ----------------------------------------------------------
// BADGE
// ----------------------------------------------------------
function _updateBadge(count) {
const b = document.getElementById('lost-badge');
if (b) { b.textContent = count; b.style.display = count > 0 ? '' : 'none'; }
}
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------
function _haversine(lat1, lon1, lat2, lon2) {
const R = 6_371_000;
const p1 = lat1 * Math.PI / 180;
const p2 = lat2 * Math.PI / 180;
const dp = (lat2 - lat1) * Math.PI / 180;
const dl = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(a));
}
function _fmtDate(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr.replace(' ', 'T'));
return d.toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric'
});
}
function _escape(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
return { init, refresh, openNew };
})();

View file

@ -28,6 +28,8 @@ window.Page_map = (() => {
let _recStartTime = null;
let _recTimerInt = null;
let _recPolyline = null;
let _pocketOverlay = null;
let _pocketHideTimer = null;
let _recMarker = null;
let _recWatchId = null;
@ -71,22 +73,22 @@ window.Page_map = (() => {
// z: zIndexOffset — höher = weiter oben bei Überlappung
const TYPEN = {
restaurant: { icon: '🍽️', label: 'Restaurant', color: '#F97316', z: 10 },
freilauf: { icon: '🐕', label: 'Freilauf', color: '#22C55E', z: 20 },
shop: { icon: '🛒', label: 'Shop', color: '#3B82F6', z: 15 },
kotbeutel: { icon: '🧻', label: 'Kotbeutel', color: '#6B7280', z: 5 },
tierarzt: { icon: '🩺', label: 'Tierarzt', color: '#EF4444', z: 40 },
hundeschule: { icon: '🎓', label: 'Hundeschule', color: '#8B5CF6', z: 30 },
poison: { icon: '⚠️', label: 'Giftköder', color: '#DC2626', z: 100 },
muell: { icon: '🗑️', label: 'Mülleimer', color: '#78716C', z: -20 },
dog_park: { icon: '🌿', label: 'Hundewiese', color: '#15803D', z: 5 },
wasser: { icon: '💧', label: 'Wasserstelle', color: '#0EA5E9', z: 35 },
bank: { icon: '🪑', label: 'Bank', color: '#92400E', z: -30 },
giftkoeder: { icon: '☠️', label: 'Giftköder', color: '#DC2626', z: 80 },
gefahr: { icon: '⚠️', label: 'Gefahr', color: '#F59E0B', z: 60 },
parkplatz: { icon: '🅿️', label: 'Parkplatz', color: '#2563EB', z: 5 },
treffpunkt: { icon: '🤝', label: 'Treffpunkt', color: '#7C3AED', z: 25 },
community: { icon: '📌', label: 'Sonstiges', color: '#F59E0B', z: 30 },
restaurant: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#fork-knife"></use></svg>', label: 'Restaurant', color: '#F97316', z: 10 },
freilauf: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>', label: 'Freilauf', color: '#22C55E', z: 20 },
shop: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shopping-cart"></use></svg>', label: 'Shop', color: '#3B82F6', z: 15 },
kotbeutel: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Kotbeutel', color: '#6B7280', z: 5 },
tierarzt: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', label: 'Tierarzt', color: '#EF4444', z: 40 },
hundeschule: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#graduation-cap"></use></svg>', label: 'Hundeschule', color: '#8B5CF6', z: 30 },
poison: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>', label: 'Giftköder', color: '#DC2626', z: 100 },
muell: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Mülleimer', color: '#78716C', z: -20 },
dog_park: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>', label: 'Hundewiese', color: '#15803D', z: 5 },
wasser: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#drop"></use></svg>', label: 'Wasserstelle', color: '#0EA5E9', z: 35 },
bank: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#chair"></use></svg>', label: 'Bank', color: '#92400E', z: -30 },
giftkoeder: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#skull"></use></svg>', label: 'Giftköder', color: '#DC2626', z: 80 },
gefahr: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>', label: 'Gefahr', color: '#F59E0B', z: 60 },
parkplatz: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>', label: 'Parkplatz', color: '#2563EB', z: 5 },
treffpunkt: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED', z: 25 },
community: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B', z: 30 },
};
// Frontend-Layer → Backend-Typ Mapping
@ -150,7 +152,7 @@ window.Page_map = (() => {
<div class="map-legend" id="map-legend">
<button class="map-legend-btn map-legend-all" id="map-legend-all" title="Alle ein-/ausblenden">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#list"></use></svg>
</button>
${Object.entries(TYPEN).filter(([k]) => k !== 'giftkoeder').map(([k, t]) => `
<button class="map-legend-btn${_visible[k] !== false ? ' active' : ''}" data-layer="${k}" style="--layer-color:${t.color}">
@ -163,22 +165,22 @@ window.Page_map = (() => {
<!-- Fadenkreuz-Overlay (nur im Placement-Modus sichtbar) -->
<div class="map-crosshair" id="map-crosshair">
<div class="map-crosshair-pin">📍</div>
<div class="map-crosshair-pin"><svg class="ph-icon" aria-hidden="true" style="width:28px;height:28px"><use href="/icons/phosphor.svg#map-pin"></use></svg></div>
<div class="map-crosshair-shadow"></div>
</div>
<div class="map-place-bar" id="map-place-bar">
<span class="map-place-hint">Karte verschieben · Pin landet genau hier</span>
<div class="map-place-btns">
<button class="btn btn-secondary" id="map-place-cancel">Abbrechen</button>
<button class="btn btn-primary" id="map-place-confirm">📌 Hier platzieren</button>
<button class="btn btn-primary" id="map-place-confirm"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg> Hier platzieren</button>
</div>
</div>
<div class="map-fabs">
<button class="map-fab map-fab--rec" id="map-rec-btn" title="Route aufzeichnen">🔴</button>
<button class="map-fab map-fab--offline" id="map-offline-btn" title="Karte offline speichern">💾</button>
<button class="map-fab map-fab--pin" id="map-pin-btn" title="Marker setzen">📌</button>
<button class="map-fab" id="map-locate-btn" title="Meinen Standort">📍</button>
<button class="map-fab map-fab--rec" id="map-rec-btn" title="Route aufzeichnen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg></button>
<button class="map-fab map-fab--offline" id="map-offline-btn" title="Karte offline speichern"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg></button>
<button class="map-fab map-fab--pin" id="map-pin-btn" title="Marker setzen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg></button>
<button class="map-fab" id="map-locate-btn" title="Meinen Standort"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg></button>
</div>
<div class="map-statusbar" id="map-statusbar">
@ -203,11 +205,11 @@ window.Page_map = (() => {
</div>
</div>
<div class="map-rec-actions">
<button class="btn btn-secondary map-rec-action-btn" id="rec-panel-pause"> Pause</button>
<button class="btn btn-danger map-rec-action-btn" id="rec-panel-stop"> Speichern</button>
<button class="btn btn-secondary map-rec-action-btn" id="rec-panel-pause">Pause</button>
<button class="btn btn-danger map-rec-action-btn" id="rec-panel-stop"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg> Speichern</button>
</div>
<div class="map-rec-hint" id="map-rec-hint">
📵 Bildschirm bleibt aktiv GPS läuft
Bildschirm bleibt aktiv GPS läuft
</div>
</div>
@ -512,7 +514,7 @@ window.Page_map = (() => {
html: `<div class="poison-marker">
<div class="poison-ring"></div>
<div class="poison-ring"></div>
<div class="poison-dot"></div>
<div class="poison-dot"><svg class="ph-icon" aria-hidden="true" style="width:20px;height:20px"><use href="/icons/phosphor.svg#skull"></use></svg></div>
</div>`,
iconSize: [48, 48],
iconAnchor: [24, 24],
@ -562,11 +564,11 @@ window.Page_map = (() => {
: `<button class="btn btn-secondary btn-sm" id="mp-action">Als ungültig melden</button>`;
const openHours = poi.opening_hours
? `<div style="font-size:11px;color:#555;margin-bottom:4px">🕐 ${poi.opening_hours}</div>` : '';
? `<div style="font-size:11px;color:#555;margin-bottom:4px"><svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#clock"></use></svg> ${poi.opening_hours}</div>` : '';
const phone = poi.phone
? `<div style="font-size:11px;margin-bottom:4px"><a href="tel:${poi.phone}" style="color:var(--c-primary);text-decoration:none">📞 ${poi.phone}</a></div>` : '';
? `<div style="font-size:11px;margin-bottom:4px"><a href="tel:${poi.phone}" style="color:var(--c-primary);text-decoration:none">${poi.phone}</a></div>` : '';
const website = poi.website
? `<div style="font-size:11px;margin-bottom:6px"><a href="${poi.website}" target="_blank" rel="noopener" style="color:var(--c-primary);text-decoration:none">🌐 Website</a></div>` : '';
? `<div style="font-size:11px;margin-bottom:6px"><a href="${poi.website}" target="_blank" rel="noopener" style="color:var(--c-primary);text-decoration:none"><svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#arrow-square-out"></use></svg> Website</a></div>` : '';
marker.bindPopup(`
<div style="min-width:170px;max-width:240px">
@ -575,8 +577,8 @@ window.Page_map = (() => {
${openHours}${phone}${website}
<div style="font-size:11px;color:#999;margin-bottom:10px">
${isUser
? `📌 Community-Pin${poi.username ? ' · <b>' + poi.username + '</b>' : ''}`
: '🗺️ OpenStreetMap'}
? `<svg class="ph-icon" aria-hidden="true" style="width:11px;height:11px"><use href="/icons/phosphor.svg#push-pin"></use></svg> Community-Pin${poi.username ? ' · <b>' + poi.username + '</b>' : ''}`
: '<svg class="ph-icon" aria-hidden="true" style="width:11px;height:11px"><use href="/icons/phosphor.svg#map-trifold"></use></svg> OpenStreetMap'}
</div>
${actionBtn}
</div>
@ -618,7 +620,7 @@ window.Page_map = (() => {
_placingMarker = false;
const btn = document.getElementById('map-pin-btn');
btn?.classList.remove('active');
btn && (btn.textContent = '\uD83D\uDCCC');
btn && (btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>');
document.getElementById('map-crosshair')?.classList.remove('active', 'dragging');
document.getElementById('map-place-bar')?.classList.remove('active');
_tempMarker?.remove();
@ -626,14 +628,14 @@ window.Page_map = (() => {
}
const PIN_TYPES = [
{ type: 'giftkoeder', icon: '☠️', label: 'Giftköder', color: '#DC2626' }, // ← wichtigster Typ, immer oben
{ type: 'waste_basket', icon: '🗑️', label: 'Mülleimer', color: '#78716C' },
{ type: 'kotbeutel', icon: '🧻', label: 'Kotbeutel', color: '#6B7280' },
{ type: 'drinking_water', icon: '💧', label: 'Wasserstelle', color: '#0EA5E9' },
{ type: 'dog_park', icon: '🌿', label: 'Hundewiese', color: '#15803D' },
{ type: 'parkplatz', icon: '🅿️', label: 'Parkplatz', color: '#2563EB' },
{ type: 'treffpunkt', icon: '🤝', label: 'Treffpunkt', color: '#7C3AED' },
{ type: 'sonstiges', icon: '📌', label: 'Sonstiges', color: '#F59E0B' },
{ type: 'giftkoeder', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#skull"></use></svg>', label: 'Giftköder', color: '#DC2626' }, // ← wichtigster Typ, immer oben
{ type: 'waste_basket', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Mülleimer', color: '#78716C' },
{ type: 'kotbeutel', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Kotbeutel', color: '#6B7280' },
{ type: 'drinking_water', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#drop"></use></svg>', label: 'Wasserstelle', color: '#0EA5E9' },
{ type: 'dog_park', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>', label: 'Hundewiese', color: '#15803D' },
{ type: 'parkplatz', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>', label: 'Parkplatz', color: '#2563EB' },
{ type: 'treffpunkt', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED' },
{ type: 'sonstiges', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B' },
];
function _confirmPlacement(latlng) {
@ -645,7 +647,7 @@ window.Page_map = (() => {
let _selectedType = 'giftkoeder';
UI.modal.open({
title: '📌 Marker setzen',
title: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg> Marker setzen',
body: `
<form id="poi-form" class="flex flex-col gap-3">
<div>
@ -930,7 +932,7 @@ window.Page_map = (() => {
navigator.serviceWorker.removeEventListener('message', onMessage);
if (btn) btn.classList.remove('loading');
_setOsmStatus('');
UI.toast.success(`\u2705 ${total} Kacheln offline gespeichert!`);
UI.toast.success(`${total} Kacheln offline gespeichert!`);
} else {
_setOsmStatus(`Offline: ${done} / ${total} Kacheln…`);
}
@ -940,6 +942,92 @@ window.Page_map = (() => {
navigator.serviceWorker.controller.postMessage({ type: 'CACHE_TILES', urls });
}
// ----------------------------------------------------------
// Pocket-Modus Overlay
// ----------------------------------------------------------
function _showPocketOverlay() {
if (_pocketOverlay) return;
const el = document.createElement('div');
el.id = 'pocket-overlay';
el.innerHTML = `
<div class="po-status" id="po-status">GPS läuft</div>
<div class="po-time" id="po-time">0:00</div>
<div class="po-dist" id="po-dist">0.00 km</div>
<div class="po-hint">Tippen für Steuerung</div>
<div class="po-controls" id="po-controls">
<button class="po-btn" id="po-pause"> Pause</button>
<button class="po-btn po-btn--stop" id="po-stop"> Stopp</button>
</div>
`;
el.style.cssText = `
position:fixed;inset:0;z-index:9998;background:#000;
display:flex;flex-direction:column;align-items:center;justify-content:center;
color:#fff;font-family:inherit;user-select:none;
`;
el.querySelector('.po-status').style.cssText =
'font-size:0.85rem;color:#888;margin-bottom:1rem;letter-spacing:0.05em;text-transform:uppercase';
el.querySelector('.po-time').style.cssText =
'font-size:4.5rem;font-weight:700;letter-spacing:-0.02em;line-height:1';
el.querySelector('.po-dist').style.cssText =
'font-size:1.5rem;color:#aaa;margin-top:0.5rem';
el.querySelector('.po-hint').style.cssText =
'font-size:0.75rem;color:#444;margin-top:2.5rem';
const ctrl = el.querySelector('.po-controls');
ctrl.style.cssText =
'display:none;gap:1rem;margin-top:2rem;flex-direction:row';
el.querySelectorAll('.po-btn').forEach(b => {
b.style.cssText =
'padding:0.75rem 1.5rem;border:1px solid #555;border-radius:0.75rem;' +
'background:#111;color:#fff;font-size:1rem;cursor:pointer';
});
el.querySelector('.po-btn--stop').style.cssText +=
'border-color:#c0392b;color:#e74c3c';
// Tippen → Controls 4s einblenden, dann ausblenden
el.addEventListener('click', e => {
if (e.target.closest('#po-controls')) return;
ctrl.style.display = 'flex';
el.querySelector('.po-hint').style.color = '#666';
clearTimeout(_pocketHideTimer);
_pocketHideTimer = setTimeout(() => {
ctrl.style.display = 'none';
el.querySelector('.po-hint').style.color = '#444';
}, 4000);
});
el.querySelector('#po-pause').addEventListener('click', () => {
_togglePause();
el.querySelector('#po-pause').textContent =
_recPaused ? '▶ Weiter' : '⏸ Pause';
el.querySelector('#po-status').textContent =
_recPaused ? 'Pausiert' : 'GPS läuft';
});
el.querySelector('#po-stop').addEventListener('click', () => {
_hidePocketOverlay();
_stopRecording();
});
document.body.appendChild(el);
_pocketOverlay = el;
}
function _hidePocketOverlay() {
clearTimeout(_pocketHideTimer);
_pocketOverlay?.remove();
_pocketOverlay = null;
}
function _updatePocketOverlay() {
if (!_pocketOverlay) return;
const elapsed = Math.floor((Date.now() - _recStartTime) / 1000);
const mm = String(Math.floor(elapsed / 60)).padStart(1, '0');
const ss = String(elapsed % 60).padStart(2, '0');
const timeEl = _pocketOverlay.querySelector('#po-time');
const distEl = _pocketOverlay.querySelector('#po-dist');
if (timeEl) timeEl.textContent = `${mm}:${ss}`;
if (distEl) distEl.textContent = `${_recDistKm.toFixed(2)} km`;
}
// ----------------------------------------------------------
// GPS-Aufzeichnung
// ----------------------------------------------------------
@ -966,7 +1054,7 @@ window.Page_map = (() => {
// FAB umschalten
const btn = document.getElementById('map-rec-btn');
if (btn) { btn.textContent = '⏹'; btn.classList.add('recording'); }
if (btn) { btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg>'; btn.classList.add('recording'); }
// Aufzeichnungs-Panel einblenden
const panel = document.getElementById('map-rec-panel');
@ -978,8 +1066,8 @@ window.Page_map = (() => {
await _acquireWakeLock();
const hint = document.getElementById('map-rec-hint');
if (hint) hint.textContent = _wakeLock
? '📵 Bildschirm bleibt aktiv — GPS läuft'
: '⚠️ Bildschirm-Lock nicht unterstützt — Bildschirm aktiv lassen';
? 'Bildschirm bleibt aktiv — GPS läuft'
: 'Bildschirm-Lock nicht unterstützt — Bildschirm aktiv lassen';
// Sichtbarkeit: Wake Lock bei Tab-Wechsel neu anfordern
document.addEventListener('visibilitychange', _onVisibilityChange);
@ -1003,7 +1091,12 @@ window.Page_map = (() => {
() => {},
{ enableHighAccuracy: true, maximumAge: 0, timeout: 10000 }
);
UI.toast.success('Aufzeichnung gestartet — los geht\'s! 🐕');
UI.toast.success('Aufzeichnung gestartet — los geht\'s!');
// Pocket-Modus aktivieren wenn in Einstellungen eingeschaltet
if (localStorage.getItem('by_pocket_mode') === 'true') {
setTimeout(_showPocketOverlay, 800); // kurz warten damit Toast sichtbar war
}
}
async function _onVisibilityChange() {
@ -1074,6 +1167,7 @@ window.Page_map = (() => {
if (distEl) distEl.textContent = _recDistKm.toFixed(2);
if (timeEl) timeEl.textContent = `${mm}:${ss}`;
if (paceEl) paceEl.textContent = pace;
_updatePocketOverlay();
}
function _stopRecording() {
@ -1081,10 +1175,11 @@ window.Page_map = (() => {
if (_recTimerInt) { clearInterval(_recTimerInt); _recTimerInt = null; }
_recActive = false;
_releaseWakeLock();
_hidePocketOverlay();
document.removeEventListener('visibilitychange', _onVisibilityChange);
const btn = document.getElementById('map-rec-btn');
if (btn) { btn.textContent = '🔴'; btn.classList.remove('recording'); }
if (btn) { btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg>'; btn.classList.remove('recording'); }
const panel = document.getElementById('map-rec-panel');
if (panel) panel.classList.remove('active', 'paused');
@ -1100,16 +1195,34 @@ window.Page_map = (() => {
_showRecSaveModal(_recTrack, _recDistKm, dauMin);
}
async function _prefillRouteName(track, distKm) {
const nameInput = document.querySelector('#rec-save-form [name="name"]');
if (!nameInput || nameInput.value) return;
const pt = track[0];
const date = new Date().toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' });
const km = distKm.toFixed(1);
let ort = '';
try {
const r = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${pt.lat}&lon=${pt.lon}&format=json&zoom=13&addressdetails=1&accept-language=de`, { cache: 'no-store' });
const data = await r.json();
const a = data.address || {};
ort = a.village || a.town || a.suburb || a.city_district || a.city || a.municipality || '';
} catch {}
if (!nameInput.value) nameInput.value = ort
? `Gassirunde ${ort} · ${date} · ${km} km`
: `Gassirunde · ${date} · ${km} km`;
}
function _showRecSaveModal(track, distKm, dauMin) {
const body = `
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
🎉 ${track.length} GPS-Punkte · ${distKm.toFixed(2)} km · ca. ${dauMin} min
${track.length} GPS-Punkte · ${distKm.toFixed(2)} km · ca. ${dauMin} min
</p>
<form id="rec-save-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Name der Route *</label>
<input class="form-control" type="text" name="name"
placeholder="z.B. Waldspaziergang am See" required>
placeholder="Wird automatisch ermittelt…" required>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
@ -1124,34 +1237,34 @@ window.Page_map = (() => {
<label class="form-label">Untergrund</label>
<select class="form-control" name="untergrund">
<option value=""> unbekannt </option>
<option value="wald">🌲 Wald</option>
<option value="asphalt">🛣 Asphalt</option>
<option value="wiese">🌿 Wiese</option>
<option value="mix">🔀 Mix</option>
<option value="wald">Wald</option>
<option value="asphalt">Asphalt</option>
<option value="wiese">Wiese</option>
<option value="mix">Mix</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Hundetauglichkeit</label>
<div class="rk-paw-select" id="rec-paw-select">
<button type="button" class="rk-paw-btn" data-val="eingeschränkt">🐾 Eingeschränkt</button>
<button type="button" class="rk-paw-btn" data-val="gut">🐾🐾 Gut</button>
<button type="button" class="rk-paw-btn selected" data-val="sehr_gut">🐾🐾🐾 Sehr gut</button>
<button type="button" class="rk-paw-btn" data-val="premium">🐾🐾🐾🐾 Premium</button>
<button type="button" class="rk-paw-btn" data-val="eingeschränkt"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Eingeschränkt</button>
<button type="button" class="rk-paw-btn" data-val="gut"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Gut</button>
<button type="button" class="rk-paw-btn selected" data-val="sehr_gut"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Sehr gut</button>
<button type="button" class="rk-paw-btn" data-val="premium"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Premium</button>
</div>
<input type="hidden" name="hunde_tauglichkeit" id="rec-paw-val" value="sehr_gut">
</div>
<div class="form-group" style="display:flex;gap:var(--space-4)">
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="schatten"> 🌳 Viel Schatten
<input type="checkbox" name="schatten"> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#tree"></use></svg> Viel Schatten
</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="leine_empfohlen"> 🔗 Leine empfohlen
<input type="checkbox" name="leine_empfohlen"> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#tag"></use></svg> Leine empfohlen
</label>
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="is_public" checked> 🌍 Öffentlich (von allen sichtbar)
<input type="checkbox" name="is_public" checked> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#globe"></use></svg> Öffentlich (von allen sichtbar)
</label>
</div>
<div class="form-group">
@ -1164,10 +1277,12 @@ window.Page_map = (() => {
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="rms-discard">Verwerfen</button>
<button type="submit" form="rec-save-form" class="btn btn-primary flex-1">💾 Speichern</button>
<button type="submit" form="rec-save-form" class="btn btn-primary flex-1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg> Speichern</button>
`;
UI.modal.open({ title: '🥾 Route benennen', body, footer });
UI.modal.open({ title: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg> Route benennen', body, footer });
_prefillRouteName(track, distKm); // async, füllt Name-Feld sobald Nominatim antwortet
document.getElementById('rec-paw-select')?.addEventListener('click', e => {
const btn = e.target.closest('.rk-paw-btn');
@ -1204,7 +1319,7 @@ window.Page_map = (() => {
UI.modal.close();
if (_recPolyline) { _recPolyline.remove(); _recPolyline = null; }
if (_recMarker) { _recMarker.remove(); _recMarker = null; }
UI.toast.success(`Route „${saved.name}" gespeichert! 🎉`);
UI.toast.success(`Route „${saved.name}" gespeichert!`);
});
});
}

View file

@ -0,0 +1,409 @@
/* ============================================================
BAN YARO Hunde-Filme
Seiten-Modul: Film-Datenbank, Promi-Hunde, Hund des Monats.
============================================================ */
window.Page_movies = (() => {
// ----------------------------------------------------------
// MODUL-STATE
// ----------------------------------------------------------
let _container = null;
let _appState = null;
let _filme = [];
let _activeTab = 'filme';
let _filter = 'alle';
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
await _render();
}
// ----------------------------------------------------------
// REFRESH
// ----------------------------------------------------------
async function refresh() {
_filme = [];
await _render();
}
// ----------------------------------------------------------
// RENDER — Haupt-Layout mit Tabs
// ----------------------------------------------------------
async function _render() {
_container.innerHTML = `
<div class="movies-tabs">
<button class="movies-tab${_activeTab === 'filme' ? ' movies-tab--active' : ''}" data-tab="filme">${UI.icon('film-slate')} Filme</button>
<button class="movies-tab${_activeTab === 'promis' ? ' movies-tab--active' : ''}" data-tab="promis">${UI.icon('star')} Berühmtheiten</button>
<button class="movies-tab${_activeTab === 'hdm' ? ' movies-tab--active' : ''}" data-tab="hdm">${UI.icon('paw-print')} Hund des Monats</button>
</div>
<div id="movies-tab-content"></div>
`;
_container.querySelectorAll('.movies-tab').forEach(btn => {
btn.addEventListener('click', () => {
_activeTab = btn.dataset.tab;
_container.querySelectorAll('.movies-tab').forEach(b => b.classList.remove('movies-tab--active'));
btn.classList.add('movies-tab--active');
_renderTab();
});
});
await _renderTab();
}
async function _renderTab() {
const content = _container.querySelector('#movies-tab-content');
if (!content) return;
content.innerHTML = UI.skeleton(3);
if (_activeTab === 'filme') await _renderFilme(content);
if (_activeTab === 'promis') _renderPromis(content);
if (_activeTab === 'hdm') await _renderHundDesMonats(content);
}
// ----------------------------------------------------------
// TAB 1: FILME
// ----------------------------------------------------------
async function _renderFilme(content) {
try {
_filme = await API.get('/movies/filme');
} catch {
content.innerHTML = UI.emptyState({ icon: '🎬', title: 'Filme konnten nicht geladen werden', text: 'Bitte versuche es erneut.' });
return;
}
content.innerHTML = `
<div class="movies-filter-row">
<button class="movies-filter-btn${_filter === 'alle' ? ' movies-filter-btn--active' : ''}" data-filter="alle">Alle</button>
<button class="movies-filter-btn${_filter === 'stirbt' ? ' movies-filter-btn--active' : ''}" data-filter="stirbt">😭 Hund stirbt</button>
<button class="movies-filter-btn${_filter === 'ueberlebt' ? ' movies-filter-btn--active' : ''}" data-filter="ueberlebt">🐾 Hund überlebt</button>
<button class="movies-filter-btn${_filter === 'top' ? ' movies-filter-btn--active' : ''}" data-filter="top">+</button>
</div>
<div class="movie-grid" id="movie-grid"></div>
`;
content.querySelectorAll('.movies-filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
_filter = btn.dataset.filter;
content.querySelectorAll('.movies-filter-btn').forEach(b => b.classList.remove('movies-filter-btn--active'));
btn.classList.add('movies-filter-btn--active');
_renderMovieGrid(content.querySelector('#movie-grid'));
});
});
_renderMovieGrid(content.querySelector('#movie-grid'));
}
function _renderMovieGrid(grid) {
if (!grid) return;
let list = [..._filme];
if (_filter === 'stirbt') list = list.filter(f => f.stirbt_der_hund);
if (_filter === 'ueberlebt') list = list.filter(f => !f.stirbt_der_hund);
if (_filter === 'top') list = list.filter(f => f.bewertung_avg >= 4.0);
if (list.length === 0) {
grid.innerHTML = `<div style="grid-column:1/-1;padding:var(--space-8);text-align:center;color:var(--c-text-secondary)">Keine Filme für diesen Filter.</div>`;
return;
}
grid.innerHTML = list.map(f => _movieCard(f)).join('');
grid.querySelectorAll('.movie-card').forEach(card => {
card.addEventListener('click', (e) => {
if (e.target.closest('.movie-star-rating')) return;
const id = card.dataset.filmId;
const film = _filme.find(f => f.id === id);
if (film) _openMovieModal(film);
});
});
_bindStarRatings(grid);
}
function _movieCard(film) {
const stirbt = film.stirbt_der_hund;
const tag = stirbt
? `<div class="movie-tag-stirbt">⚠️ ACHTUNG: Der Hund stirbt</div>`
: `<div class="movie-tag-ueberlebt">✅ Der Hund überlebt</div>`;
const stars = _starsHtml(film.bewertung_avg, film.id, film.user_rating, false);
return `
<div class="movie-card" data-film-id="${_esc(film.id)}">
<div class="movie-card-emoji">${film.bild_emoji}</div>
<div class="movie-card-body">
<div class="movie-card-title">${_esc(film.titel)} <span class="movie-card-year">(${film.jahr})</span></div>
<div class="movie-card-genre">${_esc(film.genre)}</div>
<div class="movie-card-rasse">🐾 ${_esc(film.hund_rasse)}</div>
${tag}
<div class="movie-card-stars">${stars}</div>
</div>
</div>
`;
}
function _openMovieModal(film) {
const stirbt = film.stirbt_der_hund;
const bannerClass = stirbt ? 'movie-tag-stirbt' : 'movie-tag-ueberlebt';
const bannerText = stirbt ? '⚠️ ACHTUNG: Der Hund stirbt!' : '✅ Der Hund überlebt';
const stars = _starsHtml(film.bewertung_avg, film.id, film.user_rating, true);
const loginHint = !_appState.user
? `<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-top:var(--space-2)">Anmelden um zu bewerten</p>`
: '';
const body = `
<div class="movie-modal-emoji">${film.bild_emoji}</div>
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3)">
<span class="badge badge-primary">${_esc(film.genre)}</span>
<span class="badge">🐾 ${_esc(film.hund_rasse)}</span>
<span class="badge">${film.jahr}</span>
</div>
<div class="${bannerClass}" style="margin-bottom:var(--space-4);font-size:var(--text-base)">${bannerText}</div>
<p style="line-height:1.6;color:var(--c-text);margin-bottom:var(--space-5)">${_esc(film.beschreibung)}</p>
<div style="margin-bottom:var(--space-2)">
<strong>Community-Bewertung:</strong>
</div>
<div id="modal-stars-${_esc(film.id)}">${stars}</div>
<div id="modal-avg-${_esc(film.id)}" style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-top:var(--space-1)">
Ø ${film.bewertung_avg} von ${film.bewertung_cnt || 0} Bewertungen
</div>
${loginHint}
`;
UI.modal.open({ title: film.titel, body });
const starsEl = document.getElementById(`modal-stars-${film.id}`);
if (starsEl && _appState.user) {
_bindStarRatingsEl(starsEl, film.id, true);
}
}
function _starsHtml(avg, filmId, userRating, interactive) {
const filled = Math.round(avg);
const stars = [1,2,3,4,5].map(i => {
const active = i <= (userRating || filled) ? ' movie-star--active' : '';
return `<span class="movie-star${active}" data-film-id="${_esc(filmId)}" data-val="${i}">★</span>`;
}).join('');
return `<div class="movie-star-rating" data-film-id="${_esc(filmId)}">${stars} <span class="movie-star-avg">${avg}</span></div>`;
}
function _bindStarRatings(container) {
container.querySelectorAll('.movie-star-rating').forEach(el => {
_bindStarRatingsEl(el, el.dataset.filmId, false);
});
}
function _bindStarRatingsEl(el, filmId, inModal) {
if (!_appState.user) return;
const stars = el.querySelectorAll('.movie-star');
stars.forEach(star => {
star.addEventListener('mouseenter', () => {
const val = parseInt(star.dataset.val);
stars.forEach((s, i) => s.classList.toggle('movie-star--active', i < val));
});
star.addEventListener('mouseleave', () => {
const film = _filme.find(f => f.id === filmId);
const cur = film?.user_rating || 0;
stars.forEach((s, i) => s.classList.toggle('movie-star--active', i < cur));
});
star.addEventListener('click', async () => {
const val = parseInt(star.dataset.val);
try {
const res = await API.post(`/movies/filme/${filmId}/vote`, { bewertung: val });
// Update in _filme array
const idx = _filme.findIndex(f => f.id === filmId);
if (idx !== -1) {
_filme[idx].user_rating = val;
_filme[idx].bewertung_avg = res.bewertung_avg;
_filme[idx].bewertung_cnt = res.bewertung_cnt;
}
// Update star display
stars.forEach((s, i) => s.classList.toggle('movie-star--active', i < val));
const avgEl = el.querySelector('.movie-star-avg') ||
(inModal ? document.getElementById(`modal-avg-${filmId}`) : null);
if (el.querySelector('.movie-star-avg')) {
el.querySelector('.movie-star-avg').textContent = res.bewertung_avg;
}
if (inModal) {
const avgInfo = document.getElementById(`modal-avg-${filmId}`);
if (avgInfo) avgInfo.textContent = `Ø ${res.bewertung_avg} von ${res.bewertung_cnt} Bewertungen`;
}
UI.toast.success('Bewertung gespeichert!');
} catch {
UI.toast.error('Bewertung konnte nicht gespeichert werden.');
}
});
});
}
// ----------------------------------------------------------
// TAB 2: BERÜHMTHEITEN (hardcoded, kein Backend)
// ----------------------------------------------------------
const PROMIS = [
{ name: "Hachikō", rasse: "Akita Inu", bekannt_fuer: "9 Jahre lang täglich auf seinen verstorbenen Herrchen am Bahnhof Shibuya gewartet. Statue in Tokio.", emoji: "🗿" },
{ name: "Rin Tin Tin", rasse: "Deutscher Schäferhund", bekannt_fuer: "Filmhund der 1920er-Jahre. Rettete Warner Bros. vor dem Bankrott. Erster Hundestar Hollywoods.", emoji: "🎬" },
{ name: "Laika", rasse: "Mischling", bekannt_fuer: "Erstes Lebewesen im Weltall (Sputnik 2, 1957). Wurde zur sowjetischen Weltraumpionierin.", emoji: "🚀" },
{ name: "Endal", rasse: "Labrador", bekannt_fuer: "Assistenzhund in England. Erster Hund der eine EC-Karte am Geldautomaten benutzte.", emoji: "💳" },
{ name: "Barry", rasse: "Bernhardiner", bekannt_fuer: "Legendärer Rettungshund der Alpen (18001812). Soll 40 Menschen das Leben gerettet haben.", emoji: "🏔️" },
{ name: "Greyfriars Bobby", rasse: "Skye Terrier", bekannt_fuer: "14 Jahre lang das Grab seines Herrchens in Edinburgh bewacht. Statue und Pub benannt nach ihm.", emoji: "⛪" },
];
function _renderPromis(content) {
content.innerHTML = `
<div style="padding:var(--space-2) 0">
${PROMIS.map(p => `
<div class="movie-promi-card">
<div class="movie-promi-emoji">${p.emoji}</div>
<div class="movie-promi-body">
<div class="movie-promi-name">${_esc(p.name)}</div>
<div class="movie-promi-rasse">${_esc(p.rasse)}</div>
<div class="movie-promi-text">${_esc(p.bekannt_fuer)}</div>
</div>
</div>
`).join('')}
</div>
`;
}
// ----------------------------------------------------------
// TAB 3: HUND DES MONATS
// ----------------------------------------------------------
async function _renderHundDesMonats(content) {
let data;
try {
data = await API.get('/movies/hund-des-monats');
} catch {
content.innerHTML = UI.emptyState({ icon: '🏆', title: 'Fehler beim Laden', text: 'Bitte versuche es erneut.' });
return;
}
const [year, month] = data.monat.split('-');
const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' })
.format(new Date(+year, +month - 1, 1));
let voteSection = '';
if (_appState.user && _appState.dogs?.length > 0) {
const voteCards = _appState.dogs.map(dog => {
const isVoted = data.user_vote === dog.id;
const av = dog.foto_url
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-vote-av-img">`
: `<span class="hdm-vote-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
return `
<div class="hdm-vote-card${isVoted ? ' hdm-vote-card--voted' : ''}" data-dog-id="${dog.id}">
<div class="hdm-vote-av">${av}</div>
<div class="hdm-vote-name">${_esc(dog.name)}</div>
${dog.rasse ? `<div class="hdm-vote-rasse">${_esc(dog.rasse)}</div>` : ''}
<button class="btn${isVoted ? ' btn-primary' : ' btn-secondary'} hdm-vote-btn" data-dog-id="${dog.id}">
${isVoted ? '✅ Gewählt' : 'Abstimmen'}
</button>
</div>
`;
}).join('');
voteSection = `
<div class="hdm-section">
<h3 class="hdm-section-title">Für welchen deiner Hunde möchtest du abstimmen?</h3>
<div class="hdm-vote-grid" id="hdm-vote-grid">${voteCards}</div>
</div>
`;
} else if (!_appState.user) {
voteSection = `
<div class="hdm-section">
<p style="color:var(--c-text-secondary);font-size:var(--text-sm)">
<a href="#" id="hdm-login-link" style="color:var(--c-primary);font-weight:var(--weight-semibold)">Anmelden</a>
um für deinen Hund abzustimmen.
</p>
</div>
`;
}
const topList = data.top.length > 0
? data.top.slice(0, 5).map((dog, i) => {
const medal = ['🥇','🥈','🥉','4⃣','5⃣'][i] || `${i+1}.`;
const av = dog.foto_url
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-top-av-img">`
: `<span class="hdm-top-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : '';
return `
<div class="hdm-top-entry">
<span class="hdm-top-medal">${medal}</span>
<div class="hdm-top-av">${av}</div>
<div class="hdm-top-info">
<div class="hdm-top-name">${_esc(dog.name)}</div>
${dog.rasse ? `<div class="hdm-top-rasse">${_esc(dog.rasse)}</div>` : ''}
${vorname ? `<div class="hdm-top-besitzer">von ${vorname}</div>` : ''}
</div>
<div class="hdm-top-stimmen">${dog.stimmen} </div>
</div>
`;
}).join('')
: `<p style="color:var(--c-text-secondary);padding:var(--space-4)">Noch keine Stimmen diesen Monat. Sei der Erste!</p>`;
content.innerHTML = `
<div class="hdm-header">
<div class="hdm-trophy">🏆</div>
<h2 class="hdm-title">Hund des Monats</h2>
<div class="hdm-monat">${_esc(monthName)}</div>
</div>
${voteSection}
<div class="hdm-section">
<h3 class="hdm-section-title">Top 5 diesen Monat</h3>
<div id="hdm-top-list">${topList}</div>
</div>
`;
// Login-Link
content.querySelector('#hdm-login-link')?.addEventListener('click', e => {
e.preventDefault();
App.navigate('settings');
});
// Vote-Buttons
content.querySelectorAll('.hdm-vote-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const dogId = parseInt(btn.dataset.dogId);
await UI.asyncButton(btn, async () => {
try {
await API.post('/movies/hund-des-monats/vote', { dog_id: dogId });
UI.toast.success('Stimme abgegeben!');
// Refresh the tab
await _renderHundDesMonats(content);
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Abstimmen.');
}
});
});
});
}
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------
function _esc(str) {
if (!str && str !== 0) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
return { init, refresh };
})();

View file

@ -18,12 +18,12 @@ window.Page_places = (() => {
// Typen-Konfiguration
// ----------------------------------------------------------
const TYPEN = {
restaurant: { icon: '🍽️', label: 'Restaurant & Café', color: '#F97316' },
freilauf: { icon: '🐕', label: 'Freilauffläche', color: '#22C55E' },
shop: { icon: '🛒', label: 'Shop', color: '#3B82F6' },
kotbeutel: { icon: '🧻', label: 'Kotbeutel-Station', color: '#6B7280' },
tierarzt: { icon: '🩺', label: 'Tierarzt', color: '#EF4444' },
hundeschule: { icon: '🎓', label: 'Hundeschule', color: '#8B5CF6' },
restaurant: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#fork-knife"></use></svg>', label: 'Restaurant & Café', color: '#F97316' },
freilauf: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>', label: 'Freilauffläche', color: '#22C55E' },
shop: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shopping-cart"></use></svg>', label: 'Shop', color: '#3B82F6' },
kotbeutel: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Kotbeutel-Station', color: '#6B7280' },
tierarzt: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', label: 'Tierarzt', color: '#EF4444' },
hundeschule: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#graduation-cap"></use></svg>', label: 'Hundeschule', color: '#8B5CF6' },
};
function _esc(s) {
@ -54,13 +54,13 @@ window.Page_places = (() => {
<!-- Toolbar -->
<div class="places-toolbar">
<div class="places-filter" id="places-filter">
<button class="places-filter-btn active" data-typ="">🗺 Alle</button>
<button class="places-filter-btn active" data-typ=""><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-trifold"></use></svg> Alle</button>
${Object.entries(TYPEN).map(([k, t]) =>
`<button class="places-filter-btn" data-typ="${k}">${t.icon} ${t.label}</button>`
).join('')}
</div>
<button class="btn btn-primary btn-sm" id="places-add-btn" style="white-space:nowrap">
+ Ort hinzufügen
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#plus"></use></svg> Ort hinzufügen
</button>
</div>
@ -139,7 +139,7 @@ window.Page_places = (() => {
L.Control.Locate = L.Control.extend({
onAdd() {
const btn = L.DomUtil.create('button', 'places-locate-btn');
btn.innerHTML = '📍';
btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>';
btn.title = 'Meinen Standort';
btn.onclick = async () => {
try {
@ -191,7 +191,7 @@ window.Page_places = (() => {
_markers = [];
_filtered().forEach(place => {
const t = TYPEN[place.typ] || { icon: '📍', color: '#6B7280' };
const t = TYPEN[place.typ] || { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>', color: '#6B7280' };
const icon = L.divIcon({
className: '',
html: `<div style="
@ -242,11 +242,11 @@ window.Page_places = (() => {
}
function _cardHTML(p) {
const t = TYPEN[p.typ] || { icon: '📍', label: p.typ, color: '#6B7280' };
const t = TYPEN[p.typ] || { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>', label: p.typ, color: '#6B7280' };
const flags = [
p.hund_rein === true ? '🐕 Hund rein' : null,
p.leine_pflicht === true ? '🔗 Leinenpflicht' : null,
p.wasser_fuer_hunde === true ? '💧 Wasser' : null,
p.hund_rein === true ? `${UI.icon('dog')} Hund rein` : null,
p.leine_pflicht === true ? `${UI.icon('tag')} Leinenpflicht` : null,
p.wasser_fuer_hunde === true ? `${UI.icon('drop')} Wasser` : null,
].filter(Boolean);
return `
@ -260,7 +260,7 @@ window.Page_places = (() => {
</div>
${flags.length ? `<div class="places-card-flags">${flags.map(f => `<span class="places-flag">${f}</span>`).join('')}</div>` : ''}
</div>
<div class="places-card-arrow"></div>
<div class="places-card-arrow">${UI.icon('arrow-right')}</div>
</div>`;
}
@ -268,13 +268,13 @@ window.Page_places = (() => {
// Detail-Modal
// ----------------------------------------------------------
function _openDetail(place) {
const t = TYPEN[place.typ] || { icon: '📍', label: place.typ, color: '#6B7280' };
const t = TYPEN[place.typ] || { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>', label: place.typ, color: '#6B7280' };
const isOwn = _appState.user?.id === place.user_id;
const flags = [
place.hund_rein === true ? '🐕 Hund erlaubt' : (place.hund_rein === false ? '🚫 Kein Hund' : null),
place.leine_pflicht === true ? '🔗 Leinenpflicht' : (place.leine_pflicht === false ? '✅ Leine optional' : null),
place.wasser_fuer_hunde === true ? '💧 Wasser vorhanden': null,
place.hund_rein === true ? `${UI.icon('dog')} Hund erlaubt` : (place.hund_rein === false ? `${UI.icon('x')} Kein Hund` : null),
place.leine_pflicht === true ? `${UI.icon('tag')} Leinenpflicht` : (place.leine_pflicht === false ? `${UI.icon('check')} Leine optional` : null),
place.wasser_fuer_hunde === true ? `${UI.icon('drop')} Wasser vorhanden`: null,
].filter(Boolean);
const body = `
@ -285,8 +285,8 @@ window.Page_places = (() => {
<div style="color:${t.color};font-size:0.9rem">${t.label}</div>
</div>
</div>
${place.adresse ? `<p style="color:var(--c-text-secondary);margin-bottom:var(--space-2)">📍 ${_esc(place.adresse)}</p>` : ''}
${place.website ? `<p style="margin-bottom:var(--space-2)"><a href="${_esc(place.website)}" target="_blank" style="color:var(--c-primary)">🌐 ${_esc(place.website)}</a></p>` : ''}
${place.adresse ? `<p style="color:var(--c-text-secondary);margin-bottom:var(--space-2)">${UI.icon('map-pin')} ${_esc(place.adresse)}</p>` : ''}
${place.website ? `<p style="margin-bottom:var(--space-2)"><a href="${_esc(place.website)}" target="_blank" style="color:var(--c-primary)">${UI.icon('arrow-square-out')} ${_esc(place.website)}</a></p>` : ''}
${flags.length ? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-top:var(--space-3)">${flags.map(f => `<span class="places-flag places-flag--detail">${f}</span>`).join('')}</div>` : ''}
<p style="color:var(--c-text-muted);font-size:0.8rem;margin-top:var(--space-4)">
Eingetragen von ${_esc(place.user_name || 'Unbekannt')}
@ -301,7 +301,7 @@ window.Page_places = (() => {
<button type="button" class="btn btn-primary flex-1" id="place-detail-close">Schließen</button>
`;
UI.modal.open({ title: `${t.icon} ${place.name}`, body, footer });
UI.modal.open({ title: `${t.icon} ${_esc(place.name)}`, body, footer });
document.getElementById('place-detail-close')?.addEventListener('click', UI.modal.close);
@ -341,7 +341,7 @@ window.Page_places = (() => {
const isEdit = !!place;
const typOpts = Object.entries(TYPEN)
.map(([k, t]) => `<option value="${k}" ${place?.typ === k ? 'selected' : ''}>${t.icon} ${t.label}</option>`)
.map(([k, t]) => `<option value="${k}" ${place?.typ === k ? 'selected' : ''}>${t.label}</option>`)
.join('');
const body = `
@ -367,12 +367,12 @@ window.Page_places = (() => {
<input class="form-control" type="text" id="pf-lon-disp"
placeholder="Länge" readonly style="flex:1"
value="${place ? place.lon.toFixed(6) : ''}">
<button type="button" class="btn btn-secondary" id="pf-gps-btn" title="GPS">📍</button>
<button type="button" class="btn btn-secondary" id="pf-gps-btn" title="GPS"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg></button>
</div>
<input type="hidden" name="lat" id="pf-lat" value="${place?.lat || ''}">
<input type="hidden" name="lon" id="pf-lon" value="${place?.lon || ''}">
<small id="pf-gps-hint" style="color:var(--c-text-secondary)">
${place ? 'Position gespeichert' : 'GPS-Button drücken oder Standort ermitteln'}
${place ? 'Position gespeichert' : 'GPS-Button drücken oder Standort ermitteln'}
</small>
</div>
@ -392,15 +392,15 @@ window.Page_places = (() => {
<label class="form-label">Hundefreundlichkeit</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="hund_rein" ${place?.hund_rein ? 'checked' : ''}>
🐕 Hund darf rein
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg> Hund darf rein
</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="leine_pflicht" ${place?.leine_pflicht ? 'checked' : ''}>
🔗 Leinenpflicht beachten
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#tag"></use></svg> Leinenpflicht beachten
</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="wasser_fuer_hunde" ${place?.wasser_fuer_hunde ? 'checked' : ''}>
💧 Wasser für Hunde vorhanden
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#drop"></use></svg> Wasser für Hunde vorhanden
</label>
</div>
@ -414,7 +414,7 @@ window.Page_places = (() => {
</button>
`;
UI.modal.open({ title: isEdit ? `${place.name} bearbeiten` : '📍 Neuer Ort', body, footer });
UI.modal.open({ title: isEdit ? `${_esc(place.name)} bearbeiten` : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg> Neuer Ort', body, footer });
document.getElementById('place-form-cancel')?.addEventListener('click', UI.modal.close);
@ -429,7 +429,7 @@ window.Page_places = (() => {
document.getElementById('pf-lon').value = pos.lon;
document.getElementById('pf-lat-disp').value = pos.lat.toFixed(6);
document.getElementById('pf-lon-disp').value = pos.lon.toFixed(6);
document.getElementById('pf-gps-hint').textContent = 'Standort ermittelt';
document.getElementById('pf-gps-hint').textContent = 'Standort ermittelt';
} catch {
UI.toast.error('GPS nicht verfügbar.');
}

View file

@ -54,8 +54,8 @@ window.Page_poison = (() => {
async function _render() {
_container.innerHTML = `
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-3);flex-wrap:wrap">
<button class="btn btn-secondary" id="poison-btn-locate">📍 Mein Standort</button>
<button class="btn btn-danger" id="poison-btn-report"> Giftköder melden</button>
<button class="btn btn-secondary" id="poison-btn-locate">${UI.icon('map-pin')} Mein Standort</button>
<button class="btn btn-danger" id="poison-btn-report">${UI.icon('warning-octagon')} Giftköder melden</button>
</div>
<div id="poison-map"

View file

@ -17,6 +17,11 @@ window.Page_routes = (() => {
let _sortBy = 'newest';
let _onlyMine = false;
// Ansichts-Modus: 'list' | 'map'
let _viewMode = 'list';
let _searchMap = null; // L.map Instanz der Suchkarte
let _searchLines = new Map(); // routeId → { line, route }
// Mini-Karten auf den Route-Cards
let _miniMaps = new Map(); // routeId → L.map
let _leafletReady = false;
@ -74,11 +79,15 @@ window.Page_routes = (() => {
<div class="rk-search-row">
<input class="rk-search" id="rk-search" type="search"
placeholder="🔍 Route suchen…" autocomplete="off">
<div class="rk-view-toggle">
<button class="rk-view-btn${_viewMode==='list'?' active':''}" id="rk-view-list" title="Liste">${UI.icon('list')}</button>
<button class="rk-view-btn${_viewMode==='map'?' active':''}" id="rk-view-map" title="Karte">${UI.icon('map-trifold')}</button>
</div>
<label class="btn btn-secondary btn-sm rk-imp-btn" title="GPX / KML / TCX importieren">
📥 Import
${UI.icon('download-simple')} Import
<input type="file" id="rk-import-input" accept=".gpx,.kml,.tcx" style="display:none">
</label>
<button class="btn btn-primary btn-sm rk-rec-btn" id="rk-rec-btn">🔴 Aufzeichnen</button>
<button class="btn btn-primary btn-sm rk-rec-btn" id="rk-rec-btn">${UI.icon('path')} Aufzeichnen</button>
</div>
<div class="rk-filters" id="rk-filters">
<div class="rk-filter-group">
@ -114,6 +123,8 @@ window.Page_routes = (() => {
document.getElementById('rk-search').addEventListener('input', e => {
_search = e.target.value.toLowerCase(); _applyFilter();
});
document.getElementById('rk-view-list').addEventListener('click', () => _switchView('list'));
document.getElementById('rk-view-map').addEventListener('click', () => _switchView('map'));
document.getElementById('rk-rec-btn').addEventListener('click', () => {
App.navigate('map');
setTimeout(() => window.Page_map?.startRecording?.(), 600);
@ -138,6 +149,177 @@ window.Page_routes = (() => {
});
}
// ----------------------------------------------------------
// View-Toggle
// ----------------------------------------------------------
function _switchView(mode) {
_viewMode = mode;
document.getElementById('rk-view-list')?.classList.toggle('active', mode === 'list');
document.getElementById('rk-view-map')?.classList.toggle('active', mode === 'map');
const layout = document.querySelector('.rk-layout');
const grid = document.getElementById('rk-grid');
if (mode === 'map') {
if (grid) grid.style.display = 'none';
// Alten Map-Container entfernen falls vorhanden
document.getElementById('rk-map-section')?.remove();
if (_searchMap) { _searchMap.remove(); _searchMap = null; _searchLines.clear(); }
// Als fixed Overlay direkt in <body> — kein Konflikt mit .rk-layout overflow:hidden
const mapH = window.innerHeight - 160;
const sec = document.createElement('div');
sec.id = 'rk-map-section';
sec.className = 'rk-map-section';
sec.innerHTML = `
<div class="rk-map-bar">
<input class="rk-map-loc-input" id="rk-map-loc" type="search"
placeholder="🔍 Ort suchen…" autocomplete="off">
<button class="btn btn-secondary btn-sm" id="rk-map-gps" title="Mein Standort">📍</button>
</div>
<div id="rk-search-map" style="height:${mapH}px;width:100%"></div>
<div id="rk-map-hint" class="rk-map-hint">Route antippen um Details zu sehen</div>
`;
document.body.appendChild(sec);
// Wie _initMiniMaps: pollen bis window.L bereit ist
_pollAndInitSearchMap();
} else {
document.getElementById('rk-map-section')?.remove();
if (_searchMap) { _searchMap.remove(); _searchMap = null; _searchLines.clear(); }
if (grid) grid.style.display = '';
}
}
// ----------------------------------------------------------
// Suchkarte
// ----------------------------------------------------------
function _pollAndInitSearchMap() {
if (window.L) { _initSearchMap(); return; }
let tries = 0;
const poll = setInterval(() => {
if (window.L || ++tries > 40) {
clearInterval(poll);
if (window.L) _initSearchMap();
}
}, 100);
}
function _initSearchMap() {
if (!document.getElementById('rk-search-map')) return;
const center = _userPos ? [_userPos.lat, _userPos.lon] : [51.1, 10.4];
const zoom = _userPos ? 13 : 6;
_searchMap = L.map('rk-search-map', { zoomControl: true, attributionControl: false })
.setView(center, zoom);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_searchMap);
setTimeout(() => _searchMap?.invalidateSize(), 100);
setTimeout(() => _searchMap?.invalidateSize(), 600);
_renderRoutesOnMap();
// Standort-Button
document.getElementById('rk-map-gps')?.addEventListener('click', async () => {
try {
const pos = await API.getLocation();
_userPos = pos;
_searchMap.setView([pos.lat, pos.lon], 14);
} catch { UI.toast.warning('Standort nicht verfügbar.'); }
});
// Geocoding-Suche
const locInput = document.getElementById('rk-map-loc');
let _geoDebounce;
locInput?.addEventListener('keydown', e => {
if (e.key !== 'Enter') return;
clearTimeout(_geoDebounce);
_geocodeAndFly(locInput.value.trim());
});
locInput?.addEventListener('input', () => {
clearTimeout(_geoDebounce);
const q = locInput.value.trim();
if (q.length < 3) return;
_geoDebounce = setTimeout(() => _geocodeAndFly(q), 800);
});
}
async function _geocodeAndFly(query) {
if (!query || !_searchMap) return;
try {
const r = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=1&accept-language=de`,
{ cache: 'no-store' }
);
const data = await r.json();
if (!data.length) { UI.toast.info('Ort nicht gefunden.'); return; }
const { lat, lon, boundingbox } = data[0];
if (boundingbox) {
_searchMap.fitBounds([[+boundingbox[0], +boundingbox[2]], [+boundingbox[1], +boundingbox[3]]],
{ maxZoom: 14 });
} else {
_searchMap.setView([+lat, +lon], 13);
}
} catch { UI.toast.warning('Suche fehlgeschlagen.'); }
}
function _renderRoutesOnMap() {
if (!_searchMap || !window.L) return;
// Alte Linien entfernen
_searchLines.forEach(({ line }) => line.remove());
_searchLines.clear();
const hint = document.getElementById('rk-map-hint');
_data.forEach(route => {
const pts = (route.preview_track || []).map(p => [p.lat, p.lon]);
if (pts.length < 2) return;
const line = L.polyline(pts, {
color: '#C4843A', weight: 4, opacity: 0.75,
}).addTo(_searchMap);
// Start-/End-Marker
const startM = L.circleMarker(pts[0], {
radius: 6, color: '#22C55E', fillColor: '#22C55E', fillOpacity: 1, weight: 1.5
}).addTo(_searchMap);
const endM = L.circleMarker(pts[pts.length - 1], {
radius: 6, color: '#EF4444', fillColor: '#EF4444', fillOpacity: 1, weight: 1.5
}).addTo(_searchMap);
// Tooltip mit Namen und Distanz
const tip = `<b>${_esc(route.name)}</b>${route.distanz_km ? ` · ${route.distanz_km.toFixed(1)} km` : ''}`;
line.bindTooltip(tip, { sticky: true, className: 'rk-map-tooltip' });
// Hover-Highlight
line.on('mouseover', () => line.setStyle({ color: '#e67e22', weight: 6, opacity: 1 }));
line.on('mouseout', () => line.setStyle({ color: '#C4843A', weight: 4, opacity: 0.75 }));
// Klick → Detail-Modal (Karte bleibt im Hintergrund erhalten)
const onClick = () => {
if (hint) hint.textContent = `Lädt „${route.name}"…`;
_openDetail(route.id).finally(() => {
if (hint) hint.textContent = 'Route antippen um Details zu sehen';
});
};
line.on('click', onClick);
startM.on('click', onClick);
_searchLines.set(route.id, { line, startM, endM });
});
// Wenn Routen vorhanden: Karte auf alle Routes zoomen (nur beim ersten Mal)
if (_data.length && _searchLines.size && !_userPos) {
const allPts = [..._searchLines.values()].flatMap(({ line }) => line.getLatLngs());
if (allPts.length) {
try { _searchMap.fitBounds(L.latLngBounds(allPts), { padding: [20, 20], maxZoom: 14 }); }
catch {}
}
}
}
// ----------------------------------------------------------
// Daten
// ----------------------------------------------------------
@ -178,6 +360,7 @@ window.Page_routes = (() => {
_filtered = list;
_renderGrid();
if (_viewMode === 'map' && _searchMap) _renderRoutesOnMap();
}
// ----------------------------------------------------------
@ -511,12 +694,27 @@ window.Page_routes = (() => {
// ----------------------------------------------------------
// Nearby POIs
// ----------------------------------------------------------
// Gibt true zurück wenn poi.lat/lon innerhalb maxMeters eines Track-Punkts liegt
function _isNearTrack(poi, track, maxMeters) {
const R = 6371000;
const plat = poi.lat * Math.PI / 180;
const plon = poi.lon * Math.PI / 180;
for (const pt of track) {
const dlat = plat - pt.lat * Math.PI / 180;
const dlon = plon - pt.lon * Math.PI / 180;
const a = dlat*dlat + Math.cos(plat) * Math.cos(pt.lat * Math.PI/180) * dlon*dlon;
if (R * Math.sqrt(a) <= maxMeters) return true;
}
return false;
}
async function _loadNearbyPois(track) {
const lats = track.map(p => p.lat), lons = track.map(p => p.lon);
const south = Math.min(...lats), north = Math.max(...lats);
const west = Math.min(...lons), east = Math.max(...lons);
// Etwas aufweiten (ca. 300m)
const pad = 0.003;
// Bbox-Padding zum Abrufen (ca. 150m) — echte Distanzfilterung danach
const pad = 0.0015;
const bbox = { south: south-pad, north: north+pad, west: west-pad, east: east+pad };
const results = [];
@ -524,7 +722,9 @@ window.Page_routes = (() => {
try {
const params = new URLSearchParams({ type, fast: 'true', ...bbox });
const pois = await fetch(`/api/osm/pois?${params}`).then(r => r.json());
pois.forEach(p => results.push({ ...p, _icon: icon, _label: label }));
pois
.filter(p => _isNearTrack(p, track, 100)) // max 100m vom Track-Verlauf
.forEach(p => results.push({ ...p, _icon: icon, _label: label }));
} catch {}
}));
return results;

View file

@ -54,7 +54,7 @@ window.Page_settings = (() => {
<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">${_esc(u.email)}</div>
${u.is_premium
? `<span class="badge badge-primary" style="margin-top:var(--space-1)">
Ban Yaro Plus
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#star"></use></svg> Ban Yaro Plus
</span>`
: `<span class="badge" style="margin-top:var(--space-1);
color:var(--c-text-secondary)">
@ -68,25 +68,52 @@ window.Page_settings = (() => {
<div class="card-body" style="padding:0">
<div class="sidebar-item" data-page="dog-profile"
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border)">
<span>🐕</span>
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>
<span>Hunde-Profile</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
<div class="sidebar-item" id="settings-push-btn"
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border)">
<span>🔔</span>
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bell"></use></svg>
<span>Push-Benachrichtigungen</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
<div class="sidebar-item" id="settings-logout-btn"
style="padding:var(--space-4);border-radius:0;cursor:pointer;
color:var(--c-danger)">
<span>🚪</span>
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sign-out"></use></svg>
<span>Abmelden</span>
</div>
</div>
</div>
<div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);
font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary);text-transform:uppercase;
letter-spacing:0.05em;border-bottom:1px solid var(--c-border)">
App-Einstellungen
</div>
<div class="card-body" style="padding:0">
<div style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-4);border-bottom:1px solid var(--c-border)">
<svg class="ph-icon" aria-hidden="true" style="width:1.25rem;height:1.25rem"><use href="/icons/phosphor.svg#eye-slash"></use></svg>
<div style="flex:1">
<div style="font-weight:500">Pocket-Modus beim Aufzeichnen</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
Schwarzes Overlay hält den Bildschirm aktiv (GPS läuft) ideal für die Hosentasche.
Helligkeit auf Minimum reduzieren für optimalen Akku-Schutz.
</div>
</div>
<label class="toggle" style="flex-shrink:0">
<input type="checkbox" id="toggle-pocket-mode"
${localStorage.getItem('by_pocket_mode') === 'true' ? 'checked' : ''}>
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<div style="text-align:center;color:var(--c-text-secondary);
font-size:var(--text-xs)">
Ban Yaro · banyaro.app<br>
@ -121,6 +148,13 @@ window.Page_settings = (() => {
UI.toast.warning('Push-Benachrichtigungen konnten nicht aktiviert werden.');
}
});
document.getElementById('toggle-pocket-mode')?.addEventListener('change', e => {
localStorage.setItem('by_pocket_mode', String(e.target.checked));
UI.toast.info(e.target.checked
? 'Pocket-Modus aktiviert — Bildschirm bleibt bei Aufzeichnung an.'
: 'Pocket-Modus deaktiviert.');
});
}
// ----------------------------------------------------------

View file

@ -9,10 +9,10 @@ window.Page_sitting = (() => {
// Konstanten
// ----------------------------------------------------------
const SERVICES = [
{ id: 'tagesbetreuung', label: 'Tagesbetreuung', icon: '☀️' },
{ id: 'uebernachtung', label: 'Übernachtung', icon: '🌙' },
{ id: 'gassi', label: 'Gassi gehen', icon: '🦮' },
{ id: 'hausbesuch', label: 'Hausbesuch', icon: '🏠' },
{ id: 'tagesbetreuung', label: 'Tagesbetreuung', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sun"></use></svg>' },
{ id: 'uebernachtung', label: 'Übernachtung', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#moon"></use></svg>' },
{ id: 'gassi', label: 'Gassi gehen', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>' },
{ id: 'hausbesuch', label: 'Hausbesuch', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#house-line"></use></svg>' },
];
// ----------------------------------------------------------
@ -44,10 +44,10 @@ window.Page_sitting = (() => {
function _render() {
_container.innerHTML = `
<div class="sitting-tabs" id="sit-tabs">
<button class="sitting-tab active" data-sit-tab="suchen">🔍 Sitter finden</button>
<button class="sitting-tab active" data-sit-tab="suchen">${UI.icon('magnifying-glass')} Sitter finden</button>
${_state.user ? `
<button class="sitting-tab" data-sit-tab="profil">👤 Mein Profil</button>
<button class="sitting-tab" data-sit-tab="anfragen">📬 Anfragen</button>
<button class="sitting-tab" data-sit-tab="profil">${UI.icon('user')} Mein Profil</button>
<button class="sitting-tab" data-sit-tab="anfragen">${UI.icon('bell')} Anfragen</button>
` : ''}
</div>
<div id="sit-content" class="sitting-content"></div>
@ -95,7 +95,7 @@ window.Page_sitting = (() => {
// ---- Tab: Sitter suchen ----
function _renderSuchen(el) {
if (!_sitters.length) {
el.innerHTML = UI.emptyState({ icon: '🐕', title: 'Keine Sitter', text: 'Noch keine Sitter in deiner Nähe registriert.' });
el.innerHTML = UI.emptyState({ icon: 'dog', title: 'Keine Sitter', text: 'Noch keine Sitter in deiner Nähe registriert.' });
return;
}
el.innerHTML = `
@ -115,10 +115,10 @@ window.Page_sitting = (() => {
: '';
return `
<div class="sitting-card" data-sit-id="${s.id}">
<div class="sitting-card-avatar">🐾</div>
<div class="sitting-card-avatar">${UI.icon('paw-print')}</div>
<div class="sitting-card-body">
<div class="sitting-card-name">${UI.escHtml(s.sitter_name)}</div>
${dist ? `<div class="sitting-card-dist">📍 ${dist} entfernt</div>` : ''}
${dist ? `<div class="sitting-card-dist">${UI.icon('map-pin')} ${dist} entfernt</div>` : ''}
${s.beschreibung ? `<div class="sitting-card-desc">${UI.escHtml(s.beschreibung)}</div>` : ''}
<div class="sitting-services">${svcs}</div>
</div>
@ -135,7 +135,7 @@ window.Page_sitting = (() => {
if (!_mySitter) {
el.innerHTML = `
<div class="sitting-empty-profil">
<div style="font-size:3rem">🐾</div>
<div style="font-size:3rem">${UI.icon('paw-print')}</div>
<h3>Werde Hundesitter</h3>
<p>Biete anderen Hundebesitzern deine Dienste an und verdiene etwas dazu.</p>
<button class="btn btn-primary" id="sit-create-profil-btn">Profil erstellen</button>
@ -149,9 +149,9 @@ window.Page_sitting = (() => {
<div class="sitting-my-profil">
<div class="sitting-profil-header">
<div class="sitting-profil-status ${s.aktiv ? 'active' : 'inactive'}">
${s.aktiv ? '✅ Aktiv' : '⏸️ Pausiert'}
${s.aktiv ? `${UI.icon('check')} Aktiv` : 'Pausiert'}
</div>
<button class="btn btn-secondary btn-sm" id="sit-edit-profil-btn"> Bearbeiten</button>
<button class="btn btn-secondary btn-sm" id="sit-edit-profil-btn">${UI.icon('pencil-simple')} Bearbeiten</button>
</div>
${s.beschreibung ? `<p>${UI.escHtml(s.beschreibung)}</p>` : ''}
<div class="sitting-profil-facts">
@ -172,17 +172,17 @@ window.Page_sitting = (() => {
let html = '';
if (inbox.length) {
html += `<div class="sitting-section-label">📬 Eingehende Anfragen (als Sitter)</div>`;
html += `<div class="sitting-section-label">${UI.icon('bell')} Eingehende Anfragen (als Sitter)</div>`;
html += inbox.map(r => _requestCardHTML(r, 'inbox')).join('');
}
if (myReqs.length) {
html += `<div class="sitting-section-label" style="margin-top:var(--space-4)">📤 Meine Anfragen</div>`;
html += `<div class="sitting-section-label" style="margin-top:var(--space-4)">${UI.icon('upload')} Meine Anfragen</div>`;
html += myReqs.map(r => _requestCardHTML(r, 'sent')).join('');
}
if (!inbox.length && !myReqs.length) {
html = UI.emptyState({ icon: '📬', title: 'Keine Anfragen', text: 'Noch keine Sitting-Anfragen vorhanden.' });
html = UI.emptyState({ icon: 'bell', title: 'Keine Anfragen', text: 'Noch keine Sitting-Anfragen vorhanden.' });
}
el.innerHTML = html;
@ -198,7 +198,7 @@ window.Page_sitting = (() => {
<span class="sitting-req-name">${UI.escHtml(name || '?')}</span>
<span class="sitting-req-status" style="color:${color}">${r.status}</span>
</div>
<div class="sitting-req-dates">📅 ${r.von} ${r.bis}</div>
<div class="sitting-req-dates">${UI.icon('calendar-dots')} ${r.von} ${r.bis}</div>
${r.nachricht ? `<div class="sitting-req-msg">${UI.escHtml(r.nachricht)}</div>` : ''}
${r.status === 'offen' ? _requestActions(r.id, mode) : ''}
</div>
@ -209,14 +209,14 @@ window.Page_sitting = (() => {
if (mode === 'inbox') {
return `
<div class="sitting-req-actions">
<button class="btn btn-primary btn-sm" data-sit-accept="${id}"> Annehmen</button>
<button class="btn btn-danger btn-sm" data-sit-decline="${id}"> Ablehnen</button>
<button class="btn btn-primary btn-sm" data-sit-accept="${id}">${UI.icon('check')} Annehmen</button>
<button class="btn btn-danger btn-sm" data-sit-decline="${id}">${UI.icon('x')} Ablehnen</button>
</div>
`;
}
return `
<div class="sitting-req-actions">
<button class="btn btn-secondary btn-sm" data-sit-cancel="${id}">🚫 Abbrechen</button>
<button class="btn btn-secondary btn-sm" data-sit-cancel="${id}">${UI.icon('x')} Abbrechen</button>
</div>
`;
}
@ -236,9 +236,9 @@ window.Page_sitting = (() => {
: null;
const body = `
<div class="sitting-detail-avatar">🐾</div>
<div class="sitting-detail-avatar">${UI.icon('paw-print')}</div>
<h3 style="margin:var(--space-2) 0">${UI.escHtml(s.sitter_name)}</h3>
${dist ? `<div style="color:var(--c-text-secondary);margin-bottom:var(--space-2)">📍 ${dist} entfernt</div>` : ''}
${dist ? `<div style="color:var(--c-text-secondary);margin-bottom:var(--space-2)">${UI.icon('map-pin')} ${dist} entfernt</div>` : ''}
${s.beschreibung ? `<p>${UI.escHtml(s.beschreibung)}</p>` : ''}
<div class="sitting-services" style="margin:var(--space-3) 0">${svcs}</div>
<div class="sitting-profil-facts">
@ -249,7 +249,7 @@ window.Page_sitting = (() => {
`;
const footer = _state.user && _mySitter?.user_id !== s.user_id ? `
<button class="btn btn-primary" id="sit-anfrage-btn">📬 Anfrage senden</button>
<button class="btn btn-primary" id="sit-anfrage-btn">${UI.icon('bell')} Anfrage senden</button>
` : (!_state.user ? `<span style="color:var(--c-text-secondary)">Zum Anfragen bitte einloggen.</span>` : '');
UI.modal.open({ title: 'Sitter-Profil', body, footer });
@ -368,7 +368,7 @@ window.Page_sitting = (() => {
<input class="form-control" type="number" step="any" name="lon" id="sit-lon" value="${s?.lon || ''}">
</div>
</div>
<button type="button" class="btn btn-secondary btn-sm" id="sit-gps-btn">📍 Meine Position</button>
<button type="button" class="btn btn-secondary btn-sm" id="sit-gps-btn">${UI.icon('map-pin')} Meine Position</button>
<div class="form-group" style="margin-top:var(--space-3)">
<label class="form-label">Umkreis (km)</label>
<input class="form-control" type="number" min="1" max="100" name="radius_km" value="${s?.radius_km ?? 20}">

View file

@ -65,10 +65,10 @@ window.Page_walks = (() => {
<!-- Toolbar -->
<div class="walks-toolbar">
<div class="walks-view-toggle" id="walks-view-toggle">
<button class="walks-view-btn active" data-view="liste">📋 Liste</button>
<button class="walks-view-btn" data-view="karte">🗺 Karte</button>
<button class="walks-view-btn active" data-view="liste">${UI.icon('list')} Liste</button>
<button class="walks-view-btn" data-view="karte">${UI.icon('map-trifold')} Karte</button>
</div>
<button class="btn btn-primary btn-sm" id="walks-create-btn">+ Treffen planen</button>
<button class="btn btn-primary btn-sm" id="walks-create-btn">${UI.icon('plus')} Treffen planen</button>
</div>
<!-- Liste -->
@ -143,7 +143,7 @@ window.Page_walks = (() => {
if (!_data.length) {
el.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">🐕</div>
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon('dog')}</div>
<p style="color:var(--c-text-secondary)">Noch keine Treffen in deiner Nähe.</p>
<button class="btn btn-primary" style="margin-top:var(--space-4)" id="walks-first-btn">
Erstes Treffen planen
@ -160,12 +160,12 @@ window.Page_walks = (() => {
let html = '';
if (heute.length) {
html += `<div class="walks-section-label">🌟 Heute</div>`;
html += `<div class="walks-section-label">${UI.icon('star')} Heute</div>`;
html += heute.map(w => _walkCardHTML(w)).join('');
}
if (upcoming.length) {
html += `<div class="walks-section-label">📅 Demnächst</div>`;
html += `<div class="walks-section-label">${UI.icon('calendar-dots')} Demnächst</div>`;
html += upcoming.map(w => _walkCardHTML(w)).join('');
}
@ -190,12 +190,12 @@ window.Page_walks = (() => {
</div>
<div class="walks-card-body">
<div class="walks-card-title">${_esc(w.titel)}</div>
${w.ort_name ? `<div class="walks-card-ort">📍 ${_esc(w.ort_name)}</div>` : ''}
${w.ort_name ? `<div class="walks-card-ort">${UI.icon('map-pin')} ${_esc(w.ort_name)}</div>` : ''}
<div class="walks-card-meta">
<span class="walks-badge ${isFull ? 'walks-badge--full' : 'walks-badge--open'}">
${isFull ? '🔴 Voll' : `🟢 ${spots} Platz${spots !== 1 ? 'e' : ''} frei`}
</span>
<span class="walks-badge">🐾 ${w.teilnehmer_count}/${w.max_teilnehmer}</span>
<span class="walks-badge">${UI.icon('paw-print')} ${w.teilnehmer_count}/${w.max_teilnehmer}</span>
${isOwn ? '<span class="walks-badge walks-badge--own">Mein Treffen</span>' : ''}
</div>
</div>
@ -241,7 +241,7 @@ window.Page_walks = (() => {
html: `<div style="background:${color};color:#fff;font-size:14px;font-weight:700;
width:32px;height:32px;border-radius:50%;display:flex;align-items:center;
justify-content:center;box-shadow:0 2px 5px rgba(0,0,0,0.3);
border:2px solid rgba(255,255,255,0.8)">🐕</div>`,
border:2px solid rgba(255,255,255,0.8)">${UI.icon('dog')}</div>`,
iconSize: [32, 32], iconAnchor: [16, 16],
});
const m = L.marker([w.lat, w.lon], { icon })
@ -273,8 +273,8 @@ window.Page_walks = (() => {
const teilnehmerHTML = walk.teilnehmer?.length
? walk.teilnehmer.map(t => `
<div class="walks-participant">
<span class="walks-participant-name">🧑 ${_esc(t.user_name)}</span>
${t.hunde ? `<span class="walks-participant-hunde">🐕 ${_esc(t.hunde)}</span>` : ''}
<span class="walks-participant-name">${UI.icon('user')} ${_esc(t.user_name)}</span>
${t.hunde ? `<span class="walks-participant-hunde">${UI.icon('dog')} ${_esc(t.hunde)}</span>` : ''}
</div>`).join('')
: `<p style="color:var(--c-text-muted)">Noch keine Teilnehmer.</p>`;
@ -284,12 +284,12 @@ window.Page_walks = (() => {
${_fmtDate(walk.datum)}<br>
<strong>um ${walk.uhrzeit} Uhr</strong>
</div>
${walk.ort_name ? `<div style="margin-top:var(--space-2);color:var(--c-text-secondary)">📍 ${_esc(walk.ort_name)}</div>` : ''}
${walk.ort_name ? `<div style="margin-top:var(--space-2);color:var(--c-text-secondary)">${UI.icon('map-pin')} ${_esc(walk.ort_name)}</div>` : ''}
<div style="margin-top:var(--space-2);display:flex;gap:var(--space-2);flex-wrap:wrap">
<span class="walks-badge ${isFull ? 'walks-badge--full' : 'walks-badge--open'}">
${isFull ? '🔴 Voll' : `🟢 ${spots} Platz${spots !== 1 ? 'e' : ''} frei`}
</span>
<span class="walks-badge">🐾 ${walk.teilnehmer_count}/${walk.max_teilnehmer} Teilnehmer</span>
<span class="walks-badge">${UI.icon('paw-print')} ${walk.teilnehmer_count}/${walk.max_teilnehmer} Teilnehmer</span>
${isOwn ? '<span class="walks-badge walks-badge--own">Dein Treffen</span>' : ''}
</div>
</div>
@ -330,11 +330,11 @@ window.Page_walks = (() => {
} else {
footer = `
<button type="button" class="btn btn-secondary flex-1" id="wd-close">Schließen</button>
<button type="button" class="btn btn-primary flex-1" id="wd-join">🐕 Mitmachen</button>
<button type="button" class="btn btn-primary flex-1" id="wd-join">${UI.icon('dog')} Mitmachen</button>
`;
}
UI.modal.open({ title: `🐕 ${walk.titel}`, body, footer });
UI.modal.open({ title: `${UI.icon('dog')} ${walk.titel}`, body, footer });
document.getElementById('wd-close')?.addEventListener('click', UI.modal.close);
@ -398,14 +398,14 @@ window.Page_walks = (() => {
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;
padding:var(--space-2) 0">
<input type="checkbox" name="dog" value="${d.id}" checked>
🐕 ${_esc(d.name)}
${UI.icon('dog')} ${_esc(d.name)}
</label>`).join('')
: `<p style="color:var(--c-text-muted)">Keine Hunde im Profil — du kannst trotzdem mitmachen.</p>`;
const body = `
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
${_fmtDate(walk.datum)} um ${walk.uhrzeit} Uhr<br>
${walk.ort_name ? `📍 ${_esc(walk.ort_name)}` : ''}
${walk.ort_name ? `${UI.icon('map-pin')} ${_esc(walk.ort_name)}` : ''}
</p>
<div class="form-group">
<label class="form-label">Mit welchen Hunden?</label>
@ -415,7 +415,7 @@ window.Page_walks = (() => {
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="join-cancel">Abbrechen</button>
<button type="button" class="btn btn-primary flex-1" id="join-confirm">🐕 Mitmachen</button>
<button type="button" class="btn btn-primary flex-1" id="join-confirm">${UI.icon('dog')} Mitmachen</button>
`;
UI.modal.open({ title: `Treffen beitreten`, body, footer });
@ -485,12 +485,12 @@ window.Page_walks = (() => {
value="${_esc(v.ort_name || '')}"
placeholder="z. B. Parkeingang Nordseite, U-Bahn Volkspark"
style="flex:1">
<button type="button" class="btn btn-secondary" id="walk-gps-btn" title="GPS">📍</button>
<button type="button" class="btn btn-secondary" id="walk-gps-btn" title="GPS">${UI.icon('map-pin')}</button>
</div>
<input type="hidden" name="lat" id="walk-lat" value="${v.lat || ''}">
<input type="hidden" name="lon" id="walk-lon" value="${v.lon || ''}">
<small id="walk-gps-hint" style="color:var(--c-text-secondary)">
${v.lat ? '✅ Position gespeichert' : 'GPS-Button für aktuellen Standort'}
${v.lat ? `${UI.icon('check')} Position gespeichert` : 'GPS-Button für aktuellen Standort'}
</small>
</div>
@ -512,11 +512,11 @@ window.Page_walks = (() => {
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="wf-cancel">Abbrechen</button>
<button type="submit" form="walk-form" class="btn btn-primary flex-1">
${isEdit ? 'Speichern' : '📅 Treffen planen'}
${isEdit ? 'Speichern' : `${UI.icon('calendar-dots')} Treffen planen`}
</button>
`;
UI.modal.open({ title: isEdit ? 'Treffen bearbeiten' : '🐕 Treffen planen', body, footer });
UI.modal.open({ title: isEdit ? 'Treffen bearbeiten' : `${UI.icon('dog')} Treffen planen`, body, footer });
document.getElementById('wf-cancel')?.addEventListener('click', UI.modal.close);
@ -528,7 +528,7 @@ window.Page_walks = (() => {
_userPos = pos;
document.getElementById('walk-lat').value = pos.lat;
document.getElementById('walk-lon').value = pos.lon;
document.getElementById('walk-gps-hint').textContent = '✅ Standort ermittelt';
document.getElementById('walk-gps-hint').innerHTML = `${UI.icon('check')} Standort ermittelt`;
} catch { UI.toast.error('GPS nicht verfügbar.'); }
UI.setLoading(btn, false);
});
@ -539,7 +539,7 @@ window.Page_walks = (() => {
const fd = UI.formData(e.target);
if (!fd.lat || !fd.lon) {
UI.toast.warning('Bitte GPS-Position ermitteln (📍).');
UI.toast.warning('Bitte GPS-Position ermitteln.');
return;
}

View file

@ -0,0 +1,687 @@
/* ============================================================
BAN YARO Hunde-Wiki
Rassen-Datenbank, Gesundheit, Recht, Quiz
============================================================ */
window.Page_wiki = (() => {
// ----------------------------------------------------------
// MODUL-STATE
// ----------------------------------------------------------
let _container = null;
let _appState = null;
let _tab = 'rassen';
let _rassen = [];
let _gruppen = [];
let _totalBreeds = 0;
let _currentOffset = 0;
const PAGE_SIZE = 30;
let _currentSearch = '';
let _currentGruppe = '';
let _quizAnswers = {};
let _quizStep = 0;
// ----------------------------------------------------------
// HARDCODED: Gesundheits-Inhalte
// ----------------------------------------------------------
const GESUNDHEIT = [
{
titel: 'Zecken & FSME',
icon: 'skull',
text: 'Zecken sind von März bis November aktiv (Spitze AprilJuni, SeptemberOktober). Täglich nach Gassi auf Zecken untersuchen — besonders Ohren, Achseln, Leiste.\n\nZecke entfernen: Zeckenzange ansetzen, nicht drehen, gerade herausziehen. KEINE Öle/Vaseline.\n\nFSME: Impfung für Menschen empfohlen in Risikogebieten (RKI-Karte: rki.de/fsme). Hunde können Borreliose bekommen — Impfung empfohlen.',
},
{
titel: 'Vergiftungen — Sofortmaßnahmen',
icon: 'skull',
text: 'Verdacht auf Vergiftung: Sofort Tierarzt oder Tiergiftzentrale (Berlin: 030 19240, München: 089 19240).\n\nNICHT versuchen den Hund zum Erbrechen zu bringen ohne tierärztlichen Rat.\n\nHäufige Giftquellen: Schokolade, Trauben/Rosinen, Zwiebeln, Xylitol (Süßungsmittel), Ibuprofen.',
},
{
titel: 'Hitzschlag',
icon: 'warning',
text: 'Symptome: Starkes Hecheln, Speichelfluss, taumeln, Kollaps.\n\nSofortmaßnahme: In den Schatten, mit lauwarmem (nicht kaltem!) Wasser abkühlen, sofort zum Tierarzt.\n\nHunde NIEMALS im Auto lassen.',
},
{
titel: 'Erste Hilfe Grundlagen',
icon: 'first-aid',
text: 'Bewusstloser Hund: Atemwege frei? Atemkontrolle.\n\nHerzdruckmassage: 100120/min, 1/3 Brusttiefe.\n\nBeatmung: Maul zu, in Nase blasen.\n\nBlutung: Druckverband.\n\nKnochenbruch: Immobilisieren, tragen.',
},
];
// ----------------------------------------------------------
// HARDCODED: Recht & Regeln
// ----------------------------------------------------------
const RECHT = [
{ land: 'Bayern', leine: 'Anleinpflicht im Wald und in Ortschaften', rasse: 'Keine allgemeine Rasseliste (Gefährlichkeitsfeststellung individuell)', steuer: '~100€/Jahr (variiert nach Gemeinde)' },
{ land: 'Baden-Württemberg', leine: 'Leinenpflicht in Ortschaften und Parks', rasse: 'American Pitbull Terrier, American Staffordshire Terrier u.a.', steuer: '~100150€/Jahr' },
{ land: 'Berlin', leine: 'Allgemeine Leinenpflicht in öffentlichen Anlagen', rasse: 'Pitbull, Staffordshire, Rottweiler (bedingt)', steuer: '~120€/Jahr (ab 2. Hund: 180€)' },
{ land: 'Brandenburg', leine: 'Leinenpflicht in Ortschaften und Wäldern AprilJuli', rasse: 'Pitbull, American Staffordshire u.a.', steuer: '~60100€/Jahr' },
{ land: 'Hamburg', leine: 'Allgemeine Leinenpflicht', rasse: 'Pitbull, Rottweiler, Staffordshire u.a.', steuer: '~90€/Jahr (Kampfhund: 900€)' },
{ land: 'Hessen', leine: 'Anleinpflicht in Ortschaften', rasse: 'Pitbull u.a. (Liste)', steuer: '~75120€/Jahr' },
{ land: 'NRW', leine: 'Leinenpflicht in bebauten Gebieten', rasse: 'Pitbull, American Staffordshire, Staffordshire Bull Terrier u.a.', steuer: '~100160€/Jahr' },
{ land: 'Niedersachsen', leine: 'Anleinpflicht in Ortschaften', rasse: 'Pitbull u.a.', steuer: '~60100€/Jahr' },
{ land: 'Sachsen', leine: 'Leinenpflicht in Ortschaften und öffentl. Anlagen', rasse: 'Keine staatliche Liste (kommunal)', steuer: '~50100€/Jahr' },
{ land: 'Thüringen', leine: 'Anleinpflicht in Wäldern und Ortschaften', rasse: 'Pitbull u.a.', steuer: '~60100€/Jahr' },
];
// ----------------------------------------------------------
// QUIZ: Fragen
// ----------------------------------------------------------
const QUIZ_FRAGEN = [
{ key: 'groesse', frage: 'Welche Größe passt zu dir?', optionen: [{val:'klein', label:'Klein (unter 10 kg)'}, {val:'mittel', label:'Mittel (1030 kg)'}, {val:'gross', label:'Groß (über 30 kg)'}] },
{ key: 'aktivitaet', frage: 'Wie aktiv bist du?', optionen: [{val:'niedrig', label:'Eher gemütlich'}, {val:'mittel', label:'Regelmäßige Spaziergänge'}, {val:'hoch', label:'Sehr sportlich'}] },
{ key: 'erfahrung', frage: 'Wie viel Hundeerfahrung hast du?', optionen: [{val:'anfaenger', label:'Ersthundehalter'}, {val:'fortgeschritten', label:'Erfahren'}, {val:'experte', label:'Profi'}] },
{ key: 'kinder', frage: 'Lebst du mit Kindern zusammen?', optionen: [{val:'true', label:'Ja'}, {val:'false', label:'Nein'}] },
{ key: 'wohnung', frage: 'Wo wohnst du?', optionen: [{val:'true', label:'Wohnung (ohne Garten)'}, {val:'false', label:'Haus mit Garten'}] },
];
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
await _render();
}
// ----------------------------------------------------------
// REFRESH
// ----------------------------------------------------------
async function refresh() {
// Wiki ist nicht hunde-spezifisch, kein Reload nötig
}
// ----------------------------------------------------------
// RENDER
// ----------------------------------------------------------
async function _render() {
_container.innerHTML = `
<div class="wiki-tab-bar" id="wiki-tab-bar">
<button class="wiki-tab-btn${_tab === 'rassen' ? ' active' : ''}" data-tab="rassen">${UI.icon('dog')} Rassen</button>
<button class="wiki-tab-btn${_tab === 'gesundheit'? ' active' : ''}" data-tab="gesundheit">${UI.icon('syringe')} Gesundheit</button>
<button class="wiki-tab-btn${_tab === 'recht' ? ' active' : ''}" data-tab="recht">${UI.icon('handshake')} Recht</button>
<button class="wiki-tab-btn${_tab === 'quiz' ? ' active' : ''}" data-tab="quiz">${UI.icon('star')} Quiz</button>
</div>
<div id="wiki-content"></div>
`;
_container.querySelector('#wiki-tab-bar').addEventListener('click', e => {
const btn = e.target.closest('[data-tab]');
if (!btn) return;
_tab = btn.dataset.tab;
_container.querySelectorAll('.wiki-tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === _tab));
_renderTab();
});
await _renderTab();
}
async function _renderTab() {
const content = _container.querySelector('#wiki-content');
if (!content) return;
if (_tab === 'rassen') await _renderRassen(content);
else if (_tab === 'gesundheit') _renderGesundheit(content);
else if (_tab === 'recht') _renderRecht(content);
else if (_tab === 'quiz') _renderQuiz(content);
}
// ----------------------------------------------------------
// TAB: Rassen
// ----------------------------------------------------------
async function _renderRassen(el) {
// Check seeding state first
let stats;
try {
stats = await _apiFetch('/api/wiki/stats');
} catch {
el.innerHTML = UI.emptyState({ icon: 'warning', title: 'Fehler', text: 'Wiki konnte nicht geladen werden.' });
return;
}
if (!stats.seeded) {
el.innerHTML = `
<div class="wiki-loading-state">
<div class="wiki-loading-spinner"></div>
<p class="wiki-loading-text">Rassen-Datenbank wird geladen ${UI.icon('dog')}</p>
<p class="wiki-loading-hint">Beim ersten Start werden ~170 Rassen von TheDogAPI abgerufen.</p>
</div>
`;
return;
}
// Reset state when re-rendering the tab fresh
_rassen = [];
_currentOffset = 0;
_currentSearch = '';
_currentGruppe = '';
el.innerHTML = `
<div class="wiki-filter-bar">
<input class="form-control wiki-search-input" type="search" id="wiki-rassen-search" placeholder="Rasse suchen…">
<select class="form-control wiki-gruppe-select" id="wiki-gruppe-select">
<option value="">Alle Gruppen</option>
</select>
</div>
<div class="wiki-breed-grid" id="wiki-breed-grid"></div>
<div id="wiki-mehr-wrap" style="text-align:center;padding:var(--space-4) 0;display:none">
<button class="btn btn-secondary" id="wiki-mehr-btn">Mehr laden</button>
</div>
`;
// Load initial batch (also populates gruppen)
await _loadBreeds(el, true);
// Search handler with debounce
let _searchTimer;
el.querySelector('#wiki-rassen-search').addEventListener('input', e => {
clearTimeout(_searchTimer);
_searchTimer = setTimeout(() => {
_currentSearch = e.target.value;
_rassen = [];
_currentOffset = 0;
_loadBreeds(el, true);
}, 300);
});
// Gruppe filter handler
el.querySelector('#wiki-gruppe-select').addEventListener('change', e => {
_currentGruppe = e.target.value;
_rassen = [];
_currentOffset = 0;
_loadBreeds(el, true);
});
// "Mehr laden" button
el.querySelector('#wiki-mehr-btn').addEventListener('click', () => {
_loadBreeds(el, false);
});
}
async function _loadBreeds(el, reset) {
const grid = el.querySelector('#wiki-breed-grid');
const mehrWrap = el.querySelector('#wiki-mehr-wrap');
const mehrBtn = el.querySelector('#wiki-mehr-btn');
if (!grid) return;
if (reset) {
grid.innerHTML = `<div class="wiki-breeds-loading">Lade Rassen…</div>`;
}
const params = new URLSearchParams({
search: _currentSearch,
gruppe: _currentGruppe,
limit: PAGE_SIZE,
offset: _currentOffset,
});
let data;
try {
data = await _apiFetch(`/api/wiki/rassen?${params}`);
} catch {
grid.innerHTML = `<p style="color:var(--c-danger);padding:var(--space-4)">Rassen konnten nicht geladen werden.</p>`;
return;
}
// Populate Gruppen dropdown (only on first load)
if (reset && data.gruppen && data.gruppen.length) {
_gruppen = data.gruppen;
const sel = el.querySelector('#wiki-gruppe-select');
if (sel) {
// Preserve current selection
const cur = _currentGruppe;
sel.innerHTML = `<option value="">Alle Gruppen</option>` +
_gruppen.map(g => `<option value="${_esc(g)}"${g === cur ? ' selected' : ''}>${_esc(g)}</option>`).join('');
}
}
if (reset) {
_rassen = data.breeds;
_totalBreeds = data.total;
grid.innerHTML = '';
} else {
_rassen = _rassen.concat(data.breeds);
_currentOffset += data.breeds.length;
}
if (reset) {
_currentOffset = data.breeds.length;
}
if (reset && _rassen.length === 0) {
grid.innerHTML = `<p style="color:var(--c-text-secondary);padding:var(--space-4)">Keine Rassen gefunden.</p>`;
if (mehrWrap) mehrWrap.style.display = 'none';
return;
}
// Render cards
const newCards = data.breeds.map(r => _breedCardHtml(r)).join('');
if (reset) {
grid.innerHTML = newCards;
} else {
grid.insertAdjacentHTML('beforeend', newCards);
}
// Attach click handlers to newly added cards
grid.querySelectorAll('.wiki-breed-card:not([data-bound])').forEach(card => {
card.dataset.bound = '1';
card.addEventListener('click', () => _openBreedDetail(card.dataset.slug));
});
// Show/hide "Mehr laden"
if (mehrWrap) {
const shown = _rassen.length;
mehrWrap.style.display = shown < _totalBreeds ? 'block' : 'none';
if (mehrBtn) mehrBtn.textContent = `Mehr laden (${_totalBreeds - shown} weitere)`;
}
}
function _breedCardHtml(r) {
const photoHtml = r.foto_url
? `<img class="wiki-breed-photo" src="${_esc(r.foto_url)}" loading="lazy" alt="${_esc(r.name)}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">`
: '';
const fallbackHtml = `<div class="wiki-breed-photo-fallback" style="${r.foto_url ? 'display:none' : ''}">${UI.icon('dog')}</div>`;
return `
<div class="wiki-breed-card" data-slug="${_esc(r.slug)}">
<div class="wiki-breed-photo-wrap">
${photoHtml}
${fallbackHtml}
</div>
<div class="wiki-breed-card-body">
<div class="wiki-breed-card-name">${_esc(r.name)}</div>
<div class="wiki-breed-card-gruppe">${_esc(r.gruppe || '—')}</div>
<div class="wiki-breed-badges">
<span class="wiki-badge-groesse wiki-badge-groesse--${_esc(r.groesse)}">${_groesseLabel(r.groesse)}</span>
<span class="wiki-badge-aktivitaet wiki-badge-aktivitaet--${_esc(r.aktivitaet)}">${_aktivLabel(r.aktivitaet)}</span>
<span class="wiki-badge-erfahrung wiki-badge-erfahrung--${_esc(r.erfahrung)}">${_erfahrungLabel(r.erfahrung)}</span>
</div>
</div>
</div>
`;
}
async function _openBreedDetail(slug) {
let rasse;
try {
rasse = await _apiFetch(`/api/wiki/rassen/${slug}`);
} catch {
UI.toast.error('Rasse konnte nicht geladen werden.');
return;
}
const berichteHtml = _renderBerichteHtml(rasse.berichte || [], slug);
// Temperament chips
const chips = rasse.temperament
? rasse.temperament.split(',').map(t => `<span class="wiki-trait-chip">${_esc(t.trim())}</span>`).join('')
: '';
// Stats row
const gewicht = (rasse.gewicht_min_kg && rasse.gewicht_max_kg)
? `${rasse.gewicht_min_kg}${rasse.gewicht_max_kg} kg`
: (rasse.gewicht_max_kg ? `bis ${rasse.gewicht_max_kg} kg` : '—');
const photoHtml = rasse.foto_url
? `<img class="wiki-detail-photo" src="${_esc(rasse.foto_url)}" alt="${_esc(rasse.name)}" onerror="this.style.display='none'">`
: '';
const body = `
${photoHtml}
<div class="wiki-detail-badges">
<span class="wiki-badge-groesse wiki-badge-groesse--${_esc(rasse.groesse)}">${_groesseLabel(rasse.groesse)}</span>
<span class="wiki-badge-aktivitaet wiki-badge-aktivitaet--${_esc(rasse.aktivitaet)}">${_aktivLabel(rasse.aktivitaet)}</span>
<span class="wiki-badge-erfahrung wiki-badge-erfahrung--${_esc(rasse.erfahrung)}">${_erfahrungLabel(rasse.erfahrung)}</span>
${rasse.gruppe ? `<span class="badge">${_esc(rasse.gruppe)}</span>` : ''}
</div>
${rasse.herkunft || rasse.bred_for ? `
<div class="wiki-detail-section">
${rasse.herkunft ? `<div class="wiki-detail-label">Herkunft</div><p>${_esc(rasse.herkunft)}</p>` : ''}
${rasse.bred_for ? `<div class="wiki-detail-label">Ursprüngliche Aufgabe</div><p>${_esc(rasse.bred_for)}</p>` : ''}
</div>` : ''}
${chips ? `
<div class="wiki-detail-section">
<div class="wiki-detail-label">Charakter</div>
<div class="wiki-trait-chips">${chips}</div>
</div>` : ''}
<div class="wiki-stat-row">
<div class="wiki-stat-item">
<span class="wiki-stat-label">Gewicht</span>
<span class="wiki-stat-value">${gewicht}</span>
</div>
<div class="wiki-stat-item">
<span class="wiki-stat-label">Lebenserwartung</span>
<span class="wiki-stat-value">${_esc(rasse.lebensdauer || '—')}</span>
</div>
</div>
<div class="wiki-fit-row">
<span>${UI.icon('house-line')} Wohnung: ${rasse.wohnung_geeignet ? UI.icon('check') : UI.icon('x')}</span>
<span>${UI.icon('users')} Kinder: ${rasse.kinder_geeignet ? UI.icon('check') : UI.icon('x')}</span>
</div>
<div class="wiki-detail-section" id="wiki-berichte-section">
<div class="wiki-detail-label">Community-Berichte</div>
${berichteHtml}
</div>
${_appState.user
? `<button class="btn btn-secondary w-full" id="wiki-bericht-add-btn" style="margin-top:var(--space-3)">+ Eigenen Bericht hinzufügen</button>`
: `<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-top:var(--space-3)">
<a href="#settings" style="color:var(--c-primary)">Anmelden</a>, um einen Bericht zu schreiben.
</p>`
}
`;
UI.modal.open({ title: _esc(rasse.name), body });
document.getElementById('wiki-bericht-add-btn')?.addEventListener('click', () => {
UI.modal.close();
setTimeout(() => _showBerichtForm(slug, rasse.name), 350);
});
}
function _renderBerichteHtml(berichte, slug) {
if (!berichte || berichte.length === 0) {
return `<p style="color:var(--c-text-secondary);font-size:var(--text-sm)">Noch keine Community-Berichte für diese Rasse.</p>`;
}
return berichte.map(b => `
<div class="wiki-bericht-item" data-id="${b.id}">
<div class="wiki-bericht-header">
<span class="wiki-bericht-autor">${_esc(b.autor)}</span>
<span class="wiki-bericht-date">${_formatDate(b.created_at)}</span>
${_appState.user && _appState.user.name === b.autor
? `<button class="btn btn-danger btn-xs wiki-bericht-del" data-id="${b.id}" data-slug="${_esc(slug)}" style="margin-left:auto;padding:2px 8px;font-size:0.7rem">Löschen</button>`
: ''}
</div>
<div class="wiki-bericht-titel">${_esc(b.titel)}</div>
<p class="wiki-bericht-text">${_esc(b.text)}</p>
</div>
`).join('');
}
function _showBerichtForm(slug, rasseName) {
const body = `
<form id="wiki-bericht-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Rasse</label>
<input class="form-control" type="text" value="${_esc(rasseName)}" disabled>
</div>
<div class="form-group">
<label class="form-label">Titel</label>
<input class="form-control" type="text" name="titel" maxlength="120" placeholder="z.B. Mein Erfahrungsbericht" required>
</div>
<div class="form-group">
<label class="form-label">Bericht</label>
<textarea class="form-control" name="text" rows="6" placeholder="Deine Erfahrungen mit dieser Rasse…" required></textarea>
</div>
</form>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="wiki-bericht-cancel">Abbrechen</button>
<button type="submit" form="wiki-bericht-form" class="btn btn-primary flex-1">Veröffentlichen</button>
`;
UI.modal.open({ title: 'Bericht schreiben', body, footer });
document.getElementById('wiki-bericht-cancel')?.addEventListener('click', UI.modal.close);
const form = document.getElementById('wiki-bericht-form');
setTimeout(() => form?.querySelector('[name="titel"]')?.focus(), 150);
form.addEventListener('submit', async e => {
e.preventDefault();
const submitBtn = document.querySelector('[form="wiki-bericht-form"][type="submit"]');
const fd = UI.formData(form);
await UI.asyncButton(submitBtn, async () => {
try {
await _apiPost('/api/wiki/berichte', { rasse: slug, titel: fd.titel, text: fd.text });
UI.modal.close();
UI.toast.success('Bericht veröffentlicht!');
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Veröffentlichen.');
}
});
});
}
// ----------------------------------------------------------
// TAB: Gesundheit
// ----------------------------------------------------------
function _renderGesundheit(el) {
const items = GESUNDHEIT.map((s, i) => `
<div class="wiki-section" data-idx="${i}">
<div class="wiki-section-header">
<span class="wiki-section-icon">${UI.icon(s.icon)}</span>
<span class="wiki-section-titel">${_esc(s.titel)}</span>
<span class="wiki-section-arrow">${UI.icon('caret-down')}</span>
</div>
<div class="wiki-section-body" style="display:none">
<p style="white-space:pre-wrap;line-height:1.6;color:var(--c-text)">${_esc(s.text)}</p>
</div>
</div>
`).join('');
el.innerHTML = `<div class="wiki-accordion">${items}</div>`;
el.querySelectorAll('.wiki-section').forEach(sec => {
sec.querySelector('.wiki-section-header').addEventListener('click', () => {
const body = sec.querySelector('.wiki-section-body');
const arrow = sec.querySelector('.wiki-section-arrow');
const open = body.style.display !== 'none';
body.style.display = open ? 'none' : 'block';
arrow.innerHTML = open ? UI.icon('caret-down') : UI.icon('caret-up');
sec.classList.toggle('open', !open);
});
});
}
// ----------------------------------------------------------
// TAB: Recht & Regeln
// ----------------------------------------------------------
function _renderRecht(el) {
const items = RECHT.map((r, i) => `
<div class="wiki-section" data-idx="${i}">
<div class="wiki-section-header">
<span class="wiki-section-icon">${UI.icon('map-pin')}</span>
<span class="wiki-section-titel">${_esc(r.land)}</span>
<span class="wiki-section-arrow">${UI.icon('caret-down')}</span>
</div>
<div class="wiki-section-body" style="display:none">
<div class="wiki-recht-row"><span class="wiki-recht-label">Leinenpflicht</span><span>${_esc(r.leine)}</span></div>
<div class="wiki-recht-row"><span class="wiki-recht-label">Rasseliste</span><span>${_esc(r.rasse)}</span></div>
<div class="wiki-recht-row"><span class="wiki-recht-label">Hundesteuer</span><span>${_esc(r.steuer)}</span></div>
</div>
</div>
`).join('');
el.innerHTML = `
<div class="wiki-accordion">${items}</div>
<p class="wiki-disclaimer">Angaben ohne Gewähr Regelungen ändern sich. Bitte beim zuständigen Ordnungsamt prüfen.</p>
`;
el.querySelectorAll('.wiki-section').forEach(sec => {
sec.querySelector('.wiki-section-header').addEventListener('click', () => {
const body = sec.querySelector('.wiki-section-body');
const arrow = sec.querySelector('.wiki-section-arrow');
const open = body.style.display !== 'none';
body.style.display = open ? 'none' : 'block';
arrow.innerHTML = open ? UI.icon('caret-down') : UI.icon('caret-up');
sec.classList.toggle('open', !open);
});
});
}
// ----------------------------------------------------------
// TAB: Quiz
// ----------------------------------------------------------
function _renderQuiz(el) {
_quizAnswers = {};
_quizStep = 0;
_renderQuizStep(el);
}
function _renderQuizStep(el) {
if (_quizStep >= QUIZ_FRAGEN.length) {
_loadQuizResult(el);
return;
}
const frage = QUIZ_FRAGEN[_quizStep];
const progress = Math.round((_quizStep / QUIZ_FRAGEN.length) * 100);
const optionsHtml = frage.optionen.map(o => `
<button class="wiki-quiz-option${_quizAnswers[frage.key] === o.val ? ' selected' : ''}"
data-key="${_esc(frage.key)}" data-val="${_esc(o.val)}">
${_esc(o.label)}
</button>
`).join('');
el.innerHTML = `
<div class="wiki-quiz-wrap">
<div class="wiki-quiz-progress-bar">
<div class="wiki-quiz-progress" style="width:${progress}%"></div>
</div>
<p class="wiki-quiz-step-info">Frage ${_quizStep + 1} von ${QUIZ_FRAGEN.length}</p>
<p class="wiki-quiz-frage">${_esc(frage.frage)}</p>
<div class="wiki-quiz-options">${optionsHtml}</div>
<div class="wiki-quiz-nav">
${_quizStep > 0
? `<button class="btn btn-secondary" id="quiz-back">Zurück</button>`
: '<span></span>'}
</div>
</div>
`;
el.querySelectorAll('.wiki-quiz-option').forEach(btn => {
btn.addEventListener('click', () => {
_quizAnswers[btn.dataset.key] = btn.dataset.val;
_quizStep++;
_renderQuizStep(el);
});
});
el.querySelector('#quiz-back')?.addEventListener('click', () => {
_quizStep--;
const prevKey = QUIZ_FRAGEN[_quizStep].key;
delete _quizAnswers[prevKey];
_renderQuizStep(el);
});
}
async function _loadQuizResult(el) {
el.innerHTML = `<div style="text-align:center;padding:var(--space-8)">Berechne Ergebnis…</div>`;
const params = new URLSearchParams(_quizAnswers).toString();
let data;
try {
data = await _apiFetch(`/api/wiki/quiz/result?${params}`);
} catch {
el.innerHTML = UI.emptyState({ icon: 'warning', title: 'Fehler', text: 'Ergebnis konnte nicht geladen werden.' });
return;
}
const cardsHtml = data.results.map(r => {
const photoHtml = r.foto_url
? `<img class="wiki-quiz-result-photo" src="${_esc(r.foto_url)}" loading="lazy" alt="${_esc(r.name)}" onerror="this.style.display='none'">`
: `<div class="wiki-quiz-result-photo-fallback">${UI.icon('dog')}</div>`;
return `
<div class="wiki-quiz-result-card">
<div class="wiki-quiz-result-photo-wrap">${photoHtml}</div>
<div class="wiki-quiz-result-card-body">
<div class="wiki-quiz-result-name">${_esc(r.name)}</div>
<div class="wiki-quiz-result-gruppe">${_esc(r.gruppe || '')}</div>
<div class="wiki-breed-badges" style="margin:var(--space-2) 0">
<span class="wiki-badge-groesse wiki-badge-groesse--${_esc(r.groesse)}">${_groesseLabel(r.groesse)}</span>
<span class="wiki-badge-aktivitaet wiki-badge-aktivitaet--${_esc(r.aktivitaet)}">${_aktivLabel(r.aktivitaet)}</span>
</div>
${r.temperament ? `<p class="wiki-quiz-result-char">${_esc(r.temperament.split(',').slice(0,4).join(', '))}</p>` : ''}
<div class="wiki-fit-row" style="font-size:var(--text-xs);margin-top:var(--space-1)">
<span>${UI.icon('house-line')} ${r.wohnung_geeignet ? 'Wohnung' : 'Haus'}</span>
<span>${UI.icon('users')} ${r.kinder_geeignet ? 'Kinderfreundlich' : 'Erfahrung nötig'}</span>
</div>
<button class="btn btn-secondary btn-sm wiki-quiz-mehr" data-slug="${_esc(r.slug)}" style="margin-top:var(--space-2)">Mehr erfahren</button>
</div>
</div>
`;
}).join('');
el.innerHTML = `
<div class="wiki-quiz-wrap">
<div class="wiki-quiz-progress-bar">
<div class="wiki-quiz-progress" style="width:100%"></div>
</div>
<h3 style="margin:var(--space-4) 0 var(--space-2);text-align:center">Deine Top 3 Rassen</h3>
<div class="wiki-quiz-results">${cardsHtml}</div>
<button class="btn btn-secondary w-full" id="quiz-restart" style="margin-top:var(--space-4)">Quiz neu starten</button>
</div>
`;
el.querySelectorAll('.wiki-quiz-mehr').forEach(btn => {
btn.addEventListener('click', () => {
_tab = 'rassen';
_container.querySelectorAll('.wiki-tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === 'rassen'));
_openBreedDetail(btn.dataset.slug);
});
});
el.querySelector('#quiz-restart')?.addEventListener('click', () => {
_renderQuiz(el);
});
}
// ----------------------------------------------------------
// HELPER: API-Fetch
// ----------------------------------------------------------
async function _apiFetch(url) {
const resp = await fetch(url, { credentials: 'include' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
return resp.json();
}
async function _apiPost(url, body) {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
return resp.json();
}
// ----------------------------------------------------------
// HELPER: Labels
// ----------------------------------------------------------
function _groesseLabel(g) {
return { klein: 'Klein', mittel: 'Mittel', gross: 'Groß', sehr_gross: 'Sehr groß' }[g] || g;
}
function _aktivLabel(a) {
return { niedrig: 'Ruhig', mittel: 'Aktiv', hoch: 'Sportlich', sehr_hoch: 'Sehr aktiv' }[a] || a;
}
function _erfahrungLabel(e) {
return { anfaenger: 'Anfänger', fortgeschritten: 'Erfahren', experte: 'Experte' }[e] || e;
}
function _formatDate(iso) {
if (!iso) return '';
try {
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
} catch { return iso; }
}
function _esc(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
return { init, refresh };
})();

View file

@ -6,6 +6,13 @@
const UI = (() => {
// ----------------------------------------------------------
// PHOSPHOR ICON HELPER — erzeugt SVG-String für Templates
// ----------------------------------------------------------
function _svgIcon(name, extraClass = '') {
return `<svg class="ph-icon${extraClass ? ' ' + extraClass : ''}" aria-hidden="true"><use href="/icons/phosphor.svg#${name}"></use></svg>`;
}
// ----------------------------------------------------------
// TOAST
// ----------------------------------------------------------
@ -16,9 +23,9 @@ const UI = (() => {
const el = document.createElement('div');
el.className = `toast${type !== 'default' ? ` toast-${type}` : ''}`;
const icon = { success: '✓', danger: '✕', warning: '⚠', info: '' }[type] || '';
el.innerHTML = icon
? `<span style="font-size:1.1em">${icon}</span><span>${message}</span>`
const iconName = { success: 'check', danger: 'x', warning: 'warning', info: 'info' }[type];
el.innerHTML = iconName
? `${_svgIcon(iconName)}<span>${message}</span>`
: `<span>${message}</span>`;
container().appendChild(el);
@ -59,7 +66,7 @@ const UI = (() => {
${title ? `
<div class="modal-header">
<span class="modal-title">${title}</span>
<button class="btn btn-ghost btn-icon modal-close-btn" aria-label="Schließen"></button>
<button class="btn btn-ghost btn-icon modal-close-btn" aria-label="Schließen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg></button>
</div>
` : ''}
<div class="modal-body">${body || ''}</div>
@ -259,6 +266,7 @@ const UI = (() => {
formData, setFormError, clearFormErrors,
emptyState, time,
setupPhotoPreview, scrollTop, skeleton,
icon: _svgIcon,
};
})();

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v62';
const CACHE_VERSION = 'by-v89';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
@ -12,6 +12,7 @@ const STATIC_ASSETS = [
'/css/design-system.css',
'/css/layout.css',
'/css/components.css',
'/icons/phosphor.svg',
'/js/api.js',
'/js/ui.js',
'/js/app.js',
@ -192,6 +193,22 @@ self.addEventListener('push', event => {
requireInteraction: data.requireInteraction || false,
};
// Chat-Nachricht
if (data.type === 'chat_message') {
options.tag = data.tag || `chat-${data.data?.conversation_id}`;
options.renotify = true;
options.actions = [
{ action: 'reply', title: 'Antworten' },
{ action: 'dismiss', title: 'Schließen' },
];
}
// Freundschaftsanfrage
if (data.type === 'friend_request') {
options.tag = 'friend-request';
options.actions = [{ action: 'view', title: 'Anzeigen' }];
}
// Giftköder-Alarm: besondere Darstellung
if (data.type === 'poison_alert') {
options.tag = 'poison-alert';
@ -219,9 +236,10 @@ self.addEventListener('notificationclick', event => {
let url = '/';
if (action === 'view' || data?.page) {
if (action === 'view' || action === 'reply' || data?.page) {
url = `/#${data.page || 'poison'}`;
if (data.id) url += `?id=${data.id}`;
if (data.conversation_id) url += `?conversation_id=${data.conversation_id}`;
else if (data.id) url += `?id=${data.id}`;
}
event.waitUntil(