diff --git a/frontend/admin.html b/frontend/admin.html index d688560..c8ccc55 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -170,20 +170,17 @@
-
- +
+
+ 📷 + Fotos hochladen (JPG/PNG/WebP, max 50 MB) + Klicken oder Dateien hierher ziehen · Mehrfachauswahl möglich +
+ +
-
- -
-
+
diff --git a/frontend/admin.js b/frontend/admin.js index bdd997c..51fa1de 100644 --- a/frontend/admin.js +++ b/frontend/admin.js @@ -52,8 +52,7 @@ const formTitle = document.querySelector("#formTitle"); const saveBtn = document.querySelector("#saveBtn"); const resetBtn = document.querySelector("#resetBtn"); const photoInput = document.querySelector("#photoInput"); -const photoPreview = document.querySelector("#photoPreview"); -const extraPhotoInput = document.querySelector("#extraPhotoInput"); +const photoUploadZone = document.querySelector("#photoUploadZone"); const extraPhotoGallery = document.querySelector("#extraPhotoGallery"); const tableBody = document.querySelector("#adminTable tbody"); @@ -324,7 +323,6 @@ function loadForEdit(id) { vehicleForm.photo_url.value = v.photo_url; vehicleForm.is_active.checked = v.is_active; state.currentPhotoPath = v.photo_path || null; - updatePreview(v.photo_url); loadVehiclePhotos(v.id); window.scrollTo({ top: 0, behavior: "smooth" }); } @@ -342,7 +340,6 @@ resetBtn.addEventListener("click", () => { vehicleForm.price_per_km_eur.value = 1.50; state.currentPhotoPath = null; state.vehiclePhotos = []; - updatePreview(""); formTitle.textContent = "Neues Fahrzeug"; formFeedback.textContent = ""; }); @@ -408,36 +405,8 @@ async function deleteVehicle(id) { renderVehicles(); } -// Photo upload -photoInput.addEventListener("change", async () => { - const file = photoInput.files?.[0]; - if (!file) return; - formFeedback.className = "form-feedback"; - formFeedback.textContent = "Uploading photo..."; - try { - // Delete old photo if exists - if (state.currentPhotoPath) { - await supabase.storage.from("vehicle-photos").remove([state.currentPhotoPath]); - } - const ext = (file.name.split(".").pop() || "jpg").toLowerCase(); - const path = `${crypto.randomUUID()}.${ext}`; - const { error: upErr } = await supabase.storage - .from("vehicle-photos") - .upload(path, file, { contentType: file.type, upsert: true }); - if (upErr) throw upErr; - const { data: pub } = supabase.storage.from("vehicle-photos").getPublicUrl(path); - state.currentPhotoPath = path; - vehicleForm.photo_url.value = pub.publicUrl; - updatePreview(pub.publicUrl); - formFeedback.textContent = "Upload ok."; - } catch (err) { - formFeedback.className = "form-feedback error"; - formFeedback.textContent = err.message || String(err); - } -}); -function updatePreview(url) { photoPreview.style.backgroundImage = url ? `url('${url}')` : ""; } +// ----- Unified Photo Upload + Gallery ----- -// ----- Vehicle Photo Gallery ----- async function loadVehiclePhotos(vehicleId) { if (!vehicleId) { state.vehiclePhotos = []; @@ -457,33 +426,136 @@ async function loadVehiclePhotos(vehicleId) { function renderExtraPhotoGallery() { if (!extraPhotoGallery) return; extraPhotoGallery.innerHTML = ""; - for (const ph of state.vehiclePhotos) { - const wrapper = document.createElement("div"); - wrapper.style.cssText = "position:relative;border-radius:8px;overflow:hidden;aspect-ratio:16/10;background:#1a1a1a;"; - wrapper.innerHTML = ` - -
- ${!ph.is_primary ? `` : ''} - + const photos = state.vehiclePhotos; + if (!photos.length) return; + + for (let i = 0; i < photos.length; i++) { + const ph = photos[i]; + const card = document.createElement("div"); + card.className = "admin-photo-card"; + card.draggable = true; + card.dataset.photoId = ph.id; + card.dataset.photoIdx = i; + card.innerHTML = ` + Foto ${i + 1} +
+ +
- ${ph.is_primary ? 'Hauptfoto' : ''} +
+ ${!ph.is_primary ? `` : ''} + +
+ ${ph.is_primary ? 'Hauptfoto' : ''} + `; - extraPhotoGallery.appendChild(wrapper); + extraPhotoGallery.appendChild(card); } - // Event listeners - extraPhotoGallery.querySelectorAll("[data-delete-photo]").forEach(btn => { + // Action buttons + extraPhotoGallery.querySelectorAll(".admin-photo-delete").forEach(btn => { btn.addEventListener("click", async () => { - const phId = btn.dataset.deletePhoto; - await deleteVehiclePhoto(phId); + await deleteVehiclePhoto(btn.dataset.photoId); }); }); - extraPhotoGallery.querySelectorAll("[data-set-primary]").forEach(btn => { + extraPhotoGallery.querySelectorAll(".admin-photo-set-primary").forEach(btn => { btn.addEventListener("click", async () => { - const phId = btn.dataset.setPrimary; - await setPrimaryPhoto(phId); + await setPrimaryPhoto(btn.dataset.photoId); }); }); + extraPhotoGallery.querySelectorAll(".admin-photo-arrow").forEach(btn => { + btn.addEventListener("click", async () => { + const card = btn.closest(".admin-photo-card"); + const idx = +card.dataset.photoIdx; + const dir = +btn.dataset.moveDir; + await reorderPhoto(idx, dir); + }); + }); + + // Drag and drop + extraPhotoGallery.querySelectorAll(".admin-photo-card").forEach(card => { + card.addEventListener("dragstart", handleDragStart); + card.addEventListener("dragover", handleDragOver); + card.addEventListener("dragenter", handleDragEnter); + card.addEventListener("dragleave", handleDragLeave); + card.addEventListener("drop", handleDrop); + card.addEventListener("dragend", handleDragEnd); + }); +} + +let draggedPhotoIdx = null; + +function handleDragStart(e) { + draggedPhotoIdx = +this.dataset.photoIdx; + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", this.dataset.photoId); + this.style.opacity = "0.4"; +} + +function handleDragOver(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; +} + +function handleDragEnter(e) { + e.preventDefault(); + if (+this.dataset.photoIdx !== draggedPhotoIdx) { + this.classList.add("admin-photo-card-drag-over"); + } +} + +function handleDragLeave() { + this.classList.remove("admin-photo-card-drag-over"); +} + +function handleDrop(e) { + e.preventDefault(); + this.classList.remove("admin-photo-card-drag-over"); + const targetIdx = +this.dataset.photoIdx; + if (draggedPhotoIdx !== null && draggedPhotoIdx !== targetIdx) { + const dir = targetIdx > draggedPhotoIdx ? 1 : -1; + reorderPhoto(draggedPhotoIdx, dir, targetIdx); + } +} + +function handleDragEnd() { + this.style.opacity = "1"; + draggedPhotoIdx = null; + extraPhotoGallery?.querySelectorAll(".admin-photo-card").forEach(c => c.classList.remove("admin-photo-card-drag-over")); +} + +async function reorderPhoto(fromIdx, dir, targetIdx) { + const photos = state.vehiclePhotos; + if (photos.length < 2) return; + + let toIdx; + if (targetIdx !== undefined) { + toIdx = targetIdx; + } else { + toIdx = fromIdx + dir; + if (toIdx < 0 || toIdx >= photos.length) return; + } + + // Swap in local array + [photos[fromIdx], photos[toIdx]] = [photos[toIdx], photos[fromIdx]]; + + // Build order payload + const orderPayload = photos.map((p, i) => ({ id: p.id, order: i })); + + const vid = vehicleForm.vid?.value; + if (vid) { + try { + await supabase.rpc("reorder_vehicle_photos", { + p_vehicle_id: vid, + p_photo_orders: orderPayload, + }); + } catch (err) { + console.error("Reorder failed:", err); + [photos[fromIdx], photos[toIdx]] = [photos[fromIdx], photos[toIdx]]; + return; + } + } + renderExtraPhotoGallery(); } async function deleteVehiclePhoto(photoId) { @@ -496,6 +568,16 @@ async function deleteVehiclePhoto(photoId) { const { error } = await supabase.from("vehicle_photos").delete().eq("id", photoId); if (error) throw error; state.vehiclePhotos = state.vehiclePhotos.filter(p => p.id !== photoId); + + // If deleted was primary, promote next photo + if (ph.is_primary && state.vehiclePhotos.length) { + const newPrimary = state.vehiclePhotos[0]; + const vid = vehicleForm.vid?.value; + if (vid) { + await supabase.rpc("set_primary_vehicle_photo", { p_vehicle_id: vid, p_photo_id: newPrimary.id }); + vehicleForm.photo_url.value = newPrimary.photo_url; + } + } renderExtraPhotoGallery(); } catch (err) { console.error("Failed to delete photo:", err); @@ -506,25 +588,28 @@ async function setPrimaryPhoto(photoId) { const vid = vehicleForm.vid?.value; if (!vid) return; try { + const ph = state.vehiclePhotos.find(p => p.id === photoId); await supabase.rpc("set_primary_vehicle_photo", { p_vehicle_id: vid, p_photo_id: photoId }); + vehicleForm.photo_url.value = ph?.photo_url || ""; await loadVehiclePhotos(vid); } catch (err) { console.error("Failed to set primary photo:", err); } } -// Extra photos upload -extraPhotoInput?.addEventListener("change", async () => { - const files = extraPhotoInput.files; +// Unified photo upload handler +photoInput.addEventListener("change", async () => { + const files = photoInput.files; if (!files.length) return; const vid = vehicleForm.vid?.value; if (!vid) { formFeedback.className = "form-feedback error"; - formFeedback.textContent = "Bitte zuerst Fahrzeug speichern, dann Fotos hinzufügen."; + formFeedback.textContent = "Bitte zuerst das Fahrzeug speichern, dann Fotos hinzufügen."; return; } formFeedback.className = "form-feedback"; - formFeedback.textContent = "Uploading photos..."; + formFeedback.textContent = `Uploading ${files.length} photo(s)...`; + let uploaded = 0; for (const file of files) { try { const ext = (file.name.split(".").pop() || "jpg").toLowerCase(); @@ -534,23 +619,49 @@ extraPhotoInput?.addEventListener("change", async () => { .upload(path, file, { contentType: file.type, upsert: true }); if (upErr) throw upErr; const { data: pub } = supabase.storage.from("vehicle-photos").getPublicUrl(path); + const isFirst = state.vehiclePhotos.length === 0; const maxOrder = state.vehiclePhotos.reduce((m, p) => Math.max(m, p.display_order), -1); await supabase.from("vehicle_photos").insert({ vehicle_id: vid, photo_url: pub.publicUrl, photo_path: path, display_order: maxOrder + 1, - is_primary: state.vehiclePhotos.length === 0, + is_primary: isFirst, }); + if (isFirst) { + vehicleForm.photo_url.value = pub.publicUrl; + } + uploaded++; } catch (err) { console.error("Upload failed:", err); } } await loadVehiclePhotos(vid); - formFeedback.textContent = `${files.length} Foto(s) hochgeladen.`; - extraPhotoInput.value = ""; + formFeedback.textContent = `${uploaded} Foto(s) hochgeladen.`; + photoInput.value = ""; }); +// Drag-and-drop on upload zone +if (photoUploadZone) { + photoUploadZone.addEventListener("dragover", e => { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + photoUploadZone.classList.add("drag-active"); + }); + photoUploadZone.addEventListener("dragleave", () => { + photoUploadZone.classList.remove("drag-active"); + }); + photoUploadZone.addEventListener("drop", e => { + e.preventDefault(); + photoUploadZone.classList.remove("drag-active"); + const files = e.dataTransfer.files; + if (files.length) { + photoInput.files = files; + photoInput.dispatchEvent(new Event("change")); + } + }); +} + // ========================================================================= // LEADS // ========================================================================= diff --git a/frontend/styles.css b/frontend/styles.css index 18b1ddb..3a15b9e 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -1135,6 +1135,172 @@ table.admin-table td:last-child { white-space: nowrap; } filter: brightness(1.1); } +/* ---- Unified Photo Upload Zone ---- */ +.admin-photo-upload-zone { + width: 100%; + min-height: 120px; + border: 2px dashed var(--line); + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: border-color 0.2s ease, background 0.2s ease; + background: var(--bg-elev); + margin-bottom: 0.5rem; + position: relative; +} +.admin-photo-upload-zone:hover { + border-color: var(--accent-strong); +} +.admin-photo-upload-zone.drag-active { + border-color: var(--accent-strong); + background: rgba(245, 158, 11, 0.08); +} +.admin-photo-upload-zone input[type="file"] { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} +.admin-photo-upload-content { + text-align: center; + pointer-events: none; +} +.admin-photo-upload-icon { + font-size: 2rem; + display: block; + margin-bottom: 0.3rem; +} + +/* ---- Photo Gallery ---- */ +.admin-photo-gallery { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 0.8rem; + margin-top: 1rem; +} + +.admin-photo-card { + position: relative; + border-radius: 10px; + overflow: hidden; + aspect-ratio: 16/10; + background: #1a1a1a; + border: 2px solid transparent; + transition: border-color 0.2s ease, transform 0.2s ease, opacity 0.2s ease; + cursor: grab; +} +.admin-photo-card:hover { + border-color: var(--accent-strong); + transform: scale(1.02); +} +.admin-photo-card:active { + cursor: grabbing; +} +.admin-photo-card-drag-over { + border-color: #f59e0b !important; + transform: scale(1.05); +} +.admin-photo-card img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + pointer-events: none; +} +.admin-photo-card-arrows { + position: absolute; + top: 6px; + left: 6px; + display: flex; + gap: 3px; + z-index: 2; +} +.admin-photo-arrow { + width: 26px; + height: 26px; + border-radius: 6px; + border: none; + background: rgba(0, 0, 0, 0.7); + color: #fff; + font-size: 1rem; + font-weight: 700; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease; +} +.admin-photo-arrow:hover { + background: rgba(0, 0, 0, 0.9); +} +.admin-photo-card-actions { + position: absolute; + top: 6px; + right: 6px; + display: flex; + gap: 3px; + z-index: 2; +} +.admin-photo-set-primary { + width: 26px; + height: 26px; + border-radius: 6px; + border: none; + background: rgba(245, 158, 11, 0.85); + color: #000; + font-size: 1rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease; +} +.admin-photo-set-primary:hover { + background: #f59e0b; +} +.admin-photo-delete { + width: 26px; + height: 26px; + border-radius: 6px; + border: none; + background: rgba(239, 68, 68, 0.85); + color: #fff; + font-size: 1rem; + font-weight: 700; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease; +} +.admin-photo-delete:hover { + background: #ef4444; +} +.admin-photo-badge { + position: absolute; + bottom: 6px; + left: 6px; + background: #22c55e; + color: #fff; + border-radius: 5px; + padding: 2px 8px; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.05em; + z-index: 2; +} +.admin-photo-drag-handle { + position: absolute; + bottom: 6px; + right: 6px; + color: rgba(255, 255, 255, 0.5); + font-size: 1.1rem; + z-index: 2; + pointer-events: none; +} + /* ---------------- Forms / Toggle Switch ---------------- */ .toggle-switch { position: relative;