diff --git a/README.md b/README.md index 9ffdf49..8b13789 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# Html.box \ No newline at end of file + diff --git a/app.js b/app.js new file mode 100644 index 0000000..195349a --- /dev/null +++ b/app.js @@ -0,0 +1,1323 @@ +const DB_NAME = 'mono-super-platform'; +const DB_VERSION = 1; +const DEV_CODE = 'dev:1234'; +const STORES = ['meta', 'users', 'profiles', 'chats', 'messages', 'posts', 'comments', 'likes', 'logs']; + +const q = (sel, parent = document) => parent.querySelector(sel); +const qa = (sel, parent = document) => Array.from(parent.querySelectorAll(sel)); + +const escapeHTML = (value = '') => + value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const sanitizeInput = (value = '') => { + const parser = new DOMParser(); + const doc = parser.parseFromString(`
${value}`, 'text/html'); + return (doc.body.textContent || '').trim(); +}; + +class DataLayer { + constructor() { + this.db = null; + this.eventTarget = new EventTarget(); + } + + async open() { + if (this.db) return this.db; + this.db = await new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = (event) => { + const db = req.result; + STORES.forEach((store) => { + if (!db.objectStoreNames.contains(store)) { + switch (store) { + case 'meta': + db.createObjectStore('meta', { keyPath: 'key' }); + break; + case 'users': + db.createObjectStore('users', { keyPath: 'id' }); + break; + case 'profiles': + db.createObjectStore('profiles', { keyPath: 'id' }); + break; + case 'chats': { + const s = db.createObjectStore('chats', { keyPath: 'id' }); + s.createIndex('by_participant', 'participants', { multiEntry: true }); + break; + } + case 'messages': { + const s = db.createObjectStore('messages', { keyPath: 'id' }); + s.createIndex('by_chat', 'chatId'); + s.createIndex('by_time', 'time'); + break; + } + case 'posts': { + const s = db.createObjectStore('posts', { keyPath: 'id' }); + s.createIndex('by_author', 'authorProfileId'); + s.createIndex('by_type', 'type'); + break; + } + case 'comments': { + const s = db.createObjectStore('comments', { keyPath: 'id' }); + s.createIndex('by_post', 'postId'); + break; + } + case 'likes': { + const s = db.createObjectStore('likes', { keyPath: 'id' }); + s.createIndex('by_post', 'postId'); + s.createIndex('by_profile', 'profileId'); + break; + } + case 'logs': { + const s = db.createObjectStore('logs', { keyPath: 'id', autoIncrement: true }); + s.createIndex('by_time', 'time'); + break; + } + default: + db.createObjectStore(store, { keyPath: 'id' }); + } + } + }); + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + + this.db.onversionchange = () => { + this.db.close(); + alert('Версия базы данных обновлена. Перезагрузите страницу.'); + }; + + const seeded = await this.get('meta', 'seeded'); + if (!seeded) { + await this.seed(); + } + return this.db; + } + + async seed() { + const response = await fetch('seed-db.json'); + const data = await response.json(); + const tx = this.db.transaction(STORES, 'readwrite'); + await Promise.all( + STORES.map((store) => { + if (store === 'meta') return Promise.resolve(); + const objectStore = tx.objectStore(store); + const items = data[store] || []; + return Promise.all( + items.map( + (item) => + new Promise((resolve, reject) => { + const req = objectStore.put(item); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }) + ) + ); + }) + ); + tx.objectStore('meta').put({ key: 'seeded', value: true, time: Date.now() }); + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + await this.recordLog('seed', { info: 'Seed database loaded' }); + } + + async transaction(storeNames, mode = 'readonly') { + const db = await this.open(); + return db.transaction(storeNames, mode); + } + + async get(store, key) { + const tx = await this.transaction([store], 'readonly'); + return new Promise((resolve, reject) => { + const req = tx.objectStore(store).get(key); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + } + + async getAll(store, indexName, query) { + const tx = await this.transaction([store], 'readonly'); + const objectStore = tx.objectStore(store); + return new Promise((resolve, reject) => { + const req = indexName + ? objectStore.index(indexName).getAll(query) + : objectStore.getAll(); + req.onsuccess = () => resolve(req.result || []); + req.onerror = () => reject(req.error); + }); + } + + async put(store, value) { + const tx = await this.transaction([store], 'readwrite'); + await new Promise((resolve, reject) => { + const req = tx.objectStore(store).put(value); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + await this.recordLog('put', { store, value }); + this.dispatch(`${store}:change`); + return value; + } + + async bulkPut(store, values) { + const tx = await this.transaction([store], 'readwrite'); + const storeRef = tx.objectStore(store); + await Promise.all( + values.map( + (value) => + new Promise((resolve, reject) => { + const req = storeRef.put(value); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }) + ) + ); + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + await this.recordLog('bulkPut', { store, count: values.length }); + this.dispatch(`${store}:change`); + return values; + } + + async delete(store, key) { + const tx = await this.transaction([store], 'readwrite'); + await new Promise((resolve, reject) => { + const req = tx.objectStore(store).delete(key); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + await this.recordLog('delete', { store, key }); + this.dispatch(`${store}:change`); + } + + async clearAll() { + const tx = await this.transaction(STORES, 'readwrite'); + await Promise.all( + STORES.map( + (store) => + new Promise((resolve, reject) => { + const req = tx.objectStore(store).clear(); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }) + ) + ); + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + await this.recordLog('reset', { info: 'database cleared' }); + this.dispatch('db:reset'); + } + + async recordLog(action, payload) { + const entry = { + action, + payload, + time: Date.now(), + }; + const tx = await this.transaction(['logs'], 'readwrite'); + await new Promise((resolve, reject) => { + const req = tx.objectStore('logs').add(entry); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); + this.dispatch('logs:change'); + } + + dispatch(name) { + this.eventTarget.dispatchEvent(new CustomEvent(name)); + } + + subscribe(name, handler) { + this.eventTarget.addEventListener(name, handler); + return () => this.eventTarget.removeEventListener(name, handler); + } +} + +const dataLayer = new DataLayer(); + +const state = { + activeScreen: 'chats', + activeProfileId: null, + activeChatId: null, + replyingTo: null, + editingMessageId: null, + searchCategory: 'posts', + searchQuery: '', + reduceMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches, +}; + +const UI = { + topTitle: q('#topTitle'), + backButton: q('#backButton'), + topAction: q('#topAction'), + main: q('#main'), + screens: { + chats: q('#screen-chats'), + chat: q('#screen-chat'), + feed: q('#screen-feed'), + video: q('#screen-video'), + photo: q('#screen-photo'), + search: q('#screen-search'), + profile: q('#screen-profile'), + }, + nav: q('.bottom-nav'), + dev: { + panel: q('#devPanel'), + backdrop: q('#devBackdrop'), + close: q('#closeDev'), + tabs: q('.dev-panel__tabs'), + tabButtons: qa('.dev-panel__tabs button'), + content: q('#devContent'), + tableSelect: q('#devTable'), + filterInput: q('#devFilter'), + refresh: q('#devRefresh'), + data: q('#devData'), + editor: q('#devEditor'), + importBtn: q('#devImport'), + exportBtn: q('#devExport'), + applyBtn: q('#devApply'), + logs: q('#devLogs'), + resetBtn: q('#devReset'), + reduceMotion: q('#reduceMotion'), + }, +}; + +const formatTime = (ts) => { + const date = new Date(ts); + return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); +}; + +const formatDate = (ts) => { + const date = new Date(ts); + return date.toLocaleDateString('ru-RU', { month: 'short', day: 'numeric' }); +}; + +const generateId = (prefix) => `${prefix}-${Math.random().toString(36).slice(2, 9)}`; + +async function loadState() { + const profiles = await dataLayer.getAll('profiles'); + const activeProfile = profiles.find((p) => p.active) || profiles[0]; + state.activeProfileId = activeProfile?.id; + renderNav(); + await renderScreen(state.activeScreen); + UI.dev.tableSelect.innerHTML = STORES.map((store) => ``).join(''); + await refreshDevData(); + updateReduceMotionToggle(); +} + +function renderNav() { + qa('.nav-item').forEach((btn) => { + const isActive = btn.dataset.target === state.activeScreen; + btn.setAttribute('aria-current', isActive ? 'page' : 'false'); + }); +} + +async function renderScreen(screen) { + Object.entries(UI.screens).forEach(([name, el]) => { + const visible = name === screen; + el.setAttribute('aria-hidden', String(!visible)); + }); + switch (screen) { + case 'chats': + UI.topTitle.textContent = 'Чаты'; + UI.backButton.dataset.visible = 'false'; + UI.topAction.dataset.visible = 'false'; + await renderChats(); + break; + case 'chat': + UI.topTitle.textContent = getActiveChatTitle(); + UI.backButton.dataset.visible = 'true'; + UI.topAction.dataset.visible = 'false'; + await renderChat(); + break; + case 'feed': + UI.topTitle.textContent = 'Лента'; + UI.backButton.dataset.visible = 'false'; + UI.topAction.dataset.visible = 'true'; + UI.topAction.onclick = () => openCreatePost(); + await renderFeed(); + break; + case 'video': + UI.topTitle.textContent = 'Видео'; + UI.backButton.dataset.visible = 'false'; + UI.topAction.dataset.visible = 'false'; + await renderVideo(); + break; + case 'photo': + UI.topTitle.textContent = 'Фото'; + UI.backButton.dataset.visible = 'false'; + UI.topAction.dataset.visible = 'false'; + await renderPhoto(); + break; + case 'search': + UI.topTitle.textContent = 'Поиск'; + UI.backButton.dataset.visible = 'false'; + UI.topAction.dataset.visible = 'false'; + await renderSearch(); + break; + case 'profile': + UI.topTitle.textContent = 'Профиль'; + UI.backButton.dataset.visible = 'false'; + UI.topAction.dataset.visible = 'false'; + await renderProfile(); + break; + default: + break; + } +} + +async function renderChats() { + const container = UI.screens.chats; + const profileId = state.activeProfileId; + const chats = await dataLayer.getAll('chats'); + const messages = await dataLayer.getAll('messages'); + const filtered = chats + .filter((chat) => chat.participants.includes(profileId) || chat.type === 'channel') + .map((chat) => { + const lastMessage = messages.find((m) => m.id === chat.lastMessageId) || null; + return { ...chat, lastMessage }; + }) + .sort((a, b) => { + if (a.pinned && !b.pinned) return -1; + if (!a.pinned && b.pinned) return 1; + return (b.lastMessage?.time || 0) - (a.lastMessage?.time || 0); + }); + + container.innerHTML = filtered + .map((chat) => { + const initials = escapeHTML(chat.title.slice(0, 2).toUpperCase()); + const snippet = chat.lastMessage?.deleted + ? 'Удалено' + : escapeHTML(chat.lastMessage?.text || 'Нет сообщений'); + const timeLabel = chat.lastMessage ? formatTime(chat.lastMessage.time) : ''; + return ` +${escapeHTML(comment.text)}
${formatTime(comment.time)}
Начните ввод для поиска.
'; + return; + } + switch (cat) { + case 'people': { + const users = await dataLayer.getAll('users'); + results = users.filter((user) => user.name.toLowerCase().includes(term)); + resultsContainer.innerHTML = results + .map((user) => `Нет результатов
'; + } +} + +async function renderProfile() { + const container = UI.screens.profile; + const users = await dataLayer.getAll('users'); + const profiles = await dataLayer.getAll('profiles'); + const activeProfile = profiles.find((p) => p.id === state.activeProfileId); + const user = users.find((u) => u.id === activeProfile?.userId); + container.innerHTML = ` +${escapeHTML(user?.bio || '')}
+