feat: unify admin photo gallery with drag-and-drop + arrows

- Merge separate 'Hauptfoto' and 'Weitere Fotos' uploads into single upload zone
- Add drag-and-drop support for photo reorder
- Add ← → arrow buttons for photo reorder
- Increase thumbnail size to 200px
- Show Hauptfoto badge, ★ set primary, × delete on each thumbnail
- Auto-promote next photo when primary is deleted
- Keep vehicle.photo_url synced with primary photo
This commit is contained in:
2026-05-31 10:29:07 +02:00
parent 8be7d5aad2
commit 9bc08d994c
3 changed files with 344 additions and 70 deletions
+169 -58
View File
@@ -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 = `
<img src="${attr(ph.photo_url)}" style="width:100%;height:100%;object-fit:cover;" />
<div style="position:absolute;top:2px;right:2px;display:flex;gap:2px;">
${!ph.is_primary ? `<button type="button" style="cursor:pointer;background:#f59e0b;color:#000;border:none;border-radius:4px;padding:1px 4px;font-size:10px;font-weight:700;" data-set-primary="${ph.id}">★</button>` : ''}
<button type="button" style="cursor:pointer;background:#ef4444;color:#fff;border:none;border-radius:4px;padding:1px 4px;font-size:10px;font-weight:700;" data-delete-photo="${ph.id}">×</button>
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 = `
<img src="${attr(ph.photo_url)}" alt="Foto ${i + 1}" />
<div class="admin-photo-card-arrows">
<button type="button" class="admin-photo-arrow" data-move-dir="-1" ${i === 0 ? 'disabled style="opacity:0.3;pointer-events:none;"' : ''} aria-label="Nach links verschieben"></button>
<button type="button" class="admin-photo-arrow" data-move-dir="1" ${i === photos.length - 1 ? 'disabled style="opacity:0.3;pointer-events:none;"' : ''} aria-label="Nach rechts verschieben"></button>
</div>
${ph.is_primary ? '<span style="position:absolute;bottom:2px;left:2px;background:#22c55e;color:#fff;border-radius:4px;padding:1px 4px;font-size:9px;font-weight:700;">Hauptfoto</span>' : ''}
<div class="admin-photo-card-actions">
${!ph.is_primary ? `<button type="button" class="admin-photo-set-primary" data-photo-id="${ph.id}" aria-label="Als Hauptfoto setzen">★</button>` : ''}
<button type="button" class="admin-photo-delete" data-photo-id="${ph.id}" aria-label="Foto löschen">×</button>
</div>
${ph.is_primary ? '<span class="admin-photo-badge">Hauptfoto</span>' : ''}
<span class="admin-photo-drag-handle" aria-hidden="true">⠿</span>
`;
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
// =========================================================================