SHA256
UI: добавить карусель вложений
This commit is contained in:
+1
-1
@@ -1,2 +1,2 @@
|
||||
client.version=1.4.1
|
||||
client.version=1.4.2
|
||||
server.version=1.4.0
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
|
||||
После последнего attach-блока идёт обычный пользовательский текст. Пользовательский текст может быть пустым, если есть хотя бы одно валидное вложение.
|
||||
|
||||
Клиент SHiNE ограничивает одно сообщение максимум 10 вложениями и сохраняет порядок attach-блоков в том порядке, в котором пользователь прикрепил файлы.
|
||||
|
||||
## Поля
|
||||
|
||||
Обязательные поля:
|
||||
@@ -67,8 +69,18 @@ UI поддерживает три сценария:
|
||||
1. читает валидные attach-блоки только из начала текста;
|
||||
2. скрывает технические attach-строки;
|
||||
3. отображает вложения над пользовательским текстом;
|
||||
4. каждое вложение показывает как карточку/ссылку на Arweave gateway;
|
||||
5. если attach-блок битый, игнорирует только этот блок и продолжает отображать сообщение.
|
||||
4. показывает вложения горизонтальной каруселью: одно вложение на экране, переключение свайпом или стрелками, счётчик вида `1 из 3`;
|
||||
5. определяет тип вложения по расширению имени файла:
|
||||
- изображение показывает как ограниченное по размеру превью;
|
||||
- видео показывает как превью с кнопкой воспроизведения и открывает большой HTML5-плеер по нажатию;
|
||||
- обычный файл показывает как карточку с именем, расширением, размером и скачиванием;
|
||||
6. при перелистывании останавливает воспроизводящееся видео;
|
||||
7. если attach-блок битый, игнорирует только этот блок и продолжает отображать сообщение.
|
||||
|
||||
Если файл по ссылке Arweave gateway недоступен, клиент показывает диагностический блок:
|
||||
|
||||
- если сообщение создано менее 20 минут назад: файл, скорее всего, ещё не распространился в Arweave, нужно повторить через несколько минут;
|
||||
- если сообщение старше 20 минут: файл считается недоступным.
|
||||
|
||||
Старые клиенты без поддержки формата могут показывать attach-блоки как обычный текст.
|
||||
|
||||
@@ -79,6 +91,6 @@ UI поддерживает три сценария:
|
||||
## Ограничения v1
|
||||
|
||||
- `ar` допускает только короткий Arweave `txId`, полный URL не используется;
|
||||
- превью типа image/video/audio не входит в формат v1;
|
||||
- MIME type не записывается, поэтому UI определяет image/video/file по расширению имени файла;
|
||||
- MIME type, width, height, duration и thumbnail не записываются в блокчейн;
|
||||
- проверка существующего `txId` скачивает файл локально через gateway, поэтому UI ограничивает максимальный размер такой проверки.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# История изменений документации блокчейна
|
||||
|
||||
## 2026-07-31 12:47:41 +0400
|
||||
- Базовый коммит-ориентир: `f961947`.
|
||||
- Для `SHiNE:attach v=1` уточнено клиентское отображение вложений: горизонтальная карусель над текстом сообщения, счётчик позиции, просмотр image/video в модальном окне и скачивание файлов.
|
||||
- Зафиксирован клиентский лимит: максимум 10 вложений в одном сообщении с сохранением порядка attach-блоков.
|
||||
- Описана диагностика недоступного файла через Arweave gateway: отдельное сообщение для свежих сообщений младше 20 минут и отдельное сообщение для старых недоступных файлов.
|
||||
|
||||
## 2026-07-30 11:12:47 +0400
|
||||
- Базовый коммит-ориентир: `e9e6628`.
|
||||
- Добавлен отдельный документ `docs/Blockchain/15_TEXT_Attachments.md` с полной спецификацией `SHiNE:attach v=1`.
|
||||
|
||||
@@ -15,7 +15,8 @@ import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
||||
import {
|
||||
composeMessageWithAttachments,
|
||||
createAttachmentListElement,
|
||||
createAttachmentCarouselElement,
|
||||
MAX_MESSAGE_ATTACHMENTS,
|
||||
parseMessageAttachments,
|
||||
} from '../services/attachment-format.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
@@ -417,6 +418,10 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
|
||||
root.querySelector('#thread-reply-attach')?.addEventListener('click', async () => {
|
||||
try {
|
||||
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
|
||||
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
|
||||
return;
|
||||
}
|
||||
const item = await openArweaveAttachmentManager({
|
||||
login: state.session.login,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
@@ -670,7 +675,10 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
|
||||
card.append(authorTile);
|
||||
if (!isDeletedMessage && parsedText.attachments.length > 0) {
|
||||
card.append(createAttachmentListElement(parsedText.attachments, { gateway: state.entrySettings.arweaveServer }));
|
||||
card.append(createAttachmentCarouselElement(parsedText.attachments, {
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
messageTimestampMs: node?.createdAtMs,
|
||||
}));
|
||||
}
|
||||
card.append(body);
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@ import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
||||
import {
|
||||
composeMessageWithAttachments,
|
||||
createAttachmentListElement,
|
||||
createAttachmentCarouselElement,
|
||||
MAX_MESSAGE_ATTACHMENTS,
|
||||
parseMessageAttachments,
|
||||
} from '../services/attachment-format.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
@@ -392,6 +393,10 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
|
||||
root.querySelector('#reply-attach')?.addEventListener('click', async () => {
|
||||
try {
|
||||
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
|
||||
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
|
||||
return;
|
||||
}
|
||||
const item = await openArweaveAttachmentManager({
|
||||
login: state.session.login,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
@@ -546,6 +551,10 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
||||
|
||||
root.querySelector('#channel-message-attach')?.addEventListener('click', async () => {
|
||||
try {
|
||||
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
|
||||
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
|
||||
return;
|
||||
}
|
||||
const item = await openArweaveAttachmentManager({
|
||||
login: state.session.login,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
@@ -1004,7 +1013,10 @@ function renderPostCard(post, {
|
||||
|
||||
card.append(authorTile);
|
||||
if (!isDeletedMessage && parsedBody.attachments.length > 0) {
|
||||
card.append(createAttachmentListElement(parsedBody.attachments, { gateway: state.entrySettings.arweaveServer }));
|
||||
card.append(createAttachmentCarouselElement(parsedBody.attachments, {
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
messageTimestampMs: post.timestampMs,
|
||||
}));
|
||||
}
|
||||
card.append(body);
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@ import { buildArweaveDataUrl, validateArweaveTxId, validateSha256Hex } from './a
|
||||
|
||||
const ATTACH_PREFIX = '<SHiNE:attach;';
|
||||
const ATTACH_BLOCK_RE = /^<SHiNE:attach;([^>]*)>\n?/u;
|
||||
export const MAX_MESSAGE_ATTACHMENTS = 10;
|
||||
const RECENT_UNAVAILABLE_MS = 20 * 60 * 1000;
|
||||
const IMAGE_EXTENSIONS = new Set(['apng', 'avif', 'bmp', 'gif', 'jpeg', 'jpg', 'png', 'svg', 'webp']);
|
||||
const VIDEO_EXTENSIONS = new Set(['avi', 'm4v', 'mkv', 'mov', 'mp4', 'mpeg', 'mpg', 'ogg', 'ogv', 'webm']);
|
||||
|
||||
export function escapeHtml(text) {
|
||||
return String(text || '')
|
||||
@@ -54,7 +58,9 @@ export function buildAttachmentBlock(attachment) {
|
||||
|
||||
export function composeMessageWithAttachments(text, attachments = []) {
|
||||
const cleanText = String(text || '').trim();
|
||||
const rows = (Array.isArray(attachments) ? attachments : []).map(buildAttachmentBlock);
|
||||
const rows = (Array.isArray(attachments) ? attachments : [])
|
||||
.slice(0, MAX_MESSAGE_ATTACHMENTS)
|
||||
.map(buildAttachmentBlock);
|
||||
if (!rows.length) return cleanText;
|
||||
return `${rows.join('\n')}${cleanText ? `\n${cleanText}` : ''}`;
|
||||
}
|
||||
@@ -98,35 +104,264 @@ export function parseMessageAttachments(rawText) {
|
||||
};
|
||||
}
|
||||
|
||||
export function createAttachmentListElement(attachments = [], { gateway = '', compact = false } = {}) {
|
||||
const items = Array.isArray(attachments) ? attachments : [];
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = `message-attachments${compact ? ' message-attachments--compact' : ''}`;
|
||||
items.forEach((raw) => {
|
||||
let item;
|
||||
export function getAttachmentExtension(name) {
|
||||
const clean = String(name || '').trim().toLowerCase();
|
||||
const dot = clean.lastIndexOf('.');
|
||||
if (dot < 0 || dot === clean.length - 1) return '';
|
||||
return clean.slice(dot + 1).replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
export function getAttachmentKind(attachment = {}) {
|
||||
const ext = getAttachmentExtension(attachment.name);
|
||||
if (IMAGE_EXTENSIONS.has(ext)) return 'image';
|
||||
if (VIDEO_EXTENSIONS.has(ext)) return 'video';
|
||||
return 'file';
|
||||
}
|
||||
|
||||
function isRecentMessage(messageTimestampMs) {
|
||||
const ts = Number(messageTimestampMs || 0);
|
||||
if (!Number.isFinite(ts) || ts <= 0) return false;
|
||||
return Date.now() - ts < RECENT_UNAVAILABLE_MS;
|
||||
}
|
||||
|
||||
function createUnavailableBlock(messageTimestampMs) {
|
||||
const block = document.createElement('div');
|
||||
block.className = 'message-attachment-unavailable';
|
||||
block.textContent = isRecentMessage(messageTimestampMs)
|
||||
? 'Скорее всего файл недоступен, потому что сообщение было создано недавно и файл ещё не распространился в Arweave. Попробуйте повторить через несколько минут.'
|
||||
: 'Файл недоступен.';
|
||||
return block;
|
||||
}
|
||||
|
||||
function stopVideos(root) {
|
||||
root.querySelectorAll('video').forEach((video) => {
|
||||
try {
|
||||
item = normalizeAttachment(raw);
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
} catch {
|
||||
return;
|
||||
// Если браузер не даёт управлять видео, просто продолжаем перелистывание.
|
||||
}
|
||||
const url = buildArweaveDataUrl({ gateway, txId: item.ar });
|
||||
const link = document.createElement('a');
|
||||
link.className = 'message-attachment-card';
|
||||
link.href = url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener';
|
||||
link.title = 'Открыть файл в Arweave';
|
||||
link.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
link.innerHTML = `
|
||||
<span class="message-attachment-icon" aria-hidden="true">📎</span>
|
||||
<span class="message-attachment-main">
|
||||
<span class="message-attachment-name">${escapeHtml(item.name)}</span>
|
||||
<span class="message-attachment-meta">${escapeHtml(formatBytes(item.size))} · ${escapeHtml(item.ar)}</span>
|
||||
</span>
|
||||
`;
|
||||
wrap.append(link);
|
||||
});
|
||||
}
|
||||
|
||||
function createDownloadLink(url, label = 'Скачать') {
|
||||
const link = document.createElement('a');
|
||||
link.className = 'message-attachment-download';
|
||||
link.href = url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener';
|
||||
link.download = '';
|
||||
link.textContent = label;
|
||||
link.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
return link;
|
||||
}
|
||||
|
||||
function openAttachmentViewer({ item, url, kind }) {
|
||||
const root = document.getElementById('modal-root') || document.body;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal attachment-viewer-modal';
|
||||
overlay.innerHTML = `
|
||||
<div class="attachment-viewer-card" role="dialog" aria-modal="true">
|
||||
<div class="attachment-viewer-head">
|
||||
<div class="attachment-viewer-title">${escapeHtml(item.name)}</div>
|
||||
<button class="icon-btn attachment-viewer-close" type="button" aria-label="Закрыть">×</button>
|
||||
</div>
|
||||
<div class="attachment-viewer-body"></div>
|
||||
<div class="attachment-viewer-actions"></div>
|
||||
</div>
|
||||
`;
|
||||
const close = () => {
|
||||
stopVideos(overlay);
|
||||
overlay.remove();
|
||||
};
|
||||
overlay.addEventListener('click', (event) => {
|
||||
if (event.target === overlay) close();
|
||||
});
|
||||
overlay.querySelector('.attachment-viewer-close')?.addEventListener('click', close);
|
||||
const body = overlay.querySelector('.attachment-viewer-body');
|
||||
if (kind === 'video') {
|
||||
const video = document.createElement('video');
|
||||
video.className = 'attachment-viewer-media';
|
||||
video.src = url;
|
||||
video.controls = true;
|
||||
video.autoplay = true;
|
||||
body.append(video);
|
||||
} else {
|
||||
const img = document.createElement('img');
|
||||
img.className = 'attachment-viewer-media';
|
||||
img.src = url;
|
||||
img.alt = item.name;
|
||||
body.append(img);
|
||||
}
|
||||
overlay.querySelector('.attachment-viewer-actions')?.append(createDownloadLink(url));
|
||||
root.append(overlay);
|
||||
}
|
||||
|
||||
function replaceWithUnavailable(slide, messageTimestampMs) {
|
||||
if (!slide || slide.dataset.unavailable === '1') return;
|
||||
slide.dataset.unavailable = '1';
|
||||
slide.replaceChildren(createUnavailableBlock(messageTimestampMs));
|
||||
}
|
||||
|
||||
function createFileCard({ item, url, messageTimestampMs, slide }) {
|
||||
const ext = getAttachmentExtension(item.name).toUpperCase() || 'FILE';
|
||||
const card = document.createElement('div');
|
||||
card.className = 'message-attachment-file-card';
|
||||
card.role = 'button';
|
||||
card.tabIndex = 0;
|
||||
card.title = 'Скачать файл';
|
||||
card.innerHTML = `
|
||||
<div class="message-attachment-file-ext">${escapeHtml(ext)}</div>
|
||||
<div class="message-attachment-file-main">
|
||||
<div class="message-attachment-name">${escapeHtml(item.name)}</div>
|
||||
<div class="message-attachment-meta">${escapeHtml(formatBytes(item.size))} · ${escapeHtml(item.ar)}</div>
|
||||
</div>
|
||||
`;
|
||||
const openFile = () => window.open(url, '_blank', 'noopener');
|
||||
card.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
openFile();
|
||||
});
|
||||
card.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
openFile();
|
||||
});
|
||||
card.append(createDownloadLink(url));
|
||||
void fetch(url, { method: 'HEAD', cache: 'no-store' })
|
||||
.then((response) => {
|
||||
if (!response.ok) replaceWithUnavailable(slide, messageTimestampMs);
|
||||
})
|
||||
.catch(() => replaceWithUnavailable(slide, messageTimestampMs));
|
||||
return card;
|
||||
}
|
||||
|
||||
function createMediaSlide({ item, url, kind, messageTimestampMs, slide }) {
|
||||
const frame = document.createElement('div');
|
||||
frame.className = `message-attachment-media-frame message-attachment-media-frame--${kind}`;
|
||||
frame.role = 'button';
|
||||
frame.tabIndex = 0;
|
||||
frame.title = kind === 'video' ? 'Открыть видео' : 'Открыть изображение';
|
||||
frame.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
openAttachmentViewer({ item, url, kind });
|
||||
});
|
||||
frame.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
openAttachmentViewer({ item, url, kind });
|
||||
});
|
||||
|
||||
if (kind === 'image') {
|
||||
const img = document.createElement('img');
|
||||
img.className = 'message-attachment-media';
|
||||
img.src = url;
|
||||
img.alt = item.name;
|
||||
img.loading = 'lazy';
|
||||
img.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true });
|
||||
frame.append(img);
|
||||
} else {
|
||||
const video = document.createElement('video');
|
||||
video.className = 'message-attachment-media';
|
||||
video.src = url;
|
||||
video.preload = 'metadata';
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true });
|
||||
const play = document.createElement('span');
|
||||
play.className = 'message-attachment-play';
|
||||
play.textContent = '▶';
|
||||
frame.append(video, play);
|
||||
}
|
||||
|
||||
frame.append(createDownloadLink(url));
|
||||
return frame;
|
||||
}
|
||||
|
||||
export function createAttachmentCarouselElement(attachments = [], { gateway = '', messageTimestampMs = 0 } = {}) {
|
||||
const items = (Array.isArray(attachments) ? attachments : [])
|
||||
.slice(0, MAX_MESSAGE_ATTACHMENTS)
|
||||
.map((raw) => {
|
||||
try {
|
||||
return normalizeAttachment(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'message-attachments message-attachment-carousel';
|
||||
wrap.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
if (!items.length) return wrap;
|
||||
|
||||
let index = 0;
|
||||
let pointerStartX = 0;
|
||||
const viewport = document.createElement('div');
|
||||
viewport.className = 'message-attachment-carousel-viewport';
|
||||
const slide = document.createElement('div');
|
||||
slide.className = 'message-attachment-slide';
|
||||
const prev = document.createElement('button');
|
||||
prev.type = 'button';
|
||||
prev.className = 'message-attachment-arrow message-attachment-arrow--prev';
|
||||
prev.textContent = '‹';
|
||||
prev.setAttribute('aria-label', 'Предыдущее вложение');
|
||||
const next = document.createElement('button');
|
||||
next.type = 'button';
|
||||
next.className = 'message-attachment-arrow message-attachment-arrow--next';
|
||||
next.textContent = '›';
|
||||
next.setAttribute('aria-label', 'Следующее вложение');
|
||||
const counter = document.createElement('div');
|
||||
counter.className = 'message-attachment-counter';
|
||||
|
||||
const render = () => {
|
||||
stopVideos(wrap);
|
||||
const item = items[index];
|
||||
const kind = getAttachmentKind(item);
|
||||
const url = buildArweaveDataUrl({ gateway, txId: item.ar });
|
||||
slide.dataset.unavailable = '0';
|
||||
slide.replaceChildren();
|
||||
slide.append(kind === 'file'
|
||||
? createFileCard({ item, url, messageTimestampMs, slide })
|
||||
: createMediaSlide({ item, url, kind, messageTimestampMs, slide }));
|
||||
prev.disabled = index <= 0;
|
||||
next.disabled = index >= items.length - 1;
|
||||
counter.textContent = `${index + 1} из ${items.length}`;
|
||||
};
|
||||
|
||||
const move = (delta) => {
|
||||
const nextIndex = Math.max(0, Math.min(items.length - 1, index + delta));
|
||||
if (nextIndex === index) return;
|
||||
index = nextIndex;
|
||||
render();
|
||||
};
|
||||
|
||||
prev.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
move(-1);
|
||||
});
|
||||
next.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
move(1);
|
||||
});
|
||||
viewport.addEventListener('pointerdown', (event) => {
|
||||
pointerStartX = event.clientX;
|
||||
});
|
||||
viewport.addEventListener('pointerup', (event) => {
|
||||
const dx = event.clientX - pointerStartX;
|
||||
if (Math.abs(dx) < 40) return;
|
||||
move(dx < 0 ? 1 : -1);
|
||||
});
|
||||
|
||||
viewport.append(slide, prev, next);
|
||||
wrap.append(viewport, counter);
|
||||
render();
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function createAttachmentListElement(attachments = [], options = {}) {
|
||||
return createAttachmentCarouselElement(attachments, options);
|
||||
}
|
||||
|
||||
@@ -2182,6 +2182,165 @@ textarea.input {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.message-attachment-carousel {
|
||||
gap: 0.35rem;
|
||||
margin: 0.5rem 0 0.4rem;
|
||||
max-width: min(100%, 34rem);
|
||||
}
|
||||
|
||||
.message-attachment-carousel-viewport {
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, rgba(15, 23, 42, 0.05), rgba(20, 184, 166, 0.08));
|
||||
border: 1px solid rgba(15, 23, 42, 0.1);
|
||||
border-radius: 1.15rem;
|
||||
display: grid;
|
||||
min-height: 12rem;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.message-attachment-slide {
|
||||
align-items: stretch;
|
||||
display: grid;
|
||||
min-height: 12rem;
|
||||
}
|
||||
|
||||
.message-attachment-arrow {
|
||||
align-items: center;
|
||||
background: rgba(15, 23, 42, 0.62);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-size: 1.85rem;
|
||||
height: 2.35rem;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 2.35rem;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.message-attachment-arrow:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.message-attachment-arrow--prev {
|
||||
left: 0.55rem;
|
||||
}
|
||||
|
||||
.message-attachment-arrow--next {
|
||||
right: 0.55rem;
|
||||
}
|
||||
|
||||
.message-attachment-counter {
|
||||
color: #64748b;
|
||||
font-size: 0.78rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message-attachment-media-frame {
|
||||
align-items: center;
|
||||
background: #0f172a;
|
||||
cursor: zoom-in;
|
||||
display: grid;
|
||||
min-height: 12rem;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-attachment-media {
|
||||
display: block;
|
||||
height: min(52vh, 20rem);
|
||||
max-height: 20rem;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message-attachment-play {
|
||||
align-items: center;
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
font-size: 1.7rem;
|
||||
height: 4rem;
|
||||
justify-content: center;
|
||||
left: 50%;
|
||||
padding-left: 0.18rem;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 4rem;
|
||||
}
|
||||
|
||||
.message-attachment-download {
|
||||
background: rgba(20, 184, 166, 0.95);
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
padding: 0.45rem 0.75rem;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
width: max-content;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.message-attachment-media-frame > .message-attachment-download {
|
||||
bottom: 0.75rem;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
}
|
||||
|
||||
.message-attachment-file-card {
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, rgba(240, 253, 250, 0.95), rgba(239, 246, 255, 0.95));
|
||||
color: #0f172a;
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.message-attachment-file-ext {
|
||||
align-items: center;
|
||||
background: #0f766e;
|
||||
border-radius: 0.9rem;
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 800;
|
||||
height: 3rem;
|
||||
justify-content: center;
|
||||
max-width: 4rem;
|
||||
min-width: 3rem;
|
||||
padding: 0 0.45rem;
|
||||
}
|
||||
|
||||
.message-attachment-file-main {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-attachment-unavailable {
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, rgba(254, 242, 242, 0.96), rgba(255, 247, 237, 0.96));
|
||||
color: #7f1d1d;
|
||||
display: flex;
|
||||
font-weight: 700;
|
||||
justify-content: center;
|
||||
min-height: 12rem;
|
||||
padding: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message-attachment-card {
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, rgba(240, 253, 250, 0.95), rgba(239, 246, 255, 0.95));
|
||||
@@ -2224,6 +2383,80 @@ textarea.input {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.attachment-viewer-modal {
|
||||
z-index: 80;
|
||||
}
|
||||
|
||||
.attachment-viewer-card {
|
||||
background: rgba(15, 23, 42, 0.96);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 1.2rem;
|
||||
box-shadow: 0 24px 90px rgba(0, 0, 0, 0.45);
|
||||
color: #fff;
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
max-height: 92vh;
|
||||
max-width: min(94vw, 68rem);
|
||||
padding: 0.9rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.attachment-viewer-head,
|
||||
.attachment-viewer-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.attachment-viewer-title {
|
||||
font-weight: 800;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachment-viewer-body {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
min-height: 12rem;
|
||||
}
|
||||
|
||||
.attachment-viewer-media {
|
||||
background: #020617;
|
||||
border-radius: 0.8rem;
|
||||
max-height: 76vh;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.message-attachment-carousel {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.message-attachment-carousel-viewport,
|
||||
.message-attachment-slide,
|
||||
.message-attachment-media-frame,
|
||||
.message-attachment-unavailable {
|
||||
min-height: 10rem;
|
||||
}
|
||||
|
||||
.message-attachment-media {
|
||||
height: min(42vh, 16rem);
|
||||
}
|
||||
|
||||
.message-attachment-file-card {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.message-attachment-file-card .message-attachment-download {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.ar-attachment-manager-card {
|
||||
max-width: min(92vw, 34rem);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user