Рефакторинг

This commit is contained in:
2026-09-08 17:38:13 +03:00
parent e2691c2d02
commit b1956a3b04
18 changed files with 778 additions and 775 deletions

View File

@ -1,66 +1,57 @@
<script>
import MainMenu from './lib/MainMenu.svelte';
import PrintMenu from './lib/PrintMenu.svelte';
import MainMenu from '$lib/pages/main/MainMenu.svelte';
import PrintMenu from '$lib/pages/print/PrintMenu.svelte';
/** @type {'main' | 'print'} */
let page = 'main';
/** @type {'main' | 'print'} */
let page = $state('main');
/** @param {'print' | 'scan' | 'photo' | 'access'} id */
function handleAction(id) {
if (id === 'print') {
page = 'print';
} else {
console.log('Выбрано действие:', id);
}
}
/** @param {'print' | 'scan' | 'photo' | 'access'} id */
function handleAction(id) {
if (id === 'print') {
page = 'print';
} else {
console.log('Выбрано действие:', id);
}
}
function handleBack() {
page = 'main';
}
function handlePrint() {
console.log('Печать с настройками...');
}
function handleLoadPreview() {
console.log('Загрузка файла для предпросмотра...');
}
function handleBack() {
page = 'main';
}
</script>
<style>
:global(html),
:global(body) {
margin: 0;
padding: 0;
background: #0b0d11;
}
:global(html),
:global(body) {
margin: 0;
padding: 0;
background: #0b0d11;
}
:global(body) {
display: block;
place-items: initial;
min-width: 0;
color: #f5f7fa;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
}
:global(body) {
display: block;
place-items: initial;
min-width: 0;
color: #f5f7fa;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
}
.app-wrapper {
display: flex;
justify-content: center;
}
.app-wrapper {
display: flex;
justify-content: center;
}
@media (max-width: 480px) {
.app-wrapper {
justify-content: center;
}
}
@media (max-width: 480px) {
.app-wrapper {
justify-content: center;
}
}
</style>
<main class="app-wrapper">
{#if page === 'main'}
<MainMenu onAction={handleAction} />
{/if}
{#if page === 'print'}
<PrintMenu onBack={handleBack} onPrint={handlePrint} />
{/if}
{#if page === 'main'}
<MainMenu onAction={handleAction} />
{:else if page === 'print'}
<PrintMenu onBack={handleBack} />
{/if}
</main>

View File

@ -1,668 +0,0 @@
<script>
import { onDestroy, tick } from 'svelte';
import SelectionPage from './preview/SelectionPage.svelte';
import PreviewPage from './preview/PreviewPage.svelte';
import PaymentPage from './PaymentPage.svelte';
let { onBack = () => {} } = $props();
// ── Модель данных ──
/** @typedef {{ id: number, file: File, previewUrl: string | null, fileType: 'image' | 'pdf' | 'other', expanded: boolean, format: 'A4' | 'A5', colorMode: 'bw' | 'color', qualityIndex: number, copies: number, pageRange: string, totalPages: number, selectedPages: Set<number>, pdfDoc: any }} FileEntry */
/** @type {FileEntry[]} */
let files = $state([]);
// ── Общие состояния оверлея ──
/** @type {FileEntry | null} */
let activeFile = $state(null);
/** Режим просмотра: 'gallery' (выбор страниц) или 'preview' (чтение) */
let viewMode = $state(/** @type {'gallery' | 'preview'} */ ('gallery'));
// ── Состояние оплаты ──
let showPayment = $state(false);
// ── Данные для галереи ( выбор страниц) ──
let galleryThumbnails = $state([]);
let galleryLoading = $state(false);
// ── Данные для превью (чтение) ──
/** Кэшированные URL больших страниц для превью */
let previewPagesCache = $state({});
let isPreviewRendering = $state(false);
// ── Состояние кнопки печати ──
let isPrinting = $state(false);
let uid = 0;
const qualities = [
{ id: 'low', label: 'Эконом' },
{ id: 'medium', label: 'Стандарт' },
{ id: 'high', label: 'Максимум' }
];
const formats = ['A4', 'A5'];
const colorModes = [
{ id: 'bw', label: 'Чб' },
{ id: 'color', label: 'Цвет' }
];
// ── Тарифная сетка ──
function getPricePerPage(pagesCount) {
if (pagesCount >= 1000) return 4;
if (pagesCount >= 25) return 9;
return 10;
}
// ── Вспомогательная функция для реактивного обновления ──
function updateFile(id, changes) {
files = files.map(f => f.id === id ? { ...f, ...changes } : f);
if (activeFile && activeFile.id === id) {
activeFile = files.find(f => f.id === id);
}
}
// ── PDF.js Core ──
async function loadPdfDocument(entry) {
if (entry.pdfDoc) return entry.pdfDoc;
try {
const pdfjsLib = await import('https://mozilla.github.io/pdf.js/build/pdf.mjs');
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://mozilla.github.io/pdf.js/build/pdf.worker.mjs';
const arrayBuffer = await entry.file.arrayBuffer();
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
const pdf = await loadingTask.promise;
entry.pdfDoc = pdf;
const numPages = pdf.numPages;
const allPages = new Set(Array.from({ length: numPages }, (_, i) => i + 1));
updateFile(entry.id, {
pdfDoc: pdf,
totalPages: numPages,
selectedPages: allPages
});
return pdf;
} catch (err) {
console.error('PDF load error:', err);
updateFile(entry.id, { totalPages: 0 });
return null;
}
}
async function renderPageToImage(pdfDoc, pageNum, scale = 1.0) {
const page = await pdfDoc.getPage(pageNum);
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
const outputScale = window.devicePixelRatio || 1;
canvas.width = Math.floor(viewport.width * outputScale);
canvas.height = Math.floor(viewport.height * outputScale);
canvas.style.width = `${Math.floor(viewport.width)}px`;
canvas.style.height = `${Math.floor(viewport.height)}px`;
const transform = outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : undefined;
await page.render({
canvasContext: context,
viewport,
transform
}).promise;
return canvas.toDataURL('image/jpeg', 0.85);
}
// ── Логика Галереи ──
async function openPageGallery(entry) {
activeFile = entry;
viewMode = 'gallery';
galleryThumbnails = [];
galleryLoading = true;
previewPagesCache = {};
await tick();
let pdfDoc = entry.pdfDoc;
if (!pdfDoc) {
pdfDoc = await loadPdfDocument(entry);
}
if (!pdfDoc || entry.totalPages === 0) {
galleryLoading = false;
return;
}
const thumbs = [];
for (let i = 1; i <= entry.totalPages; i++) {
try {
const url = await renderPageToImage(pdfDoc, i, 0.4);
thumbs.push(url);
galleryThumbnails = [...thumbs];
} catch (err) {
thumbs.push('');
galleryThumbnails = [...thumbs];
}
}
galleryLoading = false;
}
// ── Логика Превью ──
async function openPreview(entry) {
activeFile = entry;
viewMode = 'preview';
previewPagesCache = {};
galleryThumbnails = [];
await tick();
if (entry.fileType === 'pdf') {
let pdfDoc = entry.pdfDoc;
if (!pdfDoc) pdfDoc = await loadPdfDocument(entry);
if (entry.totalPages > 0) {
preloadPreviewPages(entry, 1, 5);
}
}
}
async function preloadPreviewPages(entry, startPage, count, direction = 'forward') {
const currentFile = files.find(f => f.id === entry.id);
const pdfDoc = currentFile ? currentFile.pdfDoc : entry.pdfDoc;
if (!pdfDoc || isPreviewRendering) return;
isPreviewRendering = true;
let pagesToLoad = [];
if (direction === 'around') {
const half = Math.floor(count / 2);
const from = Math.max(1, startPage - half);
const to = Math.min(entry.totalPages, startPage + half);
for(let i = from; i <= to; i++) pagesToLoad.push(i);
} else {
const endPage = Math.min(startPage + count - 1, entry.totalPages);
for (let i = startPage; i <= endPage; i++) pagesToLoad.push(i);
}
pagesToLoad = pagesToLoad.filter(p => !previewPagesCache[p]);
if (pagesToLoad.length > 0) {
const newCacheEntries = {};
for (const pageNum of pagesToLoad) {
try {
const url = await renderPageToImage(pdfDoc, pageNum, 1.5);
newCacheEntries[pageNum] = url;
} catch (e) {
console.error(`Failed to render preview page ${pageNum}`, e);
}
}
if (Object.keys(newCacheEntries).length > 0) {
previewPagesCache = { ...previewPagesCache, ...newCacheEntries };
}
}
isPreviewRendering = false;
}
function handlePreviewRequestPages(start, count, direction) {
if (activeFile) preloadPreviewPages(activeFile, start, count, direction);
}
// ── Общие функции закрытия ──
function closeOverlay() {
activeFile = null;
galleryThumbnails = [];
previewPagesCache = {};
}
// ── Управление выбором страниц ──
function togglePageSelection(pageNum) {
if (!activeFile?.selectedPages) return;
const newSet = new Set(activeFile.selectedPages);
if (newSet.has(pageNum)) {
if (newSet.size > 1) newSet.delete(pageNum);
} else {
newSet.add(pageNum);
}
updateFile(activeFile.id, { selectedPages: newSet });
}
function selectAllPages() {
if (!activeFile?.totalPages) return;
const allPages = new Set(Array.from({ length: activeFile.totalPages }, (_, i) => i + 1));
updateFile(activeFile.id, { selectedPages: allPages });
}
function deselectAllPages() {
if (!activeFile) return;
updateFile(activeFile.id, { selectedPages: new Set([1]) });
}
function pagesLabel(entry) {
if (!entry.totalPages || entry.totalPages === 0) return '...';
if (!entry.selectedPages || entry.selectedPages.size === 0) return 'Нет';
if (entry.selectedPages.size === entry.totalPages) return 'Все';
if (entry.selectedPages.size === 1) return `стр. ${[...entry.selectedPages][0]}`;
return `${entry.selectedPages.size} из ${entry.totalPages}`;
}
// ── Файловые операции ──
let fileInput;
function detectFileType(file) {
if (!file) return 'other';
if (file.type.startsWith('image/')) return 'image';
if (file.type === 'application/pdf') return 'pdf';
return 'other';
}
async function handleFileSelect(event) {
const input = /** @type {HTMLInputElement} */(event.target);
const chosen = input.files;
if (!chosen || chosen.length === 0) return;
const newFiles = [];
for (let i = 0; i < chosen.length; i++) {
const fType = detectFileType(chosen[i]);
const entry = {
id: ++uid,
file: chosen[i],
previewUrl: URL.createObjectURL(chosen[i]),
fileType: fType,
expanded: false,
format: 'A4',
colorMode: 'bw',
qualityIndex: 1,
copies: 1,
pageRange: 'all',
totalPages: fType === 'image' ? 1 : 0,
selectedPages: fType === 'image' ? new Set([1]) : new Set(),
pdfDoc: null,
};
newFiles.push(entry);
}
files = [...files, ...newFiles];
for (const entry of newFiles) {
if (entry.fileType === 'pdf') {
loadPdfDocument(entry);
}
}
input.value = '';
}
function triggerFileInput(event) {
event.preventDefault();
fileInput.click();
}
function toggleExpanded(file) {
updateFile(file.id, { expanded: !file.expanded });
}
function removeFile(file) {
if (file.previewUrl) URL.revokeObjectURL(file.previewUrl);
files = files.filter((f) => f.id !== file.id);
}
// ── Опции печати ──
function toggleFormat(file, f) { updateFile(file.id, { format: f }); }
function toggleColor(file, c) { updateFile(file.id, { colorMode: c.id }); }
function incrementCopies(file) { updateFile(file.id, { copies: file.copies + 1 }); }
function decrementCopies(file) { if (file.copies > 1) updateFile(file.id, { copies: file.copies - 1 }); }
function qualityFillFor(file) { return Math.round((file.qualityIndex / (qualities.length - 1)) * 100); }
function updateQuality(file, val) { updateFile(file.id, { qualityIndex: val }); }
// ── Расчет цены (ОБНОВЛЕННАЯ ЛОГИКА) ──
const totalPrice = $derived(() => {
let sum = 0;
for (const f of files) {
const pagesCount = f.selectedPages ? f.selectedPages.size : 0;
if (pagesCount > 0) {
const pricePerPage = getPricePerPage(pagesCount);
sum += pagesCount * f.copies * pricePerPage;
}
}
return sum;
});
const extraCopies = $derived(files.reduce((sum, f) => sum + (f.copies - 1), 0));
onDestroy(() => {
files.forEach((f) => { if (f.previewUrl) URL.revokeObjectURL(f.previewUrl); });
});
// ── Плюрализация ──
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 plFiles = (n) => pluralize(n, ['файл', 'файла', 'файлов']);
const plCopies = (n) => pluralize(n, ['копия', 'копии', 'копий']);
function handlePagesClick(file) {
if (file.fileType === 'pdf') openPageGallery(file);
else openPreview(file);
}
// ── Показ страницы оплаты ──
function showPaymentPage() {
if (files.length === 0 || isPrinting) return;
showPayment = true;
}
// ── Реальная отправка на сервер (после оплаты) ──
async function realSubmitPrint() {
if (files.length === 0 || isPrinting) return;
isPrinting = true;
const formData = new FormData();
for (const f of files) {
formData.append('files', f.file, f.file.name);
const settings = {
filename: f.file.name,
pages: f.selectedPages ? Array.from(f.selectedPages).sort((a, b) => a - b).join(',') : 'all',
copies: f.copies,
colorMode: f.colorMode,
format: f.format
};
formData.append('settings', JSON.stringify(settings));
}
try {
const response = await fetch('/api/print', {
method: 'POST',
body: formData
});
const text = await response.text();
if (response.ok) {
alert(`✅ Успешно отправлено в печать!\n${text}`);
} else {
alert(`❌ Ошибка печати (${response.status}):\n${text}`);
}
} catch (error) {
console.error('Print submission error:', error);
alert(`⚠️ Не удалось связаться с сервером печати.`);
} finally {
isPrinting = false;
showPayment = false;
}
}
</script>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- СТРАНИЦА ОПЛАТЫ (полный экран) -->
<!-- ═══════════════════════════════════════════════════════════ -->
{#if showPayment}
<PaymentPage
totalPrice={totalPrice()}
filesCount={files.length}
onBack={() => showPayment = false}
onConfirmPayment={realSubmitPrint}
/>
{/if}
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- ОСНОВНАЯ СТРАНИЦА ПЕЧАТИ -->
<!-- ═══════════════════════════════════════════════════════════ -->
{#if !activeFile && !showPayment}
<div class="page-container">
<header class="header">
<button class="back-btn" onclick={onBack}>←</button>
<h1>Печать</h1>
</header>
<main class="options">
{#if files.length === 0}
<div class="empty-upload">
<p class="empty-hint">Загрузите файлы для печати</p>
<button class="action-btn primary" onclick={triggerFileInput}>Загрузить файл(ы)</button>
</div>
{:else}
{#each files as file (file.id)}
<section class="option-group file-card" class:expanded={file.expanded}>
<div class="file-row" onclick={() => toggleExpanded(file)}>
<span class="chevron">{#if file.expanded}{:else}{/if}</span>
<span class="file-name" title={file.file.name}>{file.file.name}</span>
<span class="file-row-actions" onclick={(e) => e.stopPropagation()}>
{#if file.previewUrl}
<button type="button" class="icon-btn" onclick={() => openPreview(file)} title="Предпросмотр">🔍</button>
{/if}
<button type="button" class="icon-btn danger" onclick={() => removeFile(file)} title="Удалить"></button>
</span>
</div>
{#if file.expanded}
<div class="file-options">
<div class="option-sub print-params-row">
<div class="pages-control">
<label class="option-title compact">Страницы:</label>
<button type="button" class="pages-btn" onclick={() => handlePagesClick(file)}>
{pagesLabel(file)}
</button>
</div>
<div class="copies-control-inline">
<label class="option-title compact">Кол-во:</label>
<div class="mini-counter">
<button type="button" class="circle-btn small" onclick={() => decrementCopies(file)} disabled={file.copies <= 1}></button>
<span class="copies-value">{file.copies} шт.</span>
<button type="button" class="circle-btn small" onclick={() => incrementCopies(file)}></button>
</div>
</div>
</div>
<div class="option-sub">
<label class="option-title">Формат</label>
<div class="toggle-group horizontal">
{#each formats as f}
<button type="button" class="toggle-btn {file.format === f ? 'active' : ''}" onclick={() => toggleFormat(file, f)}>{f}</button>
{/each}
</div>
</div>
<div class="option-sub">
<label class="option-title">Цветность</label>
<div class="toggle-group horizontal">
{#each colorModes as c}
<button type="button" class="toggle-btn {file.colorMode === c.id ? 'active' : ''}" onclick={() => toggleColor(file, c)}>{c.label}</button>
{/each}
</div>
</div>
<div class="option-sub quality-sub">
<label class="option-title">Качество {qualities[file.qualityIndex].label}</label>
<input
type="range"
min={0}
max={qualities.length - 1}
step={1}
value={file.qualityIndex}
oninput={(e) => updateQuality(file, parseInt(e.target.value))}
class="quality-slider"
style="--fill: {qualityFillFor(file)}%"
/>
<div class="quality-marks-inline">
{#each qualities as q}<span class="quality-mark">{q.label}</span>{/each}
</div>
</div>
</div>
{/if}
</section>
{/each}
<button type="button" class="add-file-btn" onclick={triggerFileInput}> Добавить файлы</button>
{/if}
</main>
<footer class="actions">
{#if files.length > 0}
<p class="file-count">
{files.length} {plFiles(files.length)}
{#if extraCopies > 0}, {extraCopies} {plCopies(extraCopies)}{/if}
</p>
{/if}
<!-- РАСЦЕНКА МАЛЕНЬКИМ ПРОЗРАЧНЫМ ШРИФТОМ -->
<div class="pricing-info">
<span class="price-tier" class:active={files.some(f => (f.selectedPages?.size || 0) < 25)}>10р от 1</span>
<span class="price-tier" class:active={files.some(f => { const s = f.selectedPages?.size || 0; return s >= 25 && s < 1000; })}>9р от 25</span>
<span class="price-tier" class:active={files.some(f => (f.selectedPages?.size || 0) >= 1000)}>4р от 1000</span>
</div>
<div class="price-summary">
<span class="summary-itogo">Итого</span>
<span class="summary-price">{totalPrice()}</span>
</div>
<!-- КНОПКА ОТКРЫВАЕТ СТРАНИЦУ ОПЛАТЫ -->
<button
type="button"
class="action-btn secondary"
onclick={showPaymentPage}
disabled={isPrinting || files.length === 0}
>
{isPrinting ? 'Отправка...' : 'Далее'}
</button>
<input type="file" bind:this={fileInput} accept="image/*,.pdf" multiple onchange={handleFileSelect} style="display:none" />
</footer>
</div>
{/if}
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- ОВЕРЛЕЙ ПРОСМОТРА/ГАЛЕРЕИ -->
<!-- ═══════════════════════════════════════════════════════════ -->
{#if activeFile}
<div class="overlay-backdrop" onclick={closeOverlay} role="dialog" aria-modal="true">
<div class="overlay-window-wrapper" onclick={(e) => e.stopPropagation()}>
{#if viewMode === 'gallery'}
<SelectionPage
file={activeFile}
thumbnails={galleryThumbnails}
loading={galleryLoading}
onTogglePage={togglePageSelection}
onSelectAll={selectAllPages}
onDeselectAll={deselectAllPages}
onClose={closeOverlay}
/>
{:else}
<PreviewPage
file={activeFile}
pagesCache={previewPagesCache}
isRendering={isPreviewRendering}
onRequestPages={handlePreviewRequestPages}
onClose={closeOverlay}
/>
{/if}
</div>
</div>
{/if}
<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; }
/* --- ИЗМЕНЕНИЯ ДЛЯ ВЫРАВНИВАНИЯ ЗАГОЛОВКА И КНОПКИ --- */
.header {
padding: 24px 20px 8px;
display: flex; /* Включаем флекс */
align-items: center;
position: relative;
}
.back-btn {
background: none;
border: none;
color: #6366f1;
font-size: 15px;
font-weight: 600;
cursor: pointer;
padding: 8px 4px;
margin: -8px -8px -8px 0; /* Компенсация отступов */
min-height: 44px;
display: inline-flex;
align-items: center;
order: -1; /* Кнопка всегда слева */
margin-right: auto; /* Прижимает кнопку к левому краю */
}
.header h1 {
margin: 0;
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
position: absolute; /* Абсолютное позиционирование для идеального центра */
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
}
/* ---------------------------------------------------- */
.options { flex: 1; padding: 24px 20px 16px; display: flex; flex-direction: column; gap: 14px; }
.option-title { font-size: 13px; font-weight: 600; color: #8b93a1; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px; display: block; }
.option-title.compact { margin-bottom: 0; font-size: 12px; white-space: nowrap; margin-right: 8px; }
.empty-upload { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; text-align: center; }
.empty-hint { color: #8b93a1; font-size: 15px; margin: 0; }
.file-card { border-radius: 16px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08); overflow: hidden; padding-bottom: 6px; }
.file-row { width: 100%; box-sizing: border-box; display: flex; align-items: center; gap: 12px; padding: 14px; background: none; border: none; color: #f5f7fa; cursor: pointer; user-select: none; text-align: left; }
.file-row:hover { background: rgba(255, 255, 255, 0.03); }
.chevron { color: #6366f1; font-size: 12px; transition: transform 0.15s; }
.file-name { flex: 1; font-size: 15px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-row-actions { display: flex; gap: 6px; }
.icon-btn { width: 34px; height: 34px; border-radius: 10px; border: none; background: rgba(255, 255, 255, 0.08); color: #f5f7fa; font-size: 14px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
.icon-btn.danger { color: #ff6b6b; }
.file-options { padding: 8px 14px 14px; display: flex; flex-direction: column; gap: 18px; }
.option-sub { display: flex; flex-direction: column; gap: 8px; }
.print-params-row { flex-direction: row; align-items: center; justify-content: space-between; gap: 12px; padding-bottom: 12px; border-bottom: 1px solid rgba(255, 255, 255, 0.06); margin-bottom: 4px; }
.pages-control { display: flex; align-items: center; flex: 1; min-width: 0; }
.pages-btn { background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.1); color: #fff; border-radius: 8px; padding: 6px 16px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.15s ease; }
.pages-btn:hover { background: rgba(255, 255, 255, 0.12); border-color: rgba(255, 255, 255, 0.2); }
.copies-control-inline { display: flex; align-items: center; flex-shrink: 0; }
.mini-counter { display: flex; align-items: center; gap: 8px; }
.circle-btn { width: 32px; height: 32px; border-radius: 50%; border: 1px solid rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.05); color: #f5f7fa; font-size: 16px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s ease; padding: 0; line-height: 1; }
.circle-btn.small { width: 28px; height: 28px; font-size: 14px; }
.circle-btn:hover:not(:disabled) { background: rgba(99, 102, 241, 0.2); border-color: #6366f1; }
.circle-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.copies-value { font-size: 14px; font-weight: 600; color: #fff; min-width: 36px; text-align: center; font-variant-numeric: tabular-nums; }
.toggle-group { display: flex; gap: 10px; }
.toggle-btn { flex: 1; padding: 12px 8px; border-radius: 14px; border: 1px solid rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.05); color: #f5f7fa; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.18s ease; }
.toggle-btn.active { background: rgba(99, 102, 241, 0.25); border-color: #6366f1; color: #fff; }
.quality-slider { -webkit-appearance: none; width: 100%; height: 6px; border-radius: 4px; background: rgba(255, 255, 255, 0.12); outline: none; }
.quality-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: #6366f1; cursor: pointer; border: 2px solid #fff; }
.quality-marks-inline { display: flex; justify-content: space-between; font-size: 11px; color: #8b93a1; margin-top: 4px; }
.add-file-btn { align-self: center; background: none; border: 1px dashed rgba(255, 255, 255, 0.18); color: #a9b0c0; font-size: 14px; font-weight: 600; padding: 12px 18px; border-radius: 14px; cursor: pointer; margin-top: 8px; }
.actions { padding: 16px 20px 32px; display: flex; flex-direction: column; gap: 12px; }
.file-count { color: #8b93a1; font-size: 13px; margin: 0; text-align: center; line-height: 1.4; }
.action-btn { width: 100%; padding: 16px 24px; border-radius: 16px; font-size: 16px; font-weight: 700; cursor: pointer; border: none; color: #fff; transition: opacity 0.2s; }
.action-btn:disabled { opacity: 0.6; cursor: wait; }
.action-btn.primary { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); box-shadow: 0 8px 24px -6px rgba(99, 102, 241, 0.5); }
.action-btn.secondary { background: linear-gradient(135deg, #5b8cff 0%, #38c6ff 100%); box-shadow: 0 8px 24px -6px rgba(72, 150, 255, 0.4); }
/* ── Стили расценки ── */
.pricing-info {
display: flex;
justify-content: center;
gap: 12px;
font-size: 10px;
line-height: 1.2;
color: rgba(255, 255, 255, 0.25);
margin-bottom: 4px;
user-select: none;
flex-wrap: wrap;
}
.price-tier {
transition: color 0.2s ease;
}
.price-tier.active {
color: rgba(255, 255, 255, 0.6);
}
.price-summary { display: flex; justify-content: space-between; align-items: baseline; width: 100%; }
.summary-itogo { font-size: 30px; font-weight: 700; color: #8b93a1; }
.summary-price { font-size: 38px; font-weight: 800; color: #fff; }
.overlay-backdrop {
position: fixed; inset: 0; z-index: 1000;
background: rgba(10, 12, 18, 0.92); backdrop-filter: blur(12px);
display: flex; align-items: center; justify-content: center; padding: 0;
}
.overlay-window-wrapper {
width: 100%; height: 100%;
display: flex; flex-direction: column;
overflow: hidden;
}
@media (min-width: 768px) {
.overlay-window-wrapper {
max-width: 800px; max-height: 90vh;
border-radius: 20px; height: auto;
border: 1px solid rgba(255,255,255,0.1);
background: #13151b;
}
}
</style>

View File

@ -0,0 +1,51 @@
// lib/common/services/pdf.service.js
let pdfjsLib = null;
async function getPdfJs() {
if (!pdfjsLib) {
pdfjsLib = await import('https://mozilla.github.io/pdf.js/build/pdf.mjs');
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://mozilla.github.io/pdf.js/build/pdf.worker.mjs';
}
return pdfjsLib;
}
/**
* Загрузка PDF документа
* @param {File} file
*/
export async function loadPdfDocument(file) {
try {
const lib = await getPdfJs();
const arrayBuffer = await file.arrayBuffer();
const loadingTask = lib.getDocument({ data: arrayBuffer });
return await loadingTask.promise;
} catch (err) {
console.error('PDF load error:', err);
return null;
}
}
/**
* Рендер страницы PDF в DataURL изображения
* @param {any} pdfDoc
* @param {number} pageNum
* @param {number} scale
*/
export async function renderPageToImage(pdfDoc, pageNum, scale = 1.0) {
const page = await pdfDoc.getPage(pageNum);
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
const outputScale = window.devicePixelRatio || 1;
canvas.width = Math.floor(viewport.width * outputScale);
canvas.height = Math.floor(viewport.height * outputScale);
canvas.style.width = `${Math.floor(viewport.width)}px`;
canvas.style.height = `${Math.floor(viewport.height)}px`;
const transform = outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : undefined;
await page.render({ canvasContext: context, viewport, transform }).promise;
return canvas.toDataURL('image/jpeg', 0.85);
}

View File

@ -0,0 +1,25 @@
// lib/common/utils/file.util.js
/**
* Определение типа файла
* @param {File} file
* @returns {'image' | 'pdf' | 'other'}
*/
export function detectFileType(file) {
if (!file) return 'other';
if (file.type.startsWith('image/')) return 'image';
if (file.type === 'application/pdf') return 'pdf';
return 'other';
}
/**
* Формирование текстовой метки выбранных страниц
* @param {import('$lib/pages/print/stores/print.store.svelte').FileEntry} entry
*/
export function pagesLabel(entry) {
if (!entry.totalPages || entry.totalPages === 0) return '...';
if (!entry.selectedPages || entry.selectedPages.size === 0) return 'Нет';
if (entry.selectedPages.size === entry.totalPages) return 'Все';
if (entry.selectedPages.size === 1) return `стр. ${[...entry.selectedPages][0]}`;
return `${entry.selectedPages.size} из ${entry.totalPages}`;
}

View File

@ -0,0 +1,37 @@
// lib/common/utils/pricing.util.js
export const QUALITIES = [
{ id: 'low', label: 'Эконом' },
{ id: 'medium', label: 'Стандарт' },
{ id: 'high', label: 'Максимум' }
];
export const COLOR_MODES = [
{ id: 'bw', label: 'Чб' },
{ id: 'color', label: 'Цвет' }
];
export const FORMATS = ['A4', 'A5'];
/**
* Расчет цены за страницу в зависимости от объема
*/
export function getPricePerPage(pagesCount) {
if (pagesCount >= 1000) return 4;
if (pagesCount >= 25) return 9;
return 10;
}
/**
* Плюрализация
*/
export function pluralize(n, forms) {
const mod10 = n % 10;
const 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];
}
export const plFiles = (n) => pluralize(n, ['файл', 'файла', 'файлов']);
export const plCopies = (n) => pluralize(n, ['копия', 'копии', 'копий']);

View File

@ -0,0 +1,41 @@
<!-- lib/pages/print/PreviewOverlay.svelte -->
<script>
import SelectionPage from './file_preview/SelectionPage.svelte';
import PreviewPage from './file_preview/PreviewPage.svelte';
let { store } = $props();
</script>
{#if store.activeFile}
<div class="overlay-backdrop" onclick={store.closeOverlay} role="dialog" aria-modal="true">
<div class="overlay-window-wrapper" onclick={(e) => e.stopPropagation()}>
{#if store.viewMode === 'gallery'}
<SelectionPage
file={store.activeFile}
thumbnails={store.galleryThumbnails}
loading={store.galleryLoading}
onTogglePage={store.togglePageSelection}
onSelectAll={store.selectAllPages}
onDeselectAll={store.deselectAllPages}
onClose={store.closeOverlay}
/>
{:else}
<PreviewPage
file={store.activeFile}
pagesCache={store.previewPagesCache}
isRendering={store.isPreviewRendering}
onRequestPages={store.requestPreviewPages}
onClose={store.closeOverlay}
/>
{/if}
</div>
</div>
{/if}
<style>
.overlay-backdrop { position: fixed; inset: 0; z-index: 1000; background: rgba(10,12,18,0.92); backdrop-filter: blur(12px); display: flex; align-items: center; justify-content: center; padding: 0; }
.overlay-window-wrapper { width: 100%; height: 100%; display: flex; flex-direction: column; overflow: hidden; }
@media (min-width: 768px) {
.overlay-window-wrapper { max-width: 800px; max-height: 90vh; border-radius: 20px; height: auto; border: 1px solid rgba(255,255,255,0.1); background: #13151b; }
}
</style>

View File

@ -0,0 +1,89 @@
<!-- lib/pages/print/PrintMenu.svelte -->
<script>
import { createPrintStore } from './store.svelte.js';
import PrintUI from './PrintUI.svelte';
import PreviewOverlay from './PreviewOverlay.svelte';
import PaymentPage from '$lib/common/ui/PaymentPage.svelte';
import { plFiles, plCopies } from '$lib/common/utils/pricing.util.js';
let { onBack = () => {} } = $props();
const store = createPrintStore();
let fileInput;
function handleFileSelect(event) {
store.addFiles(event.target.files);
event.target.value = '';
}
const hasTierSmall = $derived(store.files.some((f) => (f.selectedPages?.size || 0) < 25));
const hasTierMedium = $derived(store.files.some((f) => {
const s = f.selectedPages?.size || 0;
return s >= 25 && s < 1000;
}));
const hasTierLarge = $derived(store.files.some((f) => (f.selectedPages?.size || 0) >= 1000));
</script>
{#if store.showPayment}
<PaymentPage
totalPrice={store.totalPrice}
filesCount={store.files.length}
onBack={store.hidePayment}
onConfirmPayment={store.submitPrint}
/>
{/if}
{#if !store.activeFile && !store.showPayment}
<div class="page-container">
<header class="header">
<button class="back-btn" onclick={onBack}>←</button>
<h1>Печать</h1>
</header>
<main class="options">
{#if store.files.length === 0}
<PrintUI as="upload-zone" onSelect={() => fileInput.click()} />
{:else}
{#each store.files as file (file.id)}
<PrintUI
as="file-card"
{file}
onToggle={store.toggleExpand}
onRemove={store.removeFile}
onUpdate={store.updateFile}
onPreview={(f) => store.openPreview(f)}
onPageSelect={(f) => (f.fileType === 'pdf' ? store.openGallery(f) : store.openPreview(f))}
pagesLabel={store.pagesLabel}
/>
{/each}
<button class="add-file-btn" onclick={() => fileInput.click()}> Добавить файлы</button>
{/if}
</main>
<PrintUI
as="footer"
totalPrice={store.totalPrice}
filesCount={store.files.length}
extraCopies={store.extraCopies}
isPrinting={store.isPrinting}
onSubmit={store.openPayment}
hasTierSmall={hasTierSmall}
hasTierMedium={hasTierMedium}
hasTierLarge={hasTierLarge}
plFiles={plFiles}
plCopies={plCopies}
/>
<input type="file" bind:this={fileInput} accept="image/*,.pdf" multiple onchange={handleFileSelect} hidden />
</div>
{/if}
<PreviewOverlay {store} />
<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; }
.header { padding: 24px 20px 8px; display: flex; align-items: center; position: relative; }
.back-btn { background: none; border: none; color: #6366f1; font-size: 15px; font-weight: 600; cursor: pointer; padding: 8px 4px; margin-right: auto; min-height: 44px; }
.header h1 { margin: 0; font-size: 28px; font-weight: 700; position: absolute; left: 50%; transform: translateX(-50%); white-space: nowrap; }
.options { flex: 1; padding: 24px 20px 16px; display: flex; flex-direction: column; gap: 14px; }
.add-file-btn { align-self: center; background: none; border: 1px dashed rgba(255,255,255,0.18); color: #a9b0c0; font-size: 14px; font-weight: 600; padding: 12px 18px; border-radius: 14px; cursor: pointer; margin-top: 8px; }
</style>

View File

@ -0,0 +1,184 @@
<!-- lib/pages/print/PrintUI.svelte -->
<script>
import { QUALITIES, COLOR_MODES, FORMATS } from '$lib/common/utils/pricing.util.js';
/** @type {'button' | 'icon-button' | 'counter' | 'toggle-group' | 'range-slider' | 'file-card' | 'upload-zone' | 'footer'} */
let { as, ...props } = $props();
</script>
{#if as === 'button'}
<button
class="action-btn {props.variant ?? 'primary'}"
disabled={props.disabled}
onclick={props.onclick}
type={props.type ?? 'button'}
>
<slot />
</button>
{:else if as === 'icon-button'}
<button
class="icon-btn"
class:danger={props.variant === 'danger'}
onclick={props.onclick}
title={props.title}
type="button"
><slot /></button>
{:else if as === 'counter'}
<div class="mini-counter">
<button class="circle-btn small" onclick={props.onDecrement} disabled={props.value <= (props.min ?? 1)} type="button"></button>
<span class="copies-value">{props.value} шт.</span>
<button class="circle-btn small" onclick={props.onIncrement} type="button"></button>
</div>
{:else if as === 'toggle-group'}
<div class="option-sub">
{#if props.label}<label class="option-title">{props.label}</label>{/if}
<div class="toggle-group horizontal">
{#each props.options as opt}
{@const val = props.valueKey ? opt[props.valueKey] : opt}
{@const lbl = props.labelKey ? opt[props.labelKey] : opt}
<button
type="button"
class="toggle-btn"
class:active={props.selected === val}
onclick={() => props.onSelect?.(val)}
>{lbl}</button>
{/each}
</div>
</div>
{:else if as === 'range-slider'}
<div class="option-sub quality-sub">
<label class="option-title">{props.label} {QUALITIES[props.value]?.label ?? ''}</label>
<input
type="range"
min={0}
max={props.options.length - 1}
step={1}
value={props.value}
oninput={(e) => props.onChange?.(parseInt(e.target.value))}
class="quality-slider"
style="--fill: {Math.round((props.value / (props.options.length - 1)) * 100)}%"
/>
<div class="quality-marks-inline">
{#each props.options as q}<span class="quality-mark">{q.label}</span>{/each}
</div>
</div>
{:else if as === 'file-card'}
{@const file = props.file}
<section class="file-card" class:expanded={file.expanded}>
<div class="file-row" onclick={() => props.onToggle?.(file.id)}>
<span class="chevron">{file.expanded ? '▼' : '►'}</span>
<span class="file-name" title={file.file.name}>{file.file.name}</span>
<span class="file-row-actions" onclick={(e) => e.stopPropagation()}>
{#if file.previewUrl}
<svelte:self as="icon-button" onclick={() => props.onPreview?.(file)} title="Предпросмотр">🔍</svelte:self>
{/if}
<svelte:self as="icon-button" variant="danger" onclick={() => props.onRemove?.(file.id)} title="Удалить"></svelte:self>
</span>
</div>
{#if file.expanded}
<div class="file-options">
<div class="print-params-row">
<div class="pages-control">
<label class="option-title compact">Страницы:</label>
<button class="pages-btn" onclick={() => props.onPageSelect?.(file)}>
{props.pagesLabel?.(file) ?? '...'}
</button>
</div>
<div class="copies-control-inline">
<label class="option-title compact">Кол-во:</label>
<svelte:self
as="counter"
value={file.copies}
min={1}
onIncrement={() => props.onUpdate?.(file.id, { copies: file.copies + 1 })}
onDecrement={() => props.onUpdate?.(file.id, { copies: Math.max(1, file.copies - 1) })}
/>
</div>
</div>
<svelte:self as="toggle-group" label="Формат" options={FORMATS} selected={file.format} onSelect={(v) => props.onUpdate?.(file.id, { format: v })} />
<svelte:self as="toggle-group" label="Цветность" options={COLOR_MODES} selected={file.colorMode} valueKey="id" labelKey="label" onSelect={(v) => props.onUpdate?.(file.id, { colorMode: v })} />
<svelte:self as="range-slider" label="Качество" options={QUALITIES} value={file.qualityIndex} onChange={(v) => props.onUpdate?.(file.id, { qualityIndex: v })} />
</div>
{/if}
</section>
{:else if as === 'upload-zone'}
<div class="empty-upload">
<p class="empty-hint">Загрузите файлы для печати</p>
<svelte:self as="button" variant="primary" onclick={props.onSelect}>Загрузить файл(ы)</svelte:self>
</div>
{:else if as === 'footer'}
<footer class="actions">
{#if props.filesCount > 0}
<p class="file-count">
{props.filesCount} {props.plFiles(props.filesCount)}
{#if props.extraCopies > 0}, {props.extraCopies} {props.plCopies(props.extraCopies)}{/if}
</p>
{/if}
<div class="pricing-info">
<span class="price-tier" class:active={props.hasTierSmall}>10р от 1</span>
<span class="price-tier" class:active={props.hasTierMedium}>9р от 25</span>
<span class="price-tier" class:active={props.hasTierLarge}>4р от 1000</span>
</div>
<div class="price-summary">
<span class="summary-itogo">Итого</span>
<span class="summary-price">{props.totalPrice}</span>
</div>
<svelte:self as="button" variant="secondary" disabled={props.isPrinting || props.filesCount === 0} onclick={props.onSubmit}>
{props.isPrinting ? 'Отправка...' : 'Далее'}
</svelte:self>
</footer>
{/if}
<style>
.action-btn { width: 100%; padding: 16px 24px; border-radius: 16px; font-size: 16px; font-weight: 700; cursor: pointer; border: none; color: #fff; transition: opacity 0.2s; }
.action-btn:disabled { opacity: 0.6; cursor: wait; }
.action-btn.primary { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); box-shadow: 0 8px 24px -6px rgba(99,102,241,0.5); }
.action-btn.secondary { background: linear-gradient(135deg, #5b8cff 0%, #38c6ff 100%); box-shadow: 0 8px 24px -6px rgba(72,150,255,0.4); }
.icon-btn { width: 34px; height: 34px; border-radius: 10px; border: none; background: rgba(255,255,255,0.08); color: #f5f7fa; font-size: 14px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
.icon-btn.danger { color: #ff6b6b; }
.mini-counter { display: flex; align-items: center; gap: 8px; }
.circle-btn { width: 32px; height: 32px; border-radius: 50%; border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05); color: #f5f7fa; font-size: 16px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s ease; padding: 0; line-height: 1; }
.circle-btn.small { width: 28px; height: 28px; font-size: 14px; }
.circle-btn:hover:not(:disabled) { background: rgba(99,102,241,0.2); border-color: #6366f1; }
.circle-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.copies-value { font-size: 14px; font-weight: 600; color: #fff; min-width: 36px; text-align: center; font-variant-numeric: tabular-nums; }
.option-sub { display: flex; flex-direction: column; gap: 8px; }
.option-title { font-size: 13px; font-weight: 600; color: #8b93a1; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px; display: block; }
.option-title.compact { margin-bottom: 0; font-size: 12px; white-space: nowrap; margin-right: 8px; }
.toggle-group { display: flex; gap: 10px; }
.toggle-btn { flex: 1; padding: 12px 8px; border-radius: 14px; border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.05); color: #f5f7fa; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.18s ease; }
.toggle-btn.active { background: rgba(99,102,241,0.25); border-color: #6366f1; color: #fff; }
.quality-sub { gap: 8px; }
.quality-slider { -webkit-appearance: none; width: 100%; height: 6px; border-radius: 4px; background: rgba(255,255,255,0.12); outline: none; }
.quality-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: #6366f1; cursor: pointer; border: 2px solid #fff; }
.quality-marks-inline { display: flex; justify-content: space-between; font-size: 11px; color: #8b93a1; margin-top: 4px; }
.file-card { border-radius: 16px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); overflow: hidden; padding-bottom: 6px; }
.file-row { width: 100%; box-sizing: border-box; display: flex; align-items: center; gap: 12px; padding: 14px; background: none; border: none; color: #f5f7fa; cursor: pointer; user-select: none; text-align: left; }
.file-row:hover { background: rgba(255,255,255,0.03); }
.chevron { color: #6366f1; font-size: 12px; transition: transform 0.15s; }
.file-name { flex: 1; font-size: 15px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-row-actions { display: flex; gap: 6px; }
.file-options { padding: 8px 14px 14px; display: flex; flex-direction: column; gap: 18px; }
.print-params-row { flex-direction: row; align-items: center; justify-content: space-between; gap: 12px; padding-bottom: 12px; border-bottom: 1px solid rgba(255,255,255,0.06); margin-bottom: 4px; }
.pages-control { display: flex; align-items: center; flex: 1; min-width: 0; }
.pages-btn { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); color: #fff; border-radius: 8px; padding: 6px 16px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.15s ease; }
.pages-btn:hover { background: rgba(255,255,255,0.12); border-color: rgba(255,255,255,0.2); }
.copies-control-inline { display: flex; align-items: center; flex-shrink: 0; }
.empty-upload { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; text-align: center; }
.empty-hint { color: #8b93a1; font-size: 15px; margin: 0; }
.actions { padding: 16px 20px 32px; display: flex; flex-direction: column; gap: 12px; }
.file-count { color: #8b93a1; font-size: 13px; margin: 0; text-align: center; line-height: 1.4; }
.pricing-info { display: flex; justify-content: center; gap: 12px; font-size: 10px; line-height: 1.2; color: rgba(255,255,255,0.25); margin-bottom: 4px; user-select: none; flex-wrap: wrap; }
.price-tier { transition: color 0.2s ease; }
.price-tier.active { color: rgba(255,255,255,0.6); }
.price-summary { display: flex; justify-content: space-between; align-items: baseline; width: 100%; }
.summary-itogo { font-size: 30px; font-weight: 700; color: #8b93a1; }
.summary-price { font-size: 38px; font-weight: 800; color: #fff; }
</style>

View File

@ -0,0 +1,243 @@
// lib/pages/print/store.svelte.js
import { onDestroy, tick } from 'svelte';
import { getPricePerPage } from '$lib/common/utils/pricing.util.js';
import { detectFileType, pagesLabel as _pagesLabel } from '$lib/common/utils/file.util.js';
import { loadPdfDocument, renderPageToImage } from '$lib/common/services/pdf.service.js';
/**
* @typedef {{ id: number, file: File, previewUrl: string | null, fileType: 'image' | 'pdf' | 'other', expanded: boolean, format: 'A4' | 'A5', colorMode: 'bw' | 'color', qualityIndex: number, copies: number, totalPages: number, selectedPages: Set<number>, pdfDoc: any }} FileEntry
*/
export function createPrintStore() {
let files = $state(/** @type {FileEntry[]} */ ([]));
let activeFile = $state(/** @type {FileEntry | null} */ (null));
let viewMode = $state(/** @type {'gallery' | 'preview'} */ ('gallery'));
let showPayment = $state(false);
let isPrinting = $state(false);
let galleryThumbnails = $state([]);
let galleryLoading = $state(false);
let previewPagesCache = $state({});
let isPreviewRendering = $state(false);
let uid = 0;
const totalPrice = $derived(
files.reduce((sum, f) => {
const pages = f.selectedPages?.size || 0;
return sum + pages * f.copies * getPricePerPage(pages);
}, 0)
);
const extraCopies = $derived(files.reduce((sum, f) => sum + (f.copies - 1), 0));
function updateFile(id, changes) {
files = files.map((f) => (f.id === id ? { ...f, ...changes } : f));
if (activeFile?.id === id) {
activeFile = files.find((f) => f.id === id);
}
}
async function ensurePdfLoaded(entry) {
if (entry.pdfDoc) return entry.pdfDoc;
const pdf = await loadPdfDocument(entry.file);
if (pdf) {
const numPages = pdf.numPages;
updateFile(entry.id, {
pdfDoc: pdf,
totalPages: numPages,
selectedPages: new Set(Array.from({ length: numPages }, (_, i) => i + 1))
});
} else {
updateFile(entry.id, { totalPages: 0 });
}
return pdf;
}
async function addFiles(fileList) {
if (!fileList || fileList.length === 0) return;
const newFiles = [];
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i];
const fType = detectFileType(file);
newFiles.push({
id: ++uid,
file,
previewUrl: URL.createObjectURL(file),
fileType: fType,
expanded: false,
format: 'A4',
colorMode: 'bw',
qualityIndex: 1,
copies: 1,
totalPages: fType === 'image' ? 1 : 0,
selectedPages: fType === 'image' ? new Set([1]) : new Set(),
pdfDoc: null
});
}
files = [...files, ...newFiles];
for (const entry of newFiles) {
if (entry.fileType === 'pdf') ensurePdfLoaded(entry);
}
}
function removeFile(id) {
const f = files.find((x) => x.id === id);
if (f?.previewUrl) URL.revokeObjectURL(f.previewUrl);
files = files.filter((x) => x.id !== id);
}
function toggleExpand(id) {
updateFile(id, { expanded: !files.find((f) => f.id === id)?.expanded });
}
async function openGallery(entry) {
activeFile = entry;
viewMode = 'gallery';
galleryThumbnails = [];
galleryLoading = true;
previewPagesCache = {};
await tick();
const pdf = await ensurePdfLoaded(entry);
if (!pdf || entry.totalPages === 0) {
galleryLoading = false;
return;
}
const thumbs = [];
for (let i = 1; i <= entry.totalPages; i++) {
try {
thumbs.push(await renderPageToImage(pdf, i, 0.4));
} catch {
thumbs.push('');
}
galleryThumbnails = [...thumbs];
}
galleryLoading = false;
}
async function openPreview(entry) {
activeFile = entry;
viewMode = 'preview';
previewPagesCache = {};
galleryThumbnails = [];
await tick();
if (entry.fileType === 'pdf') {
const pdf = await ensurePdfLoaded(entry);
if (pdf && entry.totalPages > 0) {
preloadPreviewPages(entry, 1, 5);
}
}
}
async function preloadPreviewPages(entry, startPage, count) {
const currentFile = files.find((f) => f.id === entry.id);
const pdf = currentFile?.pdfDoc || entry.pdfDoc;
if (!pdf || isPreviewRendering) return;
isPreviewRendering = true;
const endPage = Math.min(startPage + count - 1, entry.totalPages);
const newCache = {};
for (let i = startPage; i <= endPage; i++) {
if (previewPagesCache[i]) continue;
try {
newCache[i] = await renderPageToImage(pdf, i, 1.5);
} catch (e) {
console.error(`Failed to render preview page ${i}`, e);
}
}
if (Object.keys(newCache).length > 0) {
previewPagesCache = { ...previewPagesCache, ...newCache };
}
isPreviewRendering = false;
}
function togglePageSelection(pageNum) {
if (!activeFile?.selectedPages) return;
const newSet = new Set(activeFile.selectedPages);
if (newSet.has(pageNum)) {
if (newSet.size > 1) newSet.delete(pageNum);
} else {
newSet.add(pageNum);
}
updateFile(activeFile.id, { selectedPages: newSet });
}
function selectAllPages() {
if (!activeFile?.totalPages) return;
updateFile(activeFile.id, {
selectedPages: new Set(Array.from({ length: activeFile.totalPages }, (_, i) => i + 1))
});
}
function deselectAllPages() {
if (!activeFile) return;
updateFile(activeFile.id, { selectedPages: new Set([1]) });
}
async function submitPrint() {
if (files.length === 0 || isPrinting) return;
isPrinting = true;
const formData = new FormData();
for (const f of files) {
formData.append('files', f.file, f.file.name);
formData.append(
'settings',
JSON.stringify({
filename: f.file.name,
pages: f.selectedPages ? Array.from(f.selectedPages).sort((a, b) => a - b).join(',') : 'all',
copies: f.copies,
colorMode: f.colorMode,
format: f.format
})
);
}
try {
const res = await fetch('/api/print', { method: 'POST', body: formData });
const text = await res.text();
alert(res.ok ? `✅ Успешно!\n${text}` : `❌ Ошибка (${res.status}):\n${text}`);
} catch {
alert('⚠️ Не удалось связаться с сервером печати.');
} finally {
isPrinting = false;
showPayment = false;
}
}
onDestroy(() => {
files.forEach((f) => f.previewUrl && URL.revokeObjectURL(f.previewUrl));
});
return {
get files() { return files; },
get activeFile() { return activeFile; },
get viewMode() { return viewMode; },
get showPayment() { return showPayment; },
get isPrinting() { return isPrinting; },
get totalPrice() { return totalPrice; },
get extraCopies() { return extraCopies; },
get galleryThumbnails() { return galleryThumbnails; },
get galleryLoading() { return galleryLoading; },
get previewPagesCache() { return previewPagesCache; },
get isPreviewRendering() { return isPreviewRendering; },
addFiles,
removeFile,
toggleExpand,
updateFile,
openGallery,
openPreview,
closeOverlay: () => { activeFile = null; galleryThumbnails = []; previewPagesCache = {}; },
openPayment: () => { if (files.length > 0 && !isPrinting) showPayment = true; },
hidePayment: () => { showPayment = false; },
submitPrint,
togglePageSelection,
selectAllPages,
deselectAllPages,
requestPreviewPages: (start, count) => { if (activeFile) preloadPreviewPages(activeFile, start, count); },
pagesLabel: _pagesLabel
};
}