Deploy MatrixHasYou.ru: Aspect Air 29 specs, Matrix theme, HOWTOs and Zero-Plaintext Admin Auth
This commit is contained in:
commit
d5e402d9f6
12 changed files with 3086 additions and 0 deletions
BIN
images/aspect_matrix_bridge.jpg
Executable file
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
BIN
images/chain_macro.jpg
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 896 KiB |
BIN
images/forest.jpg
Executable file
BIN
images/forest.jpg
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
images/hero.jpg
Executable file
BIN
images/hero.jpg
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 1,019 KiB |
BIN
images/neon_city.jpg
Executable file
BIN
images/neon_city.jpg
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 911 KiB |
1605
index.html
Executable file
1605
index.html
Executable file
File diff suppressed because it is too large
Load diff
696
js/admin-controller.js
Executable file
696
js/admin-controller.js
Executable 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, "&")
|
||||||
|
.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
|
||||||
|
};
|
||||||
|
})();
|
||||||
41
js/gallery.js
Executable file
41
js/gallery.js
Executable 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
294
js/image-processor.js
Executable 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
120
js/markdown-parser.js
Executable 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, "&")
|
||||||
|
.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 `
|
||||||
|
<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: 
|
||||||
|
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
299
js/posts-store.js
Executable 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.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
|
||||||
|
};
|
||||||
|
})();
|
||||||
31
js/style.css
Executable file
31
js/style.css
Executable 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;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue