Lignora Atelier
Yönetici Paneli
Lignora Atelier
Yönetim
LIGNORA ATELIER — ADMIN

Customers

/* ═══════════════════════════════════════════════════════════ Lignora Atelier — Admin Panel (statik, self-contained) Siteden TAMAMEN bağımsız ayrı bir uygulamadır: - Yalnızca {origin}/admin-panel.html adresinden erişilir. - Site'da hiçbir yerde linki yoktur. - Giriş: kendi şifresi (LE_ADMIN_PASSWORD) → HMAC cookie. - Site oturumu / site hesabı GEREKMEZ, kullanılmaz. Bölümler (sidebar → renderers): - customers → müşteri listesi + detay + sohbet geçmişi - chatbot-settings → bot ayarları (welcome mesajı, prompt, açık/kapalı) - live-edit → canlı düzenleme başlatıcı ═══════════════════════════════════════════════════════════ */ (function () { 'use strict'; /* ── helpers ─────────────────────────────────────────── */ function $(id) { return document.getElementById(id); } function q(s) { return document.querySelector(s); } function qa(s) { return document.querySelectorAll(s); } function esc(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } function fmtDate(iso) { if (!iso) return '—'; try { return new Date(iso).toLocaleString('tr-TR', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit', }); } catch { return String(iso); } } function fmtMoney(n) { const v = Number(n || 0); return v.toLocaleString('tr-TR', { minimumFractionDigits: 0, maximumFractionDigits: 2 }) + ' ₼'; } var toastTimer = null; function toast(msg, cls) { var t = $('toast'); t.textContent = msg; t.className = 'toast show' + (cls ? ' ' + cls : ''); clearTimeout(toastTimer); toastTimer = setTimeout(function () { t.className = 'toast'; }, 3200); } function openModal(html) { $('modalBody').innerHTML = html; $('modalOverlay').classList.add('open'); } function closeModal() { $('modalOverlay').classList.remove('open'); } async function api(path, opts) { opts = opts || {}; try { var r = await fetch(path, { credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, ...opts, }); if (r.status === 401) { location.reload(); throw new Error('Auth'); } if (!r.ok) { var e = null; try { e = await r.json(); } catch {} throw new Error((e && e.error) || 'Hata ' + r.status); } return r.json(); } catch (err) { if (err.message === 'Auth') throw err; throw new Error('Bağlantı hatası (' + err.message + ')'); } } /* ── auth ────────────────────────────────────────────── */ async function doLogin() { var pwd = $('pw').value; if (!pwd) { setLoginStatus('Şifre girin.', 'err'); return; } var btn = $('loginBtn'); btn.disabled = true; setLoginStatus('Doğrulanıyor…'); try { var res = await fetch('/api/admin/live-edit/login', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pwd }), }); var data = null; try { data = await res.json(); } catch {} if (res.status === 401) { setLoginStatus('❌ Yanlış şifre.', 'err'); btn.disabled = false; $('pw').select(); return; } if (!res.ok) throw new Error('HTTP ' + res.status); $('pw').value = ''; showApp(); } catch (err) { console.error('[admin-panel] login error:', err); setLoginStatus('⚠ Sunucuya ulaşılamadı.', 'err'); btn.disabled = false; } } function setLoginStatus(msg, cls) { var el = $('loginStatus'); if (!el) return; el.textContent = msg; el.className = 'status' + (cls ? ' ' + cls : ''); } function doLogout() { // HMAC cookie is not server-revocable; clear it client-side and reload. document.cookie = 'le.admin=; Path=/; Max-Age=0; SameSite=Lax'; document.cookie = 'le.edit=; Path=/; Max-Age=0; SameSite=Lax'; location.reload(); } /* ── app shell ───────────────────────────────────────── */ function showApp() { $('loginView').style.display = 'none'; $('sidebar').style.display = ''; $('mainArea').style.display = ''; init(); } var TABS = [ { id: 'customers', label: 'Customers' }, { id: 'chatbot-settings', label: 'Bot Settings' }, { id: 'live-edit', label: 'Live Edit' }, ]; var renderers = {}; var _custData = { customers: [], conversations: [] }; var _custSelected = null; var _custSearch = ''; var _custStatus = 'all'; function switchTab(id) { qa('.tab-btn').forEach(function (t) { t.classList.toggle('active', t.dataset.tab === id); }); var tab = TABS.find(function (t) { return t.id === id; }); $('pageTitle').textContent = tab ? tab.label : ''; $('headerActions').innerHTML = ''; var R = renderers[id]; if (R) R(); } function init() { qa('.tab-btn').forEach(function (btn) { btn.addEventListener('click', function () { switchTab(btn.dataset.tab); }); }); switchTab('customers'); } /* ═══════════════ CUSTOMERS ═══════════════ */ function custStatusKey(c) { if (!c) return 'new'; var last = c.last_order_at || c.updated_at || c.created_at || null; if (!last) return 'new'; var days = (Date.now() - new Date(last).getTime()) / 86400000; if (days < 30) return 'hot'; if (days < 90) return 'warm'; return 'cold'; } function custStatusLabel(key) { return { hot: 'Aktif', warm: 'Ilık', cold: 'Soğuk', new: 'Yeni', lead: 'Lead', handoff: 'Devir', blocked: 'Engelli' }[key] || key; } function convForCustomer(c) { if (!c) return []; var phone = (c.phone || '').replace(/\D/g, ''); if (!phone) return []; return (_custData.conversations || []).filter(function (cv) { return (cv.customer_phone || '').replace(/\D/g, '') === phone; }); } function customerStats() { var list = _custData.customers || []; var total = list.length; var hot = 0, warm = 0, cold = 0, leads = 0; list.forEach(function (c) { var k = custStatusKey(c); if (k === 'hot') hot++; else if (k === 'warm') warm++; else cold++; if (c.lead_score > 0) leads++; }); var spent = list.reduce(function (s, c) { return s + Number(c.total_spent || 0); }, 0); return { total: total, hot: hot, warm: warm, cold: cold, leads: leads, spent: spent }; } renderers.customers = async function () { try { var data = await api('/api/admin/customers'); _custData = { customers: data.customers || [], conversations: data.conversations || [] }; if (_custSelected && !_custData.customers.some(function (c) { return c.id === _custSelected; })) { _custSelected = null; } renderCustomersUI(); } catch (e) { $('content').innerHTML = '
⚠️Müşteriler yüklenemedi: ' + esc(e.message) + '
'; } }; function renderCustomersUI() { var st = customerStats(); var list = _custData.customers || []; var filtered = list.filter(function (c) { if (_custStatus !== 'all' && custStatusKey(c) !== _custStatus) return false; if (_custSearch) { var hay = ((c.name || '') + ' ' + (c.phone || '') + ' ' + (c.email || '')).toLowerCase(); if (hay.indexOf(_custSearch.toLowerCase()) === -1) return false; } return true; }); var statusChips = [ { key: 'all', label: 'Tümü' }, { key: 'hot', label: '🔥 Aktif' }, { key: 'warm', label: '🌤 Ilık' }, { key: 'cold', label: '❄️ Soğuk' }, { key: 'lead', label: '🧲 Lead' }, ].map(function (s) { return ''; }).join(''); var listItems = filtered.map(function (c) { var k = custStatusKey(c); var convs = convForCustomer(c); var isActive = _custSelected === c.id; return ( '
' + '
' + esc(c.name || 'İsimsiz') + '' + '' + custStatusLabel(k) + '
' + '📞 ' + esc(c.phone || '-') + '' + (convs.length ? '
💬 ' + convs.length + ' sohbet
' : '') + '
' ); }).join('') || '
🔍Müşteri bulunamadı
'; $('content').innerHTML = '
' + '

Customers

' + st.total + ' toplam · ' + filtered.length + ' gösteriliyor · Toplam harcama ' + fmtMoney(st.spent) + '

' + '
' + '' + '
' + '
' + '
' + '
' + st.total + '
Toplam
' + '
' + st.hot + '
🔥 Aktif
' + '
' + st.warm + '
🌤 Ilık
' + '
' + st.cold + '
❄️ Soğuk
' + '
' + st.leads + '
🧲 Lead
' + '
' + '
' + '
' + '
🔍' + '' + '
' + '
' + statusChips + '
' + '
' + listItems + '
' + '
' + '
' + (_custSelected ? detailHtml(_custData.customers.find(function (c) { return c.id === _custSelected; })) : detailEmptyHtml()) + '
' + '
'; // focus search while keeping cursor at end var si = $('custSearch'); if (si) { si.focus(); var len = si.value.length; si.setSelectionRange(len, len); } } function detailEmptyHtml() { return '
👥
Soldan bir müşteri seçin
Sohbet geçmişi ve notlar burada görünür
'; } function detailHtml(c) { if (!c) return detailEmptyHtml(); var k = custStatusKey(c); var convs = convForCustomer(c); var convHtml; if (!convs.length) { convHtml = '
💬Bu müşterinin sohbeti yok
'; } else { convHtml = '
' + convs.map(function (cv) { return '
' + esc(cv.last_message || '(boş)') + '' + fmtDate(cv.last_active_at) + '
'; }).join('') + '
'; } return ( '
' + '

' + esc(c.name || 'İsimsiz') + '

' + '
Müşteri ' + fmtDate(c.created_at) + ' tarihinde eklendi · ' + c.total_orders + ' sipariş · ' + fmtMoney(c.total_spent) + ' harcama
' + '' + custStatusLabel(k) + '' + '
' + '
' + '
Telefon
' + esc(c.phone || '—') + '
' + '
E-posta
' + esc(c.email || '—') + '
' + '
Son sipariş
' + fmtDate(c.last_order_at) + '
' + '
Durum
' + custStatusLabel(k) + '
' + '
' + '
Düzenle
' + '
' + '
' + '
' + '
' + '
' + '' + '' + '
' + '
Sohbet Geçmişi
' + convHtml ); } /* customers: window-exposed actions (inline onclick) */ window.leSetStatusFilter = function (k) { _custStatus = k; renderers.customers(); }; window.leSearch = function (v) { _custSearch = v; var list = $('custDetailPane'); renderers.customers(); }; window.leSelectCustomer = function (id) { _custSelected = id; var list = _custData.customers || []; var c = list.find(function (x) { return x.id === id; }); var pane = $('custDetailPane'); if (pane) pane.innerHTML = detailHtml(c); qa('.cust-list-item').forEach(function (el) { el.classList.toggle('active', el.getAttribute('onclick') && el.getAttribute('onclick').indexOf(id) !== -1); }); }; window.leSaveCustomer = async function () { var id = _custSelected; if (!id) return; try { var body = { name: $('ceName').value, phone: $('cePhone').value, email: $('ceEmail').value, admin_notes: $('ceNotes').value, }; var res = await api('/api/admin/customers/' + id, { method: 'PUT', body: JSON.stringify(body) }); toast('✅ Müşteri güncellendi'); _custData.customers = _custData.customers.map(function (c) { return c.id === id ? res.customer : c; }); var pane = $('custDetailPane'); if (pane) pane.innerHTML = detailHtml(res.customer); renderers.customers(); _custSelected = id; } catch (e) { toast('❌ ' + e.message, 'err'); } }; window.leDeleteCustomer = async function () { var id = _custSelected; if (!id) return; openModal( '

Müşteriyi sil

' + '

Bu müşteri kalıcı olarak silinecek. Sohbet geçmişi korunur. Emin misin?

' + '
' + '' + '' + '
' ); }; window.leConfirmDelete = async function () { try { await api('/api/admin/customers/' + _custSelected, { method: 'DELETE' }); closeModal(); _custSelected = null; toast('✅ Müşteri silindi'); renderers.customers(); } catch (e) { toast('❌ ' + e.message, 'err'); } }; window.leAddCustomer = function () { openModal( '

Yeni Müşteri

' + '
' + '
' + '
' + '
' + '' + '' + '
' ); }; window.leSaveNewCustomer = async function () { try { var body = { name: $('ncName').value, phone: $('ncPhone').value, email: $('ncEmail').value, }; if (!body.name) { toast('❌ Ad Soyad gerekli', 'err'); return; } await api('/api/admin/customers', { method: 'POST', body: JSON.stringify(body) }); closeModal(); _custSearch = ''; _custStatus = 'all'; toast('✅ Müşteri eklendi'); renderers.customers(); } catch (e) { toast('❌ ' + e.message, 'err'); } }; /* ═══════════════ BOT SETTINGS ═══════════════ */ var _botData = { enabled: true, welcomeMessages: {}, systemPrompt: '' }; var _botLoaded = false; renderers['chatbot-settings'] = async function () { try { if (!_botLoaded) { var d = await api('/api/admin/bot-settings'); _botData = { enabled: d.bot.enabled !== false, welcomeMessages: d.bot.welcomeMessages || {}, systemPrompt: d.bot.systemPrompt || '' }; _botLoaded = true; } renderBotSettingsUI(); } catch (e) { $('content').innerHTML = '
⚠️Ayarlar yüklenemedi: ' + esc(e.message) + '
'; } }; function renderBotSettingsUI() { var w = _botData.welcomeMessages || {}; var locNames = { az: 'Azərbaycanca', tr: 'Türkçe', ru: 'Русский', en: 'English', es: 'Español' }; var welcomeRows = Object.keys(locNames).map(function (loc) { return ( '
' + '
' ); }).join(''); $('content').innerHTML = '
' + '

Bot Settings

AI danışman botunun davranışı · değişiklikler anında siteye yansır

' + '
' + '
' + '
' + '

🤖 Genel

' + '
Bot açık
Site ziyaretçileri chat widget’ını görebilir
' + '
' + '
' + '
' + '

💬 Karşılama mesajları

' + '

5 dilde — boş bırakılırsa varsayılan çeviri kullanılır

' + welcomeRows + '
' + '
' + '

🧠 System Prompt

' + '

Boş bırakılırsa kod içindeki varsayılan prompt kullanılır

' + '
' + '
' + '
' + '
' + '' + '' + '
'; } window.leSaveBotSettings = async function () { try { var welcome = {}; qa('[data-welcome-loc]').forEach(function (t) { var loc = t.getAttribute('data-welcome-loc'); var v = t.value.trim(); if (v) welcome[loc] = v; }); var body = { enabled: $('bsEnabled').checked, welcomeMessages: welcome, systemPrompt: $('bsPrompt').value, }; await api('/api/admin/bot-settings', { method: 'PUT', body: JSON.stringify(body) }); _botData = { enabled: body.enabled, welcomeMessages: body.welcomeMessages, systemPrompt: body.systemPrompt, }; toast('✅ Bot ayarları kaydedildi'); } catch (e) { toast('❌ ' + e.message, 'err'); } }; window.leBotDiscard = function () { _botLoaded = false; renderers['chatbot-settings'](); }; /* ═══════════════ LIVE EDIT ═══════════════ */ renderers['live-edit'] = function () { $('content').innerHTML = '
' + ' ⚡ FLAGSHIP REAL-TIME INLINE SITE EDITOR' + '

Direct On-Page Visual Editor

' + '

Sitede herhangi bir metne, başlığa veya kart görseline doğrudan tıklayıp içeriği anında güncelleyin. Değişiklikler 5 dilde otomatik eşzamanlanır.

' + '
' + '' + '
' + '
' + '
✏️
Multi-Lingual Sync

Metin düzenlemeleri 5 dilde otomatik eşzamanlanır.

' + '
🖼️
Media Selector

Görselleri doğrudan site üzerinden değiştirin.

' + '
🛡️
Safe Save Bar

Yayınlamadan önce tüm değişiklikleri bar üzerinden onaylayın.

' + '
' + '
'; }; /* ── boot ───────────────────────────────────────────── */ window.doLogin = doLogin; window.doLogout = doLogout; window.closeModal = closeModal; window.leSetStatusFilter = window.leSetStatusFilter; // defined above via window.x = ... function boot() { if (new URLSearchParams(location.search).has('auth')) { $('loginView').style.display = ''; setLoginStatus('Önce admin paneline giriş yapın.', 'err'); } else { $('loginView').style.display = ''; } $('pw').addEventListener('keydown', function (e) { if (e.key === 'Enter') doLogin(); }); fetch('/api/admin/live-edit/check-auth', { credentials: 'same-origin' }) .then(function (r) { return r.status === 401 ? null : r.json(); }) .then(function (d) { if (d && d.authenticated === true) showApp(); }) .catch(function () {}); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { boot(); } })(); /* ═══════════════════════════════════════════════════════════ Lignora Atelier LIVE EDIT — Canlı Düzenleme ?edit=1 ile açılır; proxy.ts admin session'ı doğrular ve le.edit cookie'sini koyar (auth cookie tabanlıdır — token yok). Mantık, hair-extensions projesinin public/live-edit.js dosyasının uyarlamasıdır (o proje salt okunur referanstır, değiştirilmedi). Özellikler: - data-i18n-key metinleri, [data-product-id]/[data-project-id]/ [data-service-id] kartları, tel/WhatsApp/Instagram/adres popup'ları ([data-site-field]), img medya seçici - İçerik ekleme (+ butonu + draft kartı), kart silme (X + özel modal) - Kaydetme barı (İptal/Kaydet + kaydedilmemiş sayaç), çeviri kuyruğu - Navigasyon koruması: iç linkler ?edit=1 ile devam eder - Renk paleti: bronz #8B7355, koyu #1A1A1A, krem #FAFAF8 ═══════════════════════════════════════════════════════════ */ (() => { const params = new URLSearchParams(location.search); if (!params.has('edit')) return; const $ = s => document.querySelector(s); const $$ = s => document.querySelectorAll(s); const API_BASE = ''; const LOCALES = ['az', 'tr', 'ru', 'en', 'es']; const LANG_NAMES = { az: 'Azerbaijani', tr: 'Turkish', ru: 'Russian', en: 'English', es: 'Spanish' }; let _lang = () => { const l = (document.documentElement.lang || '').toLowerCase(); if (LOCALES.includes(l)) return l; const m = location.pathname.match(/^\/(az|tr|ru|en|es)(\/|$)/); return m ? m[1] : 'az'; }; let dirty = new Set(), i18nDirty = new Set(), i18nDirtyLangs = new Set(), dirtySrcLangs = new Set(), sitePatch = {}, _editing = null, _busy = false, addDraft = null, _addUI = null, calcPatch = {}; /* ---------- bar metinleri: 5 dil (site dilinde gösterilir) ---------- */ const BAR_I18N = { az: { unsaved: 'Saxlanmamış: ', allSaved: 'Hər şey saxlanıldı', save: '💾 Saxla', cancel: 'Ləğv et', saving: 'Saxlanılır…', uploading: 'Şəkillər yüklənir…', saved: '✔ Saxlanıldı', cancelled: 'Dəyişikliklər geri alındı', error: 'Xəta:', autosaved: 'Tərcümələr arxa planda saxlanıldı', hint: '✏️ Elementin üzərinə gəl → Düzenle simgesine tıkla (və ya iki dəfə tıkla)', edit: '✏️ Düzenle', change: '🖼️ Dəyişdir', huge: 'Fayl çox böyükdür (max 25MB)', mediaReady: 'Medya hazır — Saxla ilə yüklənir', add: '➕ Yeni əlavə et', addCancel: 'Ləğv et', addName: 'Ad', addPrice: 'Qiymət (AZN)', addDesc: 'Açıqlama', addImg: 'Şəkil yüklə', addSave: 'Əlavə et', addNew: 'Yeni kart', delConfirm: 'Bu element silinsin?', delYes: 'Sil', authError: '🔒 Bu funksiya yalnız Admin Panelində mövcuddur.' }, tr: { unsaved: 'Kaydedilmemiş: ', allSaved: 'Her şey kaydedildi', save: '💾 Kaydet', cancel: 'İptal', saving: 'Kaydediliyor…', uploading: 'Görseller yükleniyor…', saved: '✔ Kaydedildi', cancelled: 'Değişiklikler geri alındı', error: 'Hata:', autosaved: 'Çeviriler arka planda kaydedildi', hint: '✏️ Öğenin üzerine gel → Düzenle simgesine tıkla (veya çift tıkla)', edit: '✏️ Düzenle', change: '🖼️ Değiştir', huge: 'Dosya çok büyük (max 25MB)', mediaReady: 'Medya hazır — Kaydet ile yüklenir', add: '➕ Yeni ekle', addCancel: 'İptal', addName: 'Ad', addPrice: 'Fiyat (AZN)', addDesc: 'Açıklama', addImg: 'Görsel yükle', addSave: 'Ekle', addNew: 'Yeni kart', delConfirm: 'Bu öğe silinsin mi?', delYes: 'Sil', authError: '🔒 Bu özellik yalnızca Admin Panelinde mevcuttur.' }, ru: { unsaved: 'Не сохранено: ', allSaved: 'Всё сохранено', save: '💾 Сохранить', cancel: 'Отмена', saving: 'Сохранение…', uploading: 'Загрузка изображений…', saved: '✔ Сохранено', cancelled: 'Изменения отменены', error: 'Ошибка:', autosaved: 'Переводы сохранены в фоне', hint: '✏️ Наведите на элемент → нажмите на значок (или дважды кликните)', edit: '✏️ Редактировать', change: '🖼️ Заменить', huge: 'Файл слишком большой (макс. 25МБ)', mediaReady: 'Медиа готово — загрузится при сохранении', add: '➕ Добавить', addCancel: 'Отмена', addName: 'Название', addPrice: 'Цена (AZN)', addDesc: 'Описание', addImg: 'Загрузить фото', addSave: 'Добавить', addNew: 'Новая карточка', delConfirm: 'Удалить этот элемент?', delYes: 'Удалить', authError: '🔒 Эта функция доступна только в панели администратора.' }, en: { unsaved: 'Unsaved: ', allSaved: 'All changes saved', save: '💾 Save', cancel: 'Cancel', saving: 'Saving…', uploading: 'Uploading images…', saved: '✔ Saved', cancelled: 'Changes discarded', error: 'Error:', autosaved: 'Translations saved in background', hint: '✏️ Hover an item → click the icon (or double-click)', edit: '✏️ Edit', change: '🖼️ Change', huge: 'File too large (max 25MB)', mediaReady: 'Media ready — uploads on Save', add: '➕ Add new', addCancel: 'Cancel', addName: 'Name', addPrice: 'Price (AZN)', addDesc: 'Description', addImg: 'Upload image', addSave: 'Add', addNew: 'New card', delConfirm: 'Delete this item?', delYes: 'Delete', authError: '🔒 This feature is available only in the admin panel.' }, es: { unsaved: 'Sin guardar: ', allSaved: 'Todo guardado', save: '💾 Guardar', cancel: 'Cancelar', saving: 'Guardando…', uploading: 'Subiendo imágenes…', saved: '✔ Guardado', cancelled: 'Cambios descartados', error: 'Error:', autosaved: 'Traducciones guardadas en segundo plano', hint: '✏️ Pase el cursor sobre un elemento → haga clic en el icono (o doble clic)', edit: '✏️ Editar', change: '🖼️ Cambiar', huge: 'Archivo demasiado grande (máx. 25MB)', mediaReady: 'Medios listos — se suben al guardar', add: '➕ Añadir nuevo', addCancel: 'Cancelar', addName: 'Nombre', addPrice: 'Precio (AZN)', addDesc: 'Descripción', addImg: 'Subir imagen', addSave: 'Añadir', addNew: 'Nueva tarjeta', delConfirm: '¿Eliminar este elemento?', delYes: 'Eliminar', authError: '🔒 Esta función está disponible solo en el panel de administración.' } }; const barT = k => (BAR_I18N[_lang()] && BAR_I18N[_lang()][k]) || BAR_I18N.en[k] || k; /* ---------- yardımcılar ---------- */ function esc(s) { return String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function sanitizeHtml(s) { return String(s) .replace(/<\s*script[\s\S]*?<\/\s*script\s*>/gi, '') .replace(/\son\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, '') .replace(/javascript\s*:/gi, ''); } function toast(msg, err) { const t = $('#ebStatus'); if (!t) return; t.textContent = msg; t.style.color = err ? '#f87171' : '#4ade80'; setTimeout(() => { if (t.textContent === msg) t.textContent = ''; }, 3500); } function getNested(o, path) { return path.split('.').reduce((c, k) => (c == null ? undefined : c[k]), o); } function setNested(o, path, v) { const p = path.split('.'); let c = o; for (let i = 0; i < p.length - 1; i++) { if (c[p[i]] == null || typeof c[p[i]] !== 'object') c[p[i]] = {}; c = c[p[i]]; } c[p[p.length - 1]] = v; } /* ---------- i18n dict'ler ---------- */ let I18N = {}; // { az: {...}, tr: {...}, ... } — server'dan (messages + overrides merge) let __aiTranslateEnabled = true; /* ---------- eşleme: DOM seçici → (i18n key | sitePath | media) ---------- */ const EDITABLES = []; function addEntry(el, kind, key, sitePath, opts = {}) { if (el.__entry) return el.__entry; const entry = Object.assign({ el, kind, key: (key || sitePath) + ':' + Math.random().toString(36).slice(2, 6), sitePath }, opts); entry.i18nKey = key; el.dataset.editable = '1'; el.dataset.editKind = kind; el.__entry = entry; EDITABLES.push(entry); return entry; } function addText(el, i18nKey, opts = {}) { if (!el) return; addEntry(el, 'text', i18nKey, null, opts); } function addMedia(el, sitePath, label, mediaType) { if (!el) return; addEntry(el, 'media', null, sitePath, { label, mediaType }); } function buildEditableMap() { /* Statik metinler — data-i18n-key özniteliği varsa ona göre */ $$('[data-i18n-key]').forEach(el => { const key = el.getAttribute('data-i18n-key'); if (key) addText(el, key, { html: el.getAttribute('data-i18n-html') === '1' }); }); /* Sayfa başlıkları (h1-h3) — data-i18n-key kapsamındakiler atlanır */ $$('h1, h2, h3').forEach(el => { if (el.closest('[data-i18n-key]')) return; const txt = el.textContent.trim(); if (txt && txt.length < 80) addText(el, null, { heading: true, fallback: txt }); }); /* Product cards — DB içeriği (db.product.{id}.title / .description) */ $$('[data-product-id]').forEach(el => { const id = el.getAttribute('data-product-id'); const nameEl = el.querySelector('.product-name, h3, .card-title'); const descEl = el.querySelector('.product-desc, p'); if (nameEl) addText(nameEl, 'db.product.' + id + '.title'); if (descEl) addText(descEl, 'db.product.' + id + '.description'); }); /* Service cards — DB içeriği (db.service.{id}.name / .description) */ $$('[data-service-id]').forEach(el => { const id = el.getAttribute('data-service-id'); const nameEl = el.querySelector('.service-name, h3, .card-title'); const descEl = el.querySelector('.service-desc, p'); if (nameEl) addText(nameEl, 'db.service.' + id + '.name'); if (descEl) addText(descEl, 'db.service.' + id + '.description'); }); /* Project (transformation) cards — db.project.{id}.title / .description */ $$('[data-project-id]').forEach(el => { const id = el.getAttribute('data-project-id'); const nameEl = el.querySelector('h3'); const descEl = el.querySelector('.italic, p'); if (nameEl) addText(nameEl, 'db.project.' + id + '.title'); if (descEl) addText(descEl, 'db.project.' + id + '.description'); }); /* Contact bilgileri (site_settings) — _site patch olarak kaydedilir */ $$('[data-site-field]').forEach(el => { const f = el.getAttribute('data-site-field'); if (!f) return; addEntry(el, 'site', null, 'contact.' + f, { popupFields: [{ sitePath: 'contact.' + f, label: f, type: 'text' }], popupTitle: f }); }); $$('a[href^="tel:"]').forEach(el => addEntry(el, 'site', null, 'contact.phone', { popupFields: [{ sitePath: 'contact.phone', label: 'Telefon', type: 'tel' }], popupTitle: 'Telefon' })); $$('a[href*="wa.me"]').forEach(el => addEntry(el, 'site', null, 'contact.whatsapp', { popupFields: [{ sitePath: 'contact.whatsapp', label: 'WhatsApp URL', type: 'url' }], popupTitle: 'WhatsApp' })); $$('a[href*="instagram.com"]').forEach(el => addEntry(el, 'site', null, 'contact.instagram', { popupFields: [{ sitePath: 'contact.instagram', label: 'Instagram URL', type: 'url' }], popupTitle: 'Instagram' })); /* Kart görselleri — db.media.* override ile kalıcı */ $$('[data-product-id] img, [data-project-id] img, [data-service-id] img').forEach((el, i) => { if (el.closest('[data-editable]')) return; addMedia(el, 'images.card' + i, 'Görsel ' + (i + 1), 'image'); }); } /* ---------- editörler ---------- */ function entryDomValue(entry) { if (entry.kind === 'text' || entry.kind === 'html') return entry.kind === 'html' ? entry.el.innerHTML : entry.el.textContent; if (entry.kind === 'media') return null; return entry.el.textContent; } function beginInlineEdit(entry) { if (_editing) endInlineEdit(); const el = entry.el; _editing = entry; entry.origValue = entryDomValue(entry); let plain = false; el.contentEditable = 'plaintext-only'; if (el.contentEditable === 'plaintext-only') plain = true; if (!plain) el.contentEditable = 'true'; el.classList.add('editing'); el.focus(); const sel = window.getSelection(); if (sel && sel.rangeCount) { const r = document.createRange(); r.selectNodeContents(el); r.collapse(false); sel.removeAllRanges(); sel.addRange(r); } const onInput = () => { const lang = _lang(); const dict = I18N[lang]; if (!dict || !entry.i18nKey) return; const val = entry.kind === 'html' ? sanitizeHtml(el.innerHTML) : el.textContent; entry._lastVal = val; setNested(dict, entry.i18nKey, val); i18nDirty.add(entry.i18nKey); i18nDirtyLangs.add(lang); dirtySrcLangs.add(lang); dirty.add(entry.key); updateBar(); if (__aiTranslateEnabled && entry.i18nKey && val && val !== entry._aiSource) { queueTranslate(val, lang, entry.i18nKey); } }; const onPaste = e => { e.preventDefault(); const t = (e.clipboardData.getData('text/plain') || '').replace(/\n+/g, ' ').trim(); document.execCommand('insertText', false, t); }; const onKey = e => { if (e.key === 'Enter') { e.preventDefault(); el.blur(); } else if (e.key === 'Escape') { const dict = I18N[_lang()]; if (entry.kind === 'html') { el.innerHTML = entry.origValue; if (dict && entry.i18nKey) setNested(dict, entry.i18nKey, entry.origValue); } else { el.textContent = entry.origValue; if (dict && entry.i18nKey) setNested(dict, entry.i18nKey, entry.origValue); } entry._lastVal = null; el.blur(); } }; const end = () => { el.removeAttribute('contenteditable'); el.classList.remove('editing'); el.removeEventListener('input', onInput); el.removeEventListener('paste', onPaste); el.removeEventListener('keydown', onKey); el.removeEventListener('blur', end); if (_editing === entry) _editing = null; if (__aiTranslateEnabled && entry.i18nKey && entry._lastVal && entry._lastVal !== entry._aiSource) { clearTimeout(entry._trTimer); queueTranslate(entry._lastVal, _lang(), entry.i18nKey); } }; entry._end = end; entry._input = onInput; entry._paste = onPaste; entry._key = onKey; el.addEventListener('input', onInput); el.addEventListener('paste', onPaste); el.addEventListener('keydown', onKey); el.addEventListener('blur', end); } function endInlineEdit() { if (_editing && _editing.el) { const el = _editing.el; el.removeAttribute('contenteditable'); el.classList.remove('editing'); if (_editing._end) { el.removeEventListener('blur', _editing._end); el.removeEventListener('input', _editing._input); el.removeEventListener('paste', _editing._paste); el.removeEventListener('keydown', _editing._key); } _editing = null; } } function openPopup(entry) { const rect = entry.el.getBoundingClientRect(); const f = entry.popupFields; entry.origFields = {}; f.forEach(p => { entry.origFields[p.sitePath] = entry.el.textContent; }); const valOf = p => { const staged = getNested(sitePatch, p.sitePath); if (staged !== undefined) return staged; return entry.origFields[p.sitePath] || ''; }; const pop = document.createElement('div'); pop.className = 'edit-popup'; pop.innerHTML = `
${esc(entry.popupTitle || 'Düzenle')}
${f.map((p, i) => ``).join('')}
`; Object.assign(pop.style, { left: Math.min(rect.left, innerWidth - 280) + 'px', top: Math.min(Math.max(10, rect.bottom + 8), innerHeight - 150) + 'px' }); document.body.appendChild(pop); const close = () => pop.remove(); pop.querySelector('.ep-x').onclick = close; pop.querySelector('.ep-cancel').onclick = close; pop.querySelector('.ep-ok').onclick = () => { f.forEach((p, i) => { const v = pop.querySelector('.ep-in[data-i="' + i + '"]').value.trim(); setNested(sitePatch, p.sitePath, v); if (p.sitePath === 'contact.phone' && entry.el.tagName === 'A' && entry.el.getAttribute('href')?.startsWith('tel:')) entry.el.href = 'tel:' + String(v).replace(/[^+\d]/g, ''); else entry.el.textContent = v; dirty.add(entry.key); updateBar(); }); close(); }; pop.querySelector('.ep-in').focus(); const kd = e => { if (e.key === 'Escape') close(); if (e.key === 'Enter') pop.querySelector('.ep-ok').click(); }; pop.addEventListener('keydown', kd); } function mediaChange(entry) { const make = capture => { const i = document.createElement('input'); i.type = 'file'; i.accept = entry.mediaType === 'video' ? 'video/*' : 'image/*'; if (capture) i.setAttribute('capture', 'environment'); return i; }; const handle = f => { if (!f) return; if (f.size > 25 * 1024 * 1024) { toast(barT('huge'), 'err'); return; } if (!entry.origUrl) entry.origUrl = entry.el.currentSrc || entry.el.src || ''; if (entry.mediaType !== 'video') { const probe = new Image(); probe.onload = () => { entry.el.style.aspectRatio = probe.naturalWidth + '/' + probe.naturalHeight; entry.el.src = URL.createObjectURL(f); entry.pendingFile = f; dirty.add(entry.key); updateBar(); toast(barT('mediaReady')); }; probe.src = URL.createObjectURL(f); } else { entry.el.src = URL.createObjectURL(f); entry.pendingFile = f; dirty.add(entry.key); updateBar(); toast(barT('mediaReady')); } }; const i = make(false); i.onchange = () => handle(i.files[0]); i.click(); } /* ---------- AI çeviri kuyruğu (arka planda, aynı anda 1 istek) ---------- */ let _trQueue = [], _trRunning = false, _trTimer = null, _autoSaveTimer = null; function queueTranslate(text, sourceLang, i18nKey) { if (!__aiTranslateEnabled || !i18nKey || !text) return; clearTimeout(_trTimer); _trTimer = setTimeout(() => { _trQueue = _trQueue.filter(j => j.key !== i18nKey); _trQueue.push({ text, sourceLang, key: i18nKey, retries: 0 }); flushTranslate(); }, 1200); } async function flushTranslate() { if (_trRunning || !_trQueue.length || _busy) return; _trRunning = true; const job = _trQueue.shift(); try { const r = await fetch(API_BASE + '/api/admin/live-edit/translate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: job.text, sourceLang: job.sourceLang }) }); if (r.status === 401) { __aiTranslateEnabled = false; return; } if (!r.ok) throw new Error('translate ' + r.status); const j = await r.json(); const tr = j.translations || {}; let applied = false; for (const lang of Object.keys(tr)) { if (!tr[lang]) continue; const dict = I18N[lang]; if (!dict) continue; setNested(dict, job.key, tr[lang]); i18nDirty.add(job.key); i18nDirtyLangs.add(lang); applied = true; } if (applied) { applyI18nLive(); dirty.add('ai-' + job.key); updateBar(); } } catch (e) { if (job.retries < 2) { job.retries++; _trQueue.unshift(job); } } finally { _trRunning = false; if (_trQueue.length) setTimeout(flushTranslate, 60); else maybeAutoSave(); } } let _applyingDom = false; function applyI18nLive() { if (_applyingDom) return; _applyingDom = true; try { const lang = _lang(); const dict = I18N[lang]; if (!dict) return; for (const entry of EDITABLES) { if (!entry.i18nKey) continue; if (_editing && _editing === entry) continue; const v = getNested(dict, entry.i18nKey); if (typeof v !== 'string') continue; if (entry.kind === 'html') entry.el.innerHTML = sanitizeHtml(v); else if (entry.kind === 'text') entry.el.textContent = v; } } finally { _applyingDom = false; } } function buildPatchForLangs(langs) { const body = {}; for (const lang of langs) { const dict = I18N[lang]; if (!dict) continue; const patch = {}; for (const key of i18nDirty) { const v = getNested(dict, key); if (typeof v === 'string') setNested(patch, key, v); } if (Object.keys(patch).length) body[lang] = patch; } return body; } function maybeAutoSave() { if (_busy || _trRunning || _trQueue.length || _editing || _autoSaveTimer) return; if (!i18nDirtyLangs.size && !Object.keys(sitePatch).length && !dirty.size) return; _autoSaveTimer = setTimeout(async () => { _autoSaveTimer = null; if (_busy || _editing) return; try { const langs = new Set([...dirtySrcLangs, ...i18nDirtyLangs]); const body = buildPatchForLangs([...langs]); if (Object.keys(sitePatch).length) body._site = sitePatch; if (Object.keys(calcPatch).length) body._calc = calcPatch; if (!Object.keys(body).length) { maybeAutoSave(); return; } await putI18n(body); i18nDirty.clear(); i18nDirtyLangs.clear(); dirtySrcLangs.clear(); sitePatch = {}; calcPatch = {}; [...dirty].forEach(k => { if (k.startsWith('ai-')) dirty.delete(k); }); updateBar(); toast(barT('autosaved')); } catch (e) { /* sessiz — bir sonraki kayıt dener */ } }, 2000); } /* ---------- kaydetme barı ---------- */ let bar = null; function ensureBar() { if (bar) return; bar = document.createElement('div'); bar.id = 'editBar'; bar.className = 'edit-bar'; bar.hidden = true; bar.innerHTML = ` `; document.body.appendChild(bar); bar.querySelector('#ebCancel').onclick = doCancel; bar.querySelector('#ebSave').onclick = doSave; if (window.ResizeObserver) new ResizeObserver(fitBody).observe(bar); window.addEventListener('resize', fitBody); if (window.visualViewport) { window.visualViewport.addEventListener('resize', () => { if (!bar) return; bar.style.bottom = Math.max(8, window.innerHeight - window.visualViewport.height) + 'px'; }); } } function fitBody() { if (!bar) return; document.body.style.paddingBottom = (bar.getBoundingClientRect().height + 12) + 'px'; } function updateBar() { ensureBar(); const n = dirty.size; bar.hidden = false; $('#ebCount').textContent = n ? (barT('unsaved') + n) : barT('allSaved'); $('#ebLang').textContent = _lang().toUpperCase(); $('#ebSave').textContent = barT('save'); $('#ebCancel').textContent = barT('cancel'); $('#ebSave').disabled = !n; fitBody(); } function setBusy(b, phase) { if (!bar) return; $('#ebSave').disabled = b; $('#ebCancel').disabled = b; if (b) $('#ebCount').textContent = phase === 'upload' ? barT('uploading') : barT('saving'); else updateBar(); } async function uploadFile(file) { const b64 = await new Promise((resolve, reject) => { const rd = new FileReader(); rd.onload = () => resolve(rd.result); rd.onerror = reject; rd.readAsDataURL(file); }); const r = await fetch(API_BASE + '/api/admin/live-edit/upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file: b64, filename: file.name }) }); if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || 'Upload failed'); } const j = await r.json(); return { url: j.url }; } async function putI18n(body) { const r = await fetch(API_BASE + '/api/admin/live-edit/i18n', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || 'Save failed'); } return r.json(); } async function apiJson(path, opts = {}) { const r = await fetch(API_BASE + path, { method: opts.method || 'GET', headers: { 'Content-Type': 'application/json' }, body: opts.body ? JSON.stringify(opts.body) : undefined }); const j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || j.message || 'Istek basarisiz (' + r.status + ')'); return j; } /* ---------- Yeni içerik ekleme (ürün / hizmet / proje) ---------- */ const ADD_BASE = { project: { path: '/api/admin/live-edit/entities', kind: 'project' }, product: { path: '/api/admin/live-edit/entities', kind: 'product' }, service: { path: '/api/admin/live-edit/entities', kind: 'service' } }; function addUI(kind, anchorEl) { closeAddUI(); const base = ADD_BASE[kind]; const pop = document.createElement('div'); pop.className = 'edit-popup add-popup'; pop.innerHTML = `
${barT('addNew')} (${kind})
`; if (anchorEl) { const rect = anchorEl.getBoundingClientRect(); Object.assign(pop.style, { left: Math.min(rect.left, innerWidth - 280) + 'px', top: Math.min(Math.max(10, rect.bottom + 8), innerHeight - 280) + 'px' }); } else Object.assign(pop.style, { left: '50%', top: '30%', transform: 'translateX(-50%)' }); document.body.appendChild(pop); _addUI = pop; const close = () => { pop.remove(); _addUI = null; }; pop.querySelector('.ep-x').onclick = close; pop.querySelector('.ep-cancel').onclick = close; const fImg = pop.querySelector('[data-f="img"]'); if (fImg) fImg.addEventListener('change', () => { const f0 = fImg.files && fImg.files[0]; const preview = pop.querySelector('[data-f="preview"]'); if (f0 && preview) { preview.src = URL.createObjectURL(f0); preview.style.display = 'block'; } }); pop.querySelector('.ep-ok').onclick = async () => { if (pop.__busy) return; const name = pop.querySelector('[data-f="name"]').value.trim(); if (!name) { pop.querySelector('[data-f="name"]').focus(); return; } const desc = pop.querySelector('[data-f="desc"]').value.trim(); const priceRaw = pop.querySelector('[data-f="price"]').value.trim(); const fileEl = pop.querySelector('[data-f="img"]'); let imgUrl = ''; pop.__busy = true; const btn = pop.querySelector('.ep-ok'); btn.disabled = true; btn.textContent = barT('saving'); if (fileEl && fileEl.files && fileEl.files[0]) { try { const res = await uploadFile(fileEl.files[0]); imgUrl = res.url; } catch (e) { toast(barT('error') + ' ' + e.message, 'err'); } } const payload = { kind: base.kind, name, description: desc }; if (priceRaw) payload.price = parseFloat(priceRaw); if (imgUrl) payload.imageUrl = imgUrl; try { setBusy(true, 'saving'); await apiJson(base.path, { method: 'POST', body: payload }); toast(barT('saved')); close(); setTimeout(() => location.reload(), 600); } catch (err) { toast(barT('error') + ' ' + (err && err.message ? err.message : err), 'err'); btn.disabled = false; btn.textContent = barT('addSave'); pop.__busy = false; } finally { setBusy(false); } }; pop.querySelector('[data-f="name"]').focus(); const kd = e => { if (e.key === 'Escape') close(); if (e.key === 'Enter') pop.querySelector('.ep-ok').click(); }; pop.addEventListener('keydown', kd); } function closeAddUI() { if (_addUI) { _addUI.remove(); _addUI = null; } } function ensureAddButtons() { if (document.querySelector('.le-add')) return; const css = document.createElement('style'); css.id = 'leAddCss'; css.textContent = `.le-add{position:fixed;z-index:2002;width:46px;height:46px;border-radius:50%;background:#1A1A1A;border:2px solid #8B7355;color:#8B7355;font-size:22px;font-weight:800;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 14px rgba(0,0,0,.5);transition:transform .15s} .le-add:hover{transform:scale(1.12)} .le-add-draft{right:14px;bottom:252px} .le-add-draft-card{position:relative;flex:0 0 auto;width:280px;min-height:320px;border:2px dashed #8B7355;border-radius:24px;background:rgba(139,115,85,.06);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:#8B7355;font:600 14px Inter,system-ui,sans-serif;cursor:pointer;text-align:center;padding:16px} .le-add-draft-card:hover{background:rgba(139,115,85,.12)} .le-del{position:absolute;top:8px;right:8px;z-index:50;width:30px;height:30px;border-radius:50%;background:rgba(26,26,26,.9);border:2px solid #f87171;color:#f87171;font-size:14px;font-weight:800;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,.4);transition:transform .15s} .le-del:hover{transform:scale(1.15);background:#f87171;color:#fff} .le-modal-backdrop{position:fixed;inset:0;z-index:3000;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;padding:20px} .le-modal{background:#1A1A1A;border:1px solid #8B7355;border-radius:16px;padding:22px 24px;max-width:320px;width:100%;text-align:center;font:600 14px Inter,system-ui,sans-serif;color:#FAFAF8;box-shadow:0 12px 40px rgba(0,0,0,.6)} .le-modal p{margin:0 0 18px;line-height:1.5} .le-modal .lm-actions{display:flex;gap:10px} .le-modal .lm-actions button{flex:1;border:0;border-radius:10px;padding:11px;font:600 13px Inter,system-ui,sans-serif;cursor:pointer} .le-modal .lm-cancel{background:#2a2a2a;color:#c9c9c9} .le-modal .lm-ok{background:#f87171;color:#fff} @media (max-width: 640px){ .le-add{width:40px;height:40px;font-size:19px} .le-add-projects{right:10px;bottom:76px} .le-add-products{right:10px;bottom:124px} .le-add-services{right:10px;bottom:172px} .le-add-draft-card{width:220px;min-height:260px} .add-popup{width:90vw;max-width:300px;left:50%!important;right:auto!important;transform:translateX(-50%);top:20%!important} } .le-add.le-add-projects{right:14px;bottom:84px} .le-add.le-add-products{right:14px;bottom:140px} .le-add.le-add-services{right:14px;bottom:196px} .add-popup .ep-in{width:100%}`; document.head.appendChild(css); const mk = (cls, kind, label) => { const b = document.createElement('button'); b.className = 'le-add ' + cls; b.textContent = '+'; b.title = label; b.setAttribute('aria-label', label); b.onclick = e => { e.preventDefault(); e.stopPropagation(); addUI(kind, b); }; document.body.appendChild(b); return b; }; const path = location.pathname; const isProjectsPage = path.includes('/transformations') || !!document.querySelector('[data-add-section="projects"]'); const isProductsPage = path.includes('/catalog') || !!document.querySelector('[data-add-section="products"]'); const isServicesPage = path.includes('/services') || !!document.querySelector('[data-add-section="services"]'); if (isProjectsPage) mk('le-add-projects', 'project', barT('add') + ' project'); if (isProductsPage) mk('le-add-products', 'product', barT('add') + ' product'); if (isServicesPage) mk('le-add-services', 'service', barT('add') + ' service'); } /* ---------- Kart silme (X) ---------- */ function ensureDeleteButtons() { const targets = [ { sel: '[data-product-id]', kind: 'product', idEl: 'product', path: '/api/admin/live-edit/entities/' }, { sel: '[data-project-id]', kind: 'project', idEl: 'project', path: '/api/admin/live-edit/entities/' }, { sel: '[data-service-id]', kind: 'service', idEl: 'service', path: '/api/admin/live-edit/entities/' } ]; for (const t of targets) { document.querySelectorAll(t.sel).forEach(card => { if (card.querySelector('.le-del')) return; const id = card.getAttribute('data-' + t.idEl + '-id'); if (!id) return; const x = document.createElement('button'); x.className = 'le-del'; x.textContent = 'X'; x.title = 'Sil'; x.setAttribute('aria-label', 'Sil'); x.onclick = e => { e.preventDefault(); e.stopPropagation(); const backdrop = document.createElement('div'); backdrop.className = 'le-modal-backdrop'; backdrop.innerHTML = `

${barT('delConfirm')} (${id})

`; document.body.appendChild(backdrop); const closeModal = () => backdrop.remove(); backdrop.querySelector('.lm-cancel').onclick = closeModal; backdrop.querySelector('.le-modal').addEventListener('click', e => e.stopPropagation()); backdrop.onclick = closeModal; backdrop.querySelector('.lm-ok').onclick = () => { closeModal(); apiJson(t.path + t.kind + '/' + id, { method: 'DELETE' }) .then(() => { toast(barT('saved')); setTimeout(() => location.reload(), 400); }) .catch(err => toast(barT('error') + ' ' + (err && err.message ? err.message : err), 'err')); }; }; card.style.position = card.style.position || 'relative'; card.appendChild(x); }); } } /* ---------- Yerinde yeni kart ekleme (draft card) ---------- */ function ensureDraftCard() { const section = document.querySelector('[data-add-section="projects"]') || document.querySelector('[data-add-section="products"]') || document.querySelector('[data-add-section="services"]'); if (!section) return; const kind = section.getAttribute('data-add-section'); if (document.querySelector('.le-add-draft-card')) return; const card = document.createElement('div'); card.className = 'le-add-draft-card'; card.textContent = '+ ' + barT('add'); card.onclick = e => { e.preventDefault(); e.stopPropagation(); addUI(kind, card); }; section.appendChild(card); } async function doSave() { if (!dirty.size || _busy) return; _busy = true; setBusy(true, 'upload'); try { // 1. Görsel yüklemeleri PARALEL (max 4) const jobs = EDITABLES.filter(e => e.pendingFile && dirty.has(e.key)); if (jobs.length) { let i = 0; const workers = Array.from({ length: Math.min(4, jobs.length) }, async () => { while (i < jobs.length) { const entry = jobs[i++]; const res = await uploadFile(entry.pendingFile); setNested(sitePatch, entry.sitePath, res.url); entry.el.src = res.url; entry.pendingFile = null; entry.origUrl = res.url; dirty.delete(entry.key); } }); await Promise.all(workers); } setBusy(true, 'save'); // 2. Tek PUT: kaynak dil + çevrilen diller + _site patch const langs = new Set([...dirtySrcLangs, ...i18nDirtyLangs]); const body = buildPatchForLangs([...langs]); if (Object.keys(sitePatch).length) body._site = sitePatch; if (Object.keys(calcPatch).length) body._calc = calcPatch; if (Object.keys(body).length) await putI18n(body); dirty.clear(); i18nDirty.clear(); i18nDirtyLangs.clear(); dirtySrcLangs.clear(); sitePatch = {}; calcPatch = {}; updateBar(); toast(barT('saved')); } catch (err) { toast(barT('error') + ' ' + (err && err.message ? err.message : err), 'err'); } finally { _busy = false; setBusy(false); } } function doCancel() { [...dirty].forEach(key => { const entry = EDITABLES.find(x => x.key === key); if (!entry) return; if (entry.kind === 'text' || entry.kind === 'html') { const dict = I18N[_lang()]; if (entry.kind === 'html') { entry.el.innerHTML = entry.origValue; if (dict && entry.i18nKey) setNested(dict, entry.i18nKey, entry.origValue); } else { entry.el.textContent = entry.origValue; if (dict && entry.i18nKey) setNested(dict, entry.i18nKey, entry.origValue); } } else if (entry.kind === 'media') { if (entry.origUrl) { entry.el.src = entry.origUrl; entry.pendingFile = null; } } else if (entry.kind === 'site') { (entry.popupFields || []).forEach(p => { const orig = entry.origFields && entry.origFields[p.sitePath]; if (orig !== undefined) entry.el.textContent = orig; }); } }); dirty.clear(); i18nDirty.clear(); i18nDirtyLangs.clear(); dirtySrcLangs.clear(); sitePatch = {}; calcPatch = {}; clearTimeout(_trTimer); clearTimeout(_autoSaveTimer); _trQueue = []; if (bar) { bar.hidden = true; } toast(barT('cancelled')); } /* ---------- navigasyon koruma: edit modu asla düşmez ---------- */ function ensureEditHref(href) { const u = new URL(href, location.origin); if (u.origin !== location.origin) return href; const sp = new URLSearchParams(u.search); sp.set('edit', '1'); u.search = sp.toString(); return u.href; } function interceptNav() { document.addEventListener('click', e => { if (!document.body.classList.contains('edit-mode')) return; if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; const a = e.target.closest && e.target.closest('a[href]'); if (!a) return; const href = a.getAttribute('href') || ''; if (/^(mailto:|tel:|javascript:|#)/i.test(href.trim())) return; const u = new URL(a.href, location.origin); if (u.origin !== location.origin) return; e.preventDefault(); e.stopPropagation(); if (_editing) endInlineEdit(); location.href = ensureEditHref(a.href); }, true); } /* ---------- dil değişimi (MutationObserver) + guard ---------- */ function watchLang() { const ob = new MutationObserver(() => { if (_editing) endInlineEdit(); updateBar(); }); ob.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] }); } const guard = e => { if ([...dirty].some(k => !k.startsWith('ai-'))) { e.preventDefault(); e.returnValue = ''; } }; window.addEventListener('beforeunload', guard); window.addEventListener('pagehide', guard); /* ---------- CSS ---------- */ function injectEditCss() { const st = document.createElement('style'); st.id = 'liveEditCss'; st.textContent = ` body.edit-mode *{cursor:auto!important} [data-editable]:hover{outline:2px dashed #8B7355!important;outline-offset:2px} [data-editable].editing{outline:2px solid #8B7355!important;overflow-wrap:anywhere;white-space:pre-wrap;max-height:48vh;overflow-y:auto} @media(hover:none){[data-editable]{outline:1px dashed rgba(139,115,85,.45)!important;outline-offset:1px}} .edit-chip{position:fixed;z-index:2001;background:#1A1A1A;color:#D4C5A9;border:1px solid #8B7355;border-radius:22px;padding:10px 18px;min-height:40px;font:600 13px/1.2 Inter,system-ui,sans-serif;letter-spacing:.02em;box-shadow:0 4px 14px rgba(0,0,0,.4);cursor:pointer;user-select:none;pointer-events:auto;transition:transform .15s,opacity .15s} .edit-chip:hover{background:#2a2a2a;transform:scale(1.05)} .edit-chip[hidden]{display:none!important} .edit-bar{position:fixed;left:8px;right:8px;bottom:8px;z-index:1001;display:flex;align-items:center;gap:8px;max-width:560px;margin-inline:auto;padding:6px 12px;background:rgba(26,26,26,.94);border:1px solid #8B7355;border-radius:14px;backdrop-filter:blur(10px);font:500 12px Inter,system-ui,sans-serif;color:#FAFAF8;box-shadow:0 6px 24px rgba(0,0,0,.45)} .edit-bar .eb-count{color:#D4C5A9;white-space:nowrap;font-weight:700} .edit-bar .eb-lang{background:#8B7355;color:#FAFAF8;border-radius:6px;padding:1px 7px;font-weight:700;font-size:10px} .edit-bar .eb-status{font-size:11px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1} .edit-bar .eb-spacer{display:none} .edit-bar button{border:0;border-radius:8px;padding:5px 12px;font:600 12px Inter,system-ui,sans-serif;cursor:pointer} .edit-bar .eb-cancel{background:#2a2a2a;color:#c9c9c9} .edit-bar .eb-save{background:#8B7355;color:#FAFAF8} .edit-bar .eb-save:disabled{opacity:.5;cursor:default} .edit-popup{position:fixed;z-index:1003;width:260px;background:#1A1A1A;border:1px solid #8B7355;border-radius:12px;padding:14px;box-shadow:0 8px 30px rgba(0,0,0,.5);font:500 12px Inter,system-ui,sans-serif;color:#FAFAF8} .edit-popup .ep-head{display:flex;justify-content:space-between;align-items:center;font-weight:700;margin-bottom:10px;color:#D4C5A9} .edit-popup .ep-x{border:0;background:none;color:#8b93a7;font-size:14px;cursor:pointer} .edit-popup label{display:block;margin:8px 0 4px;color:#9aa3b8;font-size:11px;text-transform:uppercase;letter-spacing:.05em} .edit-popup input,.edit-popup textarea{width:100%;box-sizing:border-box;background:#2a2a2a;border:1px solid #8B7355;border-radius:8px;color:#FAFAF8;padding:8px 10px;font:500 13px Inter,system-ui,sans-serif} .edit-popup input:focus{outline:1px solid #8B7355} .edit-popup .ep-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:12px} .edit-popup .ep-actions button{border:0;border-radius:8px;padding:7px 14px;font:600 12px Inter,system-ui,sans-serif;cursor:pointer} .edit-popup .ep-cancel{background:#2a2a2a;color:#c9c9c9} .edit-popup .ep-ok{background:#8B7355;color:#FAFAF8}`; document.head.appendChild(st); } /* ---------- chip + tıklama politikası (mobil: basılı tutma) ---------- */ let chip = null, _chipEntry = null, _chipTimer = null, _touchTimer2 = null; function ensureChip() { if (chip) return; chip = document.createElement('div'); chip.id = 'editChip'; chip.className = 'edit-chip'; chip.hidden = true; document.body.appendChild(chip); } function showChip(entry, el) { ensureChip(); _chipEntry = entry; chip.textContent = entry.kind === 'media' ? barT('change') : barT('edit'); const r = el.getBoundingClientRect(); chip.hidden = false; chip.style.left = Math.min(Math.max(4, r.left), innerWidth - chip.offsetWidth - 4) + 'px'; chip.style.top = Math.max(4, r.top - chip.offsetHeight - 8) + 'px'; chip.onclick = e => { e.preventDefault(); e.stopPropagation(); hideChip(); openEditor(entry); }; } function hideChip() { if (chip) { chip.hidden = true; _chipEntry = null; } } const chipBounds = () => { if (!chip || chip.hidden) return null; const r = chip.getBoundingClientRect(); return { x: r.left, y: r.top, w: r.width, h: r.height }; }; const overChip = e => { const b = chipBounds(); if (!b) return false; const t = e.touches && e.touches[0]; const x = (t ? t.clientX : e.clientX), y = (t ? t.clientY : e.clientY); return x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h; }; document.addEventListener('pointermove', e => { if (!document.body.classList.contains('edit-mode')) return; const b = chipBounds(); if (!b) return; if (overChip(e)) { clearTimeout(_chipTimer); return; } if (_chipEntry && _chipEntry.el && _chipEntry.el.contains && _chipEntry.el.contains(e.target)) return; if (e.target.closest && e.target.closest('.edit-chip,.edit-bar,.edit-popup')) return; clearTimeout(_chipTimer); _chipTimer = setTimeout(() => { if (!overChip(e)) hideChip(); }, 250); }); document.addEventListener('pointerover', e => { if (!document.body.classList.contains('edit-mode')) return; if (e.target.closest && e.target.closest('.edit-chip,.edit-bar,.edit-popup')) { clearTimeout(_chipTimer); return; } const t = e.target.closest && e.target.closest('[data-editable]'); if (!t) { if (!overChip(e)) hideChip(); return; } const entry = t.__entry; if (!entry) { if (!overChip(e)) hideChip(); return; } clearTimeout(_chipTimer); _chipTimer = setTimeout(() => showChip(entry, t), 80); }); document.addEventListener('touchstart', e => { if (!document.body.classList.contains('edit-mode')) return; const t = e.target.closest && e.target.closest('[data-editable]'); if (!t || !t.__entry) return; clearTimeout(_touchTimer2); _touchTimer2 = setTimeout(() => showChip(t.__entry, t), 350); }, { passive: true }); document.addEventListener('touchend', () => clearTimeout(_touchTimer2)); document.addEventListener('touchmove', () => clearTimeout(_touchTimer2), { passive: true }); function openEditor(entry) { if (entry.kind === 'text' || entry.kind === 'html') beginInlineEdit(entry); else if (entry.kind === 'site') openPopup(entry); else mediaChange(entry); } document.addEventListener('dblclick', e => { if (!document.body.classList.contains('edit-mode')) return; if (e.target.closest('.edit-chip,.edit-bar,.edit-popup')) return; const t = e.target.closest && e.target.closest('[data-editable]'); if (!t) return; const entry = t.__entry; if (!entry) return; e.preventDefault(); e.stopPropagation(); if (entry.kind === 'media') { mediaChange(entry); return; } openEditor(entry); }, true); /* ---------- başlat ---------- */ async function start() { document.body.classList.add('edit-mode'); injectEditCss(); buildEditableMap(); ensureAddButtons(); ensureDraftCard(); ensureDeleteButtons(); const obs = new MutationObserver(() => ensureDeleteButtons()); obs.observe(document.body, { childList: true, subtree: true }); interceptNav(); watchLang(); ensureBar(); updateBar(); if (!sessionStorage.getItem('editHintShown')) { sessionStorage.setItem('editHintShown', '1'); const hb = document.createElement('div'); hb.id = 'editHint'; hb.style.cssText = 'position:fixed;top:120px;left:50%;transform:translateX(-50%);z-index:2000;background:#1A1A1A;color:#D4C5A9;border:1px solid #8B7355;border-radius:12px;padding:12px 20px;font:600 13px Inter,system-ui,sans-serif;box-shadow:0 8px 30px rgba(0,0,0,.5);pointer-events:none;opacity:0;transition:opacity .3s'; hb.textContent = barT('hint'); document.body.appendChild(hb); requestAnimationFrame(() => { hb.style.opacity = '1'; setTimeout(() => { hb.style.opacity = '0'; setTimeout(() => hb.remove(), 350); }, 2600); }); } } (async function init() { try { // 1. Yetki doğrula (cookie session — token yok) const authRes = await fetch(API_BASE + '/api/admin/live-edit/check-auth'); if (!authRes.ok) { const msg = document.createElement('div'); msg.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:9999;background:#1A1A1A;color:#f87171;border:1px solid #f87171;border-radius:12px;padding:14px 22px;font:600 13px Inter,system-ui,sans-serif;box-shadow:0 8px 30px rgba(0,0,0,.5)'; msg.textContent = barT('authError'); document.body.appendChild(msg); return; } // 2. i18n'i yükle (messages + overrides) const i18nRes = await fetch(API_BASE + '/api/admin/live-edit/i18n'); if (i18nRes.ok) { const j = await i18nRes.json(); if (j.locales) I18N = j.locales; } start(); } catch (e) { console.error('[live-edit] init error:', e); } })(); })();