Chore/marco changes #3

Merged
Lago merged 6 commits from chore/marco-changes into main 2026-05-31 12:13:56 +02:00
3 changed files with 344 additions and 70 deletions
Showing only changes of commit 9bc08d994c - Show all commits
+9 -12
View File
@@ -170,20 +170,17 @@
<form class="admin-form" id="vehicleForm">
<input type="hidden" name="vid" />
<div class="admin-photo-preview" id="photoPreview"></div>
<label>
<span data-i18n="adminPhotoUpload">Hauptfoto hochladen (JPG/PNG/WebP, max 50 MB)</span>
<input type="file" id="photoInput" accept="image/*" />
</label>
<div class="admin-photo-upload-zone" id="photoUploadZone">
<div class="admin-photo-upload-content">
<span class="admin-photo-upload-icon">📷</span>
<span>Fotos hochladen (JPG/PNG/WebP, max 50 MB)</span>
<span class="muted" style="font-size:0.85rem;">Klicken oder Dateien hierher ziehen · Mehrfachauswahl möglich</span>
</div>
<input type="file" id="photoInput" accept="image/*" multiple />
</div>
<input type="hidden" name="photo_url" />
<div style="margin-top:1.2rem;">
<label>
<span>Weitere Fotos hinzufügen</span>
<input type="file" id="extraPhotoInput" accept="image/*" multiple />
</label>
<div id="extraPhotoGallery" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:0.5rem;margin-top:0.5rem;"></div>
</div>
<div class="admin-photo-gallery" id="extraPhotoGallery"></div>
<div class="row2">
<label><span data-i18n="adminBrand">Marke</span><input name="brand" required /></label>
+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
// =========================================================================
+166
View File
@@ -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;