Дизайн Артёма

This commit is contained in:
AidarKC
2026-09-22 14:09:02 +03:00
parent cae64639bd
commit 8b12760dde
40 changed files with 3114 additions and 5008 deletions
+363
View File
@@ -0,0 +1,363 @@
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from './arweave-attachment-manager.js';
import { composeMessageWithAttachments, MAX_MESSAGE_ATTACHMENTS } from '../services/attachment-format.js';
import { state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import { renderUserAvatar } from './avatar-image.js';
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
const drafts = new Map();
function draftKey(key) {
const login = String(state.session.login || 'guest').trim().toLowerCase();
return `${login}:${String(key || 'editor')}`;
}
function attachmentLabel(item) {
const name = String(item?.name || 'Файл');
const size = Number(item?.size || 0);
if (!size) return name;
if (size < 1024) return `${name} · ${size} Б`;
if (size < 1024 * 1024) return `${name} · ${Math.ceil(size / 1024)} КБ`;
return `${name} · ${(size / 1024 / 1024).toFixed(1)} МБ`;
}
export function openChannelEditor({
id = 'channel-editor',
title = 'Ответ',
submitLabel = 'Ответить',
placeholder = 'Напишите ответ',
context = null,
key = 'reply',
extraControl = null,
initialText = '',
allowEmptyText = false,
allowAttachments = true,
rawText = false,
onSubmit,
isActive = () => true,
} = {}) {
if (!state.session.isAuthorized) {
openAuthRequiredModal({ title: 'Войдите, чтобы написать', text: 'Для публикации и ответа нужен активный профиль.' });
return null;
}
const root = document.getElementById('modal-root');
if (!root || typeof onSubmit !== 'function') return null;
if (root.querySelector('.channel-editor-overlay')) return null;
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const storageKey = draftKey(key);
const login = state.session.login;
const saved = drafts.get(storageKey) || { text: initialText, attachments: [] };
const attachments = Array.isArray(saved.attachments) ? [...saved.attachments] : [];
const extraFields = extraControl ? [...extraControl.querySelectorAll('select,input')] : [];
extraFields.forEach((field, index) => {
if (saved.controls?.[index] !== undefined) field.value = saved.controls[index];
});
let inFlight = false;
let composing = false;
let closed = false;
let picking = false;
let completed = false;
const attachmentController = new AbortController();
const overlay = document.createElement('div');
overlay.className = 'channel-editor-overlay';
overlay.id = id;
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-labelledby', `${id}-title`);
const dialog = document.createElement('div');
dialog.className = 'channel-editor';
const header = document.createElement('header');
header.className = 'channel-editor__header';
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'icon-btn channel-editor__close';
closeButton.setAttribute('aria-label', 'Закрыть редактор');
closeButton.textContent = '×';
const heading = document.createElement('h2');
heading.id = `${id}-title`;
heading.textContent = title;
header.append(closeButton, heading, document.createElement('span'));
const body = document.createElement('div');
body.className = 'channel-editor__body';
if (context && (context.author || context.text || context.attachmentLabel)) {
const contextBox = document.createElement('div');
contextBox.className = 'channel-editor__context';
const contextTitle = document.createElement('strong');
contextTitle.textContent = context.author ? `В ответ ${context.author}` : 'Контекст сообщения';
const quote = document.createElement('p');
quote.textContent = String(context.text || context.attachmentLabel || 'Сообщение без текста');
const expand = document.createElement('button');
expand.type = 'button';
expand.className = 'text-btn channel-editor__context-toggle';
expand.textContent = 'Показать целиком';
expand.setAttribute('aria-expanded', 'false');
expand.addEventListener('click', () => {
const expanded = contextBox.classList.toggle('is-expanded');
expand.textContent = expanded ? 'Свернуть' : 'Показать целиком';
expand.setAttribute('aria-expanded', String(expanded));
});
contextBox.append(contextTitle, quote, expand);
body.append(contextBox);
}
const textarea = document.createElement('textarea');
textarea.className = 'channel-editor__input';
textarea.maxLength = 2000;
textarea.placeholder = placeholder;
textarea.setAttribute('aria-label', placeholder);
textarea.value = String(saved.text || '');
const attachmentsBox = document.createElement('div');
attachmentsBox.className = 'channel-editor__attachments';
attachmentsBox.setAttribute('aria-live', 'polite');
const error = document.createElement('p');
error.className = 'channel-editor__error';
error.setAttribute('role', 'alert');
const clearButton = document.createElement('button');
clearButton.type = 'button';
clearButton.className = 'text-btn channel-editor__clear';
clearButton.textContent = 'Очистить черновик';
const author = document.createElement('div');
author.className = 'channel-editor__author';
author.append(renderUserAvatar({ login: login || 'guest', className: 'avatar-plain', size: 'md' }));
const authorName = document.createElement('strong');
authorName.textContent = login || 'Гость';
author.append(authorName);
body.append(author);
if (extraControl instanceof Node) body.append(extraControl);
body.append(textarea, attachmentsBox, error, clearButton);
const footer = document.createElement('footer');
footer.className = 'channel-editor__footer';
const attachButton = document.createElement('button');
attachButton.type = 'button';
attachButton.className = 'secondary-btn channel-editor__attach';
attachButton.textContent = 'Прикрепить';
attachButton.hidden = !allowAttachments;
const counter = document.createElement('span');
counter.className = 'channel-editor__counter';
const submitButton = document.createElement('button');
submitButton.type = 'button';
submitButton.className = 'primary-btn channel-editor__submit';
submitButton.textContent = submitLabel;
submitButton.title = 'Отправить · Ctrl+Enter / ⌘+Enter';
footer.append(attachButton);
footer.append(counter, submitButton);
dialog.append(header, body, footer);
overlay.append(dialog);
root.replaceChildren(overlay);
const saveDraft = () => {
const value = { text: textarea.value, attachments: [...attachments], controls: extraFields.map((field) => field.value) };
if (completed) { drafts.delete(storageKey); return; }
if (value.text || value.attachments.length) drafts.set(storageKey, value);
else drafts.delete(storageKey);
};
const sync = () => {
textarea.style.height = 'auto';
textarea.style.height = `${Math.max(160, textarea.scrollHeight)}px`;
const length = textarea.value.length;
counter.textContent = `${length} / 2000`;
counter.classList.toggle('is-near-limit', length >= 1800);
submitButton.disabled = inFlight || picking || length > textarea.maxLength || (!allowEmptyText && !textarea.value.trim() && attachments.length === 0);
dialog.setAttribute('aria-busy', String(inFlight || picking));
clearButton.hidden = !textarea.value && attachments.length === 0;
attachmentsBox.replaceChildren();
attachments.forEach((item, index) => {
const card = document.createElement('div');
card.className = 'channel-editor__attachment';
const label = document.createElement('span');
label.textContent = attachmentLabel(item);
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'icon-btn';
remove.setAttribute('aria-label', `Убрать вложение ${item?.name || ''}`);
remove.textContent = '×';
remove.disabled = inFlight;
remove.addEventListener('click', () => {
attachments.splice(index, 1);
saveDraft();
sync();
});
card.append(label, remove);
attachmentsBox.append(card);
});
};
const openedUrl = location.href;
const historyId = `${Date.now()}:${Math.random()}`;
history.pushState({ ...history.state, channelEditor: historyId }, '', openedUrl);
const viewport = window.visualViewport;
const updateViewport = () => {
overlay.style.setProperty('--editor-height', `${viewport?.height || window.innerHeight}px`);
overlay.style.setProperty('--editor-top', `${viewport?.offsetTop || 0}px`);
};
const onBack = (event) => {
if (closed) return;
event.stopImmediatePropagation();
close({ fromHistory: true });
};
const observer = new MutationObserver(() => {
if (!overlay.isConnected) close({ restoreFocus: false });
});
const close = ({ fromHistory = false, restoreFocus = true } = {}) => {
if (closed) return;
closed = true;
saveDraft();
observer.disconnect();
attachmentController.abort();
document.removeEventListener('keydown', onDocumentKeydown);
viewport?.removeEventListener('resize', updateViewport);
viewport?.removeEventListener('scroll', updateViewport);
window.removeEventListener('resize', updateViewport);
window.removeEventListener('popstate', onBack, true);
overlay.remove();
if (!fromHistory && location.href === openedUrl && history.state?.channelEditor === historyId) {
window.addEventListener('popstate', (event) => {
if (location.href === openedUrl) event.stopImmediatePropagation();
}, { capture: true, once: true });
history.back();
}
if (restoreFocus && opener?.isConnected) opener.focus({ preventScroll: true });
};
overlay.cleanup = () => close({ restoreFocus: false });
const submit = async () => {
if (inFlight || submitButton.disabled) return;
inFlight = true;
error.textContent = '';
textarea.disabled = true;
attachButton.disabled = true;
clearButton.disabled = true;
if (extraControl) extraControl.querySelectorAll('select,button,input').forEach((el) => { el.disabled = true; });
submitButton.textContent = 'Отправляем…';
sync();
try {
await onSubmit({
text: rawText ? textarea.value.trim() : composeMessageWithAttachments(textarea.value.trim(), attachments),
attachments: [...attachments],
});
completed = true;
attachments.forEach((item) => markArweaveAttachmentPlaced(login, item));
// Не удаляем новый черновик, открытый после закрытия отправляющего редактора.
if (!closed || drafts.get(storageKey)?.text === textarea.value) drafts.delete(storageKey);
if (!isActive() || closed) return;
close();
} catch (submitError) {
if (!isActive() || closed) return;
inFlight = false;
textarea.disabled = false;
attachButton.disabled = false;
clearButton.disabled = false;
if (extraControl) extraControl.querySelectorAll('select,button,input').forEach((el) => { el.disabled = false; });
submitButton.textContent = submitLabel;
error.textContent = toUserMessage(submitError, 'Не удалось отправить. Текст сохранён.');
saveDraft();
sync();
}
};
const onDocumentKeydown = (event) => {
if (!overlay.isConnected || document.querySelector('.ar-attachment-manager-root')) return;
if (event.key === 'Escape') {
event.preventDefault();
close();
return;
}
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey) && !composing && !event.isComposing) {
event.preventDefault();
void submit();
return;
}
if (event.key !== 'Tab') return;
const focusable = [...overlay.querySelectorAll('button:not(:disabled), textarea:not(:disabled), select:not(:disabled)')].filter((el) => !el.hidden);
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
closeButton.addEventListener('click', () => close());
clearButton.addEventListener('click', () => {
textarea.value = '';
attachments.splice(0);
drafts.delete(storageKey);
error.textContent = '';
sync();
textarea.focus();
});
textarea.addEventListener('input', () => {
error.textContent = '';
saveDraft();
sync();
});
textarea.addEventListener('compositionstart', () => { composing = true; });
extraFields.forEach((field) => field.addEventListener('change', saveDraft));
textarea.addEventListener('compositionend', () => { composing = false; });
attachButton.addEventListener('click', async () => {
if (picking || inFlight) return;
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
error.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
return;
}
try {
picking = true;
dialog.inert = true;
sync();
const item = await openArweaveAttachmentManager({
login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
selectedTxIds: attachments.map((attachment) => attachment.ar),
signal: attachmentController.signal,
});
if (!isActive() || !overlay.isConnected || !item) return;
attachments.push(item);
saveDraft();
sync();
} catch (attachError) {
if (isActive() && overlay.isConnected) {
error.textContent = toUserMessage(attachError, 'Не удалось добавить вложение.');
}
} finally {
picking = false;
dialog.inert = false;
if (!closed) { sync(); attachButton.focus(); }
}
});
submitButton.addEventListener('click', () => void submit());
document.addEventListener('keydown', onDocumentKeydown);
window.addEventListener('popstate', onBack, true);
viewport?.addEventListener('resize', updateViewport);
viewport?.addEventListener('scroll', updateViewport);
window.addEventListener('resize', updateViewport);
observer.observe(root, { childList: true });
updateViewport();
sync();
requestAnimationFrame(() => {
if (closed || !isActive()) return;
const quote = overlay.querySelector('.channel-editor__context p');
const expand = overlay.querySelector('.channel-editor__context-toggle');
if (quote && expand && quote.clientHeight > 0) expand.hidden = quote.scrollHeight <= quote.clientHeight;
sync();
textarea.focus();
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
});
return { close };
}