SHA256
Палитра «Сияние» по умолчанию, новые роли цветов. Лента канала: плоские записи, ряд действий с равным шагом иконок, дата у записи. Обсуждение со строкой ввода, личный чат, шторки. Вход и регистрация: стартовый экран (надпись «СИЯНИЕ», сияние мандалы), настройки входа со статусами серверов, статус логина в одну строку, вход с другого устройства (код, таймер, шаги), сохранение ключей с переключателями. Корневые разделы – заголовок по центру; фильтр чатов – переключатель; уведомления – список как в чатах; фото – просмотр на весь экран. Общее: один зазор под шапкой (--page-top-gap), переключатели высотой с поле ввода, второстепенные кнопки по одной роли (в том числе на экранах устройств и серверов), иконки только из ui-icon.js, полоса прокрутки скрыта, среднее тире в текстах интерфейса. Что нужно от сервера и разработчика – docs/UI-Design/UI-v4-for-dev.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
166 lines
6.8 KiB
JavaScript
166 lines
6.8 KiB
JavaScript
// Полноэкранный просмотр фото и видео из вложений: листание, «Скачать».
|
||
// Закрывается крестиком, 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 });
|
||
}
|