Перенос предпросмотра PrintMenu в модули
This commit is contained in:
@ -1,5 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onDestroy, tick } from 'svelte';
|
import { onDestroy, tick } from 'svelte';
|
||||||
|
import SelectionPage from './preview/SelectionPage.svelte';
|
||||||
|
import PreviewPage from './preview/PreviewPage.svelte';
|
||||||
|
|
||||||
let { onBack = () => {}, onPrint = () => {} } = $props();
|
let { onBack = () => {}, onPrint = () => {} } = $props();
|
||||||
|
|
||||||
@ -22,12 +24,8 @@
|
|||||||
// ── Данные для превью (чтение) ──
|
// ── Данные для превью (чтение) ──
|
||||||
/** Кэшированные URL больших страниц для превью */
|
/** Кэшированные URL больших страниц для превью */
|
||||||
let previewPagesCache = $state({});
|
let previewPagesCache = $state({});
|
||||||
let previewScrollContainer;
|
|
||||||
let isPreviewRendering = $state(false);
|
let isPreviewRendering = $state(false);
|
||||||
|
|
||||||
// Флаг для предотвращения множественных вызовов preload во время одного тика скролла
|
|
||||||
let preloadScheduled = false;
|
|
||||||
|
|
||||||
let uid = 0;
|
let uid = 0;
|
||||||
|
|
||||||
const qualities = [
|
const qualities = [
|
||||||
@ -41,7 +39,7 @@
|
|||||||
{ id: 'color', label: 'Цвет' }
|
{ id: 'color', label: 'Цвет' }
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── PDF.js Core ──
|
// ── PDF.js Core (Остается в родителе для управления памятью) ──
|
||||||
async function loadPdfDocument(entry) {
|
async function loadPdfDocument(entry) {
|
||||||
if (entry.pdfDoc) return entry.pdfDoc;
|
if (entry.pdfDoc) return entry.pdfDoc;
|
||||||
try {
|
try {
|
||||||
@ -61,9 +59,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Рендерит страницу в DataURL
|
|
||||||
*/
|
|
||||||
async function renderPageToImage(pdfDoc, pageNum, scale = 1.0) {
|
async function renderPageToImage(pdfDoc, pageNum, scale = 1.0) {
|
||||||
const page = await pdfDoc.getPage(pageNum);
|
const page = await pdfDoc.getPage(pageNum);
|
||||||
const viewport = page.getViewport({ scale });
|
const viewport = page.getViewport({ scale });
|
||||||
@ -88,7 +83,7 @@
|
|||||||
return canvas.toDataURL('image/jpeg', 0.85);
|
return canvas.toDataURL('image/jpeg', 0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Логика Галереи (Выбор страниц) ──
|
// ── Логика Галереи ──
|
||||||
async function openPageGallery(entry) {
|
async function openPageGallery(entry) {
|
||||||
activeFile = entry;
|
activeFile = entry;
|
||||||
viewMode = 'gallery';
|
viewMode = 'gallery';
|
||||||
@ -118,7 +113,7 @@
|
|||||||
galleryLoading = false;
|
galleryLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Логика Превью (Просмотр) ──
|
// ── Логика Превью ──
|
||||||
async function openPreview(entry) {
|
async function openPreview(entry) {
|
||||||
activeFile = entry;
|
activeFile = entry;
|
||||||
viewMode = 'preview';
|
viewMode = 'preview';
|
||||||
@ -129,18 +124,10 @@
|
|||||||
|
|
||||||
if (entry.fileType === 'pdf') {
|
if (entry.fileType === 'pdf') {
|
||||||
if (!entry.pdfDoc) await loadPdfDocument(entry);
|
if (!entry.pdfDoc) await loadPdfDocument(entry);
|
||||||
// Загружаем первые страницы сразу с большим буфером
|
|
||||||
preloadPreviewPages(entry, 1, 5);
|
preloadPreviewPages(entry, 1, 5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ленивая подгрузка страниц для режима Preview
|
|
||||||
* @param {FileEntry} entry
|
|
||||||
* @param {number} startPage - Страница, от которой начинаем загрузку
|
|
||||||
* @param {number} count - Сколько страниц грузим
|
|
||||||
* @param {'forward' | 'around'} direction - Направление загрузки
|
|
||||||
*/
|
|
||||||
async function preloadPreviewPages(entry, startPage, count, direction = 'forward') {
|
async function preloadPreviewPages(entry, startPage, count, direction = 'forward') {
|
||||||
if (!entry.pdfDoc || isPreviewRendering) return;
|
if (!entry.pdfDoc || isPreviewRendering) return;
|
||||||
isPreviewRendering = true;
|
isPreviewRendering = true;
|
||||||
@ -148,22 +135,18 @@
|
|||||||
let pagesToLoad = [];
|
let pagesToLoad = [];
|
||||||
|
|
||||||
if (direction === 'around') {
|
if (direction === 'around') {
|
||||||
// Загружаем окно вокруг текущей страницы
|
|
||||||
const half = Math.floor(count / 2);
|
const half = Math.floor(count / 2);
|
||||||
const from = Math.max(1, startPage - half);
|
const from = Math.max(1, startPage - half);
|
||||||
const to = Math.min(entry.totalPages, startPage + half);
|
const to = Math.min(entry.totalPages, startPage + half);
|
||||||
for(let i = from; i <= to; i++) pagesToLoad.push(i);
|
for(let i = from; i <= to; i++) pagesToLoad.push(i);
|
||||||
} else {
|
} else {
|
||||||
// Загружаем вперед от startPage
|
|
||||||
const endPage = Math.min(startPage + count - 1, entry.totalPages);
|
const endPage = Math.min(startPage + count - 1, entry.totalPages);
|
||||||
for (let i = startPage; i <= endPage; i++) pagesToLoad.push(i);
|
for (let i = startPage; i <= endPage; i++) pagesToLoad.push(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Фильтруем уже загруженные
|
|
||||||
pagesToLoad = pagesToLoad.filter(p => !previewPagesCache[p]);
|
pagesToLoad = pagesToLoad.filter(p => !previewPagesCache[p]);
|
||||||
|
|
||||||
if (pagesToLoad.length > 0) {
|
if (pagesToLoad.length > 0) {
|
||||||
// Создаем локальную копию, чтобы не триггерить реактивность на каждой итерации
|
|
||||||
const newCacheEntries = {};
|
const newCacheEntries = {};
|
||||||
|
|
||||||
for (const pageNum of pagesToLoad) {
|
for (const pageNum of pagesToLoad) {
|
||||||
@ -175,7 +158,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Одноразовое обновление стейта
|
|
||||||
if (Object.keys(newCacheEntries).length > 0) {
|
if (Object.keys(newCacheEntries).length > 0) {
|
||||||
previewPagesCache = { ...previewPagesCache, ...newCacheEntries };
|
previewPagesCache = { ...previewPagesCache, ...newCacheEntries };
|
||||||
}
|
}
|
||||||
@ -184,65 +166,9 @@
|
|||||||
isPreviewRendering = false;
|
isPreviewRendering = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обработчик скролла для ленивой загрузки в превью
|
// Обработчик запроса страниц от дочернего PreviewPage
|
||||||
function handlePreviewScroll() {
|
function handlePreviewRequestPages(start, count, direction) {
|
||||||
if (!activeFile || viewMode !== 'preview' || !previewScrollContainer) return;
|
if (activeFile) preloadPreviewPages(activeFile, start, count, direction);
|
||||||
|
|
||||||
// Debounce/Throttle через requestAnimationFrame
|
|
||||||
if (preloadScheduled) return;
|
|
||||||
preloadScheduled = true;
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
preloadScheduled = false;
|
|
||||||
|
|
||||||
const container = previewScrollContainer;
|
|
||||||
// Ищем ВСЕ элементы страниц: и загруженные картинки, и плейсхолдеры
|
|
||||||
const pageElements = container.querySelectorAll('[data-page]');
|
|
||||||
const buffer = window.innerHeight * 1.5; // Увеличенный буфер предзагрузки
|
|
||||||
|
|
||||||
let closestVisiblePage = 0;
|
|
||||||
let minDistance = Infinity;
|
|
||||||
let needsPreload = false;
|
|
||||||
let firstUnloadedInView = null;
|
|
||||||
|
|
||||||
const viewportCenter = window.innerHeight / 2;
|
|
||||||
|
|
||||||
pageElements.forEach((el) => {
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
const pageNum = parseInt(el.dataset.page);
|
|
||||||
|
|
||||||
// Элемент находится в зоне видимости + буфер
|
|
||||||
const inView = rect.bottom > -buffer && rect.top < window.innerHeight + buffer;
|
|
||||||
|
|
||||||
if (inView) {
|
|
||||||
// Определяем ближайшую к центру экрана страницу для контекста
|
|
||||||
const elCenter = rect.top + rect.height / 2;
|
|
||||||
const dist = Math.abs(elCenter - viewportCenter);
|
|
||||||
if (dist < minDistance) {
|
|
||||||
minDistance = dist;
|
|
||||||
closestVisiblePage = pageNum;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если это плейсхолдер (нет картинки в кэше), запоминаем первую такую
|
|
||||||
if (!previewPagesCache[pageNum] && firstUnloadedInView === null) {
|
|
||||||
firstUnloadedInView = pageNum;
|
|
||||||
needsPreload = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (needsPreload && firstUnloadedInView) {
|
|
||||||
// Загружаем пакет страниц начиная с первой ненайденной
|
|
||||||
preloadPreviewPages(activeFile, firstUnloadedInView, 4, 'forward');
|
|
||||||
} else if (closestVisiblePage > 0 && !isPreviewRendering) {
|
|
||||||
// Фоновая догрузка вокруг текущей просматриваемой страницы
|
|
||||||
// Проверяем, есть ли следующие страницы в кэше
|
|
||||||
const nextPage = closestVisiblePage + 2;
|
|
||||||
if (nextPage <= activeFile.totalPages && !previewPagesCache[nextPage]) {
|
|
||||||
preloadPreviewPages(activeFile, closestVisiblePage, 3, 'around');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Общие функции закрытия ──
|
// ── Общие функции закрытия ──
|
||||||
@ -252,25 +178,26 @@
|
|||||||
previewPagesCache = {};
|
previewPagesCache = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Управление выбором страниц (для Gallery) ──
|
// ── Управление выбором страниц ──
|
||||||
function togglePageSelection(entry, pageNum) {
|
function togglePageSelection(pageNum) {
|
||||||
if (!entry.selectedPages) return;
|
if (!activeFile?.selectedPages) return;
|
||||||
const newSet = new Set(entry.selectedPages);
|
const newSet = new Set(activeFile.selectedPages);
|
||||||
if (newSet.has(pageNum)) {
|
if (newSet.has(pageNum)) {
|
||||||
if (newSet.size > 1) newSet.delete(pageNum);
|
if (newSet.size > 1) newSet.delete(pageNum);
|
||||||
} else {
|
} else {
|
||||||
newSet.add(pageNum);
|
newSet.add(pageNum);
|
||||||
}
|
}
|
||||||
entry.selectedPages = newSet;
|
activeFile.selectedPages = newSet;
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectAllPages(entry) {
|
function selectAllPages() {
|
||||||
if (!entry.totalPages) return;
|
if (!activeFile?.totalPages) return;
|
||||||
entry.selectedPages = new Set(Array.from({ length: entry.totalPages }, (_, i) => i + 1));
|
activeFile.selectedPages = new Set(Array.from({ length: activeFile.totalPages }, (_, i) => i + 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
function deselectAllPages(entry) {
|
function deselectAllPages() {
|
||||||
entry.selectedPages = new Set([1]);
|
if (!activeFile) return;
|
||||||
|
activeFile.selectedPages = new Set([1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pagesLabel(entry) {
|
function pagesLabel(entry) {
|
||||||
@ -354,7 +281,6 @@
|
|||||||
}
|
}
|
||||||
const plFiles = (n) => pluralize(n, ['файл', 'файла', 'файлов']);
|
const plFiles = (n) => pluralize(n, ['файл', 'файла', 'файлов']);
|
||||||
const plCopies = (n) => pluralize(n, ['копия', 'копии', 'копий']);
|
const plCopies = (n) => pluralize(n, ['копия', 'копии', 'копий']);
|
||||||
const plPages = (n) => pluralize(n, ['страница', 'страницы', 'страниц']);
|
|
||||||
|
|
||||||
function handlePagesClick(file) {
|
function handlePagesClick(file) {
|
||||||
if (file.fileType === 'pdf') openPageGallery(file);
|
if (file.fileType === 'pdf') openPageGallery(file);
|
||||||
@ -453,130 +379,45 @@
|
|||||||
<span class="summary-price">{totalPrice}</span>
|
<span class="summary-price">{totalPrice}</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="action-btn secondary" onclick={onPrint}>Печать</button>
|
<button type="button" class="action-btn secondary" onclick={onPrint}>Печать</button>
|
||||||
<!-- Input можно оставить здесь, он скрыт через display:none и не влияет на верстку -->
|
|
||||||
<input type="file" bind:this={fileInput} accept="image/*,.pdf" multiple onchange={handleFileSelect} style="display:none" />
|
<input type="file" bind:this={fileInput} accept="image/*,.pdf" multiple onchange={handleFileSelect} style="display:none" />
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<!-- УНИВЕРСАЛЬНЫЙ ОВЕРЛЕЙ (Галерея выбора ИЛИ Просмотр) -->
|
<!-- ДЕЛЕГИРОВАНИЕ В ДОЧЕРНИЕ КОМПОНЕНТЫ -->
|
||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
{#if activeFile}
|
{#if activeFile}
|
||||||
<div class="overlay-backdrop" onclick={closeOverlay} role="dialog" aria-modal="true">
|
<div class="overlay-backdrop" onclick={closeOverlay} role="dialog" aria-modal="true">
|
||||||
<div class="overlay-window" class:is-preview-mode={viewMode === 'preview'} onclick={(e) => e.stopPropagation()}>
|
<!-- stopPropagation чтобы клики внутри окна не закрывали его -->
|
||||||
|
<div class="overlay-window-wrapper" onclick={(e) => e.stopPropagation()}>
|
||||||
|
|
||||||
<!-- Шапка -->
|
|
||||||
<div class="overlay-header">
|
|
||||||
<div class="overlay-header-info">
|
|
||||||
<h2 class="overlay-title">
|
|
||||||
{#if viewMode === 'gallery'}Выбор страниц{:else}Просмотр{/if}
|
|
||||||
</h2>
|
|
||||||
{#if viewMode === 'gallery' && activeFile.selectedPages}
|
|
||||||
<span class="overlay-subtitle">
|
|
||||||
{activeFile.selectedPages.size} из {activeFile.totalPages} {plPages(activeFile.selectedPages.size)}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<button type="button" class="overlay-close" onclick={closeOverlay} aria-label="Закрыть">✕</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Панель действий (только для галереи) -->
|
|
||||||
{#if viewMode === 'gallery'}
|
{#if viewMode === 'gallery'}
|
||||||
<div class="gallery-actions-bar">
|
<SelectionPage
|
||||||
<button type="button" class="gallery-quick-btn" class:active-quick={activeFile.selectedPages?.size === activeFile.totalPages} onclick={() => selectAllPages(activeFile)}>Выбрать все</button>
|
file={activeFile}
|
||||||
<button type="button" class="gallery-quick-btn" onclick={() => deselectAllPages(activeFile)}>Снять все</button>
|
thumbnails={galleryThumbnails}
|
||||||
</div>
|
loading={galleryLoading}
|
||||||
|
onTogglePage={togglePageSelection}
|
||||||
|
onSelectAll={selectAllPages}
|
||||||
|
onDeselectAll={deselectAllPages}
|
||||||
|
onClose={closeOverlay}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<PreviewPage
|
||||||
|
file={activeFile}
|
||||||
|
pagesCache={previewPagesCache}
|
||||||
|
isRendering={isPreviewRendering}
|
||||||
|
onRequestPages={handlePreviewRequestPages}
|
||||||
|
onClose={closeOverlay}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- КОНТЕНТ: ГАЛЕРЕЯ (Сетка 3 колонки) -->
|
|
||||||
{#if viewMode === 'gallery'}
|
|
||||||
<div class="gallery-grid">
|
|
||||||
{#if galleryLoading && galleryThumbnails.length === 0}
|
|
||||||
<div class="gallery-loading"><div class="spinner"></div><span>Загрузка…</span></div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#each galleryThumbnails as thumb, i}
|
|
||||||
{@const pageNum = i + 1}
|
|
||||||
{@const isSelected = activeFile.selectedPages?.has(pageNum)}
|
|
||||||
<button type="button" class="gallery-thumb" class:selected={isSelected} onclick={() => togglePageSelection(activeFile, pageNum)}>
|
|
||||||
<div class="thumb-image-wrap">
|
|
||||||
{#if thumb}
|
|
||||||
<img src={thumb} alt="Стр. {pageNum}" class="thumb-img" loading="lazy" />
|
|
||||||
{:else}
|
|
||||||
<div class="thumb-placeholder">?</div>
|
|
||||||
{/if}
|
|
||||||
{#if isSelected}
|
|
||||||
<div class="thumb-check">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<span class="thumb-label">{pageNum}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if galleryLoading && galleryThumbnails.length > 0}
|
|
||||||
<div class="gallery-thumb loading-placeholder"><div class="thumb-image-wrap"><div class="thumb-placeholder"><div class="spinner small"></div></div></div></div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- КОНТЕНТ: ПРЕВЬЮ (Вертикальный скролл, одна страница в строке) -->
|
|
||||||
{#if viewMode === 'preview'}
|
|
||||||
<!-- passive: true улучшает производительность скролла -->
|
|
||||||
<div class="preview-scroll-area" bind:this={previewScrollContainer} onscroll={handlePreviewScroll} onscrollcapture={handlePreviewScroll}>
|
|
||||||
|
|
||||||
{#if activeFile.fileType === 'image'}
|
|
||||||
<div class="preview-single-item">
|
|
||||||
<img src={activeFile.previewUrl} alt={activeFile.file.name} class="preview-full-img" />
|
|
||||||
</div>
|
|
||||||
{:else if activeFile.fileType === 'pdf'}
|
|
||||||
{#each Array(activeFile.totalPages) as _, i}
|
|
||||||
{@const pageNum = i + 1}
|
|
||||||
<div class="preview-page-item" data-page={pageNum}>
|
|
||||||
{#if previewPagesCache[pageNum]}
|
|
||||||
<img
|
|
||||||
src={previewPagesCache[pageNum]}
|
|
||||||
class="preview-page-img"
|
|
||||||
data-page={pageNum}
|
|
||||||
alt="Страница {pageNum}"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<!-- ВАЖНО: data-page должен быть и на плейсхолдере, чтобы скролл-хендлер мог его найти -->
|
|
||||||
<div class="preview-page-placeholder" data-page={pageNum}>
|
|
||||||
<div class="placeholder-content">
|
|
||||||
{#if pageNum <= 5 || isPreviewRendering}
|
|
||||||
<div class="spinner"></div>
|
|
||||||
{:else}
|
|
||||||
<span class="placeholder-text">Стр. {pageNum}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<div class="preview-end-marker">
|
|
||||||
Конец документа ({activeFile.totalPages} {plPages(activeFile.totalPages)})
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="preview-fallback">Предпросмотр недоступен</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Подвал оверлея -->
|
|
||||||
<div class="overlay-footer">
|
|
||||||
<button type="button" class="action-btn primary" onclick={closeOverlay}>
|
|
||||||
{#if viewMode === 'gallery'}Готово{:else}Закрыть{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* Стили основного меню остались без изменений */
|
||||||
.page-container { width: 100%; min-height: 100dvh; display: flex; flex-direction: column; background: radial-gradient(120% 60% at 50% -10%, rgba(99, 102, 241, 0.18) 0%, transparent 60%); font-family: system-ui, -apple-system, sans-serif; color: #f5f7fa; }
|
.page-container { width: 100%; min-height: 100dvh; display: flex; flex-direction: column; background: radial-gradient(120% 60% at 50% -10%, rgba(99, 102, 241, 0.18) 0%, transparent 60%); font-family: system-ui, -apple-system, sans-serif; color: #f5f7fa; }
|
||||||
.header { padding: 24px 20px 8px; }
|
.header { padding: 24px 20px 8px; }
|
||||||
.back-btn { background: none; border: none; color: #6366f1; font-size: 15px; font-weight: 600; cursor: pointer; padding: 8px 4px; margin-bottom: 12px; }
|
.back-btn { background: none; border: none; color: #6366f1; font-size: 15px; font-weight: 600; cursor: pointer; padding: 8px 4px; margin-bottom: 12px; }
|
||||||
@ -623,153 +464,25 @@
|
|||||||
.summary-itogo { font-size: 30px; font-weight: 700; color: #8b93a1; }
|
.summary-itogo { font-size: 30px; font-weight: 700; color: #8b93a1; }
|
||||||
.summary-price { font-size: 38px; font-weight: 800; color: #fff; }
|
.summary-price { font-size: 38px; font-weight: 800; color: #fff; }
|
||||||
|
|
||||||
/* ═══════════════════════════════════════ */
|
/* Обертка оверлея */
|
||||||
/* ОБЩИЙ ОВЕРЛЕЙ */
|
|
||||||
/* ═══════════════════════════════════════ */
|
|
||||||
.overlay-backdrop {
|
.overlay-backdrop {
|
||||||
position: fixed; inset: 0; z-index: 1000;
|
position: fixed; inset: 0; z-index: 1000;
|
||||||
background: rgba(10, 12, 18, 0.92); backdrop-filter: blur(12px);
|
background: rgba(10, 12, 18, 0.92); backdrop-filter: blur(12px);
|
||||||
display: flex; align-items: center; justify-content: center; padding: 0;
|
display: flex; align-items: center; justify-content: center; padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay-window {
|
.overlay-window-wrapper {
|
||||||
width: 100%; height: 100%;
|
width: 100%; height: 100%;
|
||||||
background: #13151b;
|
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
animation: slide-up 0.3s ease-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slide-up {
|
|
||||||
from { transform: translateY(20px); opacity: 0; }
|
|
||||||
to { transform: translateY(0); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay-header {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
padding: 16px 20px; flex-shrink: 0;
|
|
||||||
background: rgba(19, 21, 27, 0.95);
|
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
.overlay-title { margin: 0; font-size: 18px; font-weight: 700; color: #f5f7fa; }
|
|
||||||
.overlay-subtitle { font-size: 13px; color: #8b93a1; font-weight: 500; margin-top: 2px; display: block;}
|
|
||||||
.overlay-close {
|
|
||||||
width: 36px; height: 36px; border-radius: 50%; border: none;
|
|
||||||
background: rgba(255, 255, 255, 0.08); color: #fff; font-size: 18px; cursor: pointer;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay-footer {
|
|
||||||
padding: 16px 20px; flex-shrink: 0;
|
|
||||||
background: rgba(19, 21, 27, 0.95);
|
|
||||||
border-top: 1px solid rgba(255,255,255,0.06);
|
|
||||||
padding-bottom: max(16px, env(safe-area-inset-bottom));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ═══════════════════════════════════════ */
|
|
||||||
/* РЕЖИМ: ГАЛЕРЕЯ (Выбор страниц) */
|
|
||||||
/* ═══════════════════════════════════════ */
|
|
||||||
.gallery-actions-bar { display: flex; gap: 8px; padding: 12px 20px; flex-shrink: 0; }
|
|
||||||
.gallery-quick-btn {
|
|
||||||
flex: 1; padding: 10px 12px; border-radius: 10px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.05);
|
|
||||||
color: #c0c5d0; font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
.gallery-quick-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
|
|
||||||
.gallery-quick-btn.active-quick { background: rgba(99, 102, 241, 0.2); border-color: #6366f1; color: #a5b4fc; }
|
|
||||||
|
|
||||||
.gallery-grid {
|
|
||||||
flex: 1; overflow-y: auto; padding: 12px 16px 20px;
|
|
||||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
.gallery-thumb {
|
|
||||||
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
|
||||||
padding: 0; border: none; background: none; cursor: pointer;
|
|
||||||
}
|
|
||||||
.thumb-image-wrap {
|
|
||||||
position: relative; width: 100%; aspect-ratio: 210 / 297;
|
|
||||||
border-radius: 8px; overflow: hidden;
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
border: 2px solid transparent; transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
.gallery-thumb.selected .thumb-image-wrap { border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3); }
|
|
||||||
.gallery-thumb:not(.selected) .thumb-image-wrap { opacity: 0.5; }
|
|
||||||
.thumb-img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
||||||
.thumb-placeholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: #555; font-size: 20px; }
|
|
||||||
.thumb-check {
|
|
||||||
position: absolute; top: 4px; right: 4px;
|
|
||||||
width: 20px; height: 20px; border-radius: 50%;
|
|
||||||
background: #6366f1; display: flex; align-items: center; justify-content: center;
|
|
||||||
}
|
|
||||||
.thumb-check svg { width: 12px; height: 12px; color: #fff; }
|
|
||||||
.thumb-label { font-size: 11px; font-weight: 600; color: #8b93a1; }
|
|
||||||
.gallery-thumb.selected .thumb-label { color: #a5b4fc; }
|
|
||||||
.gallery-loading { grid-column: 1 / -1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 40px 0; color: #8b93a1; }
|
|
||||||
|
|
||||||
/* ═══════════════════════════════════════ */
|
|
||||||
/* РЕЖИМ: ПРЕВЬЮ (Вертикальный скролл) */
|
|
||||||
/* ═══════════════════════════════════════ */
|
|
||||||
.preview-scroll-area {
|
|
||||||
flex: 1; overflow-y: auto; overflow-x: hidden;
|
|
||||||
background: #0d0f14;
|
|
||||||
padding: 20px 0;
|
|
||||||
display: flex; flex-direction: column; align-items: center; gap: 16px;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-single-item {
|
|
||||||
width: 100%; display: flex; justify-content: center; padding: 0 16px; box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.preview-full-img {
|
|
||||||
max-width: 100%; max-height: 80vh; border-radius: 4px;
|
|
||||||
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-page-item {
|
|
||||||
width: 100%; max-width: 600px;
|
|
||||||
padding: 0 16px; box-sizing: border-box;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-page-img {
|
|
||||||
width: 100%; height: auto; display: block;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: 0 8px 30px rgba(0,0,0,0.6);
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-page-placeholder {
|
|
||||||
width: 100%; aspect-ratio: 210 / 297;
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
}
|
|
||||||
.placeholder-content { display: flex; flex-direction: column; align-items: center; gap: 8px; }
|
|
||||||
.placeholder-text { color: #444; font-size: 14px; font-weight: 600; }
|
|
||||||
|
|
||||||
.preview-end-marker {
|
|
||||||
padding: 40px 0; color: #555; font-size: 13px; text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-fallback { padding: 40px; color: #8b93a1; text-align: center; }
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
width: 32px; height: 32px;
|
|
||||||
border: 3px solid rgba(255, 255, 255, 0.1); border-top-color: #6366f1;
|
|
||||||
border-radius: 50%; animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
.spinner.small { width: 20px; height: 20px; border-width: 2px; }
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.overlay-window {
|
.overlay-window-wrapper {
|
||||||
max-width: 800px; max-height: 90vh;
|
max-width: 800px; max-height: 90vh;
|
||||||
border-radius: 20px; height: auto;
|
border-radius: 20px; height: auto;
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
background: #13151b; /* Fallback bg for wrapper on desktop */
|
||||||
}
|
}
|
||||||
.gallery-grid { grid-template-columns: repeat(4, 1fr); }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -0,0 +1,167 @@
|
|||||||
|
<script>
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
|
/** @typedef {{ id: number, fileType: string, previewUrl: string | null, totalPages: number }} FileEntry */
|
||||||
|
|
||||||
|
let {
|
||||||
|
file,
|
||||||
|
pagesCache = {},
|
||||||
|
isRendering = false,
|
||||||
|
onRequestPages = (start, count, dir) => {},
|
||||||
|
onClose = () => {}
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let scrollContainer;
|
||||||
|
let preloadScheduled = false;
|
||||||
|
|
||||||
|
// Плюрализация
|
||||||
|
function pluralize(n, forms) {
|
||||||
|
const mod10 = n % 10, mod100 = n % 100;
|
||||||
|
if (mod10 === 1 && mod100 !== 11) return forms[0];
|
||||||
|
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 > 14)) return forms[1];
|
||||||
|
return forms[2];
|
||||||
|
}
|
||||||
|
const plPages = (n) => pluralize(n, ['страница', 'страницы', 'страниц']);
|
||||||
|
|
||||||
|
function handleScroll() {
|
||||||
|
if (!file || !scrollContainer) return;
|
||||||
|
|
||||||
|
// Debounce via rAF
|
||||||
|
if (preloadScheduled) return;
|
||||||
|
preloadScheduled = true;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
preloadScheduled = false;
|
||||||
|
|
||||||
|
const container = scrollContainer;
|
||||||
|
const pageElements = container.querySelectorAll('[data-page]');
|
||||||
|
const buffer = window.innerHeight * 1.5;
|
||||||
|
|
||||||
|
let closestVisiblePage = 0;
|
||||||
|
let minDistance = Infinity;
|
||||||
|
let needsPreload = false;
|
||||||
|
let firstUnloadedInView = null;
|
||||||
|
const viewportCenter = window.innerHeight / 2;
|
||||||
|
|
||||||
|
pageElements.forEach((el) => {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const pageNum = parseInt(el.dataset.page);
|
||||||
|
|
||||||
|
const inView = rect.bottom > -buffer && rect.top < window.innerHeight + buffer;
|
||||||
|
|
||||||
|
if (inView) {
|
||||||
|
const elCenter = rect.top + rect.height / 2;
|
||||||
|
const dist = Math.abs(elCenter - viewportCenter);
|
||||||
|
if (dist < minDistance) {
|
||||||
|
minDistance = dist;
|
||||||
|
closestVisiblePage = pageNum;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pagesCache[pageNum] && firstUnloadedInView === null) {
|
||||||
|
firstUnloadedInView = pageNum;
|
||||||
|
needsPreload = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (needsPreload && firstUnloadedInView) {
|
||||||
|
onRequestPages(firstUnloadedInView, 4, 'forward');
|
||||||
|
} else if (closestVisiblePage > 0 && !isRendering) {
|
||||||
|
const nextPage = closestVisiblePage + 2;
|
||||||
|
if (nextPage <= file.totalPages && !pagesCache[nextPage]) {
|
||||||
|
onRequestPages(closestVisiblePage, 3, 'around');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="preview-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-info">
|
||||||
|
<h2 class="title">Просмотр</h2>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="close-btn" onclick={onClose} aria-label="Закрыть">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scroll Area -->
|
||||||
|
<div
|
||||||
|
class="scroll-area"
|
||||||
|
bind:this={scrollContainer}
|
||||||
|
onscroll={handleScroll}
|
||||||
|
onscrollcapture={handleScroll}
|
||||||
|
>
|
||||||
|
{#if file?.fileType === 'image'}
|
||||||
|
<div class="single-item">
|
||||||
|
<img src={file.previewUrl} alt={file.file.name} class="full-img" />
|
||||||
|
</div>
|
||||||
|
{:else if file?.fileType === 'pdf'}
|
||||||
|
{#each Array(file.totalPages) as _, i}
|
||||||
|
{@const pageNum = i + 1}
|
||||||
|
<div class="page-item" data-page={pageNum}>
|
||||||
|
{#if pagesCache[pageNum]}
|
||||||
|
<img
|
||||||
|
src={pagesCache[pageNum]}
|
||||||
|
class="page-img"
|
||||||
|
data-page={pageNum}
|
||||||
|
alt="Страница {pageNum}"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="placeholder" data-page={pageNum}>
|
||||||
|
<div class="placeholder-content">
|
||||||
|
{#if pageNum <= 5 || isRendering}
|
||||||
|
<div class="spinner"></div>
|
||||||
|
{:else}
|
||||||
|
<span class="placeholder-text">Стр. {pageNum}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<div class="end-marker">
|
||||||
|
Конец документа ({file.totalPages} {plPages(file.totalPages)})
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="fallback">Предпросмотр недоступен</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="footer">
|
||||||
|
<button type="button" class="action-btn secondary" onclick={onClose}>Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.preview-container { width: 100%; height: 100%; display: flex; flex-direction: column; background: #13151b; overflow: hidden; animation: slide-up 0.3s ease-out; }
|
||||||
|
@keyframes slide-up { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||||
|
|
||||||
|
.header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; flex-shrink: 0; background: rgba(19, 21, 27, 0.95); border-bottom: 1px solid rgba(255,255,255,0.06); z-index: 10; }
|
||||||
|
.title { margin: 0; font-size: 18px; font-weight: 700; color: #f5f7fa; }
|
||||||
|
.close-btn { width: 36px; height: 36px; border-radius: 50%; border: none; background: rgba(255, 255, 255, 0.08); color: #fff; font-size: 18px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
||||||
|
|
||||||
|
.scroll-area { flex: 1; overflow-y: auto; overflow-x: hidden; background: #0d0f14; padding: 20px 0; display: flex; flex-direction: column; align-items: center; gap: 16px; -webkit-overflow-scrolling: touch; }
|
||||||
|
|
||||||
|
.single-item { width: 100%; display: flex; justify-content: center; padding: 0 16px; box-sizing: border-box; }
|
||||||
|
.full-img { max-width: 100%; max-height: 80vh; border-radius: 4px; box-shadow: 0 10px 40px rgba(0,0,0,0.5); }
|
||||||
|
|
||||||
|
.page-item { width: 100%; max-width: 600px; padding: 0 16px; box-sizing: border-box; flex-shrink: 0; }
|
||||||
|
.page-img { width: 100%; height: auto; display: block; border-radius: 4px; box-shadow: 0 8px 30px rgba(0,0,0,0.6); background: #fff; }
|
||||||
|
|
||||||
|
.placeholder { width: 100%; aspect-ratio: 210 / 297; background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 4px; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.placeholder-content { display: flex; flex-direction: column; align-items: center; gap: 8px; }
|
||||||
|
.placeholder-text { color: #444; font-size: 14px; font-weight: 600; }
|
||||||
|
|
||||||
|
.end-marker { padding: 40px 0; color: #555; font-size: 13px; text-align: center; }
|
||||||
|
.fallback { padding: 40px; color: #8b93a1; text-align: center; }
|
||||||
|
|
||||||
|
.footer { padding: 16px 20px; flex-shrink: 0; background: rgba(19, 21, 27, 0.95); border-top: 1px solid rgba(255,255,255,0.06); padding-bottom: max(16px, env(safe-area-inset-bottom)); }
|
||||||
|
.action-btn { width: 100%; padding: 16px 24px; border-radius: 16px; font-size: 16px; font-weight: 700; cursor: pointer; border: none; color: #fff; }
|
||||||
|
.action-btn.secondary { background: linear-gradient(135deg, #5b8cff 0%, #38c6ff 100%); box-shadow: 0 8px 24px -6px rgba(72, 150, 255, 0.4); }
|
||||||
|
|
||||||
|
.spinner { width: 32px; height: 32px; border: 3px solid rgba(255, 255, 255, 0.1); border-top-color: #6366f1; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
|
|||||||
@ -0,0 +1,137 @@
|
|||||||
|
<script>
|
||||||
|
/** @typedef {{ id: number, selectedPages: Set<number>, totalPages: number }} FileEntry */
|
||||||
|
|
||||||
|
let {
|
||||||
|
file,
|
||||||
|
thumbnails = [],
|
||||||
|
loading = false,
|
||||||
|
onTogglePage = () => {},
|
||||||
|
onSelectAll = () => {},
|
||||||
|
onDeselectAll = () => {},
|
||||||
|
onClose = () => {}
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Плюрализация локальная для компонента
|
||||||
|
function pluralize(n, forms) {
|
||||||
|
const mod10 = n % 10, mod100 = n % 100;
|
||||||
|
if (mod10 === 1 && mod100 !== 11) return forms[0];
|
||||||
|
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 > 14)) return forms[1];
|
||||||
|
return forms[2];
|
||||||
|
}
|
||||||
|
const plPages = (n) => pluralize(n, ['страница', 'страницы', 'страниц']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="selection-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-info">
|
||||||
|
<h2 class="title">Выбор страниц</h2>
|
||||||
|
{#if file?.selectedPages}
|
||||||
|
<span class="subtitle">
|
||||||
|
{file.selectedPages.size} из {file.totalPages} {plPages(file.selectedPages.size)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="close-btn" onclick={onClose} aria-label="Закрыть">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions Bar -->
|
||||||
|
<div class="actions-bar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="quick-btn"
|
||||||
|
class:active-quick={file?.selectedPages?.size === file?.totalPages}
|
||||||
|
onclick={onSelectAll}
|
||||||
|
>
|
||||||
|
Выбрать все
|
||||||
|
</button>
|
||||||
|
<button type="button" class="quick-btn" onclick={onDeselectAll}>Снять все</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grid -->
|
||||||
|
<div class="grid">
|
||||||
|
{#if loading && thumbnails.length === 0}
|
||||||
|
<div class="loading-state"><div class="spinner"></div><span>Загрузка…</span></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each thumbnails as thumb, i}
|
||||||
|
{@const pageNum = i + 1}
|
||||||
|
{@const isSelected = file?.selectedPages?.has(pageNum)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="thumb-card"
|
||||||
|
class:selected={isSelected}
|
||||||
|
onclick={() => onTogglePage(pageNum)}
|
||||||
|
>
|
||||||
|
<div class="thumb-image-wrap">
|
||||||
|
{#if thumb}
|
||||||
|
<img src={thumb} alt="Стр. {pageNum}" class="thumb-img" loading="lazy" />
|
||||||
|
{:else}
|
||||||
|
<div class="thumb-placeholder">?</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isSelected}
|
||||||
|
<div class="check-badge">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<span class="thumb-label">{pageNum}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if loading && thumbnails.length > 0}
|
||||||
|
<div class="thumb-card loading-placeholder">
|
||||||
|
<div class="thumb-image-wrap">
|
||||||
|
<div class="thumb-placeholder"><div class="spinner small"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="footer">
|
||||||
|
<button type="button" class="action-btn primary" onclick={onClose}>Готово</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.selection-container { width: 100%; height: 100%; display: flex; flex-direction: column; background: #13151b; overflow: hidden; animation: slide-up 0.3s ease-out; }
|
||||||
|
@keyframes slide-up { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||||
|
|
||||||
|
/* Header & Footer shared styles can be passed or duplicated. Keeping self-contained here. */
|
||||||
|
.header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; flex-shrink: 0; background: rgba(19, 21, 27, 0.95); border-bottom: 1px solid rgba(255,255,255,0.06); z-index: 10; }
|
||||||
|
.title { margin: 0; font-size: 18px; font-weight: 700; color: #f5f7fa; }
|
||||||
|
.subtitle { font-size: 13px; color: #8b93a1; font-weight: 500; margin-top: 2px; display: block;}
|
||||||
|
.close-btn { width: 36px; height: 36px; border-radius: 50%; border: none; background: rgba(255, 255, 255, 0.08); color: #fff; font-size: 18px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
||||||
|
|
||||||
|
.actions-bar { display: flex; gap: 8px; padding: 12px 20px; flex-shrink: 0; }
|
||||||
|
.quick-btn { flex: 1; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.05); color: #c0c5d0; font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.15s ease; }
|
||||||
|
.quick-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
|
||||||
|
.quick-btn.active-quick { background: rgba(99, 102, 241, 0.2); border-color: #6366f1; color: #a5b4fc; }
|
||||||
|
|
||||||
|
.grid { flex: 1; overflow-y: auto; padding: 12px 16px 20px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; -webkit-overflow-scrolling: touch; }
|
||||||
|
.thumb-card { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 0; border: none; background: none; cursor: pointer; }
|
||||||
|
.thumb-image-wrap { position: relative; width: 100%; aspect-ratio: 210 / 297; border-radius: 8px; overflow: hidden; background: rgba(255, 255, 255, 0.06); border: 2px solid transparent; transition: all 0.2s ease; }
|
||||||
|
.thumb-card.selected .thumb-image-wrap { border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3); }
|
||||||
|
.thumb-card:not(.selected) .thumb-image-wrap { opacity: 0.5; }
|
||||||
|
.thumb-img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.thumb-placeholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: #555; font-size: 20px; }
|
||||||
|
.check-badge { position: absolute; top: 4px; right: 4px; width: 20px; height: 20px; border-radius: 50%; background: #6366f1; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.check-badge svg { width: 12px; height: 12px; color: #fff; }
|
||||||
|
.thumb-label { font-size: 11px; font-weight: 600; color: #8b93a1; }
|
||||||
|
.thumb-card.selected .thumb-label { color: #a5b4fc; }
|
||||||
|
.loading-state { grid-column: 1 / -1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 40px 0; color: #8b93a1; }
|
||||||
|
|
||||||
|
.footer { padding: 16px 20px; flex-shrink: 0; background: rgba(19, 21, 27, 0.95); border-top: 1px solid rgba(255,255,255,0.06); padding-bottom: max(16px, env(safe-area-inset-bottom)); }
|
||||||
|
.action-btn { width: 100%; padding: 16px 24px; border-radius: 16px; font-size: 16px; font-weight: 700; cursor: pointer; border: none; color: #fff; }
|
||||||
|
.action-btn.primary { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); box-shadow: 0 8px 24px -6px rgba(99, 102, 241, 0.5); }
|
||||||
|
|
||||||
|
.spinner { width: 32px; height: 32px; border: 3px solid rgba(255, 255, 255, 0.1); border-top-color: #6366f1; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||||
|
.spinner.small { width: 20px; height: 20px; border-width: 2px; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.grid { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user