commit d5e402d9f6da21393bce87e4069926b4b390f537 Author: MatrixHasYou Date: Mon Aug 24 07:37:07 2026 +0400 Deploy MatrixHasYou.ru: Aspect Air 29 specs, Matrix theme, HOWTOs and Zero-Plaintext Admin Auth diff --git a/images/aspect_matrix_bridge.jpg b/images/aspect_matrix_bridge.jpg new file mode 100755 index 0000000..7738ed8 Binary files /dev/null and b/images/aspect_matrix_bridge.jpg differ diff --git a/images/chain_macro.jpg b/images/chain_macro.jpg new file mode 100755 index 0000000..07745b6 Binary files /dev/null and b/images/chain_macro.jpg differ diff --git a/images/forest.jpg b/images/forest.jpg new file mode 100755 index 0000000..32eb822 Binary files /dev/null and b/images/forest.jpg differ diff --git a/images/hero.jpg b/images/hero.jpg new file mode 100755 index 0000000..cc87b10 Binary files /dev/null and b/images/hero.jpg differ diff --git a/images/neon_city.jpg b/images/neon_city.jpg new file mode 100755 index 0000000..a9f278d Binary files /dev/null and b/images/neon_city.jpg differ diff --git a/index.html b/index.html new file mode 100755 index 0000000..c245385 --- /dev/null +++ b/index.html @@ -0,0 +1,1605 @@ + + + + + + + + MatrixHasYou // Ульяновск • Aspect Air 2025 29" MTB + + + + + + + +
+ + +
+ + +
+
+ 📅 --.--.---- + ⏱️ --:--:-- + 🚲 Aspect Air 2025 29" + 📍 54.3141° N, 48.4031° E (Ульяновск) +
+
+ + + +
+
+ + +
+
+███╗ ███╗ █████╗ ████████╗██████╗ ██╗██╗ ██╗██╗ ██╗ █████╗ ███████╗██╗ ██╗ ██████╗ ██╗ ██╗ +████╗ ████║██╔══██╗╚══██╔══╝██╔══██╗██║╚██╗██╔╝██║ ██║██╔══██╗██╔════╝╚██╗ ██╔╝██╔═══██╗██║ ██║ +██╔████╔██║███████║ ██║ ██████╔╝██║ ╚███╔╝ ███████║███████║███████╗ ╚████╔╝ ██║ ██║██║ ██║ +██║╚██╔╝██║██╔══██║ ██║ ██╔══██╗██║ ██╔██╗ ██╔══██║██╔══██║╚════██║ ╚██╔╝ ██║ ██║██║ ██║ +██║ ╚═╝ ██║██║ ██║ ██║ ██║ ██║██║██╔╝ ██╗██║ ██║██║ ██║███████║ ██║ ╚██████╔╝╚██████╔╝ +╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═════╝ ╚═════╝ +
+ +
+ matrixhasyou@ulyanovsk-aspect:~/volga/feed$ ./init_rm-rf.sh --ride --photo --book +
+ +

+ Персональный журнал заездов по Ульяновску и берегам Волги на найнере Aspect Air 2025 29" MTB, полевые HOWTO по ремонту и ТО, книжная полка, тренировочные программы и архив мобильной RAW-фотографии. +

+ +
+
+ ТЕКУЩИЙ ВЕЛОСИПЕД + Aspect Air 2025 29" MTB (Custom) +
+
+ ЛОКАЦИЯ + Ульяновск +
+
+ ТРАНСМИССИЯ И ПОКРЫШКИ + 1x9 Shimano CUES / Kenda Booster 29x2.25" (2.0 бар) +
+
+
+ + + + + +
+
+ +
+
+ + + + + +
+
+ +
+
+ + +
+
+
+

⚙️ Interactive 29" MTB Drivetrain Calculator

+ +
+

+ Калькулятор передаточного отношения, шага велосипеда (Development / Gear Inches) и скорости для найнера 29" на различных передачах и каденсе. +

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
3.09
+
Gear Ratio (Передаточное)
+
+
+
88.2"
+
Gear Inches (Дюймы)
+
+
+
7.05 m
+
Шаг за 1 оборот шатуна
+
+
+
35.9 km/h
+
Скорость при каденсе
+
+
+
+
+ + +
+
+ +
+
+ + +
+ + +
+
+

🔐 Авторизация Оператора

+
+
+
+ + +
+
+ + +
+ +
+
+ + + +
+ + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+ [MatrixHasYou] © 2026 // Ульяновск • Волга • Aspect Air 2025 29" • ЖПЧХ. +
+ +
+ +
+ + + + + +
+ + + + + + + + + + + diff --git a/js/admin-controller.js b/js/admin-controller.js new file mode 100755 index 0000000..c7141e2 --- /dev/null +++ b/js/admin-controller.js @@ -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 = ` +
+

${escapeHtml(emptyText)}

+
`; + 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 ? ` +
+ ${escapeHtml(note.title)} +
` : ""; + + const contentHtml = MatrixMarkdown.render(note.content); + + return ` +
+
+

${escapeHtml(note.title)}

+ +
+ ${telemetryHtml} + + ${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")} + ${safeTitle} +
+
+
+

${safeTitle}

+

${safeDesc}

+
+
+ 📱 ${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 + }; +})(); diff --git a/js/gallery.js b/js/gallery.js new file mode 100755 index 0000000..363b290 --- /dev/null +++ b/js/gallery.js @@ -0,0 +1,41 @@ + + + + +Галерея + + + + +

Галерея

+ + + + diff --git a/js/image-processor.js b/js/image-processor.js new file mode 100755 index 0000000..ea71697 --- /dev/null +++ b/js/image-processor.js @@ -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} + */ + 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 + }; +})(); diff --git a/js/markdown-parser.js b/js/markdown-parser.js new file mode 100755 index 0000000..65926d1 --- /dev/null +++ b/js/markdown-parser.js @@ -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, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + 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 ` +
+
+ ${escapeHtml(headerTitle)} + ${escapeHtml((lang || "SHELL").toUpperCase())} +
+
${escapedCode}
+
`; + }); + + // 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 ` +
+ ${escapeHtml(alertTitle)} +
${renderedBody}
+
`; + }); + + // 3. Blockquotes: > quote text + html = html.replace(/^>\s+(.+)$/gm, function (match, text) { + return `
${renderInline(text)}
`; + }); + + // 4. Headers: #, ##, ###, #### + html = html.replace(/^#### (.*?)$/gm, '

$1

'); + html = html.replace(/^### (.*?)$/gm, '

$1

'); + html = html.replace(/^## (.*?)$/gm, '

$1

'); + html = html.replace(/^# (.*?)$/gm, '

$1

'); + + // 5. Images: ![alt](url) + html = html.replace(/!\[(.*?)\]\((.*?)\)/g, function (match, alt, url) { + return `
${escapeHtml(alt)}
`; + }); + + // 6. Links: [text](url) + html = html.replace(/\[(.*?)\]\((.*?)\)/g, '$1'); + + // 7. Unordered lists: - item or * item + html = html.replace(/^(?:-|\*)\s+(.+)$/gm, '
  • $1
  • '); + html = html.replace(/((?:
  • .*?<\/li>\s*)+)/gs, '
      $1
    '); + + // 8. Ordered lists: 1. item + html = html.replace(/^\d+\.\s+(.+)$/gm, '
  • $1
  • '); + html = html.replace(/((?:
  • .*?<\/li>\s*)+)/gs, '
      $1
    '); + + // 9. Horizontal rules: --- or *** + html = html.replace(/^---|\*\*\*$/gm, '
    '); + + // 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("${renderInline(part.replace(/\n/g, "
    "))}

    `; + }); + + 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, '$1'); + // Bold: **text** + inline = inline.replace(/\*\*(.*?)\*\*/g, '$1'); + // Italic: *text* or _text_ + inline = inline.replace(/\*([^\*]+)\*/g, '$1'); + inline = inline.replace(/_([^_]+)_/g, '$1'); + // Strikethrough: ~~text~~ + inline = inline.replace(/~~(.*?)~~/g, '$1'); + // Inline code: `code` + inline = inline.replace(/`([^`]+)`/g, '$1'); + + return inline; + } + + return { + render: render, + renderInline: renderInline + }; +})(); diff --git a/js/posts-store.js b/js/posts-store.js new file mode 100755 index 0000000..a3fe530 --- /dev/null +++ b/js/posts-store.js @@ -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.8–1.9 бар (26–28 PSI)** + * Асфальт / Твердое покрытие: **2.0–2.2 бар (29–32 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 + }; +})(); diff --git a/js/style.css b/js/style.css new file mode 100755 index 0000000..bc87065 --- /dev/null +++ b/js/style.css @@ -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; +}