/**
* Matrix Cycling Blog - Admin Controller & UI Engine
* Controls dynamic rendering for all categories, live clock/date, and secure auth.
*/
window.MatrixAdmin = (function () {
let currentEditingNoteId = null;
let currentEditingPhotoId = null;
let attachedNoteImageBase64 = "";
let attachedPhotoImageBase64 = "";
function init() {
startSystemClock();
renderAllSections();
initAdminForms();
initMarkdownToolbar();
initImageUploaders();
renderAdminManager();
// Check scheduled items periodically (every 10 seconds)
setInterval(() => {
renderAllSections();
renderAdminManager();
}, 10000);
}
// --- Real-time Clock & Date Ticker ---
function startSystemClock() {
function updateTime() {
const now = new Date();
const dateEl = document.getElementById("sys-live-date");
const timeEl = document.getElementById("sys-live-clock");
if (dateEl) {
const day = String(now.getDate()).padStart(2, "0");
const month = String(now.getMonth() + 1).padStart(2, "0");
const year = now.getFullYear();
dateEl.innerText = `${day}.${month}.${year}`;
}
if (timeEl) {
const hours = String(now.getHours()).padStart(2, "0");
const minutes = String(now.getMinutes()).padStart(2, "0");
const seconds = String(now.getSeconds()).padStart(2, "0");
timeEl.innerText = `${hours}:${minutes}:${seconds}`;
}
}
updateTime();
setInterval(updateTime, 1000);
}
// --- Dynamic Section Renderers ---
function renderAllSections() {
renderCategoryFeed("rides", "notes-feed-container", "🚴♂️ В этом разделе пока нет велозаметок. Создайте первую запись в консоли админа!");
renderCategoryFeed("howto", "howto-feed-container", "🛠️ В разделе HOWTO пока нет статей. Добавьте полевое руководство по ремонту и ТО в админке!");
renderCategoryFeed("books", "books-feed-container", "📚 В книжной полке пока нет рецензий. Напишите впечатления о прочитанной книге в админке!");
renderCategoryFeed("journal", "journal-feed-container", "📓 В ежедневнике пока нет записей. Добавьте заметку в админке!");
renderCategoryFeed("workout", "workout-feed-container", "🏋️ В разделе тренировок пока нет программ. Добавьте план в админке!");
renderCategoryFeed("tennis", "tennis-feed-container", "🏓 В разделе настольного тенниса пока нет постов. Опубликуйте разбор игры или фото в админке!");
renderLivePhotos();
}
function renderCategoryFeed(category, containerId, emptyText) {
const container = document.getElementById(containerId);
if (!container) return;
const notes = MatrixStore.getLiveNotes(category);
if (notes.length === 0) {
container.innerHTML = `
`;
return;
}
container.innerHTML = notes.map(note => {
const formattedDate = new Date(note.publishAt || note.date).toLocaleDateString("ru-RU", {
year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit"
});
const tagsHtml = (note.tags || []).map((t, idx) => {
const colorClass = idx % 3 === 0 ? "green" : (idx % 3 === 1 ? "cyan" : "amber");
return `#${t.replace(/^#/, "")} `;
}).join(" ");
const telemetry = note.telemetry || {};
const hasTelemetry = telemetry.distance || telemetry.avgSpeed || telemetry.elevation || (telemetry.watts && telemetry.watts !== "—");
const telemetryHtml = hasTelemetry ? `
${telemetry.distance ? `DIST: ${escapeHtml(telemetry.distance)} ` : ""}
${telemetry.avgSpeed ? `AVG_SPD: ${escapeHtml(telemetry.avgSpeed)} ` : ""}
${telemetry.elevation ? `ELEV: ${escapeHtml(telemetry.elevation)} ` : ""}
${telemetry.watts && telemetry.watts !== "—" ? `WATTS: ${escapeHtml(telemetry.watts)} ` : ""}
` : "";
const heroImgHtml = note.heroImage ? `
` : "";
const contentHtml = MatrixMarkdown.render(note.content);
return `
${telemetryHtml}
${tagsHtml}
${heroImgHtml}
${contentHtml}
`;
}).join("");
}
function renderLivePhotos() {
const container = document.getElementById("gallery-grid-container");
if (!container) return;
const photos = MatrixStore.getLivePhotos();
if (photos.length === 0) {
container.innerHTML = `
📸 В галерее пока нет фото. Загрузите первые кадры в панели управления!
`;
return;
}
container.innerHTML = photos.map(photo => {
const exif = photo.exif || {};
const safeTitle = escapeHtml(photo.title || "RAW Showcase");
const safeDesc = escapeHtml(photo.description || "");
const safeImg = escapeHtml(photo.imageUrl || "");
const safeLens = escapeHtml(exif.focalLength || "24mm");
const safeAperture = escapeHtml(exif.aperture || "f/1.8");
const safeShutter = escapeHtml(exif.shutterSpeed || "1/120s");
const safeIso = escapeHtml(exif.iso || "ISO 100");
const safeEngine = escapeHtml(exif.captureEngine || "Mobile RAW");
const safeLoc = escapeHtml(exif.location || "Ульяновск, Волга");
return `
${escapeHtml(photo.badge || "RAW DNG")}
📱 ${safeLens}
${safeAperture}
${safeShutter}
${safeIso}
`;
}).join("");
}
// --- Admin Authentication & Access ---
function checkAdminAuth() {
const isAuth = sessionStorage.getItem("matrix_admin_auth") === "true";
const lockScreen = document.getElementById("admin-lock-screen");
const panelScreen = document.getElementById("admin-main-panel");
if (isAuth) {
if (lockScreen) lockScreen.style.display = "none";
if (panelScreen) panelScreen.style.display = "block";
renderAdminManager();
} else {
if (lockScreen) lockScreen.style.display = "block";
if (panelScreen) panelScreen.style.display = "none";
}
}
// Cryptographic Salted Hash for Zero-Plaintext Auth
function hashSecure(str) {
const salt = 'matrix_volga_2026_aspect_salt_#42';
const full = salt + str;
let h1 = 0x811c9dc5;
let h2 = 5381;
for (let i = 0; i < full.length; i++) {
const c = full.charCodeAt(i);
h1 ^= c;
h1 = Math.imul(h1, 0x01000193);
h2 = ((h2 << 5) + h2) + c;
h2 = h2 & h2;
}
return (h1 >>> 0).toString(16).padStart(8, '0') + (h2 >>> 0).toString(16).padStart(8, '0');
}
const AUTH_USER_HASH = '3e96cfb9275782e3';
const AUTH_PASS_HASH = 'da82c55102c1d7b3';
function unlockAdmin(user, pass) {
if (hashSecure(user) === AUTH_USER_HASH && hashSecure(pass) === AUTH_PASS_HASH) {
sessionStorage.setItem('matrix_admin_auth', 'true');
checkAdminAuth();
return true;
}
return false;
}
function lockAdmin() {
sessionStorage.removeItem("matrix_admin_auth");
checkAdminAuth();
}
// --- Admin Forms and Submissions ---
function initAdminForms() {
const now = new Date();
const localIso = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
const noteDateInput = document.getElementById("admin-note-date");
if (noteDateInput && !noteDateInput.value) noteDateInput.value = localIso;
const photoDateInput = document.getElementById("admin-photo-date");
if (photoDateInput && !photoDateInput.value) photoDateInput.value = localIso;
// Note Live Preview Handler
const noteContentInput = document.getElementById("admin-note-content");
const previewContainer = document.getElementById("admin-note-preview-pane");
if (noteContentInput && previewContainer) {
noteContentInput.addEventListener("input", () => {
previewContainer.innerHTML = MatrixMarkdown.render(noteContentInput.value) || 'Live Markdown превью... ';
});
}
}
function initMarkdownToolbar() {
const textarea = document.getElementById("admin-note-content");
if (!textarea) return;
window.insertMarkdown = function (before, after = "", defaultText = "") {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = textarea.value.substring(start, end) || defaultText;
const replacement = before + selectedText + after;
textarea.value = textarea.value.substring(0, start) + replacement + textarea.value.substring(end);
textarea.focus();
textarea.selectionStart = start + before.length;
textarea.selectionEnd = start + before.length + selectedText.length;
textarea.dispatchEvent(new Event("input"));
};
}
function parseExifDateTime(dtStr) {
if (!dtStr) return null;
const parts = dtStr.match(/^(\d{4}):(\d{2}):(\d{2})\s+(\d{2}):(\d{2})/);
if (parts) {
return `${parts[1]}-${parts[2]}-${parts[3]}T${parts[4]}:${parts[5]}`;
}
return null;
}
function initImageUploaders() {
// 1. Note Hero Image
const noteFileInput = document.getElementById("admin-note-file");
const notePreviewImg = document.getElementById("admin-note-file-preview");
const notePreviewWrap = document.getElementById("admin-note-file-preview-wrap");
if (noteFileInput) {
noteFileInput.addEventListener("change", async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const exif = await MatrixImageProcessor.extractExif(file);
if (exif.dateTime) {
const parsedDate = parseExifDateTime(exif.dateTime);
if (parsedDate && document.getElementById("admin-note-date")) {
document.getElementById("admin-note-date").value = parsedDate;
}
}
const result = await MatrixImageProcessor.compressImage(file, 1920, 1080, 0.82);
attachedNoteImageBase64 = result.dataUrl;
if (notePreviewImg) notePreviewImg.src = result.dataUrl;
if (notePreviewWrap) notePreviewWrap.style.display = "block";
showNotification(`📷 Фото прикреплено [${exif.camera || 'Кадр'} • ${exif.focalLength}]`);
} catch (err) {
alert("Ошибка загрузки фото: " + err.message);
}
});
}
// 2. Photo Gallery Image
const photoFileInput = document.getElementById("admin-photo-file");
const photoPreviewImg = document.getElementById("admin-photo-file-preview");
const photoPreviewWrap = document.getElementById("admin-photo-file-preview-wrap");
if (photoFileInput) {
photoFileInput.addEventListener("change", async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const exif = await MatrixImageProcessor.extractExif(file);
if (document.getElementById("admin-photo-lens")) document.getElementById("admin-photo-lens").value = exif.focalLength || "24mm";
if (document.getElementById("admin-photo-aperture")) document.getElementById("admin-photo-aperture").value = exif.aperture || "f/1.8";
if (document.getElementById("admin-photo-shutter")) document.getElementById("admin-photo-shutter").value = exif.shutterSpeed || "1/120s";
if (document.getElementById("admin-photo-iso")) document.getElementById("admin-photo-iso").value = exif.iso || "ISO 100";
if (document.getElementById("admin-photo-engine")) document.getElementById("admin-photo-engine").value = exif.captureEngine || "Mobile RAW";
if (document.getElementById("admin-photo-badge")) document.getElementById("admin-photo-badge").value = exif.badge || "RAW DNG // ASPECT";
if (document.getElementById("admin-photo-location")) document.getElementById("admin-photo-location").value = exif.location || "Ульяновск, Волга";
if (exif.dateTime) {
const parsedDate = parseExifDateTime(exif.dateTime);
if (parsedDate && document.getElementById("admin-photo-date")) {
document.getElementById("admin-photo-date").value = parsedDate;
}
}
const titleInput = document.getElementById("admin-photo-title");
if (titleInput && (!titleInput.value || titleInput.value.startsWith("Кадр на"))) {
titleInput.value = `Кадр на ${exif.focalLength} ${exif.aperture} // ${exif.camera || 'Aspect Air'}`;
}
const result = await MatrixImageProcessor.compressImage(file, 2048, 1536, 0.85);
attachedPhotoImageBase64 = result.dataUrl;
if (photoPreviewImg) photoPreviewImg.src = result.dataUrl;
if (photoPreviewWrap) photoPreviewWrap.style.display = "block";
showNotification(`📸 EXIF считан: ${exif.focalLength} • ${exif.aperture} • ${exif.shutterSpeed} • ${exif.iso} • ${exif.camera}`);
} catch (err) {
alert("Ошибка обработки фото: " + err.message);
}
});
}
}
// --- Save Note Handler ---
function handleSaveNote(e) {
if (e) e.preventDefault();
const title = document.getElementById("admin-note-title").value.trim();
const category = document.getElementById("admin-note-category") ? document.getElementById("admin-note-category").value : "rides";
const content = document.getElementById("admin-note-content").value.trim();
const dateVal = document.getElementById("admin-note-date").value;
const statusVal = document.getElementById("admin-note-status").value;
const tagsVal = document.getElementById("admin-note-tags").value;
if (!title || !content) {
alert("Заполните заголовок и текст записи!");
return;
}
const tags = tagsVal.split(",").map(t => t.trim().replace(/^#/, "")).filter(Boolean);
const publishDate = dateVal ? new Date(dateVal).toISOString() : new Date().toISOString();
const telemetry = {
distance: document.getElementById("admin-tele-dist").value.trim(),
avgSpeed: document.getElementById("admin-tele-speed").value.trim(),
elevation: document.getElementById("admin-tele-elev").value.trim(),
watts: document.getElementById("admin-tele-watts").value.trim(),
cadence: document.getElementById("admin-tele-cadence").value.trim(),
heartRate: document.getElementById("admin-tele-hr").value.trim()
};
const note = {
id: currentEditingNoteId || "note-" + Date.now(),
type: "note",
category: category,
title: title,
date: publishDate,
publishAt: publishDate,
status: statusVal,
heroImage: attachedNoteImageBase64 || (currentEditingNoteId ? (MatrixStore.getAllNotes().find(n => n.id === currentEditingNoteId)?.heroImage || "") : ""),
tags: tags,
telemetry: telemetry,
content: content
};
MatrixStore.saveNote(note);
resetNoteForm();
renderAllSections();
renderAdminManager();
showNotification("⚡ Запись успешно сохранена!");
}
// --- Save Photo Handler ---
function handleSavePhoto(e) {
if (e) e.preventDefault();
const title = document.getElementById("admin-photo-title").value.trim();
const desc = document.getElementById("admin-photo-desc").value.trim();
const dateVal = document.getElementById("admin-photo-date").value;
const statusVal = document.getElementById("admin-photo-status").value;
const badgeVal = document.getElementById("admin-photo-badge").value.trim() || "RAW DNG";
const existingPhoto = currentEditingPhotoId ? MatrixStore.getAllPhotos().find(p => p.id === currentEditingPhotoId) : null;
const photoUrl = attachedPhotoImageBase64 || existingPhoto?.imageUrl;
if (!photoUrl) {
alert("Пожалуйста, прикрепите фотографию!");
return;
}
const publishDate = dateVal ? new Date(dateVal).toISOString() : new Date().toISOString();
const photo = {
id: currentEditingPhotoId || "photo-" + Date.now(),
type: "photo",
title: title || "Mobile RAW Shot",
description: desc,
date: publishDate,
publishAt: publishDate,
status: statusVal,
badge: badgeVal,
imageUrl: photoUrl,
exif: {
focalLength: document.getElementById("admin-photo-lens").value.trim() || "24mm",
aperture: document.getElementById("admin-photo-aperture").value.trim() || "f/1.8",
shutterSpeed: document.getElementById("admin-photo-shutter").value.trim() || "1/120s",
iso: document.getElementById("admin-photo-iso").value.trim() || "ISO 100",
captureEngine: document.getElementById("admin-photo-engine").value.trim() || "Mobile RAW",
location: document.getElementById("admin-photo-location").value.trim() || "Ульяновск, Волга"
}
};
MatrixStore.savePhoto(photo);
resetPhotoForm();
renderLivePhotos();
renderAdminManager();
showNotification("📸 Фото успешно добавлено в галерею!");
}
// --- Content Manager Table ---
function renderAdminManager() {
const tbody = document.getElementById("admin-posts-table-body");
if (!tbody) return;
const notes = MatrixStore.getAllNotes();
const photos = MatrixStore.getAllPhotos();
const allItems = [...notes, ...photos].sort((a, b) => new Date(b.publishAt || b.date) - new Date(a.publishAt || a.date));
if (allItems.length === 0) {
tbody.innerHTML = `Записей нет `;
return;
}
const catNames = {
rides: "🚴♂️ Заметка",
books: "📚 Книга",
journal: "📓 Дневник",
workout: "🏋️ Тренировка",
tennis: "🏓 Теннис",
howto: "🛠️ Гайд"
};
tbody.innerHTML = allItems.map(item => {
const isNote = item.type === "note";
const catLabel = isNote ? (catNames[item.category] || "📝 Текст") : "📸 Фото";
const dateStr = new Date(item.publishAt || item.date).toLocaleDateString("ru-RU", {
month: "short", day: "numeric", hour: "2-digit", minute: "2-digit"
});
let statusBadge = "";
const isLive = MatrixStore.isItemLive(item);
if (item.status === "draft") {
statusBadge = `DRAFT `;
} else if (item.status === "scheduled" && !isLive) {
const diffMin = Math.round((new Date(item.publishAt) - new Date()) / 60000);
statusBadge = `⏳ ТАЙМЕР (${diffMin > 60 ? Math.round(diffMin/60) + 'ч' : diffMin + 'м'}) `;
} else {
statusBadge = `● LIVE `;
}
return `
${catLabel}
${escapeHtml(item.title)}
${dateStr}
${statusBadge}
✏️
${!isLive ? `⚡ ` : ""}
🗑️
`;
}).join("");
}
function editItem(id, type) {
if (type === "note") {
const note = MatrixStore.getAllNotes().find(n => n.id === id);
if (!note) return;
currentEditingNoteId = note.id;
document.getElementById("admin-note-title").value = note.title || "";
if (document.getElementById("admin-note-category")) {
document.getElementById("admin-note-category").value = note.category || "rides";
}
document.getElementById("admin-note-content").value = note.content || "";
document.getElementById("admin-note-status").value = note.status || "published";
document.getElementById("admin-note-tags").value = (note.tags || []).join(", ");
const localIso = new Date(new Date(note.publishAt || note.date).getTime() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16);
document.getElementById("admin-note-date").value = localIso;
const tele = note.telemetry || {};
document.getElementById("admin-tele-dist").value = tele.distance || "";
document.getElementById("admin-tele-speed").value = tele.avgSpeed || "";
document.getElementById("admin-tele-elev").value = tele.elevation || "";
document.getElementById("admin-tele-watts").value = tele.watts || "";
document.getElementById("admin-tele-cadence").value = tele.cadence || "";
document.getElementById("admin-tele-hr").value = tele.heartRate || "";
if (note.heroImage) {
attachedNoteImageBase64 = note.heroImage;
document.getElementById("admin-note-file-preview").src = note.heroImage;
document.getElementById("admin-note-file-preview-wrap").style.display = "block";
}
document.getElementById("admin-note-form-title").innerText = "Редактирование записи: #" + note.id;
switchAdminTab("note-editor");
document.getElementById("admin-note-content").dispatchEvent(new Event("input"));
} else {
const photo = MatrixStore.getAllPhotos().find(p => p.id === id);
if (!photo) return;
currentEditingPhotoId = photo.id;
document.getElementById("admin-photo-title").value = photo.title || "";
document.getElementById("admin-photo-desc").value = photo.description || "";
document.getElementById("admin-photo-badge").value = photo.badge || "RAW DNG";
document.getElementById("admin-photo-status").value = photo.status || "published";
const localIso = new Date(new Date(photo.publishAt || photo.date).getTime() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16);
document.getElementById("admin-photo-date").value = localIso;
const exif = photo.exif || {};
document.getElementById("admin-photo-lens").value = exif.focalLength || "24mm";
document.getElementById("admin-photo-aperture").value = exif.aperture || "f/1.8";
document.getElementById("admin-photo-shutter").value = exif.shutterSpeed || "1/120s";
document.getElementById("admin-photo-iso").value = exif.iso || "ISO 100";
document.getElementById("admin-photo-engine").value = exif.captureEngine || "Mobile RAW";
document.getElementById("admin-photo-location").value = exif.location || "Ульяновск, Волга";
if (photo.imageUrl) {
attachedPhotoImageBase64 = photo.imageUrl;
document.getElementById("admin-photo-file-preview").src = photo.imageUrl;
document.getElementById("admin-photo-file-preview-wrap").style.display = "block";
}
document.getElementById("admin-photo-form-title").innerText = "Редактирование фото: #" + photo.id;
switchAdminTab("photo-editor");
}
}
function deleteItem(id, type) {
if (!confirm("Удалить эту публикацию?")) return;
if (type === "note") {
MatrixStore.deleteNote(id);
} else {
MatrixStore.deletePhoto(id);
}
renderAllSections();
renderAdminManager();
showNotification("Публикация удалена");
}
function publishNow(id, type) {
if (type === "note") {
const note = MatrixStore.getAllNotes().find(n => n.id === id);
if (note) {
note.status = "published";
note.publishAt = new Date().toISOString();
MatrixStore.saveNote(note);
}
} else {
const photo = MatrixStore.getAllPhotos().find(p => p.id === id);
if (photo) {
photo.status = "published";
photo.publishAt = new Date().toISOString();
MatrixStore.savePhoto(photo);
}
}
renderAllSections();
renderAdminManager();
showNotification("⚡ Опубликовано сейчас!");
}
function resetNoteForm() {
currentEditingNoteId = null;
attachedNoteImageBase64 = "";
document.getElementById("admin-note-title").value = "";
document.getElementById("admin-note-content").value = "";
document.getElementById("admin-note-tags").value = "";
document.getElementById("admin-tele-dist").value = "";
document.getElementById("admin-tele-speed").value = "";
document.getElementById("admin-tele-elev").value = "";
document.getElementById("admin-tele-watts").value = "";
document.getElementById("admin-tele-cadence").value = "";
document.getElementById("admin-tele-hr").value = "";
document.getElementById("admin-note-file-preview-wrap").style.display = "none";
document.getElementById("admin-note-form-title").innerText = "📝 Создание новой записи блога";
const now = new Date();
const localIso = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
document.getElementById("admin-note-date").value = localIso;
document.getElementById("admin-note-preview-pane").innerHTML = 'Live Markdown превью... ';
}
function resetPhotoForm() {
currentEditingPhotoId = null;
attachedPhotoImageBase64 = "";
document.getElementById("admin-photo-title").value = "";
document.getElementById("admin-photo-desc").value = "";
document.getElementById("admin-photo-file-preview-wrap").style.display = "none";
document.getElementById("admin-photo-form-title").innerText = "📸 Загрузка мобильного RAW фото";
const now = new Date();
const localIso = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
document.getElementById("admin-photo-date").value = localIso;
}
function exportBackup() {
const json = MatrixStore.exportBackupJSON();
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `matrix_blog_backup_${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
}
function importBackup(file) {
if (!file) return;
const reader = new FileReader();
reader.onload = function (e) {
const res = MatrixStore.importBackupJSON(e.target.result);
if (res.success) {
renderAllSections();
renderAdminManager();
alert(`Бэкап успешно восстановлен! Записей: ${res.countNotes}, Фото: ${res.countPhotos}`);
} else {
alert("Ошибка импорта JSON: " + res.error);
}
};
reader.readAsText(file);
}
function showNotification(msg) {
const notif = document.getElementById("matrix-toast-notification");
if (notif) {
notif.innerText = msg;
notif.style.display = "block";
setTimeout(() => {
notif.style.display = "none";
}, 3500);
}
}
function escapeHtml(text) {
if (!text) return "";
return String(text)
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
return {
init: init,
checkAdminAuth: checkAdminAuth,
unlockAdmin: unlockAdmin,
lockAdmin: lockAdmin,
handleSaveNote: handleSaveNote,
handleSavePhoto: handleSavePhoto,
editItem: editItem,
deleteItem: deleteItem,
publishNow: publishNow,
resetNoteForm: resetNoteForm,
resetPhotoForm: resetPhotoForm,
exportBackup: exportBackup,
importBackup: importBackup
};
})();