Files
SHiNE-server/shine-UI/js/pages/add-channel-view.js
T

331 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { renderHeader } from '../components/header.js';
import { authService, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import {
channelNameErrorText,
normalizeChannelDisplayName,
validateChannelDisplayName,
} from '../services/channel-name-rules.js';
import {
getArweaveAttachmentAvailability,
openArweaveAttachmentManager,
markArweaveAttachmentPlaced,
} from '../components/arweave-attachment-manager.js';
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
import { makeShineChannelRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'add-channel-view', title: 'Создание канала' };
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
const CHANNEL_TYPE_PUBLIC = 1;
const CHANNEL_LOGIN_HINT = 'Разрешены латинские буквы, цифры, _ и -. Длина: от 3 до 32 символов. Название не должно состоять только из цифр.';
function persistCreateSuccessFlash(message) {
try {
sessionStorage.setItem(CREATE_CHANNEL_FLASH_KEY, String(message || '').trim());
} catch {
// ignore storage errors
}
}
function normalizeMetaText(value, max, label) {
const normalized = String(value || '').trim();
const length = Array.from(normalized).length;
if (length > max) {
return { ok: false, normalized, length, error: `${label}: максимум ${max} символов.` };
}
return { ok: true, normalized, length, error: '' };
}
function renderAvatarPreview(slot, avatar, title) {
if (!slot) return;
slot.innerHTML = '';
const wrap = document.createElement('div');
wrap.className = 'channel-profile-avatar';
wrap.style.setProperty('--channel-avatar-size', '104px');
if (avatar?.ar) {
const img = document.createElement('img');
img.alt = 'Аватар канала';
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: avatar.ar });
wrap.append(img);
} else {
wrap.textContent = String(title || 'К').trim().slice(0, 1).toUpperCase() || 'К';
}
slot.append(wrap);
}
function buildAbsoluteChannelUrl({ ownerBlockchainName = '', channelName = '' } = {}) {
const route = makeShineChannelRoute({ ownerBlockchainName, channelName });
if (!route) return '';
try {
return new URL(`/${route}`, window.location.origin).toString();
} catch {
return `${window.location.origin}/${route}`;
}
}
function sanitizeChannelLoginInput(value) {
const source = String(value || '');
let result = '';
for (const ch of source) {
if (/[A-Za-z0-9_-]/.test(ch)) result += ch;
}
return result.slice(0, 32);
}
function shortAvatarBlockchainAddress(value) {
const raw = String(value || '').trim();
if (raw.length <= 24) return raw;
return raw.slice(-24);
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--add';
screen.append(
renderHeader({
title: 'Создание канала',
leftAction: { label: '<', onClick: () => navigate('channels-list') },
}),
);
const form = document.createElement('form');
form.className = 'card stack';
form.innerHTML = `
<div class="channel-create-avatar-head">
<div id="channel-avatar-preview"></div>
<div class="channel-create-avatar-side">
<div class="channel-create-avatar-status-row">
<div class="channel-create-avatar-status" id="channel-avatar-status"></div>
<button type="button" class="channel-avatar-remove-btn" id="channel-avatar-remove" title="Убрать аватар" aria-label="Убрать аватар">✕</button>
</div>
<button type="button" class="secondary-btn" id="channel-avatar-btn">Выбрать аватар</button>
</div>
</div>
<label for="channel-name">Технический логин канала</label>
<input id="channel-name" class="input" maxlength="32" placeholder="Например: My-Channel_1" required />
<div class="meta-muted">${CHANNEL_LOGIN_HINT}</div>
<div class="meta-muted" id="channel-link-preview"></div>
<div id="channel-name-error" class="meta-muted inline-error"></div>
<label for="channel-title">Красивое имя (то, как канал будет виден пользователям)</label>
<input id="channel-title" class="input" maxlength="50" placeholder="Например: Мой красивый канал" />
<div class="meta-muted" id="channel-title-counter">0 / 50 символов</div>
<div id="channel-title-error" class="meta-muted inline-error"></div>
<label for="channel-description">Описание канала (необязательно)</label>
<textarea id="channel-description" class="input" rows="4" maxlength="250" placeholder="Коротко о канале, до 250 символов"></textarea>
<div class="meta-muted" id="channel-description-counter">0 / 250 символов</div>
<div id="channel-description-error" class="meta-muted inline-error"></div>
<div id="channel-create-error" class="meta-muted inline-error"></div>
<div class="form-actions-grid">
<button type="button" class="secondary-btn" id="cancel-create-channel">Отмена</button>
<button type="submit" class="primary-btn" id="submit-create-channel">Создать</button>
</div>
`;
const nameEl = form.querySelector('#channel-name');
const titleEl = form.querySelector('#channel-title');
const descriptionEl = form.querySelector('#channel-description');
const avatarPreviewEl = form.querySelector('#channel-avatar-preview');
const avatarStatusEl = form.querySelector('#channel-avatar-status');
const avatarBtn = form.querySelector('#channel-avatar-btn');
const avatarRemoveBtn = form.querySelector('#channel-avatar-remove');
const linkPreviewEl = form.querySelector('#channel-link-preview');
const nameErrorEl = form.querySelector('#channel-name-error');
const titleErrorEl = form.querySelector('#channel-title-error');
const titleCounterEl = form.querySelector('#channel-title-counter');
const descriptionErrorEl = form.querySelector('#channel-description-error');
const descriptionCounterEl = form.querySelector('#channel-description-counter');
const errorEl = form.querySelector('#channel-create-error');
const submitEl = form.querySelector('#submit-create-channel');
const cancelEl = form.querySelector('#cancel-create-channel');
let submitInFlight = false;
let selectedAvatar = null;
let ownerBlockchainName = '';
let avatarAvailabilityLabel = '';
let avatarAvailabilityTxId = '';
let invalidChannelLoginTyped = false;
const renderAvatarStatus = (nameValue, titleValue) => {
renderAvatarPreview(avatarPreviewEl, selectedAvatar, titleValue || nameValue);
avatarRemoveBtn.hidden = !selectedAvatar?.ar;
if (!selectedAvatar?.ar) {
avatarStatusEl.innerHTML = '<p class="meta-muted">Аватар не установлен.</p>';
return;
}
const txId = String(selectedAvatar.ar || '').trim();
const shortTxId = shortAvatarBlockchainAddress(txId);
const isPending = avatarAvailabilityLabel === 'pending' || avatarAvailabilityTxId !== txId;
avatarStatusEl.innerHTML = `<p class="channel-avatar-status-text">Адрес аватара в блокчейне Arweave ${shortTxId}${isPending ? ', ещё не обновился в блокчейне.' : '.'}</p>`;
};
const renderChannelLinkPreview = (nameValue) => {
const normalizedChannelName = normalizeChannelDisplayName(nameValue);
if (!ownerBlockchainName || !normalizedChannelName) {
linkPreviewEl.textContent = 'Прямая ссылка появится после ввода технического имени канала.';
return;
}
const directUrl = buildAbsoluteChannelUrl({
ownerBlockchainName,
channelName: normalizedChannelName,
});
linkPreviewEl.innerHTML = `Прямая ссылка: <a href="${directUrl}" target="_blank" rel="noopener noreferrer">${directUrl}</a>`;
};
const refreshAvatarAvailability = async () => {
const txId = String(selectedAvatar?.ar || '').trim();
avatarAvailabilityTxId = txId;
avatarAvailabilityLabel = txId ? 'pending' : '';
renderAvatarStatus(nameEl.value, titleEl.value);
if (!txId) return;
try {
const availability = await getArweaveAttachmentAvailability({
gateway: state.entrySettings.arweaveServer,
attachment: selectedAvatar,
});
if (avatarAvailabilityTxId !== txId) return;
avatarAvailabilityLabel = String(availability?.status || '').trim().toLowerCase();
} catch {
if (avatarAvailabilityTxId !== txId) return;
avatarAvailabilityLabel = 'pending';
}
renderAvatarStatus(nameEl.value, titleEl.value);
};
const setBusy = (busy) => {
submitInFlight = !!busy;
submitEl.disabled = submitInFlight;
cancelEl.disabled = submitInFlight;
nameEl.disabled = submitInFlight;
titleEl.disabled = submitInFlight;
descriptionEl.disabled = submitInFlight;
avatarBtn.disabled = submitInFlight;
submitEl.textContent = submitInFlight ? 'Создаём...' : 'Создать';
};
const updateValidation = () => {
const nameCheck = validateChannelDisplayName(nameEl.value);
const titleCheck = normalizeMetaText(titleEl.value, 50, 'Название');
const descriptionCheck = normalizeMetaText(descriptionEl.value, 250, 'Описание');
nameErrorEl.textContent = invalidChannelLoginTyped
? CHANNEL_LOGIN_HINT
: (nameCheck.ok ? '' : channelNameErrorText(nameCheck.code));
titleErrorEl.textContent = titleCheck.error;
descriptionErrorEl.textContent = descriptionCheck.error;
titleCounterEl.textContent = `${Number(titleCheck.length || 0)} / 50 символов`;
descriptionCounterEl.textContent = `${Number(descriptionCheck.length || 0)} / 250 символов`;
const ok = nameCheck.ok && titleCheck.ok && descriptionCheck.ok;
submitEl.disabled = submitInFlight || !ok;
renderAvatarStatus(nameCheck.normalized, titleCheck.normalized);
renderChannelLinkPreview(nameCheck.normalized);
return {
ok,
name: nameCheck.normalized,
title: titleCheck.normalized,
description: descriptionCheck.normalized,
};
};
nameEl.addEventListener('input', () => {
const raw = String(nameEl.value || '');
const sanitized = sanitizeChannelLoginInput(raw);
invalidChannelLoginTyped = raw !== sanitized;
if (raw !== sanitized) nameEl.value = sanitized;
updateValidation();
});
titleEl.addEventListener('input', updateValidation);
descriptionEl.addEventListener('input', updateValidation);
avatarBtn.addEventListener('click', async () => {
try {
const nextAvatar = await openArweaveAttachmentManager({
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
mode: 'avatar',
historyPurpose: 'avatar',
});
if (nextAvatar?.ar) {
selectedAvatar = nextAvatar;
void refreshAvatarAvailability();
}
updateValidation();
} catch (error) {
errorEl.textContent = toUserMessage(error, 'Не удалось выбрать аватар.');
}
});
avatarRemoveBtn.addEventListener('click', () => {
if (!selectedAvatar?.ar) return;
const confirmed = window.confirm('Убрать аватар и сделать канал без аватара?');
if (!confirmed) return;
selectedAvatar = null;
avatarAvailabilityLabel = '';
avatarAvailabilityTxId = '';
updateValidation();
});
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (submitInFlight) return;
const login = state.session.login;
const storagePwd = state.session.storagePwdInMemory;
if (!login || !storagePwd) {
errorEl.textContent = 'Сессия недействительна. Выполните вход заново.';
return;
}
const check = updateValidation();
if (!check.ok) return;
setBusy(true);
errorEl.textContent = '';
try {
await authService.addBlockCreateChannel({
login,
storagePwd,
channelName: normalizeChannelDisplayName(check.name),
channelDescription: check.description,
channelProfileTitle: check.title,
channelAvatar: selectedAvatar,
channelType: CHANNEL_TYPE_PUBLIC,
channelTypeVersion: 1,
});
if (selectedAvatar?.ar) markArweaveAttachmentPlaced(login, selectedAvatar);
persistCreateSuccessFlash(`Канал "${normalizeChannelDisplayName(check.name)}" создан.`);
navigate('channels-list');
} catch (error) {
errorEl.textContent = toUserMessage(error, 'Не удалось создать канал.');
setBusy(false);
updateValidation();
}
});
cancelEl.addEventListener('click', () => navigate('channels-list'));
screen.append(form);
nameEl.focus();
updateValidation();
void authService.getUser(String(state.session.login || '').trim())
.then((user) => {
ownerBlockchainName = String(user?.blockchainName || '').trim();
renderChannelLinkPreview(nameEl.value);
})
.catch(() => {
ownerBlockchainName = '';
renderChannelLinkPreview(nameEl.value);
});
return screen;
}