diff --git a/VERSION.properties b/VERSION.properties index 0452f76c..531b2876 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.5.1 +client.version=1.5.2 server.version=1.4.5 diff --git a/shine-UI/js/pages/add-channel-view.js b/shine-UI/js/pages/add-channel-view.js index b6f3b4d7..0ec3b82a 100644 --- a/shine-UI/js/pages/add-channel-view.js +++ b/shine-UI/js/pages/add-channel-view.js @@ -8,8 +8,9 @@ import { } from '../services/channel-name-rules.js'; import { 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: 'Создать канал' }; +export const pageMeta = { id: 'add-channel-view', title: 'Создание канала' }; const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success'; const CHANNEL_TYPE_PUBLIC = 1; @@ -48,13 +49,23 @@ function renderAvatarPreview(slot, avatar, title) { 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}`; + } +} + export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack channels-screen channels-screen--add'; screen.append( renderHeader({ - title: 'Создать канал', + title: 'Создание канала', leftAction: { label: '<', onClick: () => navigate('channels-list') }, }), ); @@ -63,23 +74,31 @@ export function render({ navigate }) { form.className = 'card stack'; form.innerHTML = ` Создание канала -
- - +${isPending ? 'Аватар выбран и ещё не сохранён.' : 'Аватар установлен.'}
+ +${txId}
+ `; + }; + + const renderChannelLinkPreview = (nameValue) => { + const normalizedChannelName = normalizeChannelDisplayName(nameValue); + if (!ownerBlockchainName || !normalizedChannelName) { + linkPreviewEl.textContent = 'Прямая ссылка появится после ввода технического имени канала.'; + return; + } + const directUrl = buildAbsoluteChannelUrl({ + ownerBlockchainName, + channelName: normalizedChannelName, + }); + linkPreviewEl.innerHTML = `Прямая ссылка: ${directUrl}`; + }; const setBusy = (busy) => { submitInFlight = !!busy; @@ -134,7 +186,8 @@ export function render({ navigate }) { const ok = nameCheck.ok && titleCheck.ok && descriptionCheck.ok; submitEl.disabled = submitInFlight || !ok; - renderAvatarPreview(avatarPreviewEl, selectedAvatar, titleCheck.normalized || nameCheck.normalized); + renderAvatarStatus(nameCheck.normalized, titleCheck.normalized); + renderChannelLinkPreview(nameCheck.normalized); return { ok, @@ -149,19 +202,28 @@ export function render({ navigate }) { descriptionEl.addEventListener('input', updateValidation); avatarBtn.addEventListener('click', async () => { try { - selectedAvatar = await openArweaveAttachmentManager({ + 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; updateValidation(); } catch (error) { errorEl.textContent = toUserMessage(error, 'Не удалось выбрать аватар.'); } }); + avatarRemoveBtn.addEventListener('click', () => { + if (!selectedAvatar?.ar) return; + const confirmed = window.confirm('Убрать аватар и сделать канал без аватара?'); + if (!confirmed) return; + selectedAvatar = null; + updateValidation(); + }); + form.addEventListener('submit', async (event) => { event.preventDefault(); if (submitInFlight) return; @@ -206,5 +268,14 @@ export function render({ navigate }) { 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; } diff --git a/shine-UI/js/pages/register-view.js b/shine-UI/js/pages/register-view.js index ff17e99f..edf8dd3f 100644 --- a/shine-UI/js/pages/register-view.js +++ b/shine-UI/js/pages/register-view.js @@ -4,6 +4,7 @@ import { toUserMessage } from '../services/ui-error-texts.js'; import { checkLoginExistsOnSolana, formatSolanaErrorDetails, + isSolanaRpcUnavailableError, precheckLoginClassOnSolana, } from '../services/solana-register-service.js'; import { @@ -251,6 +252,10 @@ export function render({ navigate }) { return isFree && className === 'free'; } catch (error) { if (runId !== loginCheckRunId) return false; + if (isSolanaRpcUnavailableError(error)) { + setStatusMessage('Нет связи с сервером Solana. Попробуйте ещё раз позже.', 'is-unavailable'); + return false; + } const base = toUserMessage(error, 'Не удалось проверить логин'); const details = formatSolanaErrorDetails(error); setStatusMessage(`${base}. Детали: ${details}`, 'is-unavailable'); diff --git a/shine-UI/js/pages/registration-payment-view.js b/shine-UI/js/pages/registration-payment-view.js index e7976e1f..5740749d 100644 --- a/shine-UI/js/pages/registration-payment-view.js +++ b/shine-UI/js/pages/registration-payment-view.js @@ -18,6 +18,7 @@ import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js'; import { checkLoginExistsOnSolana, formatSolanaErrorDetails, + isSolanaRpcUnavailableError, isUserAlreadyExistsSolanaError, registerUserOnSolana, } from '../services/solana-register-service.js'; @@ -311,6 +312,16 @@ export function render({ navigate }) { const keyBundle = state.registrationDraft.preGeneratedKeyBundle; if (!keyBundle) throw new Error('Ключи не найдены. Вернитесь на предыдущий шаг.'); + // Перед on-chain регистрацией перепроверяем логин ещё раз: + // между экраном выбора логина и оплатой его мог занять кто-то другой. + const loginStateBeforeRegister = await checkLoginExistsOnSolana({ + login: state.registrationDraft.login, + solanaEndpoint: state.entrySettings.solanaServer, + }); + if (loginStateBeforeRegister?.exists) { + throw new Error('Этот логин уже зарегистрирован. Войдите в существующий аккаунт или выберите другой логин.'); + } + // Регистрация на Solana (смарт контракт) submitButton.textContent = 'Регистрация в Solana...'; let registrationTxId = ''; @@ -325,15 +336,20 @@ export function render({ navigate }) { registrationTxId = String(registrationResult?.signature || '').trim(); } catch (solanaError) { const solanaMsg = formatSolanaErrorDetails(solanaError); - // Пользователь уже зарегистрирован в Solana — продолжаем - if (!solanaMsg.includes('already') && !isUserAlreadyExistsSolanaError(solanaError)) { - throw new Error(`Ошибка регистрации в Solana: ${solanaMsg}`); + if (solanaMsg.includes('already') || isUserAlreadyExistsSolanaError(solanaError)) { + throw new Error('Этот логин уже зарегистрирован. Войдите в существующий аккаунт или выберите другой логин.'); } + if (isSolanaRpcUnavailableError(solanaError)) { + throw new Error('Нет связи с сервером Solana. Попробуйте ещё раз позже.'); + } + throw new Error(`Ошибка регистрации в Solana: ${solanaMsg}`); } renderSolanaRegistrationStage({ navigate, status, keyBundle, registrationTxId }); } catch (error) { - const message = toUserMessage(error, 'Не удалось завершить регистрацию.'); + const message = isSolanaRpcUnavailableError(error) + ? 'Нет связи с сервером Solana. Попробуйте ещё раз позже.' + : toUserMessage(error, 'Не удалось завершить регистрацию.'); setAuthError(message); status.className = 'status-line is-unavailable'; status.textContent = message; diff --git a/shine-UI/js/services/solana-register-service.js b/shine-UI/js/services/solana-register-service.js index f9717a8f..f19306e6 100644 --- a/shine-UI/js/services/solana-register-service.js +++ b/shine-UI/js/services/solana-register-service.js @@ -72,6 +72,19 @@ export function formatSolanaErrorDetails(error) { return parts.join(' :: '); } +export function isSolanaRpcUnavailableError(error) { + const details = formatSolanaErrorDetails(error).toLowerCase(); + return details.includes('service unavailable') + || details.includes('503') + || details.includes('failed to fetch') + || details.includes('fetch failed') + || details.includes('networkerror') + || details.includes('network error') + || details.includes('rpc unavailable') + || details.includes('rpc error') + || details.includes('failed to get info about account'); +} + export function isUserAlreadyExistsSolanaError(error) { const details = formatSolanaErrorDetails(error); return details.includes('UserAlreadyExists') diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 594907b3..17ab1625 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -6557,6 +6557,73 @@ html, body { overflow-x: hidden; } box-shadow: 0 0 22px rgba(212, 175, 55, 0.2), inset 0 0 8px rgba(212, 175, 55, 0.1); } +.channel-create-avatar-head { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 14px; + align-items: start; +} + +.channel-create-avatar-side { + min-width: 0; + display: grid; + gap: 10px; + align-content: start; +} + +.channel-create-avatar-status-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: start; +} + +.channel-create-avatar-status { + min-width: 0; +} + +.channel-avatar-status-text { + margin: 0; + color: rgba(255, 236, 194, 0.95); + font-weight: 600; +} + +.channel-avatar-txid { + margin: 4px 0 0; + word-break: break-all; + color: rgba(255, 255, 255, 0.86); + font-size: 12px; +} + +.channel-avatar-remove-btn { + width: 26px; + min-width: 26px; + height: 26px; + padding: 0; + border: 1px solid rgba(255, 107, 107, 0.75); + border-radius: 8px; + background: rgba(150, 18, 18, 0.3); + color: #ff8b8b; + font-weight: 700; + line-height: 1; +} + +.channel-avatar-remove-btn:hover, +.channel-avatar-remove-btn:focus-visible { + background: rgba(180, 22, 22, 0.42); + box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.16); +} + +.channel-avatar-remove-btn[hidden] { + display: none; +} + +@media (max-width: 420px) { + .channel-create-avatar-head { + grid-template-columns: 1fr; + } +} + /* ===== Final microinteractions: breathe cards + static energy buttons ===== */ @keyframes breatheCard { 0%, 100% { transform: translateY(0px); }