diff options
| -rw-r--r-- | cheatsheet.html | 2 | ||||
| -rw-r--r-- | downloads/thai-massage-booking-mockup.html | 925 | ||||
| -rw-r--r-- | downloads/thai-massage-legal-pages.html | 747 | ||||
| -rw-r--r-- | downloads/thai-massage-privacy-first.html | 280 | ||||
| -rw-r--r-- | help.html | 2 | ||||
| -rw-r--r-- | main.html | 5 | ||||
| -rw-r--r-- | modules/ext/webdav.js | 1028 |
7 files changed, 2676 insertions, 313 deletions
diff --git a/cheatsheet.html b/cheatsheet.html index 88250ac..71ef651 100644 --- a/cheatsheet.html +++ b/cheatsheet.html @@ -140,7 +140,7 @@ pre{background:var(--bg2);border:1px solid var(--border);border-radius:var(--rad <div class="item"><span class="cmd">mindmap</span><span class="desc">Brainstorming tool</span></div> <div class="item"><span class="cmd">webdav-status</span><span class="desc">Cloud sync status</span></div> <div class="item"><span class="cmd">webdav-push / pull</span><span class="desc">Sync files</span></div> -<div class="item"><span class="cmd">webdav-browser</span><span class="desc">File-tree UI browser</span></div> +<div class="item"><span class="cmd">webdav-browser</span><span class="desc">File-tree UI browser (preview, new-tab, minimize, share)</span></div> <div class="item"><span class="cmd">webdav-rm / mkdir</span><span class="desc">Delete / create dir</span></div> <div class="item"><span class="cmd">webdav-rename / move</span><span class="desc">Rename / move files</span></div> <div class="item"><span class="cmd">webdav-backup</span><span class="desc">Backup space</span></div> diff --git a/downloads/thai-massage-booking-mockup.html b/downloads/thai-massage-booking-mockup.html new file mode 100644 index 0000000..9c2e031 --- /dev/null +++ b/downloads/thai-massage-booking-mockup.html @@ -0,0 +1,925 @@ +<!DOCTYPE html> +<html lang="de"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Thai Massage Kiefersfelden & Kufstein – Online Termin buchen</title> + <meta name="description" content="Professionelle Thai Massage in Kiefersfelden und Kufstein. Jetzt online Ihren Termin buchen – schnell, einfach und bequem."> + + <!-- ===== STYLES ===== --> + <style> + :root { + --gold: #c5a55a; + --gold-light: #e8d5a3; + --gold-dark: #a08838; + --dark: #1a1a1a; + --dark-soft: #2a2722; + --cream: #faf6ef; + --cream-dark: #f0e9db; + --text: #333; + --text-light: #666; + --whatsapp: #25d366; + --whatsapp-dark: #1da851; + --shadow: 0 4px 20px rgba(0,0,0,0.08); + --shadow-lg: 0 8px 40px rgba(0,0,0,0.12); + --radius: 12px; + } + + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif; + background: var(--cream); + color: var(--text); + line-height: 1.6; + } + + /* ===== HEADER ===== */ + header { + background: var(--dark); + color: #fff; + padding: 0; + position: sticky; + top: 0; + z-index: 100; + box-shadow: 0 2px 10px rgba(0,0,0,0.3); + } + + .header-inner { + max-width: 1200px; + margin: 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + gap: 16px; + } + + .logo { + display: flex; + align-items: center; + gap: 10px; + font-size: 1.3rem; + font-weight: 700; + white-space: nowrap; + } + + .logo-icon { + font-size: 1.6rem; + } + + .logo span { + color: var(--gold); + } + + .location-switch { + display: flex; + gap: 8px; + flex-wrap: wrap; + } + + .loc-btn { + background: var(--dark-soft); + border: 1px solid var(--gold-dark); + color: #ccc; + padding: 8px 18px; + border-radius: 30px; + cursor: pointer; + font-size: 0.85rem; + transition: all 0.3s ease; + white-space: nowrap; + } + + .loc-btn:hover { + background: var(--dark-soft); + border-color: var(--gold); + color: var(--gold-light); + } + + .loc-btn.active { + background: var(--gold); + color: var(--dark); + border-color: var(--gold); + font-weight: 600; + } + + @media (max-width: 600px) { + .header-inner { + flex-direction: column; + padding: 12px 16px; + } + .location-switch { + width: 100%; + justify-content: center; + } + } + + /* ===== HERO ===== */ + .hero { + background: linear-gradient(135deg, #1a1a1a 0%, #2d2a22 50%, #1a1a1a 100%); + color: #fff; + text-align: center; + padding: 60px 24px 50px; + position: relative; + overflow: hidden; + } + + .hero::before { + content: "🧘"; + position: absolute; + font-size: 20rem; + opacity: 0.04; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + + .hero h1 { + font-size: 2.4rem; + margin-bottom: 12px; + position: relative; + } + + .hero h1 span { + color: var(--gold); + } + + .hero p { + font-size: 1.15rem; + color: #ddd; + max-width: 600px; + margin: 0 auto 28px; + position: relative; + } + + .hero-cta { + display: flex; + gap: 14px; + justify-content: center; + flex-wrap: wrap; + position: relative; + } + + @media (max-width: 600px) { + .hero h1 { font-size: 1.7rem; } + .hero p { font-size: 1rem; } + } + + /* ===== BUTTONS ===== */ + .btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 14px 32px; + border-radius: 50px; + font-size: 1rem; + font-weight: 600; + text-decoration: none; + cursor: pointer; + border: none; + transition: all 0.3s ease; + } + + .btn-primary { + background: var(--gold); + color: var(--dark); + box-shadow: 0 4px 15px rgba(197, 165, 90, 0.3); + } + + .btn-primary:hover { + background: var(--gold-light); + transform: translateY(-2px); + box-shadow: 0 6px 25px rgba(197, 165, 90, 0.5); + } + + .btn-whatsapp { + background: var(--whatsapp); + color: #fff; + box-shadow: 0 4px 15px rgba(37, 211, 102, 0.3); + } + + .btn-whatsapp:hover { + background: var(--whatsapp-dark); + transform: translateY(-2px); + box-shadow: 0 6px 25px rgba(37, 211, 102, 0.5); + } + + .btn-outline { + background: transparent; + color: #fff; + border: 2px solid rgba(255,255,255,0.3); + } + + .btn-outline:hover { + border-color: var(--gold); + color: var(--gold); + } + + /* ===== BOOKING SECTION ===== */ + .booking-section { + max-width: 1100px; + margin: -30px auto 0; + padding: 0 24px 60px; + position: relative; + z-index: 10; + } + + .booking-card { + background: #fff; + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + overflow: hidden; + } + + .booking-header { + background: linear-gradient(135deg, var(--gold-dark), var(--gold)); + color: #fff; + padding: 20px 32px; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + } + + .booking-header h2 { + font-size: 1.3rem; + display: flex; + align-items: center; + gap: 10px; + } + + .booking-header .location-badge { + background: rgba(255,255,255,0.2); + padding: 4px 14px; + border-radius: 20px; + font-size: 0.85rem; + font-weight: 500; + } + + /* ===== BOOKING WIDGET (Placeholder for Massagebooking.de iFrame) ===== */ + .booking-widget { + padding: 32px; + min-height: 420px; + background: var(--cream); + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + } + + .widget-placeholder { + width: 100%; + min-height: 380px; + border: 2px dashed var(--gold-light); + border-radius: 10px; + background: #fff; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + text-align: center; + } + + .widget-placeholder .icon { + font-size: 3rem; + margin-bottom: 16px; + } + + .widget-placeholder h3 { + color: var(--dark); + margin-bottom: 8px; + font-size: 1.2rem; + } + + .widget-placeholder p { + color: var(--text-light); + max-width: 400px; + margin-bottom: 20px; + } + + .widget-placeholder code { + display: block; + background: var(--dark); + color: var(--gold-light); + padding: 14px 20px; + border-radius: 8px; + font-size: 0.8rem; + max-width: 500px; + overflow-x: auto; + text-align: left; + margin: 0 auto; + } + + /* ===== SERVICES GRID ===== */ + .services { + max-width: 1100px; + margin: 0 auto; + padding: 0 24px 60px; + } + + .section-title { + text-align: center; + margin-bottom: 40px; + } + + .section-title h2 { + font-size: 2rem; + color: var(--dark); + margin-bottom: 8px; + } + + .section-title p { + color: var(--text-light); + } + + .services-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 24px; + } + + .service-card { + background: #fff; + border-radius: var(--radius); + padding: 28px; + box-shadow: var(--shadow); + transition: all 0.3s ease; + cursor: pointer; + border: 2px solid transparent; + } + + .service-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-lg); + border-color: var(--gold-light); + } + + .service-card .service-icon { + font-size: 2rem; + margin-bottom: 12px; + } + + .service-card h3 { + color: var(--dark); + margin-bottom: 6px; + font-size: 1.15rem; + } + + .service-card .duration { + color: var(--gold-dark); + font-size: 0.85rem; + font-weight: 600; + margin-bottom: 10px; + } + + .service-card p { + color: var(--text-light); + font-size: 0.9rem; + margin-bottom: 16px; + } + + .service-card .price { + font-size: 1.4rem; + font-weight: 700; + color: var(--dark); + } + + .service-card .price small { + font-size: 0.75rem; + font-weight: 400; + color: var(--text-light); + } + + .service-card .book-btn { + display: block; + text-align: center; + margin-top: 16px; + padding: 10px; + background: var(--cream-dark); + color: var(--dark); + border-radius: 8px; + font-weight: 600; + font-size: 0.85rem; + text-decoration: none; + transition: all 0.3s ease; + } + + .service-card:hover .book-btn { + background: var(--gold); + color: var(--dark); + } + + /* ===== LOCATIONS SECTION ===== */ + .locations { + max-width: 1100px; + margin: 0 auto; + padding: 0 24px 60px; + } + + .loc-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + } + + @media (max-width: 768px) { + .loc-grid { + grid-template-columns: 1fr; + } + } + + .loc-card { + background: #fff; + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; + display: flex; + flex-direction: column; + } + + .loc-card .loc-image { + height: 200px; + background: linear-gradient(135deg, var(--dark-soft), var(--dark)); + display: flex; + align-items: center; + justify-content: center; + font-size: 4rem; + } + + .loc-card .loc-body { + padding: 24px; + flex: 1; + display: flex; + flex-direction: column; + } + + .loc-card h3 { + color: var(--dark); + font-size: 1.3rem; + margin-bottom: 8px; + } + + .loc-card .loc-address { + color: var(--text-light); + font-size: 0.9rem; + margin-bottom: 16px; + line-height: 1.8; + } + + .loc-card .loc-hours { + background: var(--cream); + padding: 14px; + border-radius: 8px; + margin-bottom: 16px; + font-size: 0.85rem; + } + + .loc-card .loc-hours .day { + display: flex; + justify-content: space-between; + padding: 2px 0; + } + + .loc-card .loc-hours .day.closed { + color: #cc5555; + } + + .loc-card .loc-cta { + margin-top: auto; + display: flex; + gap: 10px; + flex-wrap: wrap; + } + + .loc-card .loc-cta .btn { + flex: 1; + min-width: 140px; + padding: 12px 20px; + font-size: 0.9rem; + justify-content: center; + } + + /* ===== COOKIE BANNER ===== */ + .cookie-banner { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: var(--dark); + color: #fff; + padding: 16px 24px; + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + flex-wrap: wrap; + z-index: 1000; + box-shadow: 0 -4px 20px rgba(0,0,0,0.3); + } + + .cookie-banner p { + font-size: 0.85rem; + max-width: 600px; + flex: 1; + } + + .cookie-banner a { + color: var(--gold); + } + + .cookie-buttons { + display: flex; + gap: 10px; + } + + .cookie-btn { + padding: 8px 20px; + border-radius: 6px; + border: none; + cursor: pointer; + font-weight: 600; + font-size: 0.85rem; + } + + .cookie-btn.accept { + background: var(--gold); + color: var(--dark); + } + + .cookie-btn.reject { + background: var(--dark-soft); + color: #ccc; + border: 1px solid #555; + } + + /* ===== FOOTER ===== */ + footer { + background: var(--dark); + color: #aaa; + padding: 40px 24px 20px; + text-align: center; + } + + footer .footer-links { + display: flex; + gap: 20px; + justify-content: center; + flex-wrap: wrap; + margin-bottom: 20px; + } + + footer .footer-links a { + color: var(--gold-light); + text-decoration: none; + font-size: 0.85rem; + transition: color 0.3s; + } + + footer .footer-links a:hover { + color: var(--gold); + } + + footer .copyright { + font-size: 0.8rem; + color: #777; + border-top: 1px solid #333; + padding-top: 16px; + } + + /* ===== FLOATING WHATSAPP BUTTON ===== */ + .wa-float { + position: fixed; + bottom: 80px; + right: 24px; + width: 60px; + height: 60px; + background: var(--whatsapp); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.8rem; + box-shadow: 0 4px 15px rgba(37, 211, 102, 0.4); + z-index: 999; + text-decoration: none; + transition: all 0.3s ease; + animation: pulse 2s infinite; + } + + .wa-float:hover { + transform: scale(1.1); + box-shadow: 0 6px 25px rgba(37, 211, 102, 0.6); + } + + @keyframes pulse { + 0% { box-shadow: 0 4px 15px rgba(37, 211, 102, 0.4); } + 50% { box-shadow: 0 4px 25px rgba(37, 211, 102, 0.7); } + 100% { box-shadow: 0 4px 15px rgba(37, 211, 102, 0.4); } + } + + @media (max-width: 600px) { + .wa-float { + bottom: 70px; + right: 16px; + width: 54px; + height: 54px; + font-size: 1.5rem; + } + } + + /* ===== DEVELOPER NOTE (hidden in production) ===== */ + .dev-note { + background: #fff3cd; + border-left: 4px solid #ffc107; + padding: 16px 24px; + margin: 0 24px 20px; + border-radius: 4px; + font-size: 0.85rem; + color: #856404; + } + + .dev-note strong { + color: #533f03; + } + + .dev-note code { + background: #fff; + padding: 2px 6px; + border-radius: 3px; + } + </style> +</head> +<body> + + <!-- ===== HEADER ===== --> + <header> + <div class="header-inner"> + <div class="logo"> + <span class="logo-icon">🌸</span> + Thai Massage <span>Kiefersfelden</span> + </div> + <div class="location-switch"> + <button class="loc-btn active" onclick="switchLocation('kiefersfelden')">🇩🇪 Kiefersfelden</button> + <button class="loc-btn" onclick="switchLocation('kufstein')">🇦🇹 Kufstein</button> + </div> + </div> + </header> + + <!-- ===== HERO ===== --> + <div class="hero"> + <h1>Entspannung beginnt hier <span>🌸</span></h1> + <p>Authentische Thai Massage in Kiefersfelden & Kufstein. Buchen Sie Ihren Termin online – in nur 30 Sekunden.</p> + <div class="hero-cta"> + <a href="#booking" class="btn btn-primary">📅 Jetzt Termin buchen</a> + <a href="https://wa.me/49XXXXXXXXXXX?text=Hallo,%20ich%20möchte%20gern%20einen%20Termin%20buchen" + class="btn btn-whatsapp" target="_blank">💬 WhatsApp schreiben</a> + <a href="tel:+499999999999" class="btn btn-outline">📞 Anrufen</a> + </div> + </div> + + <!-- ===== BOOKING SECTION ===== --> + <section class="booking-section" id="booking"> + <div class="booking-card"> + <div class="booking-header"> + <h2>📅 Online Termin buchen</h2> + <span class="location-badge" id="loc-badge">📍 Kiefersfelden</span> + </div> + + <div class="booking-widget"> + <!-- + ═══════════════════════════════════════════════════════ + PRODUCTION: Replace this placeholder with the actual + Massagebooking.de or SimplyBook.me iFrame embed code. + + Example (Massagebooking.de): + <iframe src="https://thaimassage-kiefersfelden.massagebooking.de/widget" + width="100%" height="500" frameborder="0"></iframe> + + Example (SimplyBook.me): + <iframe src="https://thaimassage.simplybook.me/v2/widget" + width="100%" height="500" frameborder="0"></iframe> + ═══════════════════════════════════════════════════════ + --> + <div class="widget-placeholder" id="widget-placeholder"> + <div class="icon">📅</div> + <h3>Booking Widget wird hier eingebettet</h3> + <p>Sobald Sie sich bei <strong>Massagebooking.de</strong> oder <strong>SimplyBook.me</strong> registriert haben, wird hier der iFrame-Code des Buchungs-Widgets eingefügt. Kunden können dann direkt auf der Webseite ihren Termin wählen.</p> + <code><iframe src="https://thaimassage.massagebooking.de/widget" + width="100%" height="500" frameborder="0"> +</iframe></code> + </div> + </div> + </div> + </section> + + <!-- ===== SERVICES ===== --> + <section class="services"> + <div class="section-title"> + <h2>Unsere Behandlungen</h2> + <p>Wählen Sie eine Behandlung und buchen Sie direkt online</p> + </div> + + <div class="services-grid"> + <div class="service-card" onclick="document.getElementById('booking').scrollIntoView({behavior:'smooth'})"> + <div class="service-icon">👐</div> + <h3>Traditionelle Thai Massage</h3> + <div class="duration">⏱ 60 Min.</div> + <p>Die klassische Thai-Massage nach alter Tradition. Dehnungen und Druckpunktmassage für den ganzen Körper.</p> + <div class="price">€45 <small>/ Termin</small></div> + <a href="#booking" class="book-btn">Jetzt buchen →</a> + </div> + + <div class="service-card" onclick="document.getElementById('booking').scrollIntoView({behavior:'smooth'})"> + <div class="service-icon">🌿</div> + <h3>Öl-Massage</h3> + <div class="duration">⏱ 60 Min.</div> + <p>Entspannende Ganzkörpermassage mit warmen Thai-Ölen. Ideal zur Lösung von Verspannungen.</p> + <div class="price">€50 <small>/ Termin</small></div> + <a href="#booking" class="book-btn">Jetzt buchen →</a> + </div> + + <div class="service-card" onclick="document.getElementById('booking').scrollIntoView({behavior:'smooth'})"> + <div class="service-icon">🔥</div> + <h3>Hot Stone Massage</h3> + <div class="duration">⏱ 75 Min.</div> + <p>Warme Steine lösen tiefe Verspannungen und bringen den Energiefluss ins Gleichgewicht.</p> + <div class="price">€65 <small>/ Termin</small></div> + <a href="#booking" class="book-btn">Jetzt buchen →</a> + </div> + + <div class="service-card" onclick="document.getElementById('booking').scrollIntoView({behavior:'smooth'})"> + <div class="service-icon">💆</div> + <h3>Rücken- & Nackenmassage</h3> + <div class="duration">⏱ 30 Min.</div> + <p>Zielgerichtete Behandlung für Rücken, Schultern und Nackenbereich. Schnelle Entspannung im Alltag.</p> + <div class="price">€28 <small>/ Termin</small></div> + <a href="#booking" class="book-btn">Jetzt buchen →</a> + </div> + + <div class="service-card" onclick="document.getElementById('booking').scrollIntoView({behavior:'smooth'})"> + <div class="service-icon">🦶</div> + <h3>Fußreflexzonenmassage</h3> + <div class="duration">⏱ 45 Min.</div> + <p>Reflexzonenmassage an den Füßen regt die Selbstheilung an und bringt tiefe Entspannung.</p> + <div class="price">€35 <small>/ Termin</small></div> + <a href="#booking" class="book-btn">Jetzt buchen →</a> + </div> + + <div class="service-card" onclick="document.getElementById('booking').scrollIntoView({behavior:'smooth'})"> + <div class="service-icon">🎁</div> + <h3>Gutschein</h3> + <div class="duration">⏱ Wählbar</div> + <p> Verschenken Sie Entspannung! Gutscheine online kaufen und per E-Mail oder Post erhalten.</p> + <div class="price">ab €28</div> + <a href="#booking" class="book-btn">Gutschein kaufen →</a> + </div> + </div> + </section> + + <!-- ===== LOCATIONS ===== --> + <section class="locations"> + <div class="section-title"> + <h2>Unsere Standorte</h2> + <p>Zwei Studios – gleiche Qualität, Ihre Wahl</p> + </div> + + <div class="loc-grid"> + <!-- Kiefersfelden --> + <div class="loc-card"> + <div class="loc-image">🏛️</div> + <div class="loc-body"> + <h3>Thai Massage Kiefersfelden 🇩🇪</h3> + <div class="loc-address"> + Musterstraße 1<br> + 83309 Kiefersfelden<br> + 📞 <a href="tel:+499999999999">+49 999 999 9999</a> + </div> + <div class="loc-hours"> + <div class="day"><span>Mo – Fr</span><span>10:00 – 19:00</span></div> + <div class="day"><span>Sa</span><span>10:00 – 16:00</span></div> + <div class="day closed"><span>Sonntag</span><span>Geschlossen</span></div> + </div> + <div class="loc-cta"> + <a href="#booking" class="btn btn-primary" onclick="switchLocation('kiefersfelden')">📅 Buchen</a> + <a href="https://wa.me/49XXXXXXXXXXX" class="btn btn-whatsapp" target="_blank">💬 WhatsApp</a> + </div> + </div> + </div> + + <!-- Kufstein --> + <div class="loc-card"> + <div class="loc-image">⛰️</div> + <div class="loc-body"> + <h3>Thai Massage Kufstein 🇦🇹</h3> + <div class="loc-address"> + Beispielfeld 2<br> + 6330 Kufstein, Tirol<br> + 📞 <a href="tel:+439999999999">+43 999 999 9999</a> + </div> + <div class="loc-hours"> + <div class="day"><span>Mo – Fr</span><span>10:00 – 19:00</span></div> + <div class="day"><span>Sa</span><span>10:00 – 16:00</span></div> + <div class="day closed"><span>Sonntag</span><span>Geschlossen</span></div> + </div> + <div class="loc-cta"> + <a href="#booking" class="btn btn-primary" onclick="switchLocation('kufstein')">📅 Buchen</a> + <a href="https://wa.me/43XXXXXXXXXXX" class="btn btn-whatsapp" target="_blank">💬 WhatsApp</a> + </div> + </div> + </div> + </div> + </section> + + <!-- ===== FOOTER ===== --> + <footer> + <div class="footer-links"> + <a href="#">Impressum</a> + <a href="#">Datenschutzerklärung</a> + <a href="#">AGB</a> + <a href="#">Widerrufsbelehrung</a> + <a href="#booking">Termin buchen</a> + </div> + <div class="copyright"> + © 2026 Thai Massage Kiefersfelden & Kufstein · Alle Rechte vorbehalten + </div> + </footer> + + <!-- ===== FLOATING WHATSAPP BUTTON ===== --> + <a href="https://wa.me/49XXXXXXXXXXX?text=Hallo,%20ich%20möchte%20gern%20einen%20Termin%20buchen" + class="wa-float" target="_blank" title="WhatsApp Nachricht senden">💬</a> + + <!-- ===== COOKIE BANNER ===== --> + <div class="cookie-banner" id="cookieBanner"> + <p>Wir verwenden Cookies, um Ihnen die bestmögliche Erfahrung zu bieten. Mit der Nutzung unserer Webseite stimmen Sie der Verwendung von Cookies zu. + <a href="#">Mehr Informationen</a></p> + <div class="cookie-buttons"> + <button class="cookie-btn reject" onclick="hideCookies('rejected')">Ablehnen</button> + <button class="cookie-btn accept" onclick="hideCookies('accepted')">Alle akzeptieren</button> + </div> + </div> + + <!-- ===== SCRIPTS ===== --> + <script> + // Location switcher + const locations = { + kiefersfelden: { + badge: '📍 Kiefersfelden', + widgetUrl: 'https://thaimassage-kiefersfelden.massagebooking.de/widget' + }, + kufstein: { + badge: '📍 Kufstein', + widgetUrl: 'https://thaimassage-kufstein.massagebooking.de/widget' + } + }; + + function switchLocation(loc) { + // Update buttons + document.querySelectorAll('.loc-btn').forEach(btn => btn.classList.remove('active')); + + // Find the right button + const buttons = document.querySelectorAll('.loc-btn'); + buttons.forEach(btn => { + const text = btn.textContent.toLowerCase(); + if (text.includes(loc.toLowerCase())) { + btn.classList.add('active'); + } + }); + + // Update badge + const badge = document.getElementById('loc-badge'); + badge.textContent = locations[loc].badge; + + // In production: reload the iFrame with the correct location's booking URL + // const iframe = document.getElementById('booking-iframe'); + // iframe.src = locations[loc].widgetUrl; + + console.log('Switched to:', loc, locations[loc].widgetUrl); + } + + // Cookie banner + function hideCookies(choice) { + document.getElementById('cookieBanner').style.display = 'none'; + console.log('Cookie choice:', choice); + // In production: store in localStorage / cookie consent tool + } + + // Smooth scroll for anchor links + document.querySelectorAll('a[href^="#"]').forEach(anchor => { + anchor.addEventListener('click', function(e) { + const target = document.querySelector(this.getAttribute('href')); + if (target) { + e.preventDefault(); + target.scrollIntoView({ behavior: 'smooth' }); + } + }); + }); + </script> + +</body> +</html>
\ No newline at end of file diff --git a/downloads/thai-massage-legal-pages.html b/downloads/thai-massage-legal-pages.html new file mode 100644 index 0000000..83be9dc --- /dev/null +++ b/downloads/thai-massage-legal-pages.html @@ -0,0 +1,747 @@ +<!DOCTYPE html> +<html lang="de"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Impressum & Datenschutzerklärung – Thai Massage Kiefersfelden & Kufstein</title> + <style> + :root { + --gold: #c5a55a; + --gold-light: #e8d5a3; + --gold-dark: #a08838; + --dark: #1a1a1a; + --dark-soft: #2a2722; + --cream: #faf6ef; + --cream-dark: #f0e9db; + --text: #333; + --text-light: #666; + --shadow: 0 4px 20px rgba(0,0,0,0.08); + --shadow-lg: 0 8px 40px rgba(0,0,0,0.12); + --radius: 12px; + } + * { margin: 0; padding: 0; box-sizing: border-box; } + body { + font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif; + background: var(--cream); + color: var(--text); + line-height: 1.7; + } + header { + background: var(--dark); + color: #fff; + padding: 16px 24px; + position: sticky; + top: 0; + z-index: 100; + box-shadow: 0 2px 10px rgba(0,0,0,0.3); + } + .header-inner { + max-width: 1000px; + margin: 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + } + .logo { + display: flex; + align-items: center; + gap: 10px; + font-size: 1.2rem; + font-weight: 700; + } + .logo span { color: var(--gold); } + .nav-tabs { + display: flex; + gap: 8px; + flex-wrap: wrap; + } + .nav-tab { + background: var(--dark-soft); + border: 1px solid var(--gold-dark); + color: #ccc; + padding: 8px 18px; + border-radius: 30px; + cursor: pointer; + font-size: 0.85rem; + transition: all 0.3s ease; + text-decoration: none; + } + .nav-tab:hover { border-color: var(--gold); color: var(--gold-light); } + .nav-tab.active { background: var(--gold); color: var(--dark); border-color: var(--gold); font-weight: 600; } + .container { + max-width: 900px; + margin: 0 auto; + padding: 40px 24px 80px; + } + .legal-section { display: none; } + .legal-section.active { display: block; } + h1 { + color: var(--dark); + font-size: 2rem; + margin-bottom: 8px; + padding-bottom: 16px; + border-bottom: 3px solid var(--gold); + } + h2 { + color: var(--dark); + font-size: 1.35rem; + margin: 32px 0 12px; + } + h3 { + color: var(--gold-dark); + font-size: 1.1rem; + margin: 24px 0 10px; + } + p { margin-bottom: 12px; } + ul, ol { margin: 12px 0 12px 24px; } + li { margin-bottom: 6px; } + .info-box { + background: #fff; + border-left: 4px solid var(--gold); + padding: 16px 20px; + border-radius: 0 8px 8px 0; + margin: 16px 0; + box-shadow: var(--shadow); + } + .info-box.at { border-left-color: #ed5565; } + .info-box.de { border-left-color: #4a90d9; } + .info-box strong { color: var(--dark); } + .warning-box { + background: #fff3cd; + border: 1px solid #ffe08a; + border-radius: 8px; + padding: 16px 20px; + margin: 16px 0; + } + .warning-box::before { content: "⚠️ "; } + .checklist { + background: #fff; + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 24px; + margin: 20px 0; + } + .checklist ul { list-style: none; margin-left: 0; } + .checklist li { + padding: 8px 0; + border-bottom: 1px solid var(--cream-dark); + } + .checklist li:last-child { border-bottom: none; } + .checklist li::before { content: "☐ "; color: var(--gold); font-size: 1.2rem; } + .placeholder { + color: var(--gold-dark); + background: var(--cream-dark); + padding: 2px 8px; + border-radius: 4px; + font-weight: 600; + font-size: 0.9em; + } + footer { + background: var(--dark); + color: #999; + text-align: center; + padding: 24px; + font-size: 0.85rem; + } + footer a { color: var(--gold); text-decoration: none; } + @media (max-width: 600px) { + .header-inner { flex-direction: column; gap: 12px; } + h1 { font-size: 1.5rem; } + h2 { font-size: 1.15rem; } + } + </style> +</head> +<body> + +<!-- ===== HEADER ===== --> +<header> + <div class="header-inner"> + <div class="logo">🧘 Thai Massage <span>Kiefersfelden & Kufstein</span></div> + <nav class="nav-tabs"> + <a class="nav-tab active" onclick="showSection('impressum')">Impressum</a> + <a class="nav-tab" onclick="showSection('datenschutz')">Datenschutzerklärungklärung</a> + <a class="nav-tab" onclick="showSection('agb')">AGB</a> + <a class="nav-tab" onclick="showSection('widerruf')">Widerruf</a> + <a class="nav-tab" onclick="showSection('checklist')">✅ Checkliste</a> + </nav> + </div> +</header> + +<div class="container"> + +<!-- ================================================================ --> +<!-- ====== IMPRESSUM ====== --> +<!-- ================================================================ --> +<section id="impressum" class="legal-section active"> + <h1>Impressum</h1> + + <div class="info-box de"> + <h3>🇩🇪 Standort Kiefersfelden (Deutschland)</h3> + <p><strong>Angaben gemäß § 5 TMG (Telemediengesetz):</strong></p> + <p> + <span class="placeholder">[Name des Betriebsinhabers / Firmenname]</span><br> + <span class="placeholder">[Straße und Hausnummer]</span><br> + <span class="placeholder">[PLZ] Kiefersfelden</span><br> + Deutschland + </p> + <p> + <strong>Vertreten durch:</strong> <span class="placeholder">[Name der Inhaberin / des Inhabers]</span><br> + <strong>Kontakt:</strong><br> + Telefon: <span class="placeholder">[+49 ...]</span><br> + E-Mail: <span class="placeholder">[info@thaimassage-kiefersfelden.de]</span> + </p> + <p> + <strong>Umsatzsteuer-ID:</strong><br> + Umsatzsteuer-Identifikationsnummer gemäß § 27a Umsatzsteuergesetz: <span class="placeholder">[DE...]</span> + </p> + <p> + <strong>Berufsbezeichnung:</strong> <span class="placeholder">[z.B. Masseur/in med. Wellness / Thai-Massage-Therapeut/in]</span><br> + Verliehen durch: <span class="placeholder">[Bundesland / zuständige Behörde]</span> + </p> + <p> + <strong>Zuständige Aufsichtsbehörde:</strong><br> + <span class="placeholder">[Landratsamt / Gewerbeamt]</span> + </p> + </div> + + <div class="info-box at"> + <h3>🇦🇹 Standort Kufstein (Österreich)</h3> + <p><strong>Angaben gemäß § 14 ECG (E-Commerce-Gesetz) und § 5 Mediengesetz:</strong></p> + <p> + <span class="placeholder">[Name des Betriebsinhabers / Firmenname]</span><br> + <span class="placeholder">[Straße und Hausnummer]</span><br> + <span class="placeholder">[PLZ] Kufstein</span><br> + Österreich + </p> + <p> + <strong>Vertreten durch:</strong> <span class="placeholder">[Name der Inhaberin / des Inhabers]</span><br> + <strong>Kontakt:</strong><br> + Telefon: <span class="placeholder">[+43 ...]</span><br> + E-Mail: <span class="placeholder">[info@thaimassage-kufstein.at]</span> + </p> + <p> + <strong>UID-Nummer:</strong> <span class="placeholder">[ATU...]</span><br> + <strong>Firmenbuchnummer:</strong> <span class="placeholder">[FN...]</span> (falls eingetragen)<br> + <strong>Firmenbuchgericht:</strong> <span class="placeholder">[z.B. Landesgericht Innsbruck]</span> + </p> + <p> + <strong>Berufsbezeichnung:</strong> <span class="placeholder">[z.B. Masseur/in]</span><br> + Verliehen durch: <span class="placeholder">[Bundesland Tirol]</span> + </p> + <p> + <strong>Zuständige Aufsichtsbehörde:</strong><br> + <span class="placeholder">[Bezirkshauptmannschaft Kufstein / Magistrat]</span> + </p> + </div> + + <div class="warning-box"> + <strong>Wichtig:</strong> Da der Betrieb in zwei verschiedenen Ländern ansässig ist, muss das Impressum + beide Standorte klar ausweisen. Kunden müssen erkennen können, welcher Recht Raum gilt und + wer ihr Vertragspartner ist. + </div> + + <h2>Haftung für Inhalte</h2> + <p> + Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG (DE) bzw. § 16 ECG (AT) + für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. + Wir sind jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen + zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen. + </p> + + <h2>Haftung für Links</h2> + <p> + Unser Angebot enthält ggf. Links zu externen Webseiten Dritter, auf deren Inhalte wir keinen + Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. + Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten + verantwortlich. + </p> + + <h2>Urheberrecht</h2> + <p> + Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen + dem deutschen bzw. österreichischen Urheberrecht. Die Vervielfältigung, Bearbeitung, + Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen + der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers. + </p> + + <h2>Streitschlichtung</h2> + <h3>🇩🇪 Deutschland</h3> + <p> + Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit: + <a href="https://ec.europa.eu/consumers/odr/" target="_blank">https://ec.europa.eu/consumers/odr/</a>. + Unsere E-Mail-Adresse finden Sie oben im Impressum. + </p> + <p> + Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer + Verbraucherschlichtungsstelle teilzunehmen. + </p> + <h3>🇦🇹 Österreich</h3> + <p> + Gemäß § 14 ECG weisen wir darauf hin, dass wir nicht an alternative + Streitbeilegungsverfahren teilnehmen. + </p> +</section> + +<!-- ================================================================ --> +<!-- ====== DATENSCHUTZERKLÄRUNG ====== --> +<!-- ================================================================ --> +<section id="datenschutz" class="legal-section"> + <h1>Datenschutzerklärungklärung</h1> + + <div class="warning-box"> + Diese Datenschutzerklärung muss alle Datenverarbeitungsprozesse abdecken, die auf der + Website stattfinden — inkl. Booking-Widget, WhatsApp-Integration, Google Maps, + Cookie-Tracking und Server-Logfiles. Die unten stehenden <span class="placeholder">[Platzhalter]</span> + müssen mit den tatsächlichen Daten ausgefüllt werden. + </div> + + <h2>1. Verantwortlicher</h2> + <p> + Verantwortlich für die Datenverarbeitung auf dieser Website im Sinne der + Datenschutz-Grundverordnung (DSGVO) und des nationalen Datenschutzrechts + (BDSG für Deutschland, DSG für Österreich) ist: + </p> + <div class="info-box de"> + <h3>🇩🇪 Kiefersfelden</h3> + <p> + <span class="placeholder">[Name des Betriebsinhabers]</span><br> + <span class="placeholder">[Straße, Kiefersfelden]</span><br> + E-Mail: <span class="placeholder">[datenschutz@thaimassage-kiefersfelden.de]</span> + </p> + </div> + <div class="info-box at"> + <h3>🇦🇹 Kufstein</h3> + <p> + <span class="placeholder">[Name des Betriebsinhabers]</span><br> + <span class="placeholder">[Straße, Kufstein]</span><br> + E-Mail: <span class="placeholder">[datenschutz@thaimassage-kufstein.at]</span> + </p> + </div> + + <h2>2. Allgemeines zur Verarbeitung</h2> + <p> + Wir verarbeiten personenbezogene Daten unserer Nutzer grundsätzlich nur, soweit dies + zur Bereitstellung einer funktionsfähigen Website sowie unserer Inhalte und Leistungen + erforderlich ist. Die Verarbeitung erfolgt regelmäßig nur nach Einwilligung der Nutzer + (Art. 6 Abs. 1 lit. a DSGVO) oder im Rahmen der Vertragsanbahnung und -erfüllung + (Art. 6 Abs. 1 lit. b DSGVO). + </p> + + <h2>3. Server-Logfiles</h2> + <p> + Beim Aufrufen unserer Website erhebt unser Server automatisch Daten, die Ihr Browser + an uns übermittelt (sog. Server-Logfiles). Diese sind: + </p> + <ul> + <li>Browsertyp und verwendete Version</li> + <li>Betriebssystem des Nutzers</li> + <li>Datum und Uhrzeit des Zugriffs</li> + <li>Website, von der aus der Zugriff erfolgt (Referrer-URL)</li> + <li>IP-Adresse (gekürzt zur Anonymisierung)</li> + </ul> + <p> + <strong>Rechtsgrundlage:</strong> Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse am + Betriebschutz und technischer Sicherheit).<br> + <strong>Speicherdauer:</strong> 7 Tage, danach automatische Löschung.<br> + <strong>Serverstandort:</strong> <span class="placeholder">[Hetzner / IONOS / anderer deutscher Anbieter, Server in DE/AT]</span> + </p> + + <h2>4. Online-Terminbuchung (Booking-Widget)</h2> + <div class="warning-box"> + Dieser Abschnitt ist <strong>entscheidend</strong>. Das Booking-Widget ist ein externer + Dienstleister — die Daten verlassen Ihre Website und werden beim Anbieter verarbeitet. + Das MUSS in der Datenschutzerklärung transparent dargestellt werden. + </div> + <p> + Für die Online-Terminbuchung nutzen wir den Dienst von + <strong><span class="placeholder">[Massagebooking.de / SimplyBook.me]</span></strong>. + </p> + <h3>4.1 Verantwortlicher für das Booking-Widget</h3> + <p> + <span class="placeholder">[Name des Anbieters, z.B. Massagebooking.de GmbH]</span><br> + <span class="placeholder">[Adresse des Anbieters]</span><br> + <span class="placeholder">[Website des Anbieters]</span> + </p> + <p> + Es besteht ein Auftragsverarbeitungsvertrag (AVV) gemäß Art. 28 DSGVO mit dem Anbieter, + der sicherstellt, dass die Datenverarbeitung im Einklang mit der DSGVO erfolgt. + </p> + <h3>4.2 Welche Daten werden erfasst?</h3> + <ul> + <li>Name und Vorname</li> + <li>Telefonnummer</li> + <li>E-Mail-Adresse (optional)</li> + <li>Gewünschte Behandlungsart und Terminzeit</li> + <li>IP-Adresse (technisch bedingt, beim Provider)</li> + </ul> + <div class="warning-box"> + <strong>Wichtig (Art. 9 DSGVO):</strong> Es dürfen KEINE Gesundheitsdaten über das + Booking-Widget erfasst werden. Das Feld für Anmerkungen/Beschwerden sollte entweder + deaktiviert oder explizit darauf hingewiesen werden, dass keine Gesundheitsdaten + eingegeben werden sollen. Gesundheitsdaten sind besondere Kategorien personenbezogener + Daten (Art. 9 DSGVO) und erfordern <strong>explizite Einwilligung</strong> und + strengere Schutzmaßnahmen. + </div> + <h3>4.3 Rechtsgrundlage</h3> + <p> + Art. 6 Abs. 1 lit. b DSGVO (Vertragsanbahnung) — die Verarbeitung ist erforderlich, + um den Termin zu bestätigen und durchzuführen. + </p> + <h3>4.4 Speicherdauer</h3> + <p> + Die Daten werden für die Dauer des Kundenverhältnisses gespeichert und danach gemäß + den gesetzlichen Aufbewahrungsfristen (Steuerrecht: 10 Jahre, sonst 6 Monate nach + letztem Kontakt). <span class="placeholder">[Speicherdauer beim Anbieter gemäß dessen + Datenschutzerklärung prüfen]</span> + </p> + <h3>4.5 Datenübermittlung</h3> + <p> + Die Daten werden an den Booking-Anbieter weitergeleitet. Der Anbieter hat seinen Sitz + in <span class="placeholder">[Deutschland / EU]</span>. Eine Übermittlung in Drittländer + außerhalb der EU findet nicht statt. <span class="placeholder">[Anbieter-spezifisch anpassen]</span> + </p> + + <h2>5. WhatsApp-Kommunikation</h2> + <p> + Für die Kommunikation mit Kunden nutzen wir WhatsApp Business API über den zertifizierten + Business Solution Provider (BSP) <strong><span class="placeholder">[SendSeven / Spoki / 360dialog]</span></strong>. + </p> + <h3>5.1 Was wird verarbeitet?</h3> + <ul> + <li>Telefonnummer des Kunden</li> + <li>Name (falls vom Kunden angegeben)</li> + <li>Inhalt der Chat-Nachrichten (Terminbestätigungen, Erinnerungen)</li> + <li>Metadaten (Zeitstempel, Zugriffsdaten)</li> + </ul> + <h3>5.2 Rechtsgrundlage</h3> + <p> + <strong>Service-Nachrichten (vom Kunden initiiert):</strong> Art. 6 Abs. 1 lit. b DSGVO + (Vertragsanbahnung / -erfüllung) — wenn der Kunde uns über WhatsApp kontaktiert.<br> + <strong>Aktive Nachrichten (Terminbestätigungen, Erinnerungen):</strong> Art. 6 Abs. 1 lit. b + DSGVO (Vertragsdurchführung) oder Art. 6 Abs. 1 lit. a DSGVO (Einwilligung, falls + Marketing-Nachrichten). + </p> + <h3>5.3 AVV mit Meta (WhatsApp)</h3> + <p> + Es besteht ein Auftragsverarbeitungsvertrag mit Meta Platforms Ireland Ltd. über den + BSP. Meta Ireland Ltd., 4 Grand Canal Square, Dublin 2, Irland. Die Datenverarbeitung + erfolgt gemäß den WhatsApp Business API-Datenschutzbestimmungen. + </p> + <h3>5.4 Einwilligung</h3> + <p> + Kunden müssen vor der aktiven Kontaktaufnahme via WhatsApp eingewilligt haben. Die + Einwilligung wird protokolliert (Datum, Uhrzeit, Art der Einwilligung). Kunden können + ihre Einwilligung jederzeit widerrufen, indem sie auf eine WhatsApp-Nachricht mit + "STOP" antworten oder uns kontaktieren. + </p> + <div class="warning-box"> + <strong>Nicht zulässig:</strong> Die Nutzung der privaten WhatsApp-App (nicht Business API) + für geschäftliche Zwecke ist <strong>nicht DSGVO-konform</strong>. Die private App liest + das Adressbuch ein und bietet keine AVV-Möglichkeit. Es MUSS die Business API über einen + zertifizierten BSP verwendet werden. + </div> + + <h2>6. Google Maps Einbindung</h2> + <p> + Zur Darstellung unserer Standorte nutzen wir Google Maps. Anbieter ist Google Ireland + Limited, Gordon House, Barrow Street, Dublin 4, Irland. Beim Aufruf von Google Maps + werden Daten (insbesondere IP-Adresse) an Google übermittelt. + </p> + <p> + <strong>Rechtsgrundlage:</strong> Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse, + Kunden die Anfahrt zu erleichtern).<br> + <strong>AVV:</strong> Es besteht ein Auftragsverarbeitungsvertrag mit Google.<br> + <strong>Weitergabe:</strong> Google kann Daten in die USA übermitteln. Es bestehen + Standardvertragsklauseln (SCC) als angemessene Garantien. + </p> + <div class="warning-box"> + Google Maps setzt Cookies. Der Cookie-Banner MUSS die Einwilligung für Google Maps + einholen, BEVOR die Karte geladen wird. Tipp: "Click-to-load" — die Karte wird erst + nach Klick auf "Google Maps laden" angezeigt. + </div> + + <h2>7. Cookies</h2> + <p> + Unsere Website verwendet Cookies. Sie können zwischen notwendigen Cookies + (für den Betrieb der Website) und optionalen Cookies (für Analyse und externe Dienste) + unterscheiden. Die Einwilligung in nicht-notwendige Cookies erfolgt über unseren + Cookie-Banner. + </p> + <h3>Verwendete Cookies:</h3> + <ul> + <li><strong>Session-Cookie (notwendig):</strong> Speichert die gewählte Filiale (DE/AT). Sitzungsbasiert, kein Tracking.</li> + <li><strong>Cookie-Banner-Cookie (notwendig):</strong> Speichert die Cookie-Einwilligung. 12 Monate.</li> + <li><strong>Google Maps (optional):</strong> Erst nach Einwilligung. <span class="placeholder">[Lifespan gemäß Google]</span></li> + <li><strong><span class="placeholder">[Booking-Widget]</span> (optional):</strong> <span class="placeholder">[Prüfen, ob das Widget eigene Cookies setzt]</span></li> + </ul> + + <h2>8. Ihre Rechte als betroffene Person</h2> + <p>Sie haben folgende Rechte:</p> + <ul> + <li><strong>Auskunft (Art. 15 DSGVO):</strong> Sie können Auskunft über Ihre verarbeiteten Daten verlangen.</li> + <li><strong>Berichtigung (Art. 16 DSGVO):</strong> Sie können die Berichtigung unrichtiger Daten verlangen.</li> + <li><strong>Löschung (Art. 17 DSGVO):</strong> Sie können die Löschung Ihrer Daten verlangen ("Recht auf Vergessenwerden").</li> + <li><strong>Einschränkung (Art. 18 DSGVO):</strong> Sie können die Einschränkung der Verarbeitung verlangen.</li> + <li><strong>Datenübertragbarkeit (Art. 20 DSGVO):</strong> Sie können Ihre Daten in einem maschinenlesbaren Format erhalten.</li> + <li><strong>Widerspruch (Art. 21 DSGVO):</strong> Sie können der Verarbeitung widersprechen.</li> + <li><strong>Widerruf der Einwilligung (Art. 7 Abs. 3 DSGVO):</strong> Sie können eine erteilte Einwilligung jederzeit widerrufen.</li> + <li><strong>Beschwerde bei der Aufsichtsbehörde:</strong> Sie können sich an die zuständige Datenschutzbehörde wenden.</li> + </ul> + <h3>Zuständige Aufsichtsbehörden:</h3> + <div class="info-box de"> + <strong>🇩🇪 Deutschland (Kiefersfelden, Bayern):</strong><br> + Bayerisches Landesamt für Datenschutzaufsicht (BayLDA)<br> + Promenade 18, 91522 Ansbach<br> + <a href="https://www.lda.bayern.de" target="_blank">www.lda.bayern.de</a> + </div> + <div class="info-box at"> + <strong>🇦🇹 Österreich (Kufstein, Tirol):</strong><br> + Datenschutzbehörde (DSB)<br> + Barichgasse 40-42, 1030 Wien<br> + <a href="https://www.dsb.gv.at" target="_blank">www.dsb.gv.at</a> + </div> + + <h2>9. SSL-Verschlüsselung</h2> + <p> + Diese Website nutzt aus Sicherheitsgründen und zum Schutz der Übertragung vertraulicher + Inhalte eine SSL/TLS-Verschlüsselung. Eine verschlüsselte Verbindung erkennen Sie daran, + dass die Adresszeile des Browsers von "http://" auf "https://" wechselt und am Schloss-Symbol + in Ihrer Browserzeile. + </p> + + <h2>10. Aktualität</h2> + <p> + Diese Datenschutzerklärung hat den Stand <strong>August 2026</strong>. Wir behalten uns vor, + diese Datenschutzerklärung anzupassen, damit sie stets den aktuellen rechtlichen + Anforderungen entspricht. + </p> +</section> + +<!-- ================================================================ --> +<!-- ====== AGB ====== --> +<!-- ================================================================ --> +<section id="agb" class="legal-section"> + <h1>Allgemeine Geschäftsbedingungen (AGB)</h1> + + <h2>§ 1 Geltungsbereich</h2> + <p> + Diese Allgemeinen Geschäftsbedingungen gelten für alle Verträge zwischen + <span class="placeholder">[Name des Betriebs]</span> (nachfolgend "Anbieter") und + deren Kunden über die Buchung und Durchführung von Massageleistungen. + </p> + <p> + Es gelten jeweils die rechtlichen Bestimmungen des Landes, in dem sich der + Standort befindet, an dem die Leistung erbracht wird (Deutschland für + Kiefersfelden, Österreich für Kufstein). + </p> + + <h2>§ 2 Buchung und Terminbestätigung</h2> + <ul> + <li>Termine können online über das Booking-Widget, telefonisch oder via WhatsApp gebucht werden.</li> + <li>Der Terminvertrag kommt mit der schriftlichen (bzw. elektronischen) Bestätigung durch den Anbieter zustande.</li> + <li>Die Bestätigung erfolgt in der Regel per E-Mail oder WhatsApp.</li> + <li>Jeder Termin ist personenbezogen und kann nicht ohne Weiteres auf eine andere Person übertragen werden.</li> + </ul> + + <h2>§ 3 Stornierung und Widerruf</h2> + <ul> + <li>Termine können bis zu <span class="placeholder">[24 Stunden]</span> vor dem vereinbarten Termin kostenlos storniert werden.</li> + <li>Bei Stornierungen nach Ablauf dieser Frist oder bei Nichterscheinen (No-Show) behalten wir uns vor, eine Ausfallpauschale in Höhe von <span class="placeholder">[50%]</span> des Buchungswertes zu berechnen.</li> + <li>Bei Krankheit oder höherer Gewalt entfällt die Ausfallpauschale gegen Vorlage eines Nachweises.</li> + </ul> + + <h2>§ 4 Preise und Zahlung</h2> + <ul> + <li>Es gelten die auf der Website veröffentlichten Preise zum Zeitpunkt der Buchung.</li> + <li>Die Zahlung erfolgt vor Ort in bar oder per EC-Karte <span class="placeholder">[/ Kreditkarte]</span>.</li> + <li>Gutscheine sind ab Ausstellungsdatum <span class="placeholder">[3 Jahre (DE) / 5 Jahre (AT)]</span> gültig.</li> + </ul> + + <h2>§ 5 Gesundheitliche Hinweise</h2> + <ul> + <li>Der Kunde ist verpflichtet, vor der Behandlung auf gesundheitliche Probleme, Allergien, Verletzungen oder Schwangerschaft hinzuweisen.</li> + <li>Der Anbieter behält sich vor, eine Behandlung abzulehnen oder abzubrechen, wenn gesundheitliche Bedenken bestehen.</li> + <li>Massagen stellen keine medizinische Behandlung im Sinne des Heilpraktikergesetzes dar.</li> + </ul> + + <h2>§ 6 Haftung</h2> + <ul> + <li>Der Anbieter haftet nur für Schäden, die auf Vorsatz oder grober Fahrlässigkeit beruhen.</li> + <li>Die Haftung für leichte Fahrlässigkeit ist auf den vertragstypischen, vorhersehbaren Schaden beschränkt.</li> + </ul> + + <h2>§ 7 Schlussbestimmungen</h2> + <ul> + <li>Es gilt das Recht der Bundesrepublik Deutschland bzw. der Republik Österreich, je nach Standort der Leistungserbringung.</li> + <li>Gerichtsstand ist <span class="placeholder">[Kiefersfelden / Kufstein]</span>, soweit der Kunde Kaufmann ist.</li> + <li>Sollte eine Bestimmung unwirksam sein, bleibt die Gültigkeit der übrigen Bestimmungen unberührt.</li> + </ul> +</section> + +<!-- ================================================================ --> +<!-- ====== WIDERRUFSBELEHRUNG ====== --> +<!-- ================================================================ --> +<section id="widerruf" class="legal-section"> + <h1>Widerrufsbelehrung</h1> + + <h2>🇩🇪 Deutschland</h2> + <h3>Widerrufsrecht</h3> + <p> + Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu + widerrufen. Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses. + </p> + <p> + Um Ihr Widerrufsrecht auszuüben, müssen Sie uns + (<span class="placeholder">[Name, Adresse, E-Mail, Telefon]</span>) mittels einer + eindeutigen Erklärung (z.B. ein mit der Post versandter Brief, Telefax oder E-Mail) + über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. + </p> + <h3>Widerrufsfolgen</h3> + <p> + Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen + erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, + die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns + angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens + binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren + Widerruf dieses Vertrags bei uns eingegangen ist. + </p> + <div class="warning-box"> + <strong>Ausnahme für Dienstleistungen:</strong> Bei Terminbuchungen für Massageleistungen + (Dienstleistungen) erlischt das Widerrufsrecht vorzeitig, wenn der Vertrag auf ausdrücklichen + Wunsch des Kunden vollständig erfüllt wurde, bevor der Kunde sein Widerrufsrecht ausgeübt hat + (§ 356 Abs. 4 BGB). D.h.: War der Termin bereits durchgeführt, ist ein Widerruf nicht mehr möglich. + </div> + + <h2>🇦🇹 Österreich</h2> + <h3>Rücktrittsrecht (Konsumentenschutzgesetz)</h3> + <p> + Gemäß § 11 FAGG (Fern- und Auswärtsgeschäfte-Gesetz) haben Verbraucher das Recht, + binnen vierzehn Tagen ohne Angabe von Gründen vom Vertrag zurückzutreten. + </p> + <p> + Die Frist beginnt ab Vertragsabschluss. Zur Wahrung der Frist genügt die rechtzeitige + Absendung der Rücktrittserklärung. + </p> + <p> + Rücktrittserklärung an: <span class="placeholder">[Name, Adresse, E-Mail, Telefon Kufstein]</span> + </p> + <div class="warning-box"> + <strong>Ausnahme:</strong> Bei Dienstleistungen, die auf ausdrücklichen Wunsch des + Konsumenten vor Ablauf der Rücktrittsfrist erbracht wurden, erlischt das + Rücktrittsrecht vorzeitig (§ 18 Abs. 1 Z 3 FAGG). + </div> +</section> + +<!-- ================================================================ --> +<!-- ====== CHECKLISTE ====== --> +<!-- ================================================================ --> +<section id="checklist" class="legal-section"> + <h1>✅ Rechts-Checkliste (Dual DE + AT)</h1> + <p> + Diese Checkliste hilft dabei, alle rechtlichen Anforderungen für den Betrieb der + Website mit zwei Standorten in Deutschland und Österreich abzudecken. + </p> + + <h2>Impressum</h2> + <div class="checklist"> + <ul> + <li>Impressum enthält beide Standorte (Kiefersfelden DE + Kufstein AT)</li> + <li>Alle Pflichtangaben nach § 5 TMG (DE) und § 14 ECG (AT) enthalten</li> + <li>USt-IdNr (DE) und UID-Nummer (AT) angegeben</li> + <li>Berufsbezeichnung und zuständige Behörde angegeben</li> + <li>Vertretungsberechtigte Person genannt</li> + <li>Streitbeilegungshinweise für beide Länder vorhanden</li> + </ul> + </div> + + <h2>Datenschutzerklärung</h2> + <div class="checklist"> + <ul> + <li>Verantwortlicher für beide Standorte benannt</li> + <li>Server-Logfiles dokumentiert (inkl. Speicherdauer & Serverstandort)</li> + <li>Booking-Widget vollständig dokumentiert (Anbieter, AVV, Datenkategorien)</li> + <li>WhatsApp Business API dokumentiert (BSP, Meta AVV, Einwilligung)</li> + <li>Google Maps Einbindung dokumentiert (inkl. Click-to-Load)</li> + <li>Cookie-Einträge vollständig und korrekt (notwendig vs. optional)</li> + <li>Rechte der betroffenen Person aufgeführt (Art. 15–21 DSGVO)</li> + <li>Zuständige Aufsichtsbehörden für DE (BayLDA) und AT (DSB) genannt</li> + <li>Keine Erfassung von Gesundheitsdaten (Art. 9 DSGVO) über Booking-Widget</li> + <li>SSL-Verschlüsselung erwähnt</li> + </ul> + </div> + + <h2>Cookie-Banner</h2> + <div class="checklist"> + <ul> + <li>Cookie-Banner beim ersten Besuch eingeblendet</li> + <li>Unterscheidung zwischen notwendigen und optionalen Cookies</li> + <li>"Alle akzeptieren" und "Nur notwendige" Buttons vorhanden</li> + <li>Einwilligung wird protokolliert (Datum, Uhrzeit, Wahl)</li> + <li>Google Maps wird erst nach Einwilligung geladen (Click-to-Load)</li> + <li>Booking-Widget: prüfen, ob eigene Cookies gesetzt werden → dokumentieren</li> + </ul> + </div> + + <h2>Verträge & AVVs</h2> + <div class="checklist"> + <ul> + <li>AVV mit Booking-Anbieter (Massagebooking.de / SimplyBook.me) abgeschlossen</li> + <li>AVV mit WhatsApp BSP (SendSeven / Spoki / 360dialog) abgeschlossen</li> + <li>AVV mit Meta (WhatsApp Business API) — wird über BSP geregelt, prüfen</li> + <li>AVV mit Google (Maps) abgeschlossen — Google bietet standardmäßigen AVV</li> + <li>AVV mit Hosting-Anbieter (Server) abgeschlossen</li> + </ul> + </div> + + <h2>AGB & Widerruf</h2> + <div class="checklist"> + <ul> + <li>AGB enthalten beide Rechtsräume (DE: BGB, AT: ABGB/KSchG)</li> + <li>Stornierungsbedingungen klar definiert (Frist, Ausfallpauschale)</li> + <li>Widerrufsbelehrung für DE (§ 355 BGB) und AT (§ 11 FAGG) vorhanden</li> + <li>Ausnahme für erbrachte Dienstleistungen erwähnt</li> + <li>Gesundheitliche Hinweise und Mitwirkungspflichten definiert</li> + </ul> + </div> + + <h2>Sonstiges</h2> + <div class="checklist"> + <ul> + <li>SSL-Zertifikat aktiv (HTTPS)</li> + <li>DSGVO-konformes Kontaktformular (falls vorhanden) mit Einwilligungstext</li> + <li>Datenschutz-Icon in der Nähe der Eingabefelder (falls Newsletter etc.)</li> + <li>Barrierefreiheit: Checklist abgeschlossen (BFSG / BITV 2.0 für DE, WCG AT)</li> + <li>Domain für Kufstein: .at-Domain erwägen (thaimassage-kufstein.at)</li> + </ul> + </div> + + <div class="warning-box"> + <strong>Haftungsausschluss:</strong> Diese Dokumente sind Vorlagen/ Templates und + ersetzen keine Rechtsberatung. Vor Veröffentlichung sollten alle Inhalte von einer + Rechtsanwältin / einem Rechtsanwalt geprüft werden, insbesondere die + österreichischen Rechtsvorschriften (FAGG, KSchG, DSG, ECG). + </div> +</section> + +</div> + +<!-- ===== FOOTER ===== --> +<footer> + <p>Thai Massage Kiefersfelden & Kufstein — Rechtliche Seiten (Vorlage/Template)</p> + <p>Stand: August 2026 | <a href="thai-massage-booking-mockup.html">← Zurück zur Website</a></p> +</footer> + +<!-- ===== SCRIPT ===== --> +<script> + function showSection(id) { + document.querySelectorAll('.legal-section').forEach(s => s.classList.remove('active')); + document.getElementById(id).classList.add('active'); + document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active')); + event.target.classList.add('active'); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } +</script> + +</body> +</html>
\ No newline at end of file diff --git a/downloads/thai-massage-privacy-first.html b/downloads/thai-massage-privacy-first.html new file mode 100644 index 0000000..782632a --- /dev/null +++ b/downloads/thai-massage-privacy-first.html @@ -0,0 +1,280 @@ +<!DOCTYPE html> +<html lang="de"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1.0"> +<title>Thai Massage Kiefersfelden & Kufstein – Termin buchen</title> +<meta name="description" content="Thai Massage in Kiefersfelden und Kufstein. Online buchen über Payhip oder per WhatsApp. Keine Cookies, kein Tracking — Privacy by Design."> +<!-- PRIVACY-FIRST: Zero cookies, zero tracking, zero third-party scripts, no iframes --> +<style> +:root{--gold:#c5a55a;--gold-l:#e8d5a3;--gold-d:#a08838;--dark:#1a1a1a;--dark-s:#2a2722;--cream:#faf6ef;--cream-d:#f0e9db;--text:#333;--text-l:#666;--wa:#25d366;--wa-d:#1da851;--payhip:#5b6eee;--payhip-d:#4457d4;--green:#2d7a3f;--green-l:#e8f5e9;--sh:0 4px 20px rgba(0,0,0,.08);--sh-l:0 8px 40px rgba(0,0,0,.12);--r:12px} +*{margin:0;padding:0;box-sizing:border-box} +body{font-family:'Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif;background:var(--cream);color:var(--text);line-height:1.6} +.pb{background:var(--green-l);color:var(--green);text-align:center;padding:8px 16px;font-size:.82rem;font-weight:500;display:flex;align-items:center;justify-content:center;gap:6px;border-bottom:1px solid #c8e6c9} +header{background:var(--dark);color:#fff;position:sticky;top:0;z-index:100;box-shadow:0 2px 10px rgba(0,0,0,.3)} +.hi{max-width:1200px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;padding:16px 24px;gap:16px} +.logo{display:flex;align-items:center;gap:10px;font-size:1.3rem;font-weight:700;white-space:nowrap} +.logo span{color:var(--gold)} +.ls{display:flex;gap:8px;flex-wrap:wrap} +.lb{background:var(--dark-s);border:1px solid var(--gold-d);color:#ccc;padding:8px 18px;border-radius:30px;cursor:pointer;font-size:.85rem;transition:all .3s;white-space:nowrap} +.lb:hover{border-color:var(--gold);color:var(--gold-l)} +.lb.active{background:var(--gold);color:var(--dark);border-color:var(--gold);font-weight:600} +.hero{background:linear-gradient(135deg,#1a1a1a 0%,#2d2a22 50%,#1a1a1a 100%);color:#fff;text-align:center;padding:60px 24px 50px;position:relative;overflow:hidden} +.hero::before{content:"🧘";position:absolute;font-size:20rem;opacity:.04;top:50%;left:50%;transform:translate(-50%,-50%)} +.hero h1{font-size:2.4rem;margin-bottom:12px;position:relative} +.hero h1 span{color:var(--gold)} +.hero p{font-size:1.15rem;color:#ddd;max-width:600px;margin:0 auto 28px;position:relative} +.cta{display:flex;gap:14px;justify-content:center;flex-wrap:wrap;position:relative} +.btn{display:inline-flex;align-items:center;gap:8px;padding:14px 32px;border-radius:50px;font-size:1rem;font-weight:600;text-decoration:none;cursor:pointer;border:none;transition:all .3s} +.btn-p{background:var(--gold);color:var(--dark);box-shadow:0 4px 15px rgba(197,165,90,.3)} +.btn-p:hover{background:var(--gold-l);transform:translateY(-2px);box-shadow:0 6px 25px rgba(197,165,90,.5)} +.btn-ph{background:var(--payhip);color:#fff;box-shadow:0 4px 15px rgba(91,110,238,.3)} +.btn-ph:hover{background:var(--payhip-d);transform:translateY(-2px);box-shadow:0 6px 25px rgba(91,110,238,.5)} +.btn-wa{background:var(--wa);color:#fff;box-shadow:0 4px 15px rgba(37,211,102,.3)} +.btn-wa:hover{background:var(--wa-d);transform:translateY(-2px);box-shadow:0 6px 25px rgba(37,211,102,.5)} +.btn-o{background:transparent;color:#fff;border:2px solid rgba(255,255,255,.3)} +.btn-o:hover{border-color:var(--gold);color:var(--gold)} +@media(max-width:600px){.hi{flex-direction:column;padding:12px 16px}.ls{width:100%;justify-content:center}.hero h1{font-size:1.7rem}.hero p{font-size:1rem}} +.bs{max-width:900px;margin:-30px auto 0;padding:0 24px 60px;position:relative;z-index:10} +.bc{background:#fff;border-radius:var(--r);box-shadow:var(--sh-l);overflow:hidden} +.bh{background:linear-gradient(135deg,var(--gold-d),var(--gold));color:#fff;padding:20px 32px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px} +.bh h2{font-size:1.3rem;display:flex;align-items:center;gap:10px} +.badge{background:rgba(255,255,255,.2);padding:4px 14px;border-radius:20px;font-size:.85rem} +.bb{padding:40px 32px;background:var(--cream);text-align:center} +.bb h3{font-size:1.4rem;color:var(--dark);margin-bottom:12px} +.bb>p{color:var(--text-l);max-width:500px;margin:0 auto 28px} +.bbtns{display:flex;gap:14px;justify-content:center;flex-wrap:wrap;margin-bottom:24px} +.bn{font-size:.82rem;color:var(--text-l);margin-top:20px;padding:12px 20px;background:var(--green-l);border-radius:8px;display:inline-block} +.fd{display:flex;align-items:center;justify-content:center;gap:12px;margin-top:28px;flex-wrap:wrap} +.fs{background:#fff;border:1px solid var(--cream-d);border-radius:10px;padding:16px 20px;text-align:center;min-width:140px} +.fs .si{font-size:1.8rem;margin-bottom:6px} +.fs .st{font-weight:600;font-size:.9rem;color:var(--dark)} +.fs .sd{font-size:.76rem;color:var(--text-l);margin-top:4px} +.fa{color:var(--gold);font-size:1.5rem;font-weight:bold} +@media(max-width:600px){.fa{transform:rotate(90deg)}.fs{min-width:80%}} +.ps{max-width:900px;margin:0 auto;padding:40px 24px;text-align:center} +.ps h2{font-size:1.6rem;color:var(--dark);margin-bottom:8px} +.ps h2 span{color:var(--green)} +.ps>p{color:var(--text-l);max-width:600px;margin:0 auto 32px} +.pf{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;margin-top:24px} +.pc{background:#fff;border-radius:var(--r);padding:24px 20px;box-shadow:var(--sh);text-align:center;border:1px solid var(--cream-d)} +.pc .icon{font-size:2rem;margin-bottom:10px} +.pc h4{font-size:1rem;color:var(--dark);margin-bottom:6px} +.pc p{font-size:.85rem;color:var(--text-l)} +.ss{max-width:1100px;margin:0 auto;padding:40px 24px} +.stt{text-align:center;margin-bottom:32px} +.stt h2{font-size:1.8rem;color:var(--dark)} +.stt p{color:var(--text-l);margin-top:6px} +.sg{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:20px} +.sc{background:#fff;border-radius:var(--r);box-shadow:var(--sh);overflow:hidden;transition:transform .3s,box-shadow .3s;cursor:pointer} +.sc:hover{transform:translateY(-4px);box-shadow:var(--sh-l)} +.sc .si2{background:linear-gradient(135deg,var(--gold-d),var(--gold));color:#fff;padding:20px;text-align:center;font-size:2rem} +.sbd{padding:20px} +.sbd h3{font-size:1.1rem;color:var(--dark);margin-bottom:6px} +.sbd p{font-size:.9rem;color:var(--text-l);margin-bottom:12px} +.sm{display:flex;justify-content:space-between;align-items:center;padding-top:12px;border-top:1px solid var(--cream-d)} +.sm .price{font-size:1.1rem;font-weight:700;color:var(--gold-d)} +.sm .dur{font-size:.85rem;color:var(--text-l)} +.lo{max-width:1100px;margin:0 auto;padding:40px 24px} +.lg{display:grid;grid-template-columns:repeat(auto-fit,minmax(350px,1fr));gap:24px} +.lc{background:#fff;border-radius:var(--r);box-shadow:var(--sh);overflow:hidden} +.lch{background:var(--dark);color:#fff;padding:20px 24px;display:flex;align-items:center;justify-content:space-between} +.lch h3{font-size:1.15rem} +.lch .flag{font-size:1.5rem} +.lcb{padding:24px} +.lcb address{font-style:normal;color:var(--text-l);margin-bottom:16px;line-height:1.8} +.ht{width:100%;margin-bottom:20px;border-collapse:collapse} +.ht td{padding:6px 0;font-size:.88rem;color:var(--text-l)} +.ht td:first-child{font-weight:600;color:var(--dark);width:45%} +.ht tr{border-bottom:1px solid var(--cream-d)} +.la{display:flex;gap:10px;flex-wrap:wrap} +.wa-f{position:fixed;bottom:24px;right:24px;width:60px;height:60px;background:var(--wa);border-radius:50%;display:flex;align-items:center;justify-content:center;text-decoration:none;box-shadow:0 4px 20px rgba(37,211,102,.4);z-index:90;transition:transform .3s} +.wa-f:hover{transform:scale(1.1)} +.wa-f svg{width:32px;height:32px;fill:#fff} +.wa-f::after{content:"";position:absolute;width:100%;height:100%;border-radius:50%;border:2px solid var(--wa);animation:wap 2s infinite} +@keyframes wap{0%{transform:scale(1);opacity:.7}100%{transform:scale(1.5);opacity:0}} +@media(max-width:600px){.wa-f{bottom:70px;right:16px;width:52px;height:52px}} +footer{background:var(--dark);color:#aaa;padding:40px 24px 24px} +.fi{max-width:1100px;margin:0 auto} +.fg{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:24px;margin-bottom:28px} +.fc h4{color:var(--gold);margin-bottom:12px;font-size:.95rem} +.fc a{color:#aaa;text-decoration:none;font-size:.88rem;display:block;padding:4px 0;transition:color .2s} +.fc a:hover{color:var(--gold-l)} +.fb{border-top:1px solid #333;padding-top:20px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px} +.fb p{font-size:.82rem} +.pt{display:inline-flex;align-items:center;gap:6px;color:var(--green);font-size:.82rem;font-weight:500} +.nc{position:fixed;bottom:0;left:0;right:0;background:var(--dark);color:#ccc;padding:10px 24px;text-align:center;font-size:.82rem;z-index:80;border-top:1px solid var(--gold-d);display:flex;align-items:center;justify-content:center;gap:8px;transition:transform .4s} +.nc .x{color:var(--gold);cursor:pointer;font-size:1.1rem;margin-left:12px;opacity:.7;transition:opacity .2s} +.nc .x:hover{opacity:1} +.nc.hidden{transform:translateY(100%)} +.mo{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:200;justify-content:center;align-items:center;padding:20px} +.mo.active{display:flex} +.mb{background:#fff;border-radius:var(--r);max-width:700px;width:100%;max-height:80vh;overflow-y:auto;box-shadow:var(--sh-l)} +.mh{background:var(--dark);color:#fff;padding:16px 24px;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;border-radius:var(--r) var(--r) 0 0} +.mh h3{font-size:1.1rem;color:var(--gold)} +.mh .cl{background:none;border:none;color:#fff;font-size:1.5rem;cursor:pointer;padding:0 4px} +.mbd{padding:24px} +.mbd h4{color:var(--dark);margin:16px 0 8px;font-size:1rem} +.mbd p{font-size:.9rem;color:var(--text);margin-bottom:10px} +.mbd ul{padding-left:20px;margin-bottom:12px} +.mbd li{font-size:.88rem;margin-bottom:4px} +.ph-t{color:var(--gold-d);font-weight:600;background:var(--cream-d);padding:1px 6px;border-radius:4px;font-size:.85em} +</style> +</head> +<body> + +<div class="pb">🔒 Diese Website verwendet keine Cookies und kein Tracking — Privacy by Design</div> + +<header><div class="hi"> +<div class="logo">🪷 Thai Massage <span>Nico</span></div> +<div class="ls"> +<button class="lb active" onclick="switchLocation('kiefersfelden')">🇩🇪 Kiefersfelden</button> +<button class="lb" onclick="switchLocation('kufstein')">🇦🇹 Kufstein</button> +</div> +</div></header> + +<section class="hero"> +<h1>Thai Massage in <span id="hl">Kiefersfelden</span></h1> +<p>Authentische thailändische Massage — traditionelle Techniken für Körper und Seele. Jetzt online buchen oder per WhatsApp anfragen.</p> +<div class="cta"> +<a href="#booking" class="btn btn-p">📅 Jetzt Buchen</a> +<a href="#" id="hero-wa" class="btn btn-wa">💬 WhatsApp Anfrage</a> +<a href="tel:+4900000000000" class="btn btn-o">📞 Anrufen</a> +</div> +</section> + +<section class="bs" id="booking"> +<div class="bc"> +<div class="bh"><h2>📅 Online Termin buchen</h2><span class="badge" id="bl-badge">📍 Kiefersfelden</span></div> +<div class="bb"> +<h3>Termin in wenigen Klicks</h3> +<p>Sie werden zu unserem externen Buchungssystem weitergeleitet. Dort wählen Sie Behandlung, Datum und Uhrzeit — sicher und unkomplizert.</p> +<div class="bbtns"> +<a href="https://payhip.com/your-studio" target="_blank" rel="noopener" class="btn btn-ph">🛒 Über Payhip buchen →</a> +<a href="#" id="booking-wa" class="btn btn-wa">💬 Lieber per WhatsApp?</a> +</div> +<div class="bn">🔒 Buchung erfolgt auf payhip.com — keine Daten auf dieser Website.</div> +<div class="fd"> +<div class="fs"><div class="si">🌐</div><div class="st">Diese Website</div><div class="sd">Nur Infos — keine Cookies</div></div> +<div class="fa">→</div> +<div class="fs"><div class="si">🛒</div><div class="st">Payhip</div><div class="sd">Buchung + Zahlung</div></div> +<div class="fa">→</div> +<div class="fs"><div class="si">✅</div><div class="st">Bestätigung</div><div class="sd">Per E-Mail + WhatsApp</div></div> +</div> +</div> +</div> +</section> + +<section class="ps"> +<h2>🔒 <span>Privacy First</span> — Wir schützen Ihre Daten</h2> +<p>Anders als viele Websites verzichten wir vollständig auf Tracking, Analyse-Tools und Cookies. Ihre Privatsphäre ist uns wichtig.</p> +<div class="pf"> +<div class="pc"><div class="icon">🚫</div><h4>Keine Cookies</h4><p>Kein Cookie-Banner nötig — wir setzen keine Cookies.</p></div> +<div class="pc"><div class="icon">📊</div><h4>Kein Tracking</h4><p>Keine Google Analytics, kein Meta Pixel, keine Werbung.</p></div> +<div class="pc"><div class="icon">📝</div><h4>Keine Formulare</h4><p>Buchung läuft über Payhip — keine Datenerfassung hier.</p></div> +<div class="pc"><div class="icon">💬</div><h4>WhatsApp Optional</h4><p>wa.me Link — kein Meta SDK auf dieser Website geladen.</p></div> +</div> +</section> + +<section class="ss" id="services"> +<div class="stt"><h2>Unsere Behandlungen</h2><p>Klassische und moderne Thai-Massage-Techniken</p></div> +<div class="sg" id="services-grid"></div> +</section> + +<section class="lo" id="locations"> +<div class="stt"><h2>Unsere Standorte</h2><p>Zwei Studios — eine Qualität</p></div> +<div class="lg" id="locations-grid"></div> +</section> + +<a href="#" id="wa-float" class="wa-f" aria-label="WhatsApp schreiben"> +<svg viewBox="0 0 24 24"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.149-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.149-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51l-.57-.01c-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-9.138 7.848h-.01c-1.488 0-2.945-.399-4.22-1.154l-.302-.18-3.132.821.836-3.057-.198-.314a8.4 8.4 0 0 1-1.288-4.482c0-4.652 3.789-8.44 8.442-8.44 2.254 0 4.371.879 5.965 2.476a8.39 8.39 0 0 1 2.473 5.968c0 4.652-3.789 8.44-8.44 8.44m7.186-15.626A10.04 10.04 0 0 0 12.32 2C6.586 2 1.926 6.66 1.926 12.395a10.36 10.36 0 0 0 1.388 5.201L1.5 22.5l5.06-1.327a10.04 10.04 0 0 0 4.758 1.213h.004c5.735 0 10.395-4.66 10.395-10.395a10.04 10.04 0 0 0-2.962-7.081"/></svg> +</a> + +<footer><div class="fi"> +<div class="fg"> +<div class="fc"><h4>🪷 Thai Massage Nico</h4><p style="font-size:.85rem;color:#999;">Authentische thailändische Massage in Kiefersfelden & Kufstein.</p></div> +<div class="fc"><h4>Schnellzugriff</h4><a href="#booking">📅 Termin buchen</a><a href="#services">Behandlungen</a><a href="#locations">Standorte</a></div> +<div class="fc"><h4>Rechtliches</h4> +<a href="#" onclick="openModal('impressum');return false;">Impressum</a> +<a href="#" onclick="openModal('datenschutz');return false;">Datenschutzerklärung</a> +<a href="#" onclick="openModal('agb');return false;">AGB</a> +<a href="#" onclick="openModal('widerruf');return false;">Widerrufsbelehrung</a> +</div> +</div> +<div class="fb"> +<p>© 2025 Thai Massage Nico. Alle Rechte vorbehalten.</p> +<span class="pt">🔒 Privacy by Design — Keine Cookies, kein Tracking</span> +</div> +</div></footer> + +<div class="nc" id="nc"> +🔒 Diese Website verwendet keine Cookies. Kein Tracking, keine Werbung. +<a href="#" onclick="openModal('datenschutz');return false;" style="color:var(--gold);text-decoration:underline;font-size:.82rem;">Mehr erfahren</a> +<span class="x" onclick="document.getElementById('nc').classList.add('hidden')">✕</span> +</div> + +<!-- MODALS --> +<div class="mo" id="mo-impressum" onclick="if(event.target===this)closeModal('impressum')"> +<div class="mb"><div class="mh"><h3>Impressum</h3><button class="cl" onclick="closeModal('impressum')">✕</button></div> +<div class="mbd"> +<h4>Angaben gemäß § 5 TMG</h4> +<p><span class="ph-t">[Vor- und Nachname]</span><br><span class="ph-t">[Straße Hausnummer]</span><br><span class="ph-t">[PLZ Ort]</span></p> +<h4>Kontakt</h4> +<p>Telefon: <span class="ph-t">[Telefonnummer]</span><br>E-Mail: <span class="ph-t">[E-Mail-Adresse]</span></p> +<h4>Umsatzsteuer-ID</h4> +<p>USt-IdNr.: <span class="ph-t">[USt-IdNr.]</span></p> +<h4>Berufsbezeichnung</h4> +<p>Heilpraktiker — verliehen in <span class="ph-t">[Land]</span><br>Aufsichtsbehörde: <span class="ph-t">[Behörde]</span></p> +<h4>Streitbeilegung</h4> +<p>Wir sind nicht bereit, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.</p> +</div></div> +</div> + +<div class="mo" id="mo-datenschutz" onclick="if(event.target===this)closeModal('datenschutz')"> +<div class="mb"><div class="mh"><h3>Datenschutzerklärung</h3><button class="cl" onclick="closeModal('datenschutz')">✕</button></div> +<div class="mbd"> +<h4>1. Verantwortlicher</h4> +<p><span class="ph-t">[Name]</span>, <span class="ph-t">[Anschrift]</span> — E-Mail: <span class="ph-t">[E-Mail]</span></p> +<h4>2. Keine Cookies, kein Tracking</h4> +<p>Diese Website verwendet <strong>keine Cookies</strong> und <strong>kein Tracking</strong>. Keine Analyse-Tools, keine Werbe-Tools, keine Social-Media-Plugins. Kein Cookie-Banner erforderlich.</p> +<h4>3. Server-Logfiles</h4> +<p>Der Hosting-Provider erhebt technische Daten (IP-Adresse, Browser, Uhrzeit) für Betriebssicherheit (Art. 6 Abs. 1 lit. f DSGVO). Löschung nach <strong>7 Tagen</strong>. IP-Adressen werden anonymisiert.</p> +<h4>4. Externe Buchung (Payhip)</h4> +<p>Buchung erfolgt über externen Link zu <strong>Payhip</strong> (payhip.com). Buchungsdaten werden ausschließlich dort verarbeitet. Kein iFrame, keine Payhip-Skripte auf dieser Website. AVV wurde geschlossen.</p> +<h4>5. WhatsApp (wa.me Link)</h4> +<p>WhatsApp-Button nutzt wa.me-Link. <strong>Kein Meta SDK</strong> auf dieser Website geladen. Verantwortlich: Meta Platforms Ireland Ltd. <a href="https://www.whatsapp.com/legal/privacy-policy" target="_blank" rel="noopener">WhatsApp-Datenschutzerklärung</a>.</p> +<h4>6. Keine Weitergabe an Dritte</h4> +<p>Diese Website erfasst keine personenbezogenen Daten direkt.</p> +<h4>7. Ihre Rechte (Art. 15–21 DSGVO)</h4> +<ul><li>Auskunft</li><li>Berichtigung</li><li>Löschung</li><li>Einschränkung</li><li>Datenübertragbarkeit</li><li>Widerspruch</li></ul> +<h4>8. Beschwerderecht</h4> +<p>BayLDA (Bayerisches Landesamt für Datenschutzaufsicht) / DSB (Datenschutzbehörde Österreich)</p> +</div></div> +</div> + +<div class="mo" id="mo-agb" onclick="if(event.target===this)closeModal('agb')"> +<div class="mb"><div class="mh"><h3>AGB</h3><button class="cl" onclick="closeModal('agb')">✕</button></div> +<div class="mbd"> +<h4>§ 1 Geltungsbereich</h4> +<p>Gelten für alle Buchungen über Payhip, WhatsApp oder Telefon.</p> +<h4>§ 2 Terminbuchung</h4> +<p>Buchung gilt erst mit Bestätigung als verbindlich.</p> +<h4>§ 3 Stornierung</h4> +<p>Bis <span class="ph-t">[24 Stunden]</span> vorher: kostenfrei. Danach: <span class="ph-t">[50%]</span> des Preises.</p> +<h4>§ 4 Gesundheit</h4> +<p>Kunden müssen vor der Behandlung auf gesundheitliche Einschränkungen hinweisen.</p> +<h4>§ 5 Gutscheine</h4> +<p>Gutscheine sind über Payhip erhältlich. Gültigkeit: <span class="ph-t">[3 Jahre]</span>.</p> +<h4>§ 6 Haftung</h4> +<p>Haftung nur bei Vorsatz und grober Fahrlässigkeit.</p> +</div></div> +</div> + +<div class="mo" id="mo-widerruf" onclick="if(event.target===this)closeModal('widerruf')"> +<div class="mb"><div class="mh"><h3>Widerrufsbelehrung</h3><button class="cl" onclick="closeModal('widerruf')">✕</button></div> +<div class="mbd"> +<h4>Widerrufsrecht (Deutschland — § 355 BGB)</h4> +<p>Sie können Ihre Buchung innerhalb von <strong>14 Tagen</strong> widerrufen, ohne Gründe anzugeben.</p> +<h4>Ausnahme (§ 312g Abs. 2 BGB)</h4> +<p>Bei Dienstleistungen, die bereits erbracht wurden (Massage durchgeführt), erlischt das Widerrufsrecht.</p> +<h4>Widerrufsrecht (
\ No newline at end of file @@ -1279,7 +1279,7 @@ vfs-import</pre> <tr><td><code>webdav-ls [path]</code></td><td>List remote files</td></tr> <tr><td><code>webdav-push <local> [remote]</code></td><td>Upload file to cloud</td></tr> <tr><td><code>webdav-pull <remote> [local]</code></td><td>Download file from cloud</td></tr> - <tr><td><code>webdav-browser</code></td><td>Open file-tree browser UI with checkboxes, context menu, inline rename</td></tr> + <tr><td><code>webdav-browser</code></td><td>Open file-tree browser UI with checkboxes, context menu, inline rename, per-file preview 👁 / open-in-new-tab ⛶ buttons, minimize-to-floating and share-URL header buttons</td></tr> <tr><td><code>webdav-rm <path></code></td><td>Delete remote file/directory</td></tr> <tr><td><code>webdav-mkdir <path></code></td><td>Create remote directory</td></tr> <tr><td><code>webdav-rename <old> <new></code></td><td>Rename remote file</td></tr> @@ -1176,7 +1176,10 @@ <div class="modal-content" style="max-width: 95%; max-height: 90vh; width: 95%;"> <div class="modal-header"> <span id="preview-title">📄 Preview</span> - <span style="cursor:pointer" onclick="webx11.closePreview()">✕</span> + <span style="display:flex;gap:10px;align-items:center"> + <button class="btn btn-sm" onclick="var f=document.getElementById('preview-iframe');if(f&&f.srcdoc){var w=window.open('','_blank');if(w){w.document.open();w.document.write(f.srcdoc);w.document.close();}}" title="Open in new tab">⛶</button> + <span style="cursor:pointer" onclick="webx11.closePreview()">✕</span> + </span> </div> <div class="modal-body" style="padding:0; overflow:hidden; height:75vh;"> <iframe id="preview-iframe" style="width:100%; height:100%; border:none; background:var(--bg-color);" sandbox="allow-same-origin allow-scripts"></iframe> diff --git a/modules/ext/webdav.js b/modules/ext/webdav.js index 464db16..a84eb12 100644 --- a/modules/ext/webdav.js +++ b/modules/ext/webdav.js @@ -32,8 +32,29 @@ class WebDAVModule { return ''; } + _extractSpaceNameFromUrl(url) { + try { + const u = new URL(url); + const parts = u.pathname.split('/').filter(p => p); + const idx = parts.indexOf('spaces'); + if (idx >= 0 && idx + 1 < parts.length) return parts[idx + 1]; + } catch (e) {} + return ''; + } + + _resolveSpaceName() { + // Prefer the configured URL's space, fall back to the page URL + if (this.config && this.config.url) { + const fromUrl = this._extractSpaceNameFromUrl(this.config.url); + if (fromUrl) return fromUrl; + } + return this._spaceName; + } + _detectSessionUser() { try { + const lsUser = localStorage.getItem('webx11_username'); + if (lsUser && lsUser.trim()) return lsUser.trim(); const cookies = document.cookie.split(';').map(c => c.trim()); for (const c of cookies) { if (c.startsWith('webx11_user=')) { @@ -44,12 +65,15 @@ class WebDAVModule { return null; } + _detectSessionToken() { + try { + return localStorage.getItem('webx11_webdav_token') || ''; + } catch (e) { return ''; } + } + _promptForToken(spaceName) { - // Use textarea+execCommand for mobile WebView compatibility const input = window.prompt('Enter your WebDAV token for space "' + spaceName + '":', ''); - if (input && input.trim()) { - return input.trim(); - } + if (input && input.trim()) return input.trim(); return null; } @@ -189,7 +213,8 @@ class WebDAVModule { } async _listDirViaAutoIndex(path) { - if (!this._spaceName) { + const spaceName = this._resolveSpaceName(); + if (!spaceName) { throw new Error('WebDAV: cannot use __list__ without space name'); } // Derive host from configured URL to support different environments @@ -202,7 +227,7 @@ class WebDAVModule { } const cleanPath = path ? (path.startsWith('/') ? path : '/' + path) : '/'; // Avoid double slash when path starts with / - const listUrl = host + '/spaces/' + this._spaceName + '/__list__' + (cleanPath === '/' ? '' : cleanPath.replace(/\/{2,}/g, '/')); + const listUrl = host + '/spaces/' + spaceName + '/__list__' + (cleanPath === '/' ? '' : cleanPath.replace(/\/{2,}/g, '/')); try { const resp = await fetch(listUrl, { method: 'GET' }); if (!resp.ok) throw new Error('Autoindex failed: HTTP ' + resp.status); @@ -216,11 +241,12 @@ class WebDAVModule { _parseAutoindex(html) { const entries = []; // Parse nginx autoindex HTML format — flexible regex that doesn't depend on date locale - const linkRegex = /<a href="([^"]+)">([^<]+)<\/a>\s+([\w]{2}-[\w]{3}-\d{4}\s+\d{2}:\d{2})\s+([\d\w.-]+)/g; + const linkRegex = /<a href="([^"]+)">([^\u003c]+)<\/a>\s+([\w]{2}-[\w]{3}-\d{4}\s+\d{2}:\d{2})\s+([\d\w.-]+)/g; let match; while ((match = linkRegex.exec(html)) !== null) { const href = match[1]; const name = match[2].replace(/\/$/, ''); + const dateStr = match[3]; const sizeStr = match[4]; if (name === '..' || name === '../' || name === '__list__') continue; const isDir = href.endsWith('/'); @@ -232,13 +258,14 @@ class WebDAVModule { name: name, type: isDir ? 'dir' : 'file', size: size, + mtime: this._parseNginxDate(dateStr), href: href }); } // Fallback: if regex failed, try simpler parse if (entries.length === 0) { // Try broadest: extract all hrefs - const broadRegex = /<a href="([^"]+)"[^>]*>/g; + const broadRegex = /<a href="([^"]+)"[^\u003e]*>/g; const names = []; while ((match = broadRegex.exec(html)) !== null) { const href = match[1]; @@ -253,6 +280,7 @@ class WebDAVModule { name: decodeURIComponent(href.replace(/\/$/, '').split('/').pop() || href), type: isDir ? 'dir' : 'file', size: 0, + mtime: null, href: href }); } @@ -268,6 +296,7 @@ class WebDAVModule { name: decodeURIComponent(href.replace(/\/$/, '').split('/').pop() || href), type: isDir ? 'dir' : 'file', size: 0, + mtime: null, href: href }); } @@ -275,7 +304,21 @@ class WebDAVModule { } return entries; } - + + _parseNginxDate(dateStr) { + if (!dateStr) return null; + // nginx default: 02-Aug-2026 14:30 + try { + const months = { 'Jan':0,'Feb':1,'Mar':2,'Apr':3,'May':4,'Jun':5,'Jul':6,'Aug':7,'Sep':8,'Oct':9,'Nov':10,'Dec':11 }; + const m = dateStr.match(/^(\d{2})-([A-Za-z]{3})-(\d{4})\s+(\d{2}):(\d{2})$/); + if (m) { + const d = new Date(Date.UTC(parseInt(m[3]), months[m[2]], parseInt(m[1]), parseInt(m[4]), parseInt(m[5]))); + if (!isNaN(d.getTime())) return d.toISOString(); + } + } catch (e) {} + return null; + } + _parsePropfind(xml) { const parser = new DOMParser(); const doc = parser.parseFromString(xml, 'text/xml'); @@ -301,31 +344,24 @@ class WebDAVModule { } async _ensureDir(remotePath) { - // Try MKCOL — ignore 405 (already exists) and 409 (parent missing) + // Try MKCOL — ignore 405 (already exists) const response = await this._webdavRequest('MKCOL', remotePath); if (response.ok || response.status === 405 || response.status === 301 || response.status === 302) return true; if (response.status === 409) { - // Parent missing — try creating parent recursively - const parts = remotePath.replace(/\/$/, '').split('/').filter(Boolean); - if (parts.length > 1) { - const parent = parts.slice(0, -1).join('/'); - await this._ensureDir(parent); - // Retry MKCOL - const retry = await this._webdavRequest('MKCOL', remotePath); - if (retry.ok || retry.status === 405) return true; - } - // Try creating path component by component as a fallback + // Parent missing — create each missing component once, bottom-up. + // (Previously this recursed + retried + looped, producing an MKCOL + // storm of 6-8 requests per call that delayed uploads.) + const parts = remotePath.replace(/\/+$/, '').split('/').filter(Boolean); let current = ''; + let created = false; for (const part of parts) { current += '/' + part; try { const attempt = await this._webdavRequest('MKCOL', current); - if (!attempt.ok && attempt.status !== 405) { - // Ignore — it likely exists already or we can't create it - } - } catch (e) { /* ignore */ } + if (attempt.ok || attempt.status === 405) created = true; + } catch (e) { /* ignore — component may exist */ } } - return true; + return created; } return false; } @@ -398,17 +434,44 @@ class WebDAVModule { // --- webdav-config: set WebDAV endpoint and credentials --- this.commands['webdav-config'] = async (args) => { - // No args: auto-detect space name and try to configure from token + const autoUrlFor = (space) => 'https://webx11.duckdns.org/spaces/' + space + '/'; + + // No args: prefer logged-in user's space, fall back to URL space if (!args || args.length === 0) { + const sessionUser = self._detectSessionUser(); + const sessionToken = self._detectSessionToken(); + + if (sessionUser && sessionToken) { + // Auto-mount the logged-in user's own space + const autoUrl = autoUrlFor(sessionUser); + self.config = { + url: autoUrl, + username: sessionUser, + password: sessionToken + }; + await self._saveConfig(); + self._autoConfigured = true; + self.terminal.print('Auto-configured WebDAV for logged-in user: ' + sessionUser, 'success'); + try { + const resp = await self._webdavRequest('OPTIONS', '/'); + self.terminal.print('Connected: ' + resp.status + ' ' + resp.statusText, 'success'); + self.terminal.print('WebDAV configured: ' + self.config.url, 'success'); + } catch (e) { + self.terminal.print('Connection test failed: ' + e.message, 'warning'); + } + return; + } + const spaceName = self._spaceName; if (!spaceName) { - self.terminal.print('Could not detect space name from URL.', 'warning'); + self.terminal.print('Could not detect user or space from URL.', 'warning'); self.terminal.print('Usage: webdav-config <url> [username] [password]', 'info'); return; } - // Auto-configure URL from space name - const autoUrl = 'https://webx11.duckdns.org/spaces/' + spaceName + '/'; + // Not logged in: pre-fill URL/username for this space and ask for token + const sessionUserOther = self._detectSessionUser(); + const autoUrl = autoUrlFor(spaceName); let token = null; // Check SecretVault first @@ -426,32 +489,6 @@ class WebDAVModule { token = localStorage.getItem('webx11_spaces_' + spaceName); } - // If still no token, use session-aware prompting - if (!token) { - const sessionUser = self._detectSessionUser(); - if (sessionUser && sessionUser === spaceName) { - self.terminal.print('👋 Welcome back, ' + sessionUser + '!', 'success'); - self.terminal.print('No saved WebDAV config found. Enter your token:', 'info'); - } else if (sessionUser) { - self.terminal.print('👋 You are logged in as "' + sessionUser + '" — this space is "' + spaceName + '".', 'info'); - self.terminal.print('Enter the WebDAV token for space "' + spaceName + '":', 'info'); - } else { - self.terminal.print('No saved WebDAV config found for space "' + spaceName + '".', 'info'); - self.terminal.print('Enter the WebDAV token (get it from the WebX11 dashboard):', 'info'); - } - self.terminal.print('', 'text-muted'); - self.terminal.print(' webdav-config ' + autoUrl + ' ' + spaceName + ' <your-token>', 'success'); - self.terminal.print('', 'text-muted'); - // Pre-configure URL + username so user only needs token - self.config = { - url: autoUrl, - username: spaceName, - password: '' - }; - await self._saveConfig(); - return; - } - if (token) { self.config = { url: autoUrl, @@ -470,18 +507,21 @@ class WebDAVModule { self.terminal.print('Config saved but connection may need CORS or network access.', 'warning'); } } else { - self.terminal.print('No WebDAV token found for space: ' + spaceName, 'warning'); - self.terminal.print('Get your token from the WebX11 dashboard.', 'info'); - self.terminal.print('Auto-setting URL: ' + autoUrl, 'info'); - self.terminal.print('Then configure with: webdav-config ' + autoUrl + ' ' + spaceName + ' <your-token>', 'info'); - // At minimum set the URL + if (sessionUserOther) { + self.terminal.print('👋 You are logged in as "' + sessionUserOther + '" — this space is "' + spaceName + '".', 'info'); + } else { + self.terminal.print('Not logged in. Please enter the WebDAV token for space "' + spaceName + '":', 'info'); + } + self.terminal.print('', 'text-muted'); + self.terminal.print(' webdav-config ' + autoUrl + ' ' + spaceName + ' <your-token>', 'success'); + self.terminal.print('', 'text-muted'); + // Pre-configure URL + username so user only needs token self.config = { url: autoUrl, username: spaceName, password: '' }; await self._saveConfig(); - self._autoConfigured = true; } return; } @@ -793,7 +833,7 @@ class WebDAVModule { // --- webdav-browser: modern visual file browser --- this.commands['webdav-browser'] = async (args) => { - const startPath = args[0] || self._browsingPath || '/'; + const startPath = (args && args[0]) || self._browsingPath || '/'; await self.openWebdavBrowser(startPath); }; @@ -860,6 +900,50 @@ class WebDAVModule { /* ── Modern WebDAV File-Tree Browser with Bulk Ops ── */ + _getFileIcon(name) { + if (!name) return '📄'; + const ext = name.split('.').pop().toLowerCase(); + const icons = { + html: '🌐', htm: '🌐', css: '🎨', js: '📜', json: '📋', yaml: '📋', yml: '📋', + ts: '📘', py: '🐍', sh: '🔧', md: '📝', txt: '📄', + png: '🖼️', jpg: '🖼️', jpeg: '🖼️', gif: '🖼️', svg: '🖼️', webp: '🖼️', + mp4: '🎬', webm: '🎬', mp3: '🎵', wav: '🎵', ogg: '🎵', + pdf: '📕', doc: '📘', docx: '📘', xls: '📗', xlsx: '📗', + zip: '📦', tar: '📦', gz: '📦', rar: '📦', '7z': '📦', + php: '🌐', sql: '🗃️', db: '🗃️', log: '📋' + }; + return icons[ext] || '📄'; + } + + _formatDate(iso) { + if (!iso) return ''; + try { + const d = new Date(iso); + return d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }); + } catch (e) { return iso; } + } + + _escapeHtml(text) { + return (text || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); + } + + async _createRemoteFile(remotePath) { + try { + const parentDir = remotePath.split('/').slice(0, -1).join('/') || '/'; + await this._ensureDir(parentDir); + const resp = await this._webdavRequest('PUT', remotePath, { + body: '', + headers: { 'Content-Type': 'text/plain' } + }); + return resp.ok || resp.status === 201 || resp.status === 204; + } catch (e) { + this.terminal.print('Create file error: ' + e.message, 'error'); + return false; + } + } + + /* ── Modern WebDAV File-Tree Browser with Bulk Ops ── */ + async openWebdavBrowser(path) { if (!this.config || !this.config.url) { if (this.terminal) this.terminal.print('WebDAV not configured. Use webdav-config first.', 'warning'); @@ -868,89 +952,360 @@ class WebDAVModule { this._browsingPath = path; const self = this; this._selected = {}; + this._browserSort = this._browserSort || { key: 'name', dir: 'asc' }; + this._browserFilter = this._browserFilter || ''; // Remove existing modal if any const existing = document.getElementById('webdav-browser-modal'); if (existing) existing.remove(); // Fetch listing - let entries = []; + let allEntries = []; let errorMsg = ''; try { - entries = await this._listDir(path); + allEntries = await this._listDir(path); } catch (e) { errorMsg = e.message; } - // Sort: dirs first, then files, alphabetically - entries.sort(function(a, b) { - if (a.type !== b.type) return a.type === 'dir' ? -1 : 1; - return a.name.localeCompare(b.name); - }); + // Hide dotfiles + allEntries = allEntries.filter(function(e) { return !e.name.startsWith('.'); }); + + if (errorMsg) { + const errOverlay = document.createElement('div'); + errOverlay.id = 'webdav-browser-modal'; + errOverlay.className = 'modal-overlay'; + errOverlay.style.cssText = 'display:flex; justify-content:center; align-items:center; z-index:9999; padding:0;'; + errOverlay.onclick = function(e) { if (e.target === this) this.remove(); }; + const errContent = document.createElement('div'); + errContent.className = 'modal-content'; + errContent.style.cssText = 'width:100vw; height:100vh; max-width:none; max-height:none; display:flex; flex-direction:column; overflow:hidden; border-radius:0;'; + const errDiv = document.createElement('div'); + errDiv.style.cssText = 'color:var(--error-color); padding:40px; text-align:center; font-size:1.05em;'; + errDiv.textContent = '❌ ' + errorMsg; + const errFooter = document.createElement('div'); + errFooter.className = 'modal-footer'; + errFooter.style.cssText = 'display:flex; justify-content:center; align-items:center; gap:8px; padding:12px; border-top:1px solid var(--border);'; + const backBtn = document.createElement('button'); + backBtn.className = 'btn'; + backBtn.textContent = '⬅ Back'; + backBtn.style.cssText = 'font-size:0.9em; padding:6px 12px;'; + backBtn.onclick = function() { + const parentPath = path.replace(/\/[^\/]*\/?$/, '') || '/'; + self.openWebdavBrowser(parentPath); + }; + const closeBtn = document.createElement('button'); + closeBtn.className = 'btn'; + closeBtn.textContent = 'Close'; + closeBtn.style.cssText = 'font-size:0.9em; padding:6px 12px;'; + closeBtn.onclick = function() { self.closeWebdavBrowser(); }; + errFooter.appendChild(backBtn); + errFooter.appendChild(closeBtn); + errContent.appendChild(errDiv); + errContent.appendChild(errFooter); + errOverlay.appendChild(errContent); + document.body.appendChild(errOverlay); + this.terminal.print('❌ WebDAV browser error: ' + errorMsg, 'error'); + return; + } + + // Sort: dirs first, then by current sort key + function sortEntries(entries) { + const sortKey = self._browserSort.key; + const dir = self._browserSort.dir; + return entries.slice().sort(function(a, b) { + if (a.type !== b.type) return a.type === 'dir' ? -1 : 1; + let cmp = 0; + if (sortKey === 'name') cmp = a.name.localeCompare(b.name); + else if (sortKey === 'size') cmp = (a.size || 0) - (b.size || 0); + else if (sortKey === 'date') cmp = new Date(a.mtime || 0) - new Date(b.mtime || 0); + if (cmp === 0) cmp = a.name.localeCompare(b.name); + return dir === 'asc' ? cmp : -cmp; + }); + } + + function filterEntries(entries) { + const f = (self._browserFilter || '').toLowerCase(); + if (!f) return entries; + return entries.filter(function(e) { return e.name.toLowerCase().indexOf(f) !== -1; }); + } + + function getEntries() { + return sortEntries(filterEntries(allEntries)); + } // Build modal const overlay = document.createElement('div'); overlay.id = 'webdav-browser-modal'; overlay.className = 'modal-overlay'; - overlay.style.cssText = 'display:flex; justify-content:center; align-items:center; z-index:999;'; - overlay.onclick = function(e) { if (e.target === this) self.closeWebdavBrowser(); }; + overlay.style.cssText = 'display:flex; justify-content:center; align-items:center; z-index:9999; padding:12px;'; + overlay.style.cssText = 'display:flex; justify-content:center; align-items:center; z-index:9999; padding:0;'; const content = document.createElement('div'); content.className = 'modal-content'; - content.style.cssText = 'max-width:750px; max-height:85vh; width:94%;'; + content.style.cssText = 'width:100vw; height:100vh; max-width:none; max-height:none; display:flex; flex-direction:column; overflow:hidden; border-radius:0;'; // Header const header = document.createElement('div'); header.className = 'modal-header'; - header.innerHTML = '<span>📁 WebDAV File Tree</span>' + - '<span style="display:flex;gap:6px;align-items:center">' + - '<span id="sel-count" style="font-size:0.8em;color:var(--text-muted)"></span>' + - '<span style="cursor:pointer" onclick="var m=document.getElementById(\'webdav-browser-modal\');if(m)m.remove();var t=window.webx11;if(t&&t.inputField)t.inputField.focus();">✕</span></span>'; + header.style.cssText = 'display:flex; justify-content:space-between; align-items:center; gap:14px; flex-wrap:wrap; padding:12px 14px;'; + header.innerHTML = '<span style="font-size:1.2em;">📁 WebDAV File Browser</span>' + + '<span style="display:flex;gap:8px;align-items:center">' + + '<span id="sel-count" style="font-size:0.9em;color:var(--text-muted)"></span>' + + '<button id="wdav-minimize" class="btn btn-sm" title="Minimize to floating button" style="font-size:0.85em;padding:3px 8px;">—</button>' + + '<button id="wdav-share" class="btn btn-sm" title="Share folder URL" style="font-size:0.85em;padding:3px 8px;">🔗</button>' + + '<span style="cursor:pointer;font-size:1.1em;" onclick="var m=document.getElementById(\'webdav-browser-modal\');if(m)m.remove();var f=document.getElementById(\'wdav-floating-restore\');if(f)f.remove();var t=window.webx11;if(t&&t.inputField)t.inputField.focus();">✕</span></span>'; content.appendChild(header); - // Body - const body = document.createElement('div'); - body.className = 'modal-body'; - body.style.cssText = 'padding:0; overflow-y:auto; flex-grow:1;'; + // Toolbar - if (errorMsg) { - const errDiv = document.createElement('div'); - errDiv.style.cssText = 'color:var(--error-color); padding:16px; text-align:center;'; - errDiv.textContent = '❌ ' + errorMsg; - body.appendChild(errDiv); - } else { - // Select-all row - const selectAllRow = document.createElement('div'); - selectAllRow.style.cssText = 'display:flex;align-items:center;padding:6px 10px;border-bottom:1px solid #222;font-size:0.8em;color:var(--text-muted);gap:8px;'; - const selAllCheck = document.createElement('input'); - selAllCheck.type = 'checkbox'; - selAllCheck.style.cssText = 'accent-color:var(--success-color);width:16px;height:16px;cursor:pointer;'; - selAllCheck.onchange = function() { - const checked = this.checked; - entries.forEach(function(e) { self._selected[e.name] = checked; }); - document.querySelectorAll('.wdav-checkbox').forEach(function(c) { c.checked = checked; }); - self._updateSelectionCount(); + const toolbar = document.createElement('div'); + toolbar.style.cssText = 'display:flex; gap:10px; align-items:center; padding:10px 14px; border-bottom:1px solid var(--border); flex-wrap:wrap; background:var(--bg-tertiary);'; + + // Breadcrumb + const breadDiv = document.createElement('div'); + breadDiv.id = 'wdav-breadcrumb'; + breadDiv.style.cssText = 'font-size:0.95em; color:var(--info-color); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; min-width:160px;'; + function renderBreadcrumb() { + const cleanPath = path.replace(/\/+$/, '') || '/'; + const parts = cleanPath.split('/').filter(Boolean); + let html = '📁 <span style="cursor:pointer;text-decoration:underline;" onclick="var m=document.getElementById(\'webdav-browser-modal\');if(m)m.__nav(\'/\');return false;">~\u003c/span>'; + let cumulative = ''; + for (let i = 0; i < parts.length; i++) { + cumulative += '/' + parts[i]; + html += ' <span style="color:var(--text-muted);font-size:0.85em;">▸</span> '; + html += '<span style="cursor:pointer;text-decoration:underline;" onclick="var m=document.getElementById(\'webdav-browser-modal\');if(m)m.__nav(\'' + cumulative.replace(/'/g, "\\'") + '/\');return false;">' + parts[i] + '</span>'; + } + breadDiv.innerHTML = html; + } + renderBreadcrumb(); + toolbar.appendChild(breadDiv); + + // Search + const searchInput = document.createElement('input'); + searchInput.type = 'text'; + searchInput.placeholder = '🔍 Search...'; + searchInput.value = self._browserFilter; + searchInput.style.cssText = 'background:var(--bg-secondary);color:var(--text-primary);border:1px solid var(--border);border-radius:6px;padding:6px 12px;font-size:0.95em;min-width:160px;flex:1;'; + searchInput.oninput = function() { + self._browserFilter = this.value; + renderList(); + }; + toolbar.appendChild(searchInput); + + // Sort buttons + function makeSortBtn(key, label) { + const btn = document.createElement('button'); + btn.className = 'btn btn-sm'; + btn.style.cssText = 'font-size:0.85em; padding:5px 10px;'; + function update() { + const active = self._browserSort.key === key; + const arrow = active ? (self._browserSort.dir === 'asc' ? ' ▾' : ' ▴') : ''; + btn.textContent = label + arrow; + btn.style.background = active ? 'var(--accent)' : 'var(--bg-secondary)'; + btn.style.color = active ? '#000' : 'var(--text-primary)'; + } + btn.onclick = function() { + if (self._browserSort.key === key) { + self._browserSort.dir = self._browserSort.dir === 'asc' ? 'desc' : 'asc'; + } else { + self._browserSort.key = key; + self._browserSort.dir = 'asc'; + } + updateSortBtns(); + renderList(); }; - selectAllRow.appendChild(selAllCheck); - selectAllRow.appendChild(document.createTextNode('Select All (' + entries.length + ' items)')); - body.appendChild(selectAllRow); + btn.update = update; + return btn; + } + const sortName = makeSortBtn('name', 'Name'); + const sortDate = makeSortBtn('date', 'Date'); + const sortSize = makeSortBtn('size', 'Size'); + toolbar.appendChild(sortName); + toolbar.appendChild(sortDate); + toolbar.appendChild(sortSize); + + function updateSortBtns() { + sortName.update(); + sortDate.update(); + sortSize.update(); + } + updateSortBtns(); + + // View toggle: list vs tree (future) — placeholder for layout parity with dashboard + const listViewBtn = document.createElement('button'); + listViewBtn.className = 'btn btn-sm'; + listViewBtn.textContent = '📋'; + listViewBtn.title = 'List view'; + listViewBtn.style.cssText = 'font-size:0.85em; padding:5px 10px;'; + toolbar.appendChild(listViewBtn); + + content.appendChild(toolbar); + + // Main body: file list + preview side-by-side on wide screens + const mainBody = document.createElement('div'); + mainBody.style.cssText = 'display:flex; flex:1; min-height:0; overflow:hidden;'; + + const filePane = document.createElement('div'); + filePane.style.cssText = 'flex:1; min-width:0; min-height:0; overflow-y:auto; overflow-x:hidden;'; + + const listContainer = document.createElement('div'); + listContainer.id = 'wdav-list-container'; + + // Select-all row + const selectAllRow = document.createElement('div'); + selectAllRow.style.cssText = 'display:flex;align-items:center;padding:8px 14px;border-bottom:1px solid var(--border);font-size:0.9em;color:var(--text-muted);gap:10px;position:sticky;top:0;background:var(--bg-tertiary);z-index:2;'; + const selAllCheck = document.createElement('input'); + selAllCheck.type = 'checkbox'; + selAllCheck.style.cssText = 'accent-color:var(--success-color);width:18px;height:18px;cursor:pointer;'; + selAllCheck.onchange = function() { + const checked = this.checked; + getEntries().forEach(function(e) { self._selected[e.name] = checked; }); + renderList(); + self._updateSelectionCount(); + }; + selectAllRow.appendChild(selAllCheck); + const selAllLabel = document.createElement('span'); + selAllLabel.id = 'wdav-select-all-label'; + selAllLabel.textContent = 'Select All'; + selectAllRow.appendChild(selAllLabel); + listContainer.appendChild(selectAllRow); + + const fileListDiv = document.createElement('div'); + fileListDiv.id = 'wdav-file-list'; + listContainer.appendChild(fileListDiv); + filePane.appendChild(listContainer); + mainBody.appendChild(filePane); + content.appendChild(mainBody); + + // Bulk action bar + const bulkBar = document.createElement('div'); + bulkBar.id = 'wdav-bulk-bar'; + bulkBar.style.cssText = 'display:none;padding:10px 14px;border-top:1px solid var(--success-color);background:var(--bg-tertiary);gap:8px;align-items:center;flex-wrap:wrap;'; + bulkBar.innerHTML = '<span id="bulk-count" style="font-size:0.95em;color:var(--text-color);flex:1;">0 selected</span>' + + '<button class="btn" id="bulk-delete" style="font-size:0.9em;padding:5px 12px;">🗑 Delete</button>' + + '<button class="btn" id="bulk-move" style="font-size:0.9em;padding:5px 12px;">📦 Move</button>' + + '<button class="btn" id="bulk-download" style="font-size:0.9em;padding:5px 12px;">⬇ Download</button>'; + content.appendChild(bulkBar); + + // Footer buttons + const footer = document.createElement('div'); + footer.className = 'modal-footer'; + footer.style.cssText = 'display:flex; justify-content:flex-start; align-items:center; gap:8px; flex-wrap:wrap; padding:10px 14px; border-top:1px solid var(--border);'; + + function makeFootBtn(label, title, onClick) { + const btn = document.createElement('button'); + btn.className = 'btn'; + btn.textContent = label; + btn.title = title; + btn.style.cssText = 'font-size:0.9em; padding:6px 12px;'; + btn.onclick = onClick; + return btn; + } + + footer.appendChild(makeFootBtn('📁+ New Folder', 'Create directory', function() { + const name = prompt('Directory name:'); + if (name) { + self._ensureDir((path.replace(/\/+$/, '') || '') + '/' + name).then(function(ok) { + if (ok) { self.terminal.print('Created: ' + name, 'success'); self.openWebdavBrowser(path); } + else self.terminal.print('Failed to create directory', 'error'); + }); + } + })); + + footer.appendChild(makeFootBtn('📄 New File', 'Create empty file', function() { + const name = prompt('File name:'); + if (name) { + self._createRemoteFile((path.replace(/\/+$/, '') || '') + '/' + name).then(function(ok) { + if (ok) { self.terminal.print('Created: ' + name, 'success'); self.openWebdavBrowser(path); } + else self.terminal.print('Failed to create file', 'error'); + }); + } + })); + + footer.appendChild(makeFootBtn('⬆ Upload', 'Upload file', function() { self.uploadToWebdav(path); })); + footer.appendChild(makeFootBtn('🔄 Refresh', 'Refresh listing', function() { self.openWebdavBrowser(path); })); + footer.appendChild(makeFootBtn('Close', 'Close browser', function() { self.closeWebdavBrowser(); })); + + content.appendChild(footer); + + overlay.appendChild(content); + document.body.appendChild(overlay); + overlay.__nav = function(p) { self.openWebdavBrowser(p); }; + + // Minimize: collapse modal into a floating restore button + document.getElementById('wdav-minimize').onclick = function() { + overlay.style.display = 'none'; + var fl = document.getElementById('wdav-floating-restore'); + if (!fl) { + fl = document.createElement('div'); + fl.id = 'wdav-floating-restore'; + fl.textContent = '📁'; + fl.title = 'Restore WebDAV browser (' + path + ')'; + fl.style.cssText = 'position:fixed; right:16px; bottom:16px; z-index:10002; width:52px; height:52px; border-radius:50%; background:var(--accent,#4a9eff); color:#000; display:flex; align-items:center; justify-content:center; font-size:22px; cursor:pointer; box-shadow:0 4px 14px rgba(0,0,0,0.5);'; + fl.onclick = function() { + overlay.style.display = 'flex'; + fl.remove(); + self.openWebdavBrowser(path); + }; + document.body.appendChild(fl); + } + if (window.webx11 && window.webx11.inputField) window.webx11.inputField.focus(); + }; + + // Share: copy direct folder URL to clipboard + document.getElementById('wdav-share').onclick = function() { + const spaceName = self._resolveSpaceName(); + const cleanPath = (path || '/').replace(/\/+$/, '') + '/'; + const shareUrl = 'https://webx11.duckdns.org/spaces/' + (spaceName || '') + cleanPath; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(shareUrl).then(function() { + self.terminal.print('🔗 Shared URL copied: ' + shareUrl, 'success'); + }, function() { + self.terminal.print('🔗 Share URL: ' + shareUrl, 'info'); + }); + } else { + self.terminal.print('🔗 Share URL: ' + shareUrl, 'info'); + } + }; + + function renderList() { + const entries = getEntries(); + fileListDiv.innerHTML = ''; + selAllLabel.textContent = 'Select All (' + entries.length + ' items)'; + + // Parent ".." row + if (path !== '/' && path !== '') { + const parentPath = path.replace(/\/[^\/]*\/?$/, '') || '/'; + const parentRow = document.createElement('div'); + parentRow.style.cssText = 'display:flex;align-items:center;padding:9px 14px;border-bottom:1px solid var(--border);cursor:pointer;min-height:44px;gap:10px;'; + parentRow.onmouseover = function() { this.style.background = 'var(--bg-tertiary)'; }; + parentRow.onmouseout = function() { this.style.background = ''; }; + parentRow.innerHTML = '<span style="flex-shrink:0;font-size:1.35em;">📁</span><span style="flex:1;font-size:1em;">..</span>'; + parentRow.onclick = function() { self.openWebdavBrowser(parentPath); }; + fileListDiv.appendChild(parentRow); + } + + if (entries.length === 0) { + const empty = document.createElement('div'); + empty.style.cssText = 'text-align:center;padding:50px;color:var(--text-secondary);font-size:1.05em;'; + empty.textContent = 'Empty directory'; + fileListDiv.appendChild(empty); + return; + } - // File rows - const listContainer = document.createElement('div'); - let rowIndex = 0; entries.forEach(function(entry) { - var row = document.createElement('div'); + const row = document.createElement('div'); row.className = 'wdav-row'; - row.style.cssText = 'display:flex;align-items:center;padding:7px 10px;border-bottom:1px solid #1a1a1a;cursor:default;min-height:38px;gap:8px;'; + row.style.cssText = 'display:flex;align-items:center;padding:9px 14px;border-bottom:1px solid var(--border);cursor:default;min-height:44px;gap:10px;'; row.onmouseover = function() { this.style.background = 'var(--bg-tertiary)'; }; row.onmouseout = function() { this.style.background = ''; }; - row.dataset.index = rowIndex++; - // Checkbox - var cb = document.createElement('input'); + const cb = document.createElement('input'); cb.type = 'checkbox'; cb.className = 'wdav-checkbox'; - cb.style.cssText = 'accent-color:var(--success-color);width:15px;height:15px;cursor:pointer;flex-shrink:0;'; + cb.style.cssText = 'accent-color:var(--success-color);width:18px;height:18px;cursor:pointer;flex-shrink:0;'; + cb.checked = !!self._selected[entry.name]; cb.onchange = function(e) { self._selected[entry.name] = this.checked; self._updateSelectionCount(); @@ -958,28 +1313,36 @@ class WebDAVModule { }; row.appendChild(cb); - // Icon - var iconSpan = document.createElement('span'); - iconSpan.textContent = entry.type === 'dir' ? '📁' : '📄'; - iconSpan.style.cssText = 'flex-shrink:0;font-size:1.1em;'; + const iconSpan = document.createElement('span'); + iconSpan.textContent = entry.type === 'dir' ? '📁' : self._getFileIcon(entry.name); + iconSpan.style.cssText = 'flex-shrink:0;font-size:1.35em;'; - // Filename (editable on dblclick) - var nameSpan = document.createElement('span'); + const nameSpan = document.createElement('span'); nameSpan.textContent = entry.name + (entry.type === 'dir' ? '/' : ''); - nameSpan.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:0.9em;cursor:pointer;'; + nameSpan.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:1em;cursor:pointer;'; nameSpan.title = entry.name; + + const metaSpan = document.createElement('span'); + metaSpan.style.cssText = 'font-size:0.8em;color:var(--text-muted);flex-shrink:0;min-width:130px;text-align:right;display:flex;flex-direction:column;'; + if (entry.type === 'file') { + metaSpan.innerHTML = '<span>' + self._formatSize(entry.size) + '</span>' + (entry.mtime ? '<span>' + self._formatDate(entry.mtime) + '</span>' : ''); + } else if (entry.mtime) { + metaSpan.innerHTML = '<span>' + self._formatDate(entry.mtime) + '</span>'; + } + + // Inline rename on double click nameSpan.ondblclick = function(e) { e.stopPropagation(); - var input = document.createElement('input'); + const input = document.createElement('input'); input.type = 'text'; input.value = entry.name; - input.style.cssText = 'width:100%;background:#1a1a1a;border:1px solid var(--success-color);color:var(--text-color);padding:2px 6px;border-radius:3px;font-size:0.9em;'; + input.style.cssText = 'width:100%;background:var(--bg-secondary);border:1px solid var(--success-color);color:var(--text-color);padding:3px 8px;border-radius:3px;font-size:0.95em;'; input.onblur = function() { - var newName = this.value.trim(); + const newName = this.value.trim(); if (newName && newName !== entry.name) { - var parentPath = path.replace(/\/+$/, '') || ''; - var oldFull = parentPath + '/' + entry.name; - var newFull = parentPath + '/' + newName; + const parentPath = path.replace(/\/+$/, '') || ''; + const oldFull = parentPath + '/' + entry.name; + const newFull = parentPath + '/' + newName; self._webdavRequest('MOVE', oldFull, { headers: { 'Destination': newFull } }) .then(function(r) { if (r.ok || r.status === 201 || r.status === 204) { @@ -1003,27 +1366,53 @@ class WebDAVModule { input.focus(); input.select(); }; - // Click = navigate dir / preview file + + // Click = navigate dir if (entry.type === 'dir') { row.onclick = function(e) { - if (e.target.type === 'checkbox') return; - self.openWebdavBrowser(entry.href); + if (e.target.type === 'checkbox' || e.target.closest('.wdav-preview-btn')) return; + // nginx autoindex hrefs are relative to the CURRENT dir — resolve against path + let target = entry.href; + if (target && !target.startsWith('/')) { + target = (path.replace(/\/+$/, '') || '') + '/' + target; + } + self.openWebdavBrowser(target); }; } else { row.onclick = function(e) { - if (e.target.type === 'checkbox') return; + if (e.target.type === 'checkbox' || e.target.closest('.wdav-preview-btn')) return; self.previewWebdavFile(entry); }; } - // Size - var sizeSpan = document.createElement('span'); - sizeSpan.style.cssText = 'font-size:0.75em;color:var(--text-muted);flex-shrink:0;min-width:60px;text-align:right;'; - if (entry.type === 'file') { - sizeSpan.textContent = self._formatSize(entry.size); - } + // Preview button next to file + const previewBtn = document.createElement('button'); + previewBtn.className = 'btn btn-sm wdav-preview-btn'; + previewBtn.textContent = '👁'; + previewBtn.title = 'Preview ' + entry.name; + previewBtn.style.cssText = 'font-size:0.85em; padding:3px 8px; flex-shrink:0; display:' + (entry.type === 'file' ? 'inline-block' : 'none') + ';'; + previewBtn.onclick = function(e) { + e.stopPropagation(); + self.previewWebdavFile(entry); + }; + + // Open in new tab button next to file + const newTabBtn = document.createElement('button'); + newTabBtn.className = 'btn btn-sm wdav-newtab-btn'; + newTabBtn.textContent = '⛶'; + newTabBtn.title = 'Open ' + entry.name + ' in new tab'; + newTabBtn.style.cssText = 'font-size:0.85em; padding:3px 8px; flex-shrink:0; display:' + (entry.type === 'file' ? 'inline-block' : 'none') + ';'; + newTabBtn.onclick = function(e) { + e.stopPropagation(); + self.previewInNewTab(entry); + }; + + // Double-click downloads files like dashboard + row.ondblclick = function(e) { + if (entry.type !== 'file' || e.target.type === 'checkbox' || e.target.tagName === 'INPUT' || e.target.closest('.wdav-preview-btn')) return; + self.downloadFromWebdav(entry); + }; - // Context menu on right-click row.oncontextmenu = function(e) { e.preventDefault(); self._showContextMenu(e, entry, path); @@ -1031,66 +1420,47 @@ class WebDAVModule { row.appendChild(iconSpan); row.appendChild(nameSpan); - row.appendChild(sizeSpan); - listContainer.appendChild(row); + row.appendChild(previewBtn); + row.appendChild(newTabBtn); + row.appendChild(metaSpan); + fileListDiv.appendChild(row); }); - body.appendChild(listContainer); - - // Keyboard navigation - body.tabIndex = -1; - body.style.outline = 'none'; - var focusIdx = 0; - body.onkeydown = function(e) { - var rows = listContainer.querySelectorAll('.wdav-row'); - if (e.key === 'ArrowDown' || e.key === 'j') { - e.preventDefault(); - focusIdx = Math.min(focusIdx + 1, rows.length - 1); - rows[focusIdx].scrollIntoView({ block: 'nearest' }); - rows.forEach(function(r, i) { r.style.borderLeft = i === focusIdx ? '2px solid var(--success-color)' : ''; }); - } else if (e.key === 'ArrowUp' || e.key === 'k') { - e.preventDefault(); - focusIdx = Math.max(focusIdx - 1, 0); - rows[focusIdx].scrollIntoView({ block: 'nearest' }); - rows.forEach(function(r, i) { r.style.borderLeft = i === focusIdx ? '2px solid var(--success-color)' : ''; }); - } else if (e.key === ' ') { - e.preventDefault(); - var cb = rows[focusIdx]?.querySelector('.wdav-checkbox'); - if (cb) { cb.checked = !cb.checked; cb.onchange({ stopPropagation: function(){} }); } - } else if (e.key === 'Enter') { - e.preventDefault(); - rows[focusIdx]?.click(); - } - }; - setTimeout(function() { body.focus(); }, 100); } - content.appendChild(body); - - // Bulk action bar - const bulkBar = document.createElement('div'); - bulkBar.id = 'wdav-bulk-bar'; - bulkBar.style.cssText = 'display:none;padding:8px 10px;border-top:1px solid var(--success-color);background:var(--bg-tertiary);gap:6px;align-items:center;flex-wrap:wrap;'; - bulkBar.innerHTML = '<span id="bulk-count" style="font-size:0.85em;color:var(--text-color);flex:1;">0 selected</span>' + - '<button class="btn" id="bulk-delete" style="font-size:0.8em;padding:3px 10px;">🗑 Delete</button>' + - '<button class="btn" id="bulk-move" style="font-size:0.8em;padding:3px 10px;">📦 Move</button>' + - '<button class="btn" id="bulk-download" style="font-size:0.8em;padding:3px 10px;">⬇ Download</button>'; - self._updateSelectionCount = function() { - var count = Object.values(self._selected).filter(Boolean).length; - var el = document.getElementById('sel-count'); - if (el) el.textContent = count > 0 ? count + ' selected' : ''; - var bar = document.getElementById('wdav-bulk-bar'); - var cnt = document.getElementById('bulk-count'); - if (bar) bar.style.display = count > 0 ? 'flex' : 'none'; - if (cnt) cnt.textContent = count + ' selected'; + // Keyboard navigation + filePane.tabIndex = -1; + filePane.style.outline = 'none'; + let focusIdx = 0; + filePane.onkeydown = function(e) { + const rows = fileListDiv.querySelectorAll('.wdav-row'); + if (e.key === 'ArrowDown' || e.key === 'j') { + e.preventDefault(); + focusIdx = Math.min(focusIdx + 1, rows.length - 1); + rows[focusIdx].scrollIntoView({ block: 'nearest' }); + rows.forEach(function(r, i) { r.style.borderLeft = i === focusIdx ? '2px solid var(--success-color)' : ''; }); + } else if (e.key === 'ArrowUp' || e.key === 'k') { + e.preventDefault(); + focusIdx = Math.max(focusIdx - 1, 0); + rows[focusIdx].scrollIntoView({ block: 'nearest' }); + rows.forEach(function(r, i) { r.style.borderLeft = i === focusIdx ? '2px solid var(--success-color)' : ''; }); + } else if (e.key === ' ') { + e.preventDefault(); + const cb = rows[focusIdx]?.querySelector('.wdav-checkbox'); + if (cb) { cb.checked = !cb.checked; cb.onchange({ stopPropagation: function(){} }); } + } else if (e.key === 'Enter') { + e.preventDefault(); + rows[focusIdx]?.click(); + } }; + setTimeout(function() { filePane.focus(); }, 100); // Wire bulk actions setTimeout(function() { document.getElementById('bulk-delete')?.addEventListener('click', function() { - var names = Object.entries(self._selected).filter(function(e) { return e[1]; }).map(function(e) { return e[0]; }); + const names = Object.entries(self._selected).filter(function(e) { return e[1]; }).map(function(e) { return e[0]; }); if (!names.length || !confirm('Delete ' + names.length + ' item(s) from ' + path + '?')) return; names.forEach(function(name) { - var fullPath = (path.replace(/\/+$/, '') || '') + '/' + name; + const fullPath = (path.replace(/\/+$/, '') || '') + '/' + name; self._deleteFile(fullPath).then(function(ok) { if (ok) self.terminal.print('Deleted: ' + name, 'success'); else self.terminal.print('Delete failed: ' + name, 'error'); @@ -1099,14 +1469,14 @@ class WebDAVModule { setTimeout(function() { self.openWebdavBrowser(path); }, 500); }); document.getElementById('bulk-move')?.addEventListener('click', function() { - var names = Object.entries(self._selected).filter(function(e) { return e[1]; }).map(function(e) { return e[0]; }); + const names = Object.entries(self._selected).filter(function(e) { return e[1]; }).map(function(e) { return e[0]; }); if (!names.length) return; - var target = prompt('Move ' + names.length + ' item(s) to directory:', '/'); + let target = prompt('Move ' + names.length + ' item(s) to directory:', '/'); if (!target) return; target = target.replace(/\/+$/, ''); names.forEach(function(name) { - var oldFull = (path.replace(/\/+$/, '') || '') + '/' + name; - var newFull = target + '/' + name; + const oldFull = (path.replace(/\/+$/, '') || '') + '/' + name; + const newFull = target + '/' + name; self._webdavRequest('MOVE', oldFull, { headers: { 'Destination': newFull } }) .then(function(r) { if (r.ok || r.status === 201 || r.status === 204) self.terminal.print('Moved: ' + name, 'success'); @@ -1116,88 +1486,29 @@ class WebDAVModule { setTimeout(function() { self.openWebdavBrowser(path); }, 500); }); document.getElementById('bulk-download')?.addEventListener('click', function() { - var names = Object.entries(self._selected).filter(function(e) { return e[1]; }).map(function(e) { return e[0]; }); + const names = Object.entries(self._selected).filter(function(e) { return e[1]; }).map(function(e) { return e[0]; }); names.forEach(function(name) { - var fullPath = (path.replace(/\/+$/, '') || '') + '/' + name; - // Find entry from entries - var entry = entries.find(function(e) { return e.name === name; }); + const entry = allEntries.find(function(e) { return e.name === name; }); if (entry && entry.type === 'file') self.downloadFromWebdav(entry); }); }); }, 50); - content.appendChild(bulkBar); - - // Footer with breadcrumb + buttons - const footer = document.createElement('div'); - footer.className = 'modal-footer'; - footer.style.cssText = 'display:flex; justify-content:space-between; align-items:center; gap:6px; flex-wrap:wrap; padding:8px 10px;'; - - // Breadcrumb - var breadDiv = document.createElement('div'); - breadDiv.style.cssText = 'font-size:0.8em; color:var(--info-color); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; min-width:0;'; - var cleanPath = path.replace(/\/+$/, '') || '/'; - var parts = cleanPath.split('/').filter(Boolean); - var breadHtml = '📁 <span style="cursor:pointer;text-decoration:underline;" onclick="var m=document.getElementById(\'webdav-browser-modal\');if(m)m.__nav(\'/\');return false;">/</span>'; - var cumulative = ''; - for (var i = 0; i < parts.length; i++) { - cumulative += '/' + parts[i]; - var seg = parts[i]; - breadHtml += ' <span style="color:var(--text-muted);font-size:0.7em;">▸</span> '; - breadHtml += '<span style="cursor:pointer;text-decoration:underline;" onclick="var m=document.getElementById(\'webdav-browser-modal\');if(m)m.__nav(\'' + cumulative.replace(/'/g, "\\'") + '/\');return false;">' + seg + '</span>'; - } - breadDiv.innerHTML = breadHtml; - overlay.__nav = function(p) { self.openWebdavBrowser(p); }; - - // Button group - var btnGroup = document.createElement('div'); - btnGroup.style.cssText = 'display:flex; gap:4px; flex-shrink:0;'; - - var mkdirBtn = document.createElement('button'); - mkdirBtn.className = 'btn'; - mkdirBtn.textContent = '📁+'; - mkdirBtn.title = 'New Directory'; - mkdirBtn.onclick = function() { - var name = prompt('Directory name:'); - if (name) { - self._ensureDir((path.replace(/\/+$/, '') || '') + '/' + name).then(function(ok) { - if (ok) { self.terminal.print('Created: ' + name, 'success'); self.openWebdavBrowser(path); } - else self.terminal.print('Failed to create directory', 'error'); - }); - } + self._updateSelectionCount = function() { + const count = Object.values(self._selected).filter(Boolean).length; + var el = document.getElementById('sel-count'); + if (el) el.textContent = count > 0 ? count + ' selected' : ''; + var bar = document.getElementById('wdav-bulk-bar'); + var cnt = document.getElementById('bulk-count'); + if (bar) bar.style.display = count > 0 ? 'flex' : 'none'; + if (cnt) cnt.textContent = count + ' selected'; }; - var uploadBtn = document.createElement('button'); - uploadBtn.className = 'btn'; - uploadBtn.textContent = '⬆ Upload'; - uploadBtn.onclick = function() { self.uploadToWebdav(path); }; - - var refreshBtn = document.createElement('button'); - refreshBtn.className = 'btn'; - refreshBtn.textContent = '🔄'; - refreshBtn.title = 'Refresh'; - refreshBtn.onclick = function() { self.openWebdavBrowser(path); }; - - var closeBtn = document.createElement('button'); - closeBtn.className = 'btn'; - closeBtn.textContent = 'Close'; - closeBtn.onclick = function() { self.closeWebdavBrowser(); }; - - btnGroup.appendChild(mkdirBtn); - btnGroup.appendChild(uploadBtn); - btnGroup.appendChild(refreshBtn); - btnGroup.appendChild(closeBtn); - - footer.appendChild(breadDiv); - footer.appendChild(btnGroup); - content.appendChild(footer); - - overlay.appendChild(content); - document.body.appendChild(overlay); - + renderList(); this.terminal.print('📂 Opened WebDAV file browser: ' + path, 'info'); } + _showContextMenu(e, entry, currentPath) { var existing = document.getElementById('wdav-context-menu'); if (existing) existing.remove(); @@ -1216,7 +1527,13 @@ class WebDAVModule { navigator.clipboard.writeText(full).then(function() { this.terminal.print('Path copied: ' + full, 'success'); }.bind(this)); }.bind(this) }); } else { - items.push({ label: '📂 Open', fn: function() { this.openWebdavBrowser(entry.href); }.bind(this) }); + items.push({ label: '📂 Open', fn: function() { + var target = entry.href; + if (target && !target.startsWith('/')) { + target = (currentPath.replace(/\/+$/, '') || '') + '/' + target; + } + this.openWebdavBrowser(target); + }.bind(this) }); items.push({ label: '📋 Copy Path', fn: function() { var full = (currentPath.replace(/\/+$/, '') || '') + '/' + entry.name + '/'; navigator.clipboard.writeText(full).then(function() { this.terminal.print('Path copied: ' + full, 'success'); }.bind(this)); @@ -1269,6 +1586,27 @@ class WebDAVModule { this.terminal.print('WebDAV browser closed', 'text-muted'); } + previewInNewTab(entry) { + // Build direct URL to the file within the configured space + const spaceName = this._resolveSpaceName(); + let base = ''; + if (this.config && this.config.url) { + try { base = new URL(this.config.url).origin; } catch (e) {} + } + base = base || 'https://webx11.duckdns.org'; + let rel = entry.href || entry.name; + // href from autoindex is relative to the current directory — prepend browsing path + if (rel && !rel.startsWith('/')) { + const dir = (this._browsingPath || '/').replace(/\/+$/, '') + '/'; + rel = dir.replace(/^\/+/, '') + rel; + } + rel = rel.replace(/^\/+/, ''); + const url = base + '/spaces/' + (spaceName || '') + '/' + rel; + const w = window.open(url, '_blank'); + if (!w) this.terminal.print('Popup blocked — open manually: ' + url, 'warning'); + return url; + } + async previewWebdavFile(entry) { try { var basePath = (this._browsingPath || '/').replace(/\/+$/, ''); @@ -1279,13 +1617,24 @@ class WebDAVModule { var isImage = entry.name.match(/\.(png|jpg|jpeg|gif|svg|webp)$/i); var size = parseInt(resp.headers.get('content-length') || '0'); + // Ensure shared preview modal exists (defined in terminal.js) + var previewModal = document.getElementById('preview-modal'); + var previewTitle = document.getElementById('preview-title'); + var previewIframe = document.getElementById('preview-iframe'); + if (!previewModal || !previewTitle || !previewIframe) { + this.terminal.print('Preview modal not available.', 'error'); + return; + } + // Always render above the WebDAV browser modal (z-index 9999) + previewModal.style.zIndex = '10001'; + if (size > 512000 && !isHtml && !isImage) { - // Large non-html file — offer download instead - this.terminal.print(entry.name + ' is ' + this._formatSize(size) + ' — double-click to download instead of preview.', 'warning'); + // Large non-html file — print truncated text to terminal instead + this.terminal.print(entry.name + ' is ' + this._formatSize(size) + ' — previewing first 2000 chars.', 'warning'); var textPreview = await resp.text(); - this.terminal.print('--- ' + entry.name + ' (first 2000 chars) ---'); - this.terminal.print(textPreview.substring(0, 2000)); - if (textPreview.length > 2000) this.terminal.print('...(truncated ' + this._formatSize(size) + ' total)'); + previewTitle.textContent = '📄 ' + entry.name; + previewIframe.srcdoc = '<pre style="white-space:pre-wrap;word-break:break-word;font-family:monospace;font-size:0.85em;color:var(--text-primary);padding:12px;margin:0;">' + this._escapeHtml(textPreview.substring(0, 2000)) + (textPreview.length > 2000 ? '\n...(truncated ' + this._formatSize(size) + ' total)' : '') + '</pre>'; + previewModal.style.display = 'flex'; return; } @@ -1296,68 +1645,127 @@ class WebDAVModule { reader.onloadend = function() { resolve(reader.result); }; reader.readAsDataURL(blob); }); - // Use preview modal - var previewTitle = document.getElementById('preview-title'); - var previewIframe = document.getElementById('preview-iframe'); - if (previewTitle && previewIframe) { - previewTitle.textContent = '🖼 ' + entry.name; - previewIframe.srcdoc = '<img src="' + dataUrl + '" style="max-width:100%; max-height:100%; display:block; margin:auto; background:var(--bg-color);">'; - document.getElementById('preview-modal').style.display = 'flex'; - return; - } + previewTitle.textContent = '🖼 ' + entry.name; + previewIframe.srcdoc = '<img src="' + dataUrl + '" style="max-width:100%; max-height:100%; display:block; margin:auto; background:var(--bg-color);">'; + previewModal.style.display = 'flex'; + return; } if (isHtml) { var content = await resp.text(); - var previewTitle = document.getElementById('preview-title'); - var previewIframe = document.getElementById('preview-iframe'); - if (previewTitle && previewIframe) { - previewTitle.textContent = '📄 ' + entry.name; - previewIframe.srcdoc = content; - document.getElementById('preview-modal').style.display = 'flex'; - return; - } + previewTitle.textContent = '📄 ' + entry.name; + previewIframe.srcdoc = content; + previewModal.style.display = 'flex'; + return; } - // Text file — print to terminal + // Text file — show in preview modal var content = await resp.text(); - this.terminal.print('--- ' + entry.name + ' (' + this._formatSize(content.length) + ') ---', 'info'); - this.terminal.print(content); - this.terminal.print('--- End: ' + entry.name + ' ---', 'text-muted'); + previewTitle.textContent = '📄 ' + entry.name + ' (' + this._formatSize(content.length) + ')'; + previewIframe.srcdoc = '<pre style="white-space:pre-wrap;word-break:break-word;font-family:monospace;font-size:0.85em;color:var(--text-primary);padding:12px;margin:0;">' + this._escapeHtml(content) + '</pre>'; + previewModal.style.display = 'flex'; } catch (e) { this.terminal.print('Preview error: ' + e.message, 'error'); } } uploadToWebdav(currentPath) { - var self = this; - var input = document.createElement('input'); + const self = this; + const input = document.createElement('input'); input.type = 'file'; input.onchange = async function(e) { - var file = e.target.files[0]; + const file = e.target.files[0]; if (!file) return; - var reader = new FileReader(); - reader.onload = async function(evt) { - var basePath = currentPath.replace(/\/+$/, ''); - var remotePath = basePath + '/' + file.name; - var parentDir = remotePath.split('/').slice(0, -1).join('/'); - try { - await self._ensureDir(parentDir); - var resp = await self._webdavRequest('PUT', remotePath, { - body: evt.target.result, - headers: { 'Content-Type': 'application/octet-stream' } - }); - if (resp.ok || resp.status === 201 || resp.status === 204) { - self.terminal.print('⬆ Uploaded: ' + file.name + ' (' + self._formatSize(file.size) + ')', 'success'); - self.openWebdavBrowser(currentPath); - } else { - self.terminal.print('Upload failed: HTTP ' + resp.status, 'error'); - } - } catch (e) { - self.terminal.print('Upload error: ' + e.message, 'error'); - } - }; - reader.readAsText(file); + const basePath = currentPath.replace(/\/+$/, ''); + const remotePath = basePath + '/' + file.name; + const parentDir = remotePath.split('/').slice(0, -1).join('/') || '/'; + + // Progress UI — floating bar, recreated if closed + let prog = document.getElementById('wdav-upload-progress'); + if (!prog) { + prog = document.createElement('div'); + prog.id = 'wdav-upload-progress'; + prog.style.cssText = 'position:fixed; left:50%; bottom:24px; transform:translateX(-50%); z-index:10003; width:400px; max-width:92vw; background:var(--bg-tertiary,#141414); border:1px solid var(--border,#333); border-radius:8px; padding:10px 12px; box-shadow:0 4px 16px rgba(0,0,0,.5); font-size:0.85em;'; + prog.innerHTML = '<div style="display:flex;justify-content:space-between;margin-bottom:6px;gap:10px;"><span id="wdav-upload-name" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span><span id="wdav-upload-pct" style="flex-shrink:0;">0%</span></div>' + + '<div style="height:8px;background:var(--bg-secondary,#222);border-radius:4px;overflow:hidden;"><div id="wdav-upload-bar" style="height:100%;width:0%;background:var(--accent,#4a9eff);transition:width .15s;"></div></div>' + + '<div style="display:flex;justify-content:space-between;margin-top:6px;color:var(--text-muted);"><span id="wdav-upload-speed"></span><span id="wdav-upload-size"></span></div>'; + document.body.appendChild(prog); + } else { + prog.style.display = 'block'; + } + const bar = document.getElementById('wdav-upload-bar'); + const pctEl = document.getElementById('wdav-upload-pct'); + const speedEl = document.getElementById('wdav-upload-speed'); + const sizeEl = document.getElementById('wdav-upload-size'); + document.getElementById('wdav-upload-name').textContent = file.name; + bar.style.width = '0%'; + pctEl.textContent = '0%'; + speedEl.textContent = ''; + sizeEl.textContent = self._formatSize(file.size); + + const startTime = Date.now(); + let lastLoaded = 0, lastTime = startTime; + + try { + // NOTE: no _ensureDir here — currentPath is the directory being + // browsed, so it already exists. Skipping it avoids the MKCOL + // request storm that delayed uploads (esp. on mobile) and + // caused client aborts (HTTP 499). + + // Read the file into memory BEFORE opening the connection. + // Android Chrome's lazy File streaming (xhr.send(file)) stalls + // for larger files picked from the file manager (content://), + // leaving the PUT half-open until nginx 408s or the browser + // aborts (499). A pre-read ArrayBuffer uploads reliably. + const buf = await new Promise(function(resolve, reject) { + const r = new FileReader(); + r.onloadend = function() { + if (r.error) reject(r.error); + else resolve(r.result); + }; + r.readAsArrayBuffer(file); + }); + + // XHR PUT so we get upload progress events (fetch has none) + await new Promise(function(resolve, reject) { + const baseUrl = self.config.url.replace(/\/+$/, ''); + const urlPath = remotePath.startsWith('/') ? remotePath : '/' + remotePath; + const xhr = new XMLHttpRequest(); + xhr.open('PUT', baseUrl + urlPath, true); + const auth = self._getAuthHeader(); + if (auth.Authorization) xhr.setRequestHeader('Authorization', auth.Authorization); + xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream'); + xhr.upload.onprogress = function(ev) { + if (!ev.lengthComputable) return; + const pct = Math.round(ev.loaded / ev.total * 100); + bar.style.width = pct + '%'; + pctEl.textContent = pct + '%'; + const now = Date.now(); + const dt = (now - lastTime) / 1000; + if (dt >= 0.5) { + speedEl.textContent = self._formatSize((ev.loaded - lastLoaded) / dt) + '/s'; + lastLoaded = ev.loaded; + lastTime = now; + } + }; + xhr.onload = function() { + if (xhr.status >= 200 && xhr.status < 300) resolve(); + else reject(new Error('HTTP ' + xhr.status)); + }; + xhr.onerror = function() { reject(new Error('Network error')); }; + xhr.send(buf); // pre-read buffer — reliable on mobile + }); + + bar.style.width = '100%'; + pctEl.textContent = '100%'; + const elapsed = Math.max((Date.now() - startTime) / 1000, 0.001); + speedEl.textContent = self._formatSize(file.size / elapsed) + '/s'; + self.terminal.print('⬆ Uploaded: ' + file.name + ' (' + self._formatSize(file.size) + ')', 'success'); + setTimeout(function() { prog.style.display = 'none'; self.openWebdavBrowser(currentPath); }, 700); + } catch (err) { + self.terminal.print('Upload failed: ' + err.message, 'error'); + prog.style.display = 'none'; + } }; input.click(); } |