/* ============================================================ admin-panel.jsx — Tab Admin customizer (Mức 1) Tuỳ biến: Font · Màu · Bố cục (kéo-thả bật/tắt & đổi thứ tự mục) Lưu localStorage (xem trước) · Xuất config-.json (deploy cho mọi người) Chỉ hiện khi ?admin=1. ============================================================ */ (function () { const { useState } = React; // ---- Phát hiện admin (đồng bộ với image-slot.js) ---- function isAdmin() { try { const p = new URLSearchParams(location.search); if (p.get('admin') === '1') localStorage.setItem('wedding-admin', '1'); if (p.get('admin') === '0') localStorage.removeItem('wedding-admin'); return localStorage.getItem('wedding-admin') === '1'; } catch { return false; } } // ---- Danh sách mục có thể bật/tắt + đổi thứ tự ---- const SECTION_DEFS = [ { id: 'invite', label: 'Thiệp mời + Gia đình' }, { id: 'band1', label: 'Dải ảnh 1' }, { id: 'countdown', label: 'Đếm ngược' }, { id: 'album', label: 'Album ảnh' }, { id: 'events', label: 'Sự kiện' }, { id: 'map', label: 'Bản đồ + QR' }, { id: 'band2', label: 'Dải ảnh 2' }, { id: 'wishes', label: 'Sổ lưu bút' }, { id: 'closing', label: 'Lời kết' }, ]; // ---- Bảng màu mặc định theo bên ---- const PALETTE = { 'nha-trai': { sage: '#8C9A7C', sageDeep: '#5C6A4E', sageSoft: '#BCC4AE', sageMist: '#DCE0D2' }, 'nha-gai': { sage: '#C9A06B', sageDeep: '#8A6A42', sageSoft: '#EFD8BD', sageMist: '#F4E8D7' }, }; // ---- Font: nhãn → font-family ---- const FONTS = { 'Cormorant Garamond': '"Cormorant Garamond", "EB Garamond", Georgia, serif', 'EB Garamond': '"EB Garamond", Georgia, serif', 'Georgia': 'Georgia, "Times New Roman", serif', }; // ---- Các trường nội dung sửa được (dot path) ---- const CONTENT_GROUPS = [ { group: 'Tên & Ngày', fields: [ { key: 'nameA', label: 'Tên hiển thị trước', type: 'text' }, { key: 'nameB', label: 'Tên hiển thị sau', type: 'text' }, { key: 'monogram', label: 'Monogram (vd V & S)', type: 'text' }, { key: 'heroLogo', label: 'Logo hero (đường dẫn ảnh, trống = dùng chữ)', type: 'text' }, { key: 'dateText', label: 'Ngày (ngắn)', type: 'text' }, { key: 'dateLong', label: 'Ngày (đầy đủ)', type: 'text' }, { key: 'dateISO', label: 'Ngày giờ ISO (đếm ngược)', type: 'text' }, { key: 'weekday', label: 'Thứ', type: 'text' }, { key: 'day', label: 'Ngày (số)', type: 'text' }, { key: 'month', label: 'Tháng (số)', type: 'text' }, { key: 'year', label: 'Năm', type: 'text' }, ]}, { group: 'Lễ & Tiệc', fields: [ { key: 'ceremony.label', label: 'Lễ — tên', type: 'text' }, { key: 'ceremony.time', label: 'Lễ — giờ', type: 'text' }, { key: 'ceremony.note', label: 'Lễ — ghi chú', type: 'text' }, { key: 'party.label', label: 'Tiệc — tên', type: 'text' }, { key: 'party.time', label: 'Tiệc — giờ', type: 'text' }, { key: 'party.note', label: 'Tiệc — ghi chú', type: 'text' }, ]}, { group: 'Địa điểm', fields: [ { key: 'venueFull', label: 'Tên địa điểm', type: 'text' }, { key: 'hall', label: 'Sảnh / đường', type: 'text' }, { key: 'address', label: 'Địa chỉ', type: 'text' }, { key: 'mapsEmbed', label: 'Link bản đồ nhúng (embed)', type: 'textarea' }, { key: 'mapsLink', label: 'Link chỉ đường', type: 'textarea' }, ]}, { group: 'Gia đình', fields: [ { key: 'groomParents', label: 'Nhà trai (mỗi dòng 1 người)', type: 'list' }, { key: 'groomAddress', label: 'Nhà trai — địa chỉ', type: 'text' }, { key: 'brideParents', label: 'Nhà gái (mỗi dòng 1 người)', type: 'list' }, { key: 'brideAddress', label: 'Nhà gái — địa chỉ', type: 'text' }, ]}, { group: 'Lời chữ', fields: [ { key: 'announce', label: 'Dòng báo tin (trên tên)', type: 'text' }, { key: 'invite', label: 'Lời mời', type: 'textarea' }, { key: 'closing', label: 'Lời kết', type: 'text' }, { key: 'bandText', label: 'Chữ trên dải ảnh 2', type: 'text' }, ]}, { group: 'Album & Ảnh band', fields: [ { key: 'albumCount', label: 'Số ảnh album', type: 'text' }, { key: 'band1Focus', label: 'Canh ảnh band 1 (vd 50% 50%)', type: 'text' }, { key: 'band2Focus', label: 'Canh ảnh band 2', type: 'text' }, ]}, ]; // ---- Tiện ích đọc/ghi theo dot path + deep merge ---- function getPath(obj, path) { return path.split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj); } function setPath(obj, path, val) { const ks = path.split('.'), out = Array.isArray(obj) ? obj.slice() : { ...obj }; let cur = out; for (let i = 0; i < ks.length - 1; i++) { cur[ks[i]] = (cur[ks[i]] && typeof cur[ks[i]] === 'object') ? { ...cur[ks[i]] } : {}; cur = cur[ks[i]]; } cur[ks[ks.length - 1]] = val; return out; } function deepMerge(base, over) { const out = { ...base }; for (const k in (over || {})) { const v = over[k]; if (v && typeof v === 'object' && !Array.isArray(v) && base[k] && typeof base[k] === 'object' && !Array.isArray(base[k])) { out[k] = deepMerge(base[k], v); } else { out[k] = v; } } return out; } function defaultCfg(side) { return { order: SECTION_DEFS.map((s) => s.id), hidden: {}, theme: { ...(PALETTE[side] || PALETTE['nha-trai']) }, font: 'Cormorant Garamond', text: {}, // ghi đè nội dung WED (chỉ field nào admin sửa) }; } function loadCfg(side) { const def = defaultCfg(side); try { const s = JSON.parse(localStorage.getItem('wedding-cfg:' + side) || 'null'); if (s) return mergeCfg(def, s); } catch {} return def; } function mergeCfg(def, s) { return { order: Array.isArray(s.order) && s.order.length ? s.order : def.order, hidden: s.hidden || {}, theme: { ...def.theme, ...(s.theme || {}) }, font: s.font || def.font, text: s.text || {}, }; } function saveCfg(side, cfg) { try { localStorage.setItem('wedding-cfg:' + side, JSON.stringify(cfg)); } catch {} } function fontFamily(name) { return FONTS[name] || FONTS['Cormorant Garamond']; } // ---- Xuất file config để deploy (mọi khách thấy) ---- function exportCfg(side, cfg) { const blob = new Blob([JSON.stringify(cfg, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'config-' + side + '.json'; a.click(); URL.revokeObjectURL(a.href); } // ---- Panel UI ---- function AdminPanel({ side, cfg, setCfg }) { const [open, setOpen] = useState(false); const [tab, setTab] = useState('layout'); const [dragIdx, setDragIdx] = useState(null); const labelOf = (id) => (SECTION_DEFS.find((s) => s.id === id) || {}).label || id; const patch = (p) => setCfg((c) => ({ ...c, ...p })); const setTheme = (k, v) => setCfg((c) => ({ ...c, theme: { ...c.theme, [k]: v } })); const toggleHide = (id) => setCfg((c) => ({ ...c, hidden: { ...c.hidden, [id]: !c.hidden[id] } })); // Giá trị hiện tại của 1 field nội dung (config > mặc định) const base = window.__WED_BASE || {}; const fieldVal = (key) => { const v = getPath(cfg.text || {}, key); return v !== undefined ? v : getPath(base, key); }; const setText = (key, val) => setCfg((c) => ({ ...c, text: setPath(c.text || {}, key, val) })); const onDrop = (to) => { if (dragIdx === null || dragIdx === to) return; setCfg((c) => { const order = c.order.slice(); const [m] = order.splice(dragIdx, 1); order.splice(to, 0, m); return { ...c, order }; }); setDragIdx(null); }; const colorRow = (key, label) => ( ); return ( <> {open && (
Tùy biến · {side === 'nha-gai' ? 'Nhà Gái' : 'Nhà Trai'}
{[['content', 'Nội dung'], ['layout', 'Bố cục'], ['theme', 'Màu & Font']].map(([k, l]) => ( ))}
{tab === 'content' && ( <>

Sửa nội dung thiệp. Để trống = dùng mặc định.

{CONTENT_GROUPS.map((g) => (

{g.group}

{g.fields.map((f) => { const raw = fieldVal(f.key); if (f.type === 'list') { const txt = Array.isArray(raw) ? raw.join('\n') : (raw || ''); return (