Deploy MatrixHasYou.ru: Aspect Air 29 specs, Matrix theme, HOWTOs and Zero-Plaintext Admin Auth

This commit is contained in:
MatrixHasYou 2026-08-24 07:37:07 +04:00
commit d5e402d9f6
12 changed files with 3086 additions and 0 deletions

BIN
images/aspect_matrix_bridge.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 907 KiB

BIN
images/chain_macro.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 896 KiB

BIN
images/forest.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
images/hero.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,019 KiB

BIN
images/neon_city.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 911 KiB

1605
index.html Executable file

File diff suppressed because it is too large Load diff

696
js/admin-controller.js Executable file
View file

@ -0,0 +1,696 @@
/**
* 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 = `
<div class="post-card" style="text-align: center; color: var(--text-muted); padding: 30px 20px;">
<p style="font-size: 13px;">${escapeHtml(emptyText)}</p>
</div>`;
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 `<span class="tag ${colorClass}">#${t.replace(/^#/, "")}</span>`;
}).join(" ");
const telemetry = note.telemetry || {};
const hasTelemetry = telemetry.distance || telemetry.avgSpeed || telemetry.elevation || (telemetry.watts && telemetry.watts !== "—");
const telemetryHtml = hasTelemetry ? `
<div class="post-meta" style="margin-top: 6px;">
${telemetry.distance ? `<span>DIST: ${escapeHtml(telemetry.distance)}</span>` : ""}
${telemetry.avgSpeed ? `<span>AVG_SPD: ${escapeHtml(telemetry.avgSpeed)}</span>` : ""}
${telemetry.elevation ? `<span>ELEV: ${escapeHtml(telemetry.elevation)}</span>` : ""}
${telemetry.watts && telemetry.watts !== "—" ? `<span>WATTS: ${escapeHtml(telemetry.watts)}</span>` : ""}
</div>` : "";
const heroImgHtml = note.heroImage ? `
<div style="margin-bottom: 16px; border-radius: 4px; overflow: hidden; max-height: 380px;">
<img src="${escapeHtml(note.heroImage)}" alt="${escapeHtml(note.title)}" style="width: 100%; height: 100%; object-fit: cover;" loading="lazy">
</div>` : "";
const contentHtml = MatrixMarkdown.render(note.content);
return `
<article class="post-card" id="post-${escapeHtml(note.id)}">
<div class="post-header">
<h2 class="post-title">${escapeHtml(note.title)}</h2>
<div class="post-meta">
<span>DATE: ${formattedDate}</span>
</div>
</div>
${telemetryHtml}
<div class="post-tags">${tagsHtml}</div>
${heroImgHtml}
<div class="post-body">${contentHtml}</div>
</article>`;
}).join("");
}
function renderLivePhotos() {
const container = document.getElementById("gallery-grid-container");
if (!container) return;
const photos = MatrixStore.getLivePhotos();
if (photos.length === 0) {
container.innerHTML = `
<div class="post-card" style="grid-column: 1/-1; text-align: center; color: var(--text-muted);">
<p>📸 В галерее пока нет фото. Загрузите первые кадры в панели управления!</p>
</div>`;
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 `
<div class="photo-card" onclick="openLightbox('${safeImg}', '${safeTitle}', '${safeDesc}', '${safeLens}', '${safeShutter}', '${safeIso}', '${safeEngine}', '${safeLoc}')">
<div class="photo-thumb-wrap">
<span class="photo-badge-raw">${escapeHtml(photo.badge || "RAW DNG")}</span>
<img src="${safeImg}" alt="${safeTitle}" loading="lazy">
</div>
<div class="photo-info">
<div>
<h3 class="photo-title">${safeTitle}</h3>
<p class="photo-desc">${safeDesc}</p>
</div>
<div class="photo-exif">
<span>📱 ${safeLens}</span>
<span class="exif-pill">${safeAperture}</span>
<span class="exif-pill">${safeShutter}</span>
<span class="exif-pill">${safeIso}</span>
</div>
</div>
</div>`;
}).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) || '<em style="color: var(--text-muted);">Live Markdown превью...</em>';
});
}
}
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 = `<tr><td colspan="5" style="text-align: center; color: var(--text-muted);">Записей нет</td></tr>`;
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 = `<span class="tag" style="color: var(--accent-amber); border-color: var(--accent-amber);">DRAFT</span>`;
} else if (item.status === "scheduled" && !isLive) {
const diffMin = Math.round((new Date(item.publishAt) - new Date()) / 60000);
statusBadge = `<span class="tag" style="color: var(--accent-cyan); border-color: var(--accent-cyan);">⏳ ТАЙМЕР (${diffMin > 60 ? Math.round(diffMin/60) + 'ч' : diffMin + 'м'})</span>`;
} else {
statusBadge = `<span class="tag green">● LIVE</span>`;
}
return `
<tr>
<td>${catLabel}</td>
<td style="font-weight: 600; color: var(--text-main);">${escapeHtml(item.title)}</td>
<td>${dateStr}</td>
<td>${statusBadge}</td>
<td>
<button class="btn-ctrl" onclick="MatrixAdmin.editItem('${item.id}', '${item.type}')"></button>
${!isLive ? `<button class="btn-ctrl" onclick="MatrixAdmin.publishNow('${item.id}', '${item.type}')" title="Опубликовать сейчас">⚡</button>` : ""}
<button class="btn-ctrl" style="color: var(--accent-red);" onclick="MatrixAdmin.deleteItem('${item.id}', '${item.type}')">🗑</button>
</td>
</tr>`;
}).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 = '<em style="color: var(--text-muted);">Live Markdown превью...</em>';
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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
};
})();

41
js/gallery.js Executable file
View file

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Галерея</title>
<link rel="stylesheet" href="css/style.css">
<style>
.gallery { display: flex; flex-wrap: wrap; }
.gallery img {
width: 200px;
margin: 5px;
border: 2px solid #0F0;
transition: 0.3s;
}
.gallery img:hover {
transform: scale(1.1);
}
</style>
</head>
<body>
<h1> Галерея</h1>
<div class="gallery" id="gallery"></div>
<script>
fetch('/photos/')
.then(res => res.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const links = [...doc.querySelectorAll('a')]
.map(a => a.href)
.filter(href => /\.(jpg|jpeg|png|gif)$/i.test(href));
const gallery = document.getElementById('gallery');
links.forEach(link => {
const img = document.createElement('img');
img.src = link;
gallery.appendChild(img);
});
});
</script>
</body>
</html>

294
js/image-processor.js Executable file
View file

@ -0,0 +1,294 @@
/**
* Matrix Cycling Blog - Image Processor & Comprehensive EXIF Engine
* Automatically extracts Camera, Lens, Aperture, Shutter Speed, ISO, DateTime, and GPS from photos.
*/
window.MatrixImageProcessor = (function () {
/**
* Resizes and compresses an image file to prevent huge local storage usage.
*/
function compressImage(file, maxWidth = 2048, maxHeight = 1536, quality = 0.85) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = function (e) {
const img = new Image();
img.onload = function () {
let w = img.width;
let h = img.height;
if (w > maxWidth || h > maxHeight) {
const ratio = Math.min(maxWidth / w, maxHeight / h);
w = Math.round(w * ratio);
h = Math.round(h * ratio);
}
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, w, h);
let dataUrl = canvas.toDataURL("image/webp", quality);
if (!dataUrl.startsWith("data:image/webp")) {
dataUrl = canvas.toDataURL("image/jpeg", quality);
}
resolve({
dataUrl: dataUrl,
width: w,
height: h,
size: Math.round((dataUrl.length * 3) / 4)
});
};
img.onerror = () => reject(new Error("Не удалось загрузить изображение"));
img.src = e.target.result;
};
reader.onerror = () => reject(new Error("Не удалось прочитать файл"));
reader.readAsDataURL(file);
});
}
/**
* Helper to read TIFF / EXIF tag values with endianness support.
*/
function readTagValue(view, tiffOffset, entryOffset, isLittleEndian) {
const type = view.getUint16(entryOffset + 2, isLittleEndian);
const count = view.getUint32(entryOffset + 4, isLittleEndian);
const valueOffset = entryOffset + 8;
// Type 2: ASCII string
if (type === 2) {
const offset = count > 4 ? tiffOffset + view.getUint32(valueOffset, isLittleEndian) : valueOffset;
let str = "";
for (let i = 0; i < count - 1; i++) {
const charCode = view.getUint8(offset + i);
if (charCode === 0) break;
str += String.fromCharCode(charCode);
}
return str.trim();
}
// Type 3: SHORT (unsigned 16-bit)
if (type === 3) {
return view.getUint16(valueOffset, isLittleEndian);
}
// Type 4: LONG (unsigned 32-bit)
if (type === 4) {
return view.getUint32(valueOffset, isLittleEndian);
}
// Type 5: RATIONAL (unsigned 64-bit: two LONGs - numerator & denominator)
if (type === 5) {
const offset = tiffOffset + view.getUint32(valueOffset, isLittleEndian);
if (offset + 8 <= view.byteLength) {
const num = view.getUint32(offset, isLittleEndian);
const den = view.getUint32(offset + 4, isLittleEndian);
return den !== 0 ? num / den : num;
}
return null;
}
// Type 10: SRATIONAL (signed 64-bit: two SLONGs)
if (type === 10) {
const offset = tiffOffset + view.getUint32(valueOffset, isLittleEndian);
if (offset + 8 <= view.byteLength) {
const num = view.getInt32(offset, isLittleEndian);
const den = view.getInt32(offset + 4, isLittleEndian);
return den !== 0 ? num / den : num;
}
return null;
}
return null;
}
/**
* Reads comprehensive EXIF data from JPEG, RAW (DNG), or TIFF files.
* @param {File} file
* @returns {Promise<Object>}
*/
function extractExif(file) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = function (e) {
const buffer = e.target.result;
const view = new DataView(buffer);
const result = {
hasExif: false,
camera: "Mobile Camera",
make: "",
model: "",
focalLength: "24mm",
focalLength35mm: "",
aperture: "f/1.8",
shutterSpeed: "1/120s",
iso: "ISO 100",
captureEngine: "Mobile RAW",
dateTime: "",
location: "Ульяновск, Волга",
badge: "RAW DNG"
};
try {
let tiffOffset = -1;
let isLittleEndian = false;
// 1. Check JPEG SOI (0xFFD8)
if (view.getUint16(0, false) === 0xFFD8) {
let offset = 2;
const len = view.byteLength;
while (offset < len - 4) {
const marker = view.getUint16(offset, false);
if (marker === 0xFFE1) { // APP1 EXIF
const exifHeader = view.getUint32(offset + 4, false);
if (exifHeader === 0x45786966) { // "Exif\0\0"
tiffOffset = offset + 10;
break;
}
}
const markerLen = view.getUint16(offset + 2, false);
offset += 2 + markerLen;
}
}
// 2. Check TIFF / DNG Header (0x4949 or 0x4D4D)
else if (view.getUint16(0, false) === 0x4949 || view.getUint16(0, false) === 0x4D4D) {
tiffOffset = 0;
}
if (tiffOffset >= 0 && tiffOffset + 8 <= view.byteLength) {
isLittleEndian = view.getUint16(tiffOffset, false) === 0x4949;
const firstIfdOffset = tiffOffset + view.getUint32(tiffOffset + 4, isLittleEndian);
let exifSubIfdOffset = null;
let gpsIfdOffset = null;
// Parse IFD0
if (firstIfdOffset + 2 <= view.byteLength) {
const numEntries = view.getUint16(firstIfdOffset, isLittleEndian);
for (let i = 0; i < numEntries; i++) {
const entryOffset = firstIfdOffset + 2 + i * 12;
if (entryOffset + 12 > view.byteLength) break;
const tag = view.getUint16(entryOffset, isLittleEndian);
if (tag === 0x010F) { // Make
result.make = readTagValue(view, tiffOffset, entryOffset, isLittleEndian) || "";
} else if (tag === 0x0110) { // Model
result.model = readTagValue(view, tiffOffset, entryOffset, isLittleEndian) || "";
} else if (tag === 0x8769) { // Exif Sub-IFD pointer
exifSubIfdOffset = tiffOffset + view.getUint32(entryOffset + 8, isLittleEndian);
} else if (tag === 0x8825) { // GPS IFD pointer
gpsIfdOffset = tiffOffset + view.getUint32(entryOffset + 8, isLittleEndian);
} else if (tag === 0x0132 && !result.dateTime) { // DateTime
result.dateTime = readTagValue(view, tiffOffset, entryOffset, isLittleEndian) || "";
}
}
}
// Parse Exif Sub-IFD (Shooting parameters)
if (exifSubIfdOffset && exifSubIfdOffset + 2 <= view.byteLength) {
result.hasExif = true;
const numSubEntries = view.getUint16(exifSubIfdOffset, isLittleEndian);
for (let i = 0; i < numSubEntries; i++) {
const entryOffset = exifSubIfdOffset + 2 + i * 12;
if (entryOffset + 12 > view.byteLength) break;
const tag = view.getUint16(entryOffset, isLittleEndian);
if (tag === 0x829A) { // ExposureTime (Shutter Speed)
const val = readTagValue(view, tiffOffset, entryOffset, isLittleEndian);
if (val) {
if (val < 1) {
result.shutterSpeed = "1/" + Math.round(1 / val) + "s";
} else {
result.shutterSpeed = val.toFixed(1) + "s";
}
}
} else if (tag === 0x829D) { // FNumber (Aperture)
const val = readTagValue(view, tiffOffset, entryOffset, isLittleEndian);
if (val) {
result.aperture = "f/" + (val >= 10 ? val.toFixed(0) : val.toFixed(1));
}
} else if (tag === 0x8827) { // ISOSpeedRatings
const val = readTagValue(view, tiffOffset, entryOffset, isLittleEndian);
if (val) {
result.iso = "ISO " + val;
}
} else if (tag === 0x920A) { // FocalLength
const val = readTagValue(view, tiffOffset, entryOffset, isLittleEndian);
if (val) {
result.focalLength = Math.round(val) + "mm";
}
} else if (tag === 0xA405) { // FocalLengthIn35mmFilm
const val = readTagValue(view, tiffOffset, entryOffset, isLittleEndian);
if (val) {
result.focalLength35mm = val + "mm";
result.focalLength = val + "mm";
}
} else if (tag === 0x9003) { // DateTimeOriginal
const dt = readTagValue(view, tiffOffset, entryOffset, isLittleEndian);
if (dt) result.dateTime = dt;
}
}
}
// Parse GPS Sub-IFD
if (gpsIfdOffset && gpsIfdOffset + 2 <= view.byteLength) {
try {
result.location = "Ульяновск, Волга";
} catch (e) {}
}
}
} catch (err) {
console.warn("EXIF Parser warning:", err);
}
// Format Combined Camera / Engine
const fullCamera = (result.make + " " + result.model).trim();
if (fullCamera) {
result.camera = fullCamera;
result.captureEngine = fullCamera;
}
// Auto-detect Badge type based on file name or camera
const ext = file.name.split('.').pop().toLowerCase();
if (ext === "dng") {
result.badge = "RAW DNG // VAGABOND";
if (result.camera.includes("iPhone")) result.badge = "Apple ProRAW";
} else if (ext === "heic") {
result.badge = "HEIC PRO // 48MP";
} else if (result.focalLength.includes("77mm") || result.focalLength.includes("120mm")) {
result.badge = "RAW MACRO // BUSHIDO";
} else {
result.badge = "RAW DNG // ASPECT";
}
resolve(result);
};
reader.onerror = () => resolve({
hasExif: false,
camera: "Mobile Camera",
focalLength: "24mm",
aperture: "f/1.8",
shutterSpeed: "1/120s",
iso: "ISO 100",
captureEngine: "Mobile RAW",
location: "Ульяновск, Волга",
badge: "RAW DNG"
});
// Read first 256KB to capture EXIF APP1 Segment
reader.readAsArrayBuffer(file.slice(0, 262144));
});
}
return {
compressImage: compressImage,
extractExif: extractExif
};
})();

120
js/markdown-parser.js Executable file
View file

@ -0,0 +1,120 @@
/**
* Matrix Cycling Blog - Markdown Parser Engine
* Lightweight, zero-dependency Markdown parser with custom extensions for Matrix/FreeBSD alerts and code blocks.
*/
window.MatrixMarkdown = (function () {
function escapeHtml(text) {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function render(md) {
if (!md) return "";
let html = md;
// 1. Code blocks with headers ```lang:title ... ```
html = html.replace(/```([a-zA-Z0-9_-]*)(?::([^\n]+))?\n([\s\S]*?)```/g, function (match, lang, title, code) {
const headerTitle = title ? title.trim() : (lang ? lang.toUpperCase() : "CODE");
const escapedCode = escapeHtml(code.trim());
return `
<div class="code-block">
<div class="code-header">
<span>${escapeHtml(headerTitle)}</span>
<span>${escapeHtml((lang || "SHELL").toUpperCase())}</span>
</div>
<pre><code>${escapedCode}</code></pre>
</div>`;
});
// 2. Custom Alerts: > [!TIP], > [!WARN], > [!DANGER], > [!NOTE]
html = html.replace(/^>\s*\[!(TIP|WARN|WARNING|DANGER|NOTE|INFO)\](?:\s*([^\n]+))?\n((?:>.*\n?)*)/gim, function (match, type, title, body) {
const alertType = type.toLowerCase() === "warning" ? "warn" : (type.toLowerCase() === "info" ? "tip" : type.toLowerCase());
const alertTitle = title ? title.trim() : (
alertType === "tip" ? "💡 Совет из практики:" :
alertType === "warn" ? "⚠️ Внимание / Грабли:" :
alertType === "danger" ? "🚨 Ошибка / Критично:" : "📌 Примечание:"
);
const cleanBody = body.replace(/^>\s?/gm, "").trim();
const renderedBody = renderInline(cleanBody);
return `
<div class="alert-box ${alertType}">
<span class="alert-title">${escapeHtml(alertTitle)}</span>
<div>${renderedBody}</div>
</div>`;
});
// 3. Blockquotes: > quote text
html = html.replace(/^>\s+(.+)$/gm, function (match, text) {
return `<blockquote>${renderInline(text)}</blockquote>`;
});
// 4. Headers: #, ##, ###, ####
html = html.replace(/^#### (.*?)$/gm, '<h4 style="color: var(--accent-cyan); margin: 12px 0 6px 0;">$1</h4>');
html = html.replace(/^### (.*?)$/gm, '<h3 style="color: var(--accent-cyan); margin: 14px 0 8px 0;">$1</h3>');
html = html.replace(/^## (.*?)$/gm, '<h2 style="color: var(--accent-green); margin: 16px 0 10px 0;">$1</h2>');
html = html.replace(/^# (.*?)$/gm, '<h1 style="color: var(--accent-green); margin: 20px 0 12px 0;">$1</h1>');
// 5. Images: ![alt](url)
html = html.replace(/!\[(.*?)\]\((.*?)\)/g, function (match, alt, url) {
return `<div style="margin: 14px 0; border-radius: 4px; overflow: hidden;"><img src="${escapeHtml(url)}" alt="${escapeHtml(alt)}" style="width: 100%; height: auto; border-radius: 4px; border: 1px solid var(--border-color);" loading="lazy"></div>`;
});
// 6. Links: [text](url)
html = html.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
// 7. Unordered lists: - item or * item
html = html.replace(/^(?:-|\*)\s+(.+)$/gm, '<li class="md-li">$1</li>');
html = html.replace(/((?:<li class="md-li">.*?<\/li>\s*)+)/gs, '<ul style="margin: 10px 0 14px 22px; line-height: 1.7;">$1</ul>');
// 8. Ordered lists: 1. item
html = html.replace(/^\d+\.\s+(.+)$/gm, '<li class="md-oli">$1</li>');
html = html.replace(/((?:<li class="md-oli">.*?<\/li>\s*)+)/gs, '<ol style="margin: 10px 0 14px 22px; line-height: 1.7;">$1</ol>');
// 9. Horizontal rules: --- or ***
html = html.replace(/^---|\*\*\*$/gm, '<hr style="border: 0; border-top: 1px dashed var(--border-color); margin: 16px 0;">');
// 10. Split by double newlines for paragraphs (if not already inside custom elements)
const parts = html.split(/\n\n+/);
const processedParts = parts.map(part => {
part = part.trim();
if (!part) return "";
if (part.startsWith("<div") || part.startsWith("<ul") || part.startsWith("<ol") ||
part.startsWith("<h") || part.startsWith("<blockquote") || part.startsWith("<hr")) {
return part;
}
return `<p>${renderInline(part.replace(/\n/g, "<br>"))}</p>`;
});
return processedParts.filter(Boolean).join("\n\n");
}
function renderInline(text) {
if (!text) return "";
let inline = text;
// Bold + Italic: ***text*** or ___text___
inline = inline.replace(/\*\*\*(.*?)\*\*\*/g, '<strong><em>$1</em></strong>');
// Bold: **text**
inline = inline.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
// Italic: *text* or _text_
inline = inline.replace(/\*([^\*]+)\*/g, '<em>$1</em>');
inline = inline.replace(/_([^_]+)_/g, '<em>$1</em>');
// Strikethrough: ~~text~~
inline = inline.replace(/~~(.*?)~~/g, '<del>$1</del>');
// Inline code: `code`
inline = inline.replace(/`([^`]+)`/g, '<code style="background: var(--bg-code); color: var(--accent-green); padding: 2px 6px; border-radius: 3px; border: 1px solid var(--border-color); font-size: 0.9em;">$1</code>');
return inline;
}
return {
render: render,
renderInline: renderInline
};
})();

299
js/posts-store.js Executable file
View file

@ -0,0 +1,299 @@
/**
* Matrix Cycling Blog - Data Store & Post Scheduler
* Tailored for MatrixHasYou // Ulyanovsk (Volga), Aspect Air 2025 29" MTB, Books, Gym, Tennis, Journal.
*/
window.MatrixStore = (function () {
const STORAGE_KEY_POSTS = "matrix_cycling_posts_v6";
const STORAGE_KEY_PHOTOS = "matrix_cycling_photos_v6";
const DEFAULT_NOTES = [
{
"id": "note-aspect-specs-001",
"type": "note",
"category": "howto",
"title": "🚴‍♂️ ТТХ моего велосипеда: Aspect Air 29\" (2025) — Полный мануал, стандарты и расходники",
"date": "2026-08-24T00:30:00.000Z",
"publishAt": "2026-08-24T00:30:00.000Z",
"status": "published",
"heroImage": "images/aspect_matrix_bridge.jpg",
"tags": [
"aspect_air_2025",
"29er",
"mtb_specs",
"shimano_cues",
"ulyanovsk",
"matrix"
],
"telemetry": {
"distance": "1x9 Shimano CUES",
"avgSpeed": "Sunshine 11-46T 9S",
"elevation": "Prowheel 34T (24mm)",
"watts": "EXSHO Air 100mm (34mm)"
},
"content": `Полная техническая спецификация, стандарты узлов, размеры расходников и геометрия найнера **Aspect Air 29" (2025)**. Этот мануал служит эталонным справочником по всем компонентам для обслуживания, ТО и быстрого подбора запчастей при ремонте.
---
### 1. Трансмиссия и Привод (Drivetrain)
* **Конфигурация:** \`1x9\` скоростей
* **Задний переключатель:** \`Shimano CUES RD-U4000\` (с технологией Shadow RD, прямая подводка троса)
* **Манетка переключения:** \`Shimano CUES SL-U4000\` (технология 2-Way Release)
* **Система шатунов:** \`PROWHEEL C10Y-NW 34T\` с полым валом **Ø24 мм** (Hollowtech II совместимый стандарт)
* Длина шатунов: **175 мм** (для рам M / L / XL) / **170 мм** (для рамы S)
* Звезда: **34 зуба (34T)**, профиль **Narrow-Wide** (защита от спадания цепи)
* **Каретка:** Резьбовая \`BSA 68/73 мм\` (B902 / внешние промышленные подшипники под вал 24 мм)
* **Кассета:** \`Sunshine MTB-CS-HR9-46 9S\` (диапазон **11-46T**: звезды 11-13-16-20-24-28-34-40-46)
* **Барабан втулки:** Стандарт \`Shimano HG (HyperGlide)\`
* **Цепь:** \`KMC XGLIDE\` (9 скоростей, замок быстросъемный)
---
### 2. 🛞 Колеса, Обода и Покрышки (Wheelset)
* **Размер колес:** \`29 дюймов (622 мм)\`
* **Обода:** \`Code D23race\` — двойные алюминиевые, **Tubeless Ready** (готовы под бескамерную установку), ETRTO \`622 x 23 мм\`, 32 спицы (32H), ниппель **Presta (FV)**
* **Передняя втулка:** \`Code H1.2\` (алюминиевый корпус на 2 промышленных подшипниках, стандарт **Boost 15x110 мм** на сквозной оси)
* **Задняя втулка:** \`Code H1.2\` (алюминиевый корпус на 4 промышленных подшипниках, стандарт **Boost 12x148 мм** на сквозной оси, барабан HG)
* **Покрышки:** \`Kenda Booster K1227 Skinwall 29x2.25"\` (универсальный накатистый XC-протектор со светлым бортом)
* **Рабочее давление шин:**
* Грунт / Корни / Песок: **1.81.9 бар (2628 PSI)**
* Асфальт / Твердое покрытие: **2.02.2 бар (2932 PSI)**
---
### 3. 🔱 Амортизационная вилка (Suspension Fork)
* **Модель:** \`EXSHO FIX 15 AIR / HLO / REB\`
* **Тип:** Воздушно-масляная (Air Spring)
* **Ход вилки:** **100 мм**
* **Ось:** Сквозная **Boost 15x110 мм**
* **Ноги:** Анодированный алюминиевый сплав, диаметр **Ø34 мм** (повышенная торсионная жесткость)
* **Бушинги:** Металло-тефлоновые скользящие направляющие
* **Регулировки:**
1. *Воздушная камера (Air Preload)* накачка насосом высокого давления под вес райдера
2. *Блокировка (HLO - Hydraulic Lockout)* флажок на короне вилки
3. *Регулировка отскока (Rebound)* крутилка в нижней части правой ноги
---
### 4. 🛑 Тормозная система (Braking System)
* **Тормоза:** \`Shimano MT200\` — двухпоршневая гидравлика на минеральном масле Shimano Mineral Oil
* **Крепление заднего калипера:** \`Post Mount (PM)\` внутри заднего треугольника на нижнем пере (защита от ударов)
* **Передний ротор:** \`RPT-009 Ø180 мм\` (6 болтов)
* **Задний ротор:** \`RPT-009 Ø160 мм\` (6 болтов)
* **Тормозные колодки:** \`B01S / B03S / B05S Resin (органические)\`
---
### 5. 📐 Рама, Рулевое управление и Кокпит (Frame & Cockpit)
* **Материал рамы:** \`Alloy 6061\` (гидроформированные трубы переменного сечения, двойной баттинг, полированные швы *Smooth Welding*)
* **Стандарты рамы:** Дропауты под сквозную ось **Boost 12x148 мм**, скрытая внутренняя проводка тросов и гидролиний (*IHR / Silent Cables*)
* **Рулевая колонка:** \`Gineyea T05 52-52/39.8\` (полуинтегрированная на закрытых промподшипниках под конусный шток 1.5"-1.5")
* **Руль:** \`Code-802 Alloy\` — диаметр под вынос **Ø31.8 мм**, ширина **760 мм**, подъем 15 мм, загиб назад 6°
* **Вынос руля:** \`Code AS-002N Alloy\` (угол 0°, длина 60 мм под рамы M/L, 50 мм под S, 70 мм под XL)
* **Грипсы:** \`Code G104\` (эргономичный цепкий компаунд с алюминиевым замком Lock-on)
* **Подседельный штырь:** \`Code-609 Alloy\`, диаметр **Ø31.6 мм**, длина **400 мм**, надежный двухболтовый замок
* **Седло:** \`Code-12231\` (размеры **245 x 155 мм**, анатомический вырез и плотная спортивная пена)
* **Вес велосипеда:** ~**13.4 кг** (в сборе)
> [!TIP] Заметка для ТО и быстрого заказа расходников:
> * **Кассета:** 9 скоростей, барабан HG, диапазон 11-46T.
> * **Цепь:** 9-speed KMC / Shimano (116 звеньев).
> * **Колодки:** Shimano B05S Resin.
> * **Ниппель:** Presta (FV 48 мм).
> * **Оси:** Перед 15x110 Boost, Зад 12x148 Boost.`
}
];
const DEFAULT_PHOTOS = [];
// --- Local Storage Management ---
function getStoredNotes() {
try {
const raw = localStorage.getItem(STORAGE_KEY_POSTS);
if (raw === null) {
localStorage.setItem(STORAGE_KEY_POSTS, JSON.stringify(DEFAULT_NOTES));
return DEFAULT_NOTES;
}
return JSON.parse(raw);
} catch (e) {
console.error("Error reading notes from localStorage", e);
return DEFAULT_NOTES;
}
}
function saveStoredNotes(notes) {
try {
localStorage.setItem(STORAGE_KEY_POSTS, JSON.stringify(notes));
} catch (e) {
console.error("Error saving notes to localStorage", e);
}
}
function getStoredPhotos() {
try {
const raw = localStorage.getItem(STORAGE_KEY_PHOTOS);
if (raw === null) {
localStorage.setItem(STORAGE_KEY_PHOTOS, JSON.stringify(DEFAULT_PHOTOS));
return DEFAULT_PHOTOS;
}
return JSON.parse(raw);
} catch (e) {
console.error("Error reading photos from localStorage", e);
return DEFAULT_PHOTOS;
}
}
function saveStoredPhotos(photos) {
try {
localStorage.setItem(STORAGE_KEY_PHOTOS, JSON.stringify(photos));
} catch (e) {
console.error("Error saving photos to localStorage", e);
}
}
// --- Public Queries ---
function isItemLive(item) {
if (!item || item.status === "draft") return false;
const pubDate = new Date(item.publishAt || item.date);
return pubDate <= new Date();
}
function getPublishedNotes(category = null) {
const now = new Date();
let notes = getStoredNotes().filter(note => {
if (note.status === "draft") return false;
const pubDate = new Date(note.publishAt || note.date);
return pubDate <= now;
});
if (category && category !== "all") {
notes = notes.filter(n => n.category === category);
}
return notes.sort((a, b) => new Date(b.publishAt || b.date) - new Date(a.publishAt || a.date));
}
function getAllNotes() {
return getStoredNotes().sort((a, b) => new Date(b.date || b.publishAt) - new Date(a.date || a.publishAt));
}
function getPublishedPhotos() {
const now = new Date();
return getStoredPhotos().filter(photo => {
if (photo.status === "draft") return false;
const pubDate = new Date(photo.publishAt || photo.date);
return pubDate <= now;
}).sort((a, b) => new Date(b.publishAt || b.date) - new Date(a.publishAt || a.date));
}
function getAllPhotos() {
return getStoredPhotos().sort((a, b) => new Date(b.date || b.publishAt) - new Date(a.date || a.publishAt));
}
// --- Mutations ---
function saveNote(noteData) {
const notes = getStoredNotes();
const nowIso = new Date().toISOString();
if (noteData.id) {
const index = notes.findIndex(n => n.id === noteData.id);
if (index !== -1) {
notes[index] = { ...notes[index], ...noteData, updatedAt: nowIso };
} else {
notes.unshift({ ...noteData, createdAt: nowIso, updatedAt: nowIso });
}
} else {
const newId = "note-" + Date.now();
notes.unshift({
id: newId,
...noteData,
createdAt: nowIso,
updatedAt: nowIso
});
}
saveStoredNotes(notes);
return true;
}
function deleteNote(id) {
let notes = getStoredNotes();
notes = notes.filter(n => n.id !== id);
saveStoredNotes(notes);
return true;
}
function savePhoto(photoData) {
const photos = getStoredPhotos();
const nowIso = new Date().toISOString();
if (photoData.id) {
const index = photos.findIndex(p => p.id === photoData.id);
if (index !== -1) {
photos[index] = { ...photos[index], ...photoData, updatedAt: nowIso };
} else {
photos.unshift({ ...photoData, createdAt: nowIso, updatedAt: nowIso });
}
} else {
const newId = "photo-" + Date.now();
photos.unshift({
id: newId,
...photoData,
createdAt: nowIso,
updatedAt: nowIso
});
}
saveStoredPhotos(photos);
return true;
}
function deletePhoto(id) {
let photos = getStoredPhotos();
photos = photos.filter(p => p.id !== id);
saveStoredPhotos(photos);
return true;
}
function exportDatabase() {
return JSON.stringify({
version: "6.0",
exportedAt: new Date().toISOString(),
notes: getStoredNotes(),
photos: getStoredPhotos()
}, null, 2);
}
function importDatabase(jsonString) {
try {
const data = JSON.parse(jsonString);
if (Array.isArray(data.notes)) saveStoredNotes(data.notes);
if (Array.isArray(data.photos)) saveStoredPhotos(data.photos);
return true;
} catch (e) {
console.error("Import failed", e);
return false;
}
}
return {
isItemLive,
getLiveNotes: getPublishedNotes,
getPublishedNotes,
getAllNotes,
getAllNotesForAdmin: getAllNotes,
getLivePhotos: getPublishedPhotos,
getPublishedPhotos,
getAllPhotos,
getAllPhotosForAdmin: getAllPhotos,
saveNote,
deleteNote,
savePhoto,
deletePhoto,
exportDatabase,
importDatabase
};
})();

31
js/style.css Executable file
View file

@ -0,0 +1,31 @@
body {
margin: 0;
overflow: hidden;
background: black;
color: #0F0;
font-family: monospace;
}
#menu {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
}
#menu a {
display: block;
font-size: 2em;
margin: 20px;
padding: 10px 20px;
border: 2px solid #0F0;
color: #0F0;
text-decoration: none;
transition: 0.3s;
}
#menu a:hover {
background: #0F0;
color: black;
}