v1.8.7: Pages — visual block builder with heading/paragraph/list/divider/hero/contact blocks, HTML editor mode, all pages (privacy/terms/about/home)

This commit is contained in:
2026-05-28 19:58:34 +00:00
parent b42e0935af
commit 49b5b195c3
3 changed files with 343 additions and 61 deletions
Binary file not shown.
@@ -52309,3 +52309,35 @@ $3
del
$13
unacked_mutex
*5
$3
SET
$13
unacked_mutex
$32
5ee2e2965acf11f199e682dacbd0314b
$4
PXAT
$13
1779998512185
*2
$3
del
$13
unacked_mutex
*5
$3
SET
$13
unacked_mutex
$32
9a7f43085acf11f199e682dacbd0314b
$4
PXAT
$13
1779998612196
*2
$3
del
$13
unacked_mutex
+311 -61
View File
@@ -244,45 +244,83 @@ a[role=button]:active {
<p id="login-err" class="text-error text-xs mt-3 hidden">Invalid credentials</p>
<section id="page-pages" class="page hidden">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-xl font-bold">Pages</h2>
<p class="text-sm text-on-surface-variant mt-1">Edit site pages content</p>
<p class="text-sm text-on-surface-variant mt-1">Frontend builder — edit any page visually</p>
</div>
<div class="flex gap-2">
<a id="pb-preview-btn" href="/privacy" target="_blank"
class="glass glass-hover px-4 py-2 rounded-xl text-sm flex items-center gap-2">
<span class="msym text-base">open_in_new</span> Preview
</a>
<button onclick="pbSave()" id="pb-save-btn"
class="bg-primary text-surface font-bold px-5 py-2 rounded-xl text-sm flex items-center gap-2 hover:shadow-[0_0_20px_rgba(78,222,163,0.4)] transition-all">
<span class="msym text-base">save</span> Save
</button>
</div>
</div>
<!-- Page Tabs -->
<div class="flex gap-2 mb-6">
<button id="pages-tab-privacy" onclick="switchPageTab('privacy')"
class="px-4 py-2 rounded-xl text-sm font-semibold bg-primary text-surface transition-all">
سياسة الخصوصية
<!-- Page selector -->
<div class="flex flex-wrap gap-2 mb-6">
<button onclick="pbSwitch('privacy')" id="pb-tab-privacy" class="pb-tab px-4 py-2 rounded-xl text-xs font-bold bg-primary text-surface transition-all">Privacy</button>
<button onclick="pbSwitch('terms')" id="pb-tab-terms" class="pb-tab px-4 py-2 rounded-xl text-xs font-bold glass text-on-surface-variant hover:text-primary transition-all">Terms</button>
<button onclick="pbSwitch('about')" id="pb-tab-about" class="pb-tab px-4 py-2 rounded-xl text-xs font-bold glass text-on-surface-variant hover:text-primary transition-all">About</button>
<button onclick="pbSwitch('index')" id="pb-tab-index" class="pb-tab px-4 py-2 rounded-xl text-xs font-bold glass text-on-surface-variant hover:text-primary transition-all">Home</button>
</div>
<!-- Mode toggle -->
<div class="flex gap-2 mb-4">
<button onclick="pbSetMode('visual')" id="pb-mode-visual"
class="px-3 py-1.5 rounded-lg text-xs font-bold bg-primary/20 text-primary border border-primary/30 flex items-center gap-1">
<span class="msym text-sm">dashboard</span> Visual Builder
</button>
<button id="pages-tab-terms" onclick="switchPageTab('terms')"
class="px-4 py-2 rounded-xl text-sm font-semibold glass text-on-surface-variant hover:text-primary transition-all">
شروط الخدمة
<button onclick="pbSetMode('html')" id="pb-mode-html"
class="px-3 py-1.5 rounded-lg text-xs font-bold glass text-on-surface-variant flex items-center gap-1">
<span class="msym text-sm">code</span> HTML Editor
</button>
</div>
<!-- Editor -->
<div class="glass rounded-2xl p-5 space-y-4" style="border:1px solid rgba(78,222,163,0.1)">
<div class="flex items-center justify-between">
<p class="text-sm text-on-surface-variant">تعديل محتوى HTML للصفحة</p>
<div class="flex gap-2">
<a id="pages-preview-btn" href="/privacy" target="_blank"
class="glass px-3 py-1.5 rounded-lg text-xs text-on-surface-variant hover:text-primary transition-colors flex items-center gap-1">
<span class="msym text-sm">open_in_new</span> معاينة
</a>
<button onclick="savePageContent()" id="pages-save-btn"
class="bg-primary text-surface font-bold px-4 py-1.5 rounded-lg text-xs flex items-center gap-1 hover:shadow-[0_0_15px_rgba(78,222,163,0.4)] transition-all">
<span class="msym text-sm">save</span> حفظ
<!-- VISUAL BUILDER -->
<div id="pb-visual" class="space-y-3">
<div id="pb-blocks" class="space-y-3"></div>
<!-- Add block toolbar -->
<div class="glass rounded-2xl p-4">
<p class="text-xs text-on-surface-variant mb-3 font-bold uppercase tracking-widest">Add Block</p>
<div class="flex flex-wrap gap-2">
<button onclick="pbAddBlock('heading')" class="glass glass-hover px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-1">
<span class="msym text-sm">title</span> Heading
</button>
<button onclick="pbAddBlock('paragraph')" class="glass glass-hover px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-1">
<span class="msym text-sm">notes</span> Paragraph
</button>
<button onclick="pbAddBlock('list')" class="glass glass-hover px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-1">
<span class="msym text-sm">format_list_bulleted</span> List
</button>
<button onclick="pbAddBlock('divider')" class="glass glass-hover px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-1">
<span class="msym text-sm">horizontal_rule</span> Divider
</button>
<button onclick="pbAddBlock('hero')" class="glass glass-hover px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-1 text-primary">
<span class="msym text-sm">auto_awesome</span> Hero
</button>
<button onclick="pbAddBlock('contact')" class="glass glass-hover px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-1">
<span class="msym text-sm">mail</span> Contact
</button>
</div>
</div>
<textarea id="pages-editor"
class="w-full bg-surface/60 border border-white/10 rounded-xl p-4 text-xs font-mono text-on-surface focus:outline-none focus:border-primary/50 transition-colors resize-none"
style="height:420px;direction:ltr"></textarea>
<p id="pages-save-status" class="text-xs text-primary hidden">✅ تم الحفظ بنجاح</p>
</div>
<!-- HTML EDITOR -->
<div id="pb-html" class="hidden">
<div class="glass rounded-2xl p-4">
<textarea id="pb-html-editor"
class="w-full bg-surface/60 border border-white/10 rounded-xl p-4 text-xs font-mono text-on-surface focus:outline-none focus:border-primary/50 transition-colors resize-none"
style="height:500px;direction:ltr"></textarea>
</div>
</div>
<p id="pb-status" class="text-xs text-primary mt-3 hidden">✅ Saved successfully</p>
</section>
</div>
@@ -2355,67 +2393,279 @@ async function retryMetaExtraction() {
<script>
// ── Pages Editor ─────────────────────────────────────────────────────────────
let _currentPage = 'privacy';
// ── Page Builder ─────────────────────────────────────────────────────────────
let _pbPage = 'privacy';
let _pbMode = 'visual';
let _pbBlocks = [];
let _pbRawHTML = '';
function switchPageTab(page) {
_currentPage = page;
['privacy','terms'].forEach(p => {
const btn = document.getElementById('pages-tab-' + p);
if (p === page) {
btn.className = 'px-4 py-2 rounded-xl text-sm font-semibold bg-primary text-surface transition-all';
} else {
btn.className = 'px-4 py-2 rounded-xl text-sm font-semibold glass text-on-surface-variant hover:text-primary transition-all';
const PB_PAGES = {
privacy: { label: 'Privacy', url: '/privacy' },
terms: { label: 'Terms', url: '/terms' },
about: { label: 'About', url: '/about' },
index: { label: 'Home', url: '/' },
};
// ── Switch page ───────────────────────────────────────────────────────────────
function pbSwitch(page) {
_pbPage = page;
document.querySelectorAll('.pb-tab').forEach(b => {
b.className = 'pb-tab px-4 py-2 rounded-xl text-xs font-bold glass text-on-surface-variant hover:text-primary transition-all';
});
const active = document.getElementById('pb-tab-' + page);
if (active) active.className = 'pb-tab px-4 py-2 rounded-xl text-xs font-bold bg-primary text-surface transition-all';
document.getElementById('pb-preview-btn').href = PB_PAGES[page]?.url || '/' + page;
pbLoad();
}
// ── Mode toggle ───────────────────────────────────────────────────────────────
function pbSetMode(mode) {
_pbMode = mode;
const mv = document.getElementById('pb-mode-visual');
const mh = document.getElementById('pb-mode-html');
const vv = document.getElementById('pb-visual');
const vh = document.getElementById('pb-html');
if (mode === 'visual') {
mv.className = 'px-3 py-1.5 rounded-lg text-xs font-bold bg-primary/20 text-primary border border-primary/30 flex items-center gap-1';
mh.className = 'px-3 py-1.5 rounded-lg text-xs font-bold glass text-on-surface-variant flex items-center gap-1';
vv.classList.remove('hidden'); vh.classList.add('hidden');
pbRenderBlocks();
} else {
mh.className = 'px-3 py-1.5 rounded-lg text-xs font-bold bg-primary/20 text-primary border border-primary/30 flex items-center gap-1';
mv.className = 'px-3 py-1.5 rounded-lg text-xs font-bold glass text-on-surface-variant flex items-center gap-1';
vh.classList.remove('hidden'); vv.classList.add('hidden');
// Sync blocks → HTML
document.getElementById('pb-html-editor').value = _pbRawHTML;
}
}
// ── Load page content ─────────────────────────────────────────────────────────
async function pbLoad() {
const blocksEl = document.getElementById('pb-blocks');
blocksEl.innerHTML = '<div class="text-on-surface-variant text-sm p-4">Loading...</div>';
try {
const res = await fetch('/admin/pages/' + _pbPage, { headers: { 'Authorization': 'Bearer ' + JWT_TOKEN } });
const data = await res.json();
_pbRawHTML = data.content || '';
_pbBlocks = pbParseHTML(_pbRawHTML);
if (_pbMode === 'visual') pbRenderBlocks();
else document.getElementById('pb-html-editor').value = _pbRawHTML;
} catch(e) {
blocksEl.innerHTML = '<div class="text-red-400 text-sm p-4">Error: ' + e.message + '</div>';
}
}
// ── Parse HTML → blocks ───────────────────────────────────────────────────────
function pbParseHTML(html) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const blocks = [];
// Find main content area
const main = doc.querySelector('main') || doc.querySelector('.space-y-6') || doc.body;
main.querySelectorAll('h1,h2,h3,p,ul,ol,hr,div[class*="hero"]').forEach((el, i) => {
const tag = el.tagName.toLowerCase();
if (tag === 'hr') {
blocks.push({ id: i, type: 'divider', content: '' });
} else if (['h1','h2','h3'].includes(tag)) {
blocks.push({ id: i, type: 'heading', level: tag, content: el.textContent.trim() });
} else if (tag === 'p') {
const txt = el.textContent.trim();
if (txt) blocks.push({ id: i, type: 'paragraph', content: txt });
} else if (tag === 'ul' || tag === 'ol') {
const items = [...el.querySelectorAll('li')].map(li => li.textContent.trim()).join('\n');
if (items) blocks.push({ id: i, type: 'list', ordered: tag === 'ol', content: items });
}
});
document.getElementById('pages-preview-btn').href = '/' + page;
document.getElementById('pages-save-status').classList.add('hidden');
loadPageContent(page);
}
async function loadPageContent(page) {
const editor = document.getElementById('pages-editor');
editor.value = 'جاري التحميل...';
try {
const res = await fetch('/admin/pages/' + page, {
headers: { 'Authorization': 'Bearer ' + JWT_TOKEN }
});
const data = await res.json();
editor.value = data.content || '';
} catch(e) {
editor.value = 'خطأ في التحميل: ' + e.message;
// If nothing found, create a single raw block
if (!blocks.length) {
const bodyText = doc.body.innerText?.trim();
if (bodyText) blocks.push({ id: 0, type: 'paragraph', content: bodyText.substring(0, 500) + '...' });
else blocks.push({ id: 0, type: 'paragraph', content: 'This page has no editable content yet.' });
}
return blocks;
}
async function savePageContent() {
const btn = document.getElementById('pages-save-btn');
const status = document.getElementById('pages-save-status');
const content = document.getElementById('pages-editor').value;
// ── Render blocks in visual editor ───────────────────────────────────────────
function pbRenderBlocks() {
const container = document.getElementById('pb-blocks');
if (!_pbBlocks.length) {
container.innerHTML = '<div class="glass rounded-2xl p-6 text-center text-on-surface-variant text-sm">No blocks yet — add one below</div>';
return;
}
container.innerHTML = _pbBlocks.map((b, idx) => pbBlockHTML(b, idx)).join('');
}
function pbBlockHTML(b, idx) {
const moveUp = idx > 0 ? `<button onclick="pbMoveBlock(${idx},-1)" class="glass glass-hover p-1 rounded-lg" title="Move up"><span class="msym text-sm">arrow_upward</span></button>` : '';
const moveDown = idx < _pbBlocks.length-1 ? `<button onclick="pbMoveBlock(${idx},1)" class="glass glass-hover p-1 rounded-lg" title="Move down"><span class="msym text-sm">arrow_downward</span></button>` : '';
const controls = `<div class="flex items-center gap-1 shrink-0">
${moveUp}${moveDown}
<button onclick="pbDeleteBlock(${idx})" class="glass glass-hover p-1 rounded-lg text-red-400" title="Delete"><span class="msym text-sm">delete</span></button>
</div>`;
let inner = '';
if (b.type === 'heading') {
inner = `<div class="flex items-center gap-3">
<select onchange="_pbBlocks[${idx}].level=this.value;pbRenderBlocks()" class="bg-surface-high border border-outline-variant rounded-lg px-2 py-1 text-xs">
<option value="h1" ${b.level==='h1'?'selected':''}>H1</option>
<option value="h2" ${b.level==='h2'?'selected':''}>H2</option>
<option value="h3" ${b.level==='h3'?'selected':''}>H3</option>
</select>
<input type="text" value="${b.content.replace(/"/g,'&quot;')}"
oninput="_pbBlocks[${idx}].content=this.value"
class="flex-1 bg-surface-high border border-outline-variant rounded-xl px-3 py-2 text-sm font-bold outline-none focus:border-primary transition-colors"
placeholder="Heading text..."/>
</div>`;
} else if (b.type === 'paragraph') {
inner = `<textarea rows="3" oninput="_pbBlocks[${idx}].content=this.value"
class="w-full bg-surface-high border border-outline-variant rounded-xl px-3 py-2 text-sm outline-none focus:border-primary transition-colors resize-none"
placeholder="Paragraph text...">${b.content.replace(/</g,'&lt;')}</textarea>`;
} else if (b.type === 'list') {
inner = `<div class="flex items-center gap-2 mb-2">
<span class="text-xs text-on-surface-variant">Type:</span>
<label class="text-xs flex items-center gap-1"><input type="radio" name="list-${idx}" value="false" ${!b.ordered?'checked':''} onchange="_pbBlocks[${idx}].ordered=false"> Bullets</label>
<label class="text-xs flex items-center gap-1"><input type="radio" name="list-${idx}" value="true" ${b.ordered?'checked':''} onchange="_pbBlocks[${idx}].ordered=true"> Numbered</label>
</div>
<textarea rows="4" oninput="_pbBlocks[${idx}].content=this.value"
class="w-full bg-surface-high border border-outline-variant rounded-xl px-3 py-2 text-xs font-mono outline-none focus:border-primary transition-colors resize-none"
placeholder="One item per line...">${b.content.replace(/</g,'&lt;')}</textarea>`;
} else if (b.type === 'divider') {
inner = `<div class="border-t border-outline-variant my-2 opacity-50"></div>`;
} else if (b.type === 'hero') {
inner = `<div class="space-y-2">
<input type="text" value="${(b.title||'').replace(/"/g,'&quot;')}" oninput="_pbBlocks[${idx}].title=this.value"
class="w-full bg-surface-high border border-outline-variant rounded-xl px-3 py-2 text-base font-bold outline-none focus:border-primary transition-colors" placeholder="Hero title..."/>
<input type="text" value="${(b.subtitle||'').replace(/"/g,'&quot;')}" oninput="_pbBlocks[${idx}].subtitle=this.value"
class="w-full bg-surface-high border border-outline-variant rounded-xl px-3 py-2 text-sm outline-none focus:border-primary transition-colors" placeholder="Hero subtitle..."/>
</div>`;
} else if (b.type === 'contact') {
inner = `<input type="email" value="${(b.email||'').replace(/"/g,'&quot;')}" oninput="_pbBlocks[${idx}].email=this.value"
class="w-full bg-surface-high border border-outline-variant rounded-xl px-3 py-2 text-sm outline-none focus:border-primary transition-colors" placeholder="contact@example.com"/>`;
}
const typeLabels = { heading:'Heading', paragraph:'Paragraph', list:'List', divider:'Divider', hero:'Hero', contact:'Contact' };
const typeColors = { heading:'text-primary', paragraph:'text-on-surface-variant', list:'text-yellow-400', divider:'text-outline-variant', hero:'text-purple-400', contact:'text-blue-400' };
return `<div class="glass rounded-2xl p-4 border border-outline-variant/20 hover:border-primary/20 transition-all">
<div class="flex items-center justify-between mb-3">
<span class="text-xs font-bold uppercase tracking-widest ${typeColors[b.type]||''}">${typeLabels[b.type]||b.type}</span>
${controls}
</div>
${inner}
</div>`;
}
// ── Block actions ─────────────────────────────────────────────────────────────
function pbAddBlock(type) {
const newBlock = { id: Date.now(), type, content: '', level: 'h2', ordered: false, title: '', subtitle: '', email: '' };
_pbBlocks.push(newBlock);
pbRenderBlocks();
// Scroll to new block
setTimeout(() => {
const last = document.getElementById('pb-blocks').lastElementChild;
last?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 100);
}
function pbDeleteBlock(idx) {
_pbBlocks.splice(idx, 1);
pbRenderBlocks();
}
function pbMoveBlock(idx, dir) {
const newIdx = idx + dir;
if (newIdx < 0 || newIdx >= _pbBlocks.length) return;
[_pbBlocks[idx], _pbBlocks[newIdx]] = [_pbBlocks[newIdx], _pbBlocks[idx]];
pbRenderBlocks();
}
// ── Blocks → HTML ─────────────────────────────────────────────────────────────
function pbBlocksToHTML(blocks) {
return blocks.map(b => {
if (b.type === 'heading') return `<${b.level||'h2'} class="text-primary font-bold mb-2">${b.content}</${b.level||'h2'}>`;
if (b.type === 'paragraph') return `<p>${b.content}</p>`;
if (b.type === 'divider') return `<hr class="border-outline-variant my-4"/>`;
if (b.type === 'list') {
const tag = b.ordered ? 'ol' : 'ul';
const cls = b.ordered ? 'list-decimal' : 'list-disc';
const items = b.content.split('\n').filter(Boolean).map(i => `<li>${i.trim()}</li>`).join('');
return `<${tag} class="${cls} pl-5 space-y-1">${items}</${tag}>`;
}
if (b.type === 'hero') return `<div class="text-center py-8"><h1 class="text-3xl font-bold text-primary mb-2">${b.title||''}</h1><p class="text-on-surface-variant">${b.subtitle||''}</p></div>`;
if (b.type === 'contact') return `<p>Contact: <a href="mailto:${b.email}" class="text-primary hover:underline">${b.email}</a></p>`;
return '';
}).join('\n');
}
// ── Inject blocks into page HTML ──────────────────────────────────────────────
function pbInjectBlocks(rawHTML, blocksHTML) {
// Try to inject into existing content area
const parser = new DOMParser();
const doc = parser.parseFromString(rawHTML, 'text/html');
const main = doc.querySelector('main');
if (main) {
// Find inner content div and replace only content sections
const contentDiv = main.querySelector('.space-y-6, .content-area, [id*="content"]');
if (contentDiv) {
contentDiv.innerHTML = blocksHTML;
} else {
// Append at end of main
const existing = main.innerHTML;
const headerMatch = existing.match(/(<div[^>]*flex[^>]*>[\s\S]*?<\/div>\s*<\/div>)/);
if (headerMatch) {
main.innerHTML = headerMatch[0] + `<div class="space-y-4 text-on-surface-variant leading-relaxed text-sm">${blocksHTML}</div>`;
} else {
main.innerHTML += `<div class="space-y-4 text-on-surface-variant leading-relaxed text-sm">${blocksHTML}</div>`;
}
}
const s = new XMLSerializer();
return '<!DOCTYPE html>' + s.serializeToString(doc.documentElement);
}
return rawHTML; // fallback — unchanged
}
// ── Save ──────────────────────────────────────────────────────────────────────
async function pbSave() {
const btn = document.getElementById('pb-save-btn');
const status = document.getElementById('pb-status');
btn.disabled = true;
btn.innerHTML = '<span class="msym text-sm">progress_activity</span> جاري الحفظ...';
btn.innerHTML = '<span class="msym text-base">progress_activity</span> Saving...';
try {
const res = await fetch('/admin/pages/' + _currentPage, {
let finalHTML;
if (_pbMode === 'html') {
finalHTML = document.getElementById('pb-html-editor').value;
} else {
const blocksHTML = pbBlocksToHTML(_pbBlocks);
finalHTML = pbInjectBlocks(_pbRawHTML, blocksHTML);
}
const res = await fetch('/admin/pages/' + _pbPage, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + JWT_TOKEN },
body: JSON.stringify({ content })
body: JSON.stringify({ content: finalHTML })
});
if (res.ok) {
status.textContent = '✅ Saved!';
status.classList.remove('hidden');
setTimeout(() => status.classList.add('hidden'), 3000);
_pbRawHTML = finalHTML;
toast('Page saved!', 'success');
} else {
toast('Save failed', 'error');
}
} catch(e) {
alert('فشل الحفظ: ' + e.message);
toast('Error: ' + e.message);
}
btn.disabled = false;
btn.innerHTML = '<span class="msym text-sm">save</span> حفظ';
btn.innerHTML = '<span class="msym text-base">save</span> Save';
}
// ── Wire into showPage ────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
const origShowPage = window.showPage;
if (origShowPage) {
window.showPage = function(page, btn) {
origShowPage(page, btn);
if (page === 'pages') loadPageContent(_currentPage);
if (page === 'pages') pbSwitch(_pbPage);
};
}
});