aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cheatsheet.html2
-rw-r--r--downloads/thai-massage-booking-mockup.html925
-rw-r--r--downloads/thai-massage-legal-pages.html747
-rw-r--r--downloads/thai-massage-privacy-first.html280
-rw-r--r--help.html2
-rw-r--r--main.html5
-rw-r--r--modules/ext/webdav.js1028
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>&lt;iframe src="https://thaimassage.massagebooking.de/widget"
+ width="100%" height="500" frameborder="0"&gt;
+&lt;/iframe&gt;</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
diff --git a/help.html b/help.html
index d588786..823004a 100644
--- a/help.html
+++ b/help.html
@@ -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 &lt;local&gt; [remote]</code></td><td>Upload file to cloud</td></tr>
<tr><td><code>webdav-pull &lt;remote&gt; [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 &lt;path&gt;</code></td><td>Delete remote file/directory</td></tr>
<tr><td><code>webdav-mkdir &lt;path&gt;</code></td><td>Create remote directory</td></tr>
<tr><td><code>webdav-rename &lt;old&gt; &lt;new&gt;</code></td><td>Rename remote file</td></tr>
diff --git a/main.html b/main.html
index aaa5f5f..b4259ed 100644
--- a/main.html
+++ b/main.html
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+ }
+
+ 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();
}