SHA256
UI v4: новый стиль по макету V3/V4 и доводка экранов
Палитра «Сияние» по умолчанию, новые роли цветов. Лента канала: плоские записи, ряд действий с равным шагом иконок, дата у записи. Обсуждение со строкой ввода, личный чат, шторки. Вход и регистрация: стартовый экран (надпись «СИЯНИЕ», сияние мандалы), настройки входа со статусами серверов, статус логина в одну строку, вход с другого устройства (код, таймер, шаги), сохранение ключей с переключателями. Корневые разделы – заголовок по центру; фильтр чатов – переключатель; уведомления – список как в чатах; фото – просмотр на весь экран. Общее: один зазор под шапкой (--page-top-gap), переключатели высотой с поле ввода, второстепенные кнопки по одной роли (в том числе на экранах устройств и серверов), иконки только из ui-icon.js, полоса прокрутки скрыта, среднее тире в текстах интерфейса. Что нужно от сервера и разработчика – docs/UI-Design/UI-v4-for-dev.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
// Полноэкранный просмотр фото и видео из вложений: листание, «Скачать».
|
||||
// Закрывается крестиком, Esc, смахиванием вниз или переходом назад.
|
||||
import { iconHtml } from './ui-icon.js';
|
||||
|
||||
let activeClose = null;
|
||||
|
||||
function safeFileName(name, kind) {
|
||||
const clean = String(name || '').trim().replace(/[\\/:*?"<>|]+/g, '_');
|
||||
if (clean) return clean;
|
||||
return kind === 'video' ? 'video.mp4' : 'image.jpg';
|
||||
}
|
||||
|
||||
// Arweave отдаёт файл с другого адреса: атрибут download там не работает, поэтому скачиваем через blob.
|
||||
async function downloadMedia(item) {
|
||||
const name = safeFileName(item.name, item.kind);
|
||||
try {
|
||||
const response = await fetch(item.url);
|
||||
if (!response.ok) throw new Error(String(response.status));
|
||||
const blobUrl = URL.createObjectURL(await response.blob());
|
||||
const link = document.createElement('a');
|
||||
link.href = blobUrl;
|
||||
link.download = name;
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30000);
|
||||
} catch {
|
||||
window.open(item.url, '_blank', 'noopener');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* items: [{ url, kind: 'image' | 'video', name }], index – с какого начать.
|
||||
*/
|
||||
export function openMediaViewer(items = [], index = 0) {
|
||||
const list = items.filter((item) => item?.url && (item.kind === 'image' || item.kind === 'video'));
|
||||
if (!list.length) return;
|
||||
activeClose?.();
|
||||
|
||||
let current = Math.max(0, Math.min(list.length - 1, index));
|
||||
const root = document.getElementById('modal-root') || document.body;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'media-viewer';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.setAttribute('aria-label', 'Просмотр');
|
||||
// Сверху – только «закрыть»; счётчик и «Скачать» – сразу под фото, по центру.
|
||||
overlay.innerHTML = `
|
||||
<div class="media-viewer__bar">
|
||||
<button class="media-viewer__btn" type="button" data-action="close" aria-label="Закрыть">${iconHtml('close')}</button>
|
||||
</div>
|
||||
<div class="media-viewer__stage">
|
||||
<div class="media-viewer__frame"></div>
|
||||
<div class="media-viewer__controls">
|
||||
<span class="media-viewer__counter"></span>
|
||||
<button class="media-viewer__btn media-viewer__btn--round" type="button" data-action="download" aria-label="Скачать" title="Скачать">${iconHtml('download')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="media-viewer__nav media-viewer__nav--prev" type="button" data-action="prev" aria-label="Предыдущее">${iconHtml('back')}</button>
|
||||
<button class="media-viewer__nav media-viewer__nav--next" type="button" data-action="next" aria-label="Следующее">${iconHtml('open')}</button>
|
||||
`;
|
||||
const stage = overlay.querySelector('.media-viewer__stage');
|
||||
const frame = overlay.querySelector('.media-viewer__frame');
|
||||
const counter = overlay.querySelector('.media-viewer__counter');
|
||||
const prevBtn = overlay.querySelector('[data-action="prev"]');
|
||||
const nextBtn = overlay.querySelector('[data-action="next"]');
|
||||
|
||||
const render = () => {
|
||||
stage.querySelectorAll('video').forEach((video) => video.pause());
|
||||
const item = list[current];
|
||||
let media;
|
||||
if (item.kind === 'video') {
|
||||
media = document.createElement('video');
|
||||
media.controls = true;
|
||||
media.autoplay = true;
|
||||
media.playsInline = true;
|
||||
} else {
|
||||
media = document.createElement('img');
|
||||
media.alt = item.name || '';
|
||||
media.decoding = 'async';
|
||||
}
|
||||
media.className = 'media-viewer__media';
|
||||
media.src = item.url;
|
||||
frame.replaceChildren(media);
|
||||
counter.textContent = list.length > 1 ? `${current + 1}/${list.length}` : '';
|
||||
counter.hidden = list.length <= 1;
|
||||
prevBtn.hidden = current <= 0;
|
||||
nextBtn.hidden = current >= list.length - 1;
|
||||
};
|
||||
|
||||
const go = (delta) => {
|
||||
const next = current + delta;
|
||||
if (next < 0 || next >= list.length) return;
|
||||
current = next;
|
||||
render();
|
||||
};
|
||||
|
||||
const onKey = (event) => {
|
||||
if (event.key === 'Escape') close();
|
||||
else if (event.key === 'ArrowLeft') go(-1);
|
||||
else if (event.key === 'ArrowRight') go(1);
|
||||
};
|
||||
const onPopState = () => close();
|
||||
|
||||
const close = () => {
|
||||
if (activeClose !== close) return;
|
||||
activeClose = null;
|
||||
document.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('popstate', onPopState);
|
||||
stage.querySelectorAll('video').forEach((video) => video.pause());
|
||||
overlay.remove();
|
||||
};
|
||||
|
||||
let swiped = false;
|
||||
overlay.addEventListener('click', (event) => {
|
||||
// Клик, который браузер присылает после смахивания, не должен прятать панель или закрывать просмотр.
|
||||
if (swiped) { swiped = false; return; }
|
||||
const action = event.target.closest('[data-action]')?.dataset.action;
|
||||
if (action === 'close') close();
|
||||
else if (action === 'prev') go(-1);
|
||||
else if (action === 'next') go(1);
|
||||
else if (action === 'download') void downloadMedia(list[current]);
|
||||
else if (event.target.closest('.media-viewer__media')?.tagName === 'IMG') overlay.classList.toggle('is-bare');
|
||||
else if (event.target === stage || event.target === frame || event.target === overlay) close();
|
||||
});
|
||||
|
||||
// Смахивание: вбок – листать, вниз – закрыть.
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let tracking = false;
|
||||
stage.addEventListener('pointerdown', (event) => {
|
||||
if (event.target.tagName === 'VIDEO') return;
|
||||
tracking = true;
|
||||
startX = event.clientX;
|
||||
startY = event.clientY;
|
||||
});
|
||||
stage.addEventListener('pointermove', (event) => {
|
||||
if (!tracking) return;
|
||||
const dy = event.clientY - startY;
|
||||
if (dy > 0 && Math.abs(dy) > Math.abs(event.clientX - startX)) {
|
||||
stage.style.transform = `translateY(${dy}px)`;
|
||||
overlay.style.setProperty('--viewer-dim', String(Math.max(0.4, 1 - dy / 400)));
|
||||
}
|
||||
});
|
||||
const endSwipe = (event) => {
|
||||
if (!tracking) return;
|
||||
tracking = false;
|
||||
const dx = event.clientX - startX;
|
||||
const dy = event.clientY - startY;
|
||||
swiped = Math.hypot(dx, dy) > 10;
|
||||
stage.style.transform = '';
|
||||
overlay.style.removeProperty('--viewer-dim');
|
||||
if (dy > 90 && dy > Math.abs(dx)) close();
|
||||
else if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) go(dx < 0 ? 1 : -1);
|
||||
};
|
||||
stage.addEventListener('pointerup', endSwipe);
|
||||
stage.addEventListener('pointercancel', endSwipe);
|
||||
|
||||
document.addEventListener('keydown', onKey);
|
||||
window.addEventListener('popstate', onPopState);
|
||||
activeClose = close;
|
||||
render();
|
||||
root.append(overlay);
|
||||
overlay.querySelector('[data-action="close"]')?.focus({ preventScroll: true });
|
||||
}
|
||||
Reference in New Issue
Block a user