Рефакторинг

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,43 +1,58 @@
# Svelte + Vite
# Структура проекта
This template should help get you started developing with Svelte in Vite.
.
├── app.css
├── App.svelte
├── assets
│ ├── hero.png
│ ├── svelte.svg
│ └── vite.svg
├── lib
│ ├── common
│ │ ├── PaymentPage.svelte
│ │ ├── services
│ │ ├── stores
│ │ ├── ui
│ │ └── utils
│ └── pages
│ ├── extra
│ ├── main
│ │ └── MainMenu.svelte
│ ├── photo-doc
│ ├── print
│ │ ├── preview
│ │ └── PrintMenu.svelte
│ └── scan
└── main.js
## Recommended IDE Setup
---
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
### 📂 Корень проекта
* **`App.svelte`**: Главный роутер. Управляет переключением между экранами (Главное меню → Печать/Фото/Скан).
* **`main.js`**: Точка входа, монтирует приложение в DOM.
* **`app.css`**: Глобальные стили и сброс CSS.
* **`assets/`**: Статические файлы (иконки, фоновые изображения, логотипы).
## Need an official Svelte framework?
---
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
### 📂 `lib/modules/` (Бизнес-модули)
Каждая папка — это независимая услуга киоска. Содержит свои UI-компоненты и локальные сторы состояния.
* **`main/`**: Главное меню с 4 кнопками навигации.
* **`print/`**: Логика печати (загрузка PDF/фото, выбор страниц, настройки формата/цвета).
* **`photo-doc/`**: Фото на документы (работа с камерой, кроп, выбор размера).
* **`scan/`**: Сканирование документов (скан через камеру или загрузка файла).
* **`extra/`**: Дополнительные услуги (копирование, отправка на email и т.д.).
## Technical considerations
---
**Why use this over SvelteKit?**
### 📂 `lib/common/` (Общая инфраструктура)
Ресурсы, которые переиспользуются несколькими модулями.
- It brings its own routing solution which might not be preferable for some users.
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
#### **UI-слой**
* **`ui/`**: Переиспользуемые визуальные компоненты (кнопки, инпуты, карточки, лоадеры).
* **`PaymentPage.svelte`**: Единый экран оплаты P2P-переводом. Используется всеми модулями после формирования заказа.
This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
**Why include `.vscode/extensions.json`?**
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
**Why enable `checkJs` in the JS template?**
It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration.
**Why is HMR not preserving my local component state?**
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state).
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
```js
// store.js
// An extremely simple external store
import { writable } from 'svelte/store'
export default writable(0)
```
#### **Логика и данные**
* **`stores/`**: Глобальные хранилища состояния (навигация, настройки приложения, очередь уведомлений).
* **`services/`**: Интеграции с внешним миром (API принтера, работа с PDF.js, доступ к камере, отправка запросов на сервер).
* **`utils/`**: Чистые вспомогательные функции (расчет стоимости, форматирование текста, детекция типов файлов, плюрализация).

View File

@ -3,31 +3,18 @@
"moduleResolution": "bundler",
"target": "ESNext",
"module": "ESNext",
/**
* svelte-preprocess cannot figure out whether you have
* a value or a type, so tell TypeScript to enforce using
* `import type` instead of `import` for Types.
*/
"verbatimModuleSyntax": true,
"isolatedModules": true,
"resolveJsonModule": true,
/**
* To have warnings / errors of the Svelte compiler at the
* correct position, enable source maps by default.
*/
"sourceMap": true,
"esModuleInterop": true,
"types": ["vite/client"],
"skipLibCheck": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable this if you'd like to use dynamic types.
*/
"checkJs": true
"checkJs": true,
"baseUrl": ".",
"paths": {
"$lib/*": ["src/lib/*"]
}
},
/**
* Use global.d.ts instead of compilerOptions.types
* to avoid limiting type declarations.
*/
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
}

6
package-lock.json generated
View File

@ -3115,9 +3115,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.422",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz",
"integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==",
"version": "1.5.423",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.423.tgz",
"integrity": "sha512-rRZfTSY8ptHYMQxa+uIycJMFKmY1T0GIApNMXJYGehguTZa56TEEl19pKPCoBqk5Gpf7QizZn/jt7xur+DYxag==",
"dev": true,
"license": "ISC"
},

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
};
}

View File

@ -9,3 +9,5 @@ nvm install --lts
# Обновляем npm до последней версии
npm install -g npm@latest
npm run dev -- --host

View File

@ -1,6 +1,7 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import { VitePWA } from 'vite-plugin-pwa'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [
@ -24,6 +25,11 @@ export default defineConfig({
}
})
],
resolve: {
alias: {
$lib: fileURLToPath(new URL('./src/lib', import.meta.url))
}
},
server: {
proxy: {
'/api': {