Рефакторинг
This commit is contained in:
169
src/lib/pages/main/MainMenu.svelte
Normal file
169
src/lib/pages/main/MainMenu.svelte
Normal file
@ -0,0 +1,169 @@
|
||||
<script>
|
||||
/** @type {Array<{ id: 'print' | 'scan' | 'photo' | 'access', label: string, grad: string, glow: string, icon: string }>} */
|
||||
const actions = [
|
||||
{
|
||||
id: 'print',
|
||||
label: 'Печать',
|
||||
grad: 'linear-gradient(135deg, #5b8cff 0%, #38c6ff 100%)',
|
||||
glow: 'rgba(72, 150, 255, 0.45)',
|
||||
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"></polyline><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path><rect x="6" y="14" width="12" height="8"></rect></svg>'
|
||||
},
|
||||
{
|
||||
id: 'scan',
|
||||
label: 'Скан',
|
||||
grad: 'linear-gradient(135deg, #34d399 0%, #2dd4bf 100%)',
|
||||
glow: 'rgba(52, 211, 153, 0.45)',
|
||||
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7V5a2 2 0 0 1 2-2h2"></path><path d="M17 3h2a2 2 0 0 1 2 2v2"></path><path d="M21 17v2a2 2 0 0 1-2 2h-2"></path><path d="M7 21H5a2 2 0 0 1-2-2v-2"></path><line x1="7" y1="12" x2="17" y2="12"></line></svg>'
|
||||
},
|
||||
{
|
||||
id: 'photo',
|
||||
label: 'Фото на документ',
|
||||
grad: 'linear-gradient(135deg, #ffb457 0%, #ff8a5c 100%)',
|
||||
glow: 'rgba(255, 150, 90, 0.45)',
|
||||
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"></rect><circle cx="8" cy="11" r="3"></circle><path d="M14 10h4"></path><path d="M14 14h4"></path><path d="M4 19c0-2 2-3 4-3s4 1 4 3"></path></svg>'
|
||||
},
|
||||
{
|
||||
id: 'access',
|
||||
label: 'Дополнительно',
|
||||
grad: 'linear-gradient(135deg, #a78bfa 0%, #f472b6 100%)',
|
||||
glow: 'rgba(167, 139, 250, 0.45)',
|
||||
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="4" r="2"></circle><path d="M12 6v6"></path><path d="M12 12l-4 8"></path><path d="M12 12l4 8"></path><path d="M6 10h12"></path></svg>'
|
||||
}
|
||||
];
|
||||
|
||||
/** @param {'print' | 'scan' | 'photo' | 'access'} id */
|
||||
export let onAction = (id) => {
|
||||
console.log('Выбрано действие:', id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="menu-container">
|
||||
<div class="content">
|
||||
<header class="header">
|
||||
<h1>Сервисы</h1>
|
||||
<p>Выберите действие</p>
|
||||
</header>
|
||||
|
||||
<main class="grid">
|
||||
{#each actions as a}
|
||||
<button
|
||||
class="card"
|
||||
style="--grad: {a.grad}; --glow: {a.glow}"
|
||||
onclick={() => onAction(a.id)}
|
||||
>
|
||||
<span class="icon" aria-hidden="true">{@html a.icon}</span>
|
||||
<span class="label">{a.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="home-bar"></div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Стили остаются здесь, но убираем глобальные сбросы body/html */
|
||||
|
||||
.menu-container {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
padding: 28px 20px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: radial-gradient(120% 60% at 50% -10%, rgba(99, 102, 241, 0.18) 0%, transparent 60%);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin: 4px 4px 26px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.header p {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: #8b93a1;
|
||||
}
|
||||
|
||||
/* Сетка 2×2 */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* Карточка-кнопка в стиле мобильного UI */
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 26px 12px 22px;
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
color: #f5f7fa;
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.card:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
/* Эффект нажатия, как в мобильных приложениях */
|
||||
.card:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
/* Иконка с градиентом и свечением */
|
||||
.icon {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--grad);
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 22px -8px var(--glow);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* «Домашняя» полоска внизу, как на iPhone */
|
||||
.footer {
|
||||
margin-top: auto;
|
||||
padding: 32px 0 8px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.home-bar {
|
||||
width: 120px;
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
</style>
|
||||
41
src/lib/pages/print/PreviewOverlay.svelte
Normal file
41
src/lib/pages/print/PreviewOverlay.svelte
Normal 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>
|
||||
89
src/lib/pages/print/PrintMenu.svelte
Normal file
89
src/lib/pages/print/PrintMenu.svelte
Normal 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>
|
||||
184
src/lib/pages/print/PrintUI.svelte
Normal file
184
src/lib/pages/print/PrintUI.svelte
Normal 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>
|
||||
167
src/lib/pages/print/file_preview/PreviewPage.svelte
Normal file
167
src/lib/pages/print/file_preview/PreviewPage.svelte
Normal file
@ -0,0 +1,167 @@
|
||||
<script>
|
||||
import { tick } from 'svelte';
|
||||
|
||||
/** @typedef {{ id: number, fileType: string, previewUrl: string | null, totalPages: number }} FileEntry */
|
||||
|
||||
let {
|
||||
file,
|
||||
pagesCache = {},
|
||||
isRendering = false,
|
||||
onRequestPages = (start, count, dir) => {},
|
||||
onClose = () => {}
|
||||
} = $props();
|
||||
|
||||
let scrollContainer;
|
||||
let preloadScheduled = false;
|
||||
|
||||
// Плюрализация
|
||||
function pluralize(n, forms) {
|
||||
const mod10 = n % 10, mod100 = n % 100;
|
||||
if (mod10 === 1 && mod100 !== 11) return forms[0];
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 > 14)) return forms[1];
|
||||
return forms[2];
|
||||
}
|
||||
const plPages = (n) => pluralize(n, ['страница', 'страницы', 'страниц']);
|
||||
|
||||
function handleScroll() {
|
||||
if (!file || !scrollContainer) return;
|
||||
|
||||
// Debounce via rAF
|
||||
if (preloadScheduled) return;
|
||||
preloadScheduled = true;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
preloadScheduled = false;
|
||||
|
||||
const container = scrollContainer;
|
||||
const pageElements = container.querySelectorAll('[data-page]');
|
||||
const buffer = window.innerHeight * 1.5;
|
||||
|
||||
let closestVisiblePage = 0;
|
||||
let minDistance = Infinity;
|
||||
let needsPreload = false;
|
||||
let firstUnloadedInView = null;
|
||||
const viewportCenter = window.innerHeight / 2;
|
||||
|
||||
pageElements.forEach((el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const pageNum = parseInt(el.dataset.page);
|
||||
|
||||
const inView = rect.bottom > -buffer && rect.top < window.innerHeight + buffer;
|
||||
|
||||
if (inView) {
|
||||
const elCenter = rect.top + rect.height / 2;
|
||||
const dist = Math.abs(elCenter - viewportCenter);
|
||||
if (dist < minDistance) {
|
||||
minDistance = dist;
|
||||
closestVisiblePage = pageNum;
|
||||
}
|
||||
|
||||
if (!pagesCache[pageNum] && firstUnloadedInView === null) {
|
||||
firstUnloadedInView = pageNum;
|
||||
needsPreload = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (needsPreload && firstUnloadedInView) {
|
||||
onRequestPages(firstUnloadedInView, 4, 'forward');
|
||||
} else if (closestVisiblePage > 0 && !isRendering) {
|
||||
const nextPage = closestVisiblePage + 2;
|
||||
if (nextPage <= file.totalPages && !pagesCache[nextPage]) {
|
||||
onRequestPages(closestVisiblePage, 3, 'around');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="preview-container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="header-info">
|
||||
<h2 class="title">Просмотр</h2>
|
||||
</div>
|
||||
<button type="button" class="close-btn" onclick={onClose} aria-label="Закрыть">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Scroll Area -->
|
||||
<div
|
||||
class="scroll-area"
|
||||
bind:this={scrollContainer}
|
||||
onscroll={handleScroll}
|
||||
onscrollcapture={handleScroll}
|
||||
>
|
||||
{#if file?.fileType === 'image'}
|
||||
<div class="single-item">
|
||||
<img src={file.previewUrl} alt={file.file.name} class="full-img" />
|
||||
</div>
|
||||
{:else if file?.fileType === 'pdf'}
|
||||
{#each Array(file.totalPages) as _, i}
|
||||
{@const pageNum = i + 1}
|
||||
<div class="page-item" data-page={pageNum}>
|
||||
{#if pagesCache[pageNum]}
|
||||
<img
|
||||
src={pagesCache[pageNum]}
|
||||
class="page-img"
|
||||
data-page={pageNum}
|
||||
alt="Страница {pageNum}"
|
||||
/>
|
||||
{:else}
|
||||
<div class="placeholder" data-page={pageNum}>
|
||||
<div class="placeholder-content">
|
||||
{#if pageNum <= 5 || isRendering}
|
||||
<div class="spinner"></div>
|
||||
{:else}
|
||||
<span class="placeholder-text">Стр. {pageNum}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="end-marker">
|
||||
Конец документа ({file.totalPages} {plPages(file.totalPages)})
|
||||
</div>
|
||||
{:else}
|
||||
<div class="fallback">Предпросмотр недоступен</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<button type="button" class="action-btn secondary" onclick={onClose}>Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.preview-container { width: 100%; height: 100%; display: flex; flex-direction: column; background: #13151b; overflow: hidden; animation: slide-up 0.3s ease-out; }
|
||||
@keyframes slide-up { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||
|
||||
.header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; flex-shrink: 0; background: rgba(19, 21, 27, 0.95); border-bottom: 1px solid rgba(255,255,255,0.06); z-index: 10; }
|
||||
.title { margin: 0; font-size: 18px; font-weight: 700; color: #f5f7fa; }
|
||||
.close-btn { width: 36px; height: 36px; border-radius: 50%; border: none; background: rgba(255, 255, 255, 0.08); color: #fff; font-size: 18px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
||||
|
||||
.scroll-area { flex: 1; overflow-y: auto; overflow-x: hidden; background: #0d0f14; padding: 20px 0; display: flex; flex-direction: column; align-items: center; gap: 16px; -webkit-overflow-scrolling: touch; }
|
||||
|
||||
.single-item { width: 100%; display: flex; justify-content: center; padding: 0 16px; box-sizing: border-box; }
|
||||
.full-img { max-width: 100%; max-height: 80vh; border-radius: 4px; box-shadow: 0 10px 40px rgba(0,0,0,0.5); }
|
||||
|
||||
.page-item { width: 100%; max-width: 600px; padding: 0 16px; box-sizing: border-box; flex-shrink: 0; }
|
||||
.page-img { width: 100%; height: auto; display: block; border-radius: 4px; box-shadow: 0 8px 30px rgba(0,0,0,0.6); background: #fff; }
|
||||
|
||||
.placeholder { width: 100%; aspect-ratio: 210 / 297; background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 4px; display: flex; align-items: center; justify-content: center; }
|
||||
.placeholder-content { display: flex; flex-direction: column; align-items: center; gap: 8px; }
|
||||
.placeholder-text { color: #444; font-size: 14px; font-weight: 600; }
|
||||
|
||||
.end-marker { padding: 40px 0; color: #555; font-size: 13px; text-align: center; }
|
||||
.fallback { padding: 40px; color: #8b93a1; text-align: center; }
|
||||
|
||||
.footer { padding: 16px 20px; flex-shrink: 0; background: rgba(19, 21, 27, 0.95); border-top: 1px solid rgba(255,255,255,0.06); padding-bottom: max(16px, env(safe-area-inset-bottom)); }
|
||||
.action-btn { width: 100%; padding: 16px 24px; border-radius: 16px; font-size: 16px; font-weight: 700; cursor: pointer; border: none; color: #fff; }
|
||||
.action-btn.secondary { background: linear-gradient(135deg, #5b8cff 0%, #38c6ff 100%); box-shadow: 0 8px 24px -6px rgba(72, 150, 255, 0.4); }
|
||||
|
||||
.spinner { width: 32px; height: 32px; border: 3px solid rgba(255, 255, 255, 0.1); border-top-color: #6366f1; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
137
src/lib/pages/print/file_preview/SelectionPage.svelte
Normal file
137
src/lib/pages/print/file_preview/SelectionPage.svelte
Normal file
@ -0,0 +1,137 @@
|
||||
<script>
|
||||
/** @typedef {{ id: number, selectedPages: Set<number>, totalPages: number }} FileEntry */
|
||||
|
||||
let {
|
||||
file,
|
||||
thumbnails = [],
|
||||
loading = false,
|
||||
onTogglePage = () => {},
|
||||
onSelectAll = () => {},
|
||||
onDeselectAll = () => {},
|
||||
onClose = () => {}
|
||||
} = $props();
|
||||
|
||||
// Плюрализация локальная для компонента
|
||||
function pluralize(n, forms) {
|
||||
const mod10 = n % 10, mod100 = n % 100;
|
||||
if (mod10 === 1 && mod100 !== 11) return forms[0];
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 > 14)) return forms[1];
|
||||
return forms[2];
|
||||
}
|
||||
const plPages = (n) => pluralize(n, ['страница', 'страницы', 'страниц']);
|
||||
</script>
|
||||
|
||||
<div class="selection-container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="header-info">
|
||||
<h2 class="title">Выбор страниц</h2>
|
||||
{#if file?.selectedPages}
|
||||
<span class="subtitle">
|
||||
{file.selectedPages.size} из {file.totalPages} {plPages(file.selectedPages.size)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button type="button" class="close-btn" onclick={onClose} aria-label="Закрыть">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Actions Bar -->
|
||||
<div class="actions-bar">
|
||||
<button
|
||||
type="button"
|
||||
class="quick-btn"
|
||||
class:active-quick={file?.selectedPages?.size === file?.totalPages}
|
||||
onclick={onSelectAll}
|
||||
>
|
||||
Выбрать все
|
||||
</button>
|
||||
<button type="button" class="quick-btn" onclick={onDeselectAll}>Снять все</button>
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div class="grid">
|
||||
{#if loading && thumbnails.length === 0}
|
||||
<div class="loading-state"><div class="spinner"></div><span>Загрузка…</span></div>
|
||||
{/if}
|
||||
|
||||
{#each thumbnails as thumb, i}
|
||||
{@const pageNum = i + 1}
|
||||
{@const isSelected = file?.selectedPages?.has(pageNum)}
|
||||
<button
|
||||
type="button"
|
||||
class="thumb-card"
|
||||
class:selected={isSelected}
|
||||
onclick={() => onTogglePage(pageNum)}
|
||||
>
|
||||
<div class="thumb-image-wrap">
|
||||
{#if thumb}
|
||||
<img src={thumb} alt="Стр. {pageNum}" class="thumb-img" loading="lazy" />
|
||||
{:else}
|
||||
<div class="thumb-placeholder">?</div>
|
||||
{/if}
|
||||
|
||||
{#if isSelected}
|
||||
<div class="check-badge">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="thumb-label">{pageNum}</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if loading && thumbnails.length > 0}
|
||||
<div class="thumb-card loading-placeholder">
|
||||
<div class="thumb-image-wrap">
|
||||
<div class="thumb-placeholder"><div class="spinner small"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<button type="button" class="action-btn primary" onclick={onClose}>Готово</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.selection-container { width: 100%; height: 100%; display: flex; flex-direction: column; background: #13151b; overflow: hidden; animation: slide-up 0.3s ease-out; }
|
||||
@keyframes slide-up { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||
|
||||
/* Header & Footer shared styles can be passed or duplicated. Keeping self-contained here. */
|
||||
.header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; flex-shrink: 0; background: rgba(19, 21, 27, 0.95); border-bottom: 1px solid rgba(255,255,255,0.06); z-index: 10; }
|
||||
.title { margin: 0; font-size: 18px; font-weight: 700; color: #f5f7fa; }
|
||||
.subtitle { font-size: 13px; color: #8b93a1; font-weight: 500; margin-top: 2px; display: block;}
|
||||
.close-btn { width: 36px; height: 36px; border-radius: 50%; border: none; background: rgba(255, 255, 255, 0.08); color: #fff; font-size: 18px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
||||
|
||||
.actions-bar { display: flex; gap: 8px; padding: 12px 20px; flex-shrink: 0; }
|
||||
.quick-btn { flex: 1; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.05); color: #c0c5d0; font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.15s ease; }
|
||||
.quick-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
|
||||
.quick-btn.active-quick { background: rgba(99, 102, 241, 0.2); border-color: #6366f1; color: #a5b4fc; }
|
||||
|
||||
.grid { flex: 1; overflow-y: auto; padding: 12px 16px 20px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; -webkit-overflow-scrolling: touch; }
|
||||
.thumb-card { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 0; border: none; background: none; cursor: pointer; }
|
||||
.thumb-image-wrap { position: relative; width: 100%; aspect-ratio: 210 / 297; border-radius: 8px; overflow: hidden; background: rgba(255, 255, 255, 0.06); border: 2px solid transparent; transition: all 0.2s ease; }
|
||||
.thumb-card.selected .thumb-image-wrap { border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3); }
|
||||
.thumb-card:not(.selected) .thumb-image-wrap { opacity: 0.5; }
|
||||
.thumb-img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.thumb-placeholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: #555; font-size: 20px; }
|
||||
.check-badge { position: absolute; top: 4px; right: 4px; width: 20px; height: 20px; border-radius: 50%; background: #6366f1; display: flex; align-items: center; justify-content: center; }
|
||||
.check-badge svg { width: 12px; height: 12px; color: #fff; }
|
||||
.thumb-label { font-size: 11px; font-weight: 600; color: #8b93a1; }
|
||||
.thumb-card.selected .thumb-label { color: #a5b4fc; }
|
||||
.loading-state { grid-column: 1 / -1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 40px 0; color: #8b93a1; }
|
||||
|
||||
.footer { padding: 16px 20px; flex-shrink: 0; background: rgba(19, 21, 27, 0.95); border-top: 1px solid rgba(255,255,255,0.06); padding-bottom: max(16px, env(safe-area-inset-bottom)); }
|
||||
.action-btn { width: 100%; padding: 16px 24px; border-radius: 16px; font-size: 16px; font-weight: 700; cursor: pointer; border: none; color: #fff; }
|
||||
.action-btn.primary { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); box-shadow: 0 8px 24px -6px rgba(99, 102, 241, 0.5); }
|
||||
|
||||
.spinner { width: 32px; height: 32px; border: 3px solid rgba(255, 255, 255, 0.1); border-top-color: #6366f1; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
.spinner.small { width: 20px; height: 20px; border-width: 2px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.grid { grid-template-columns: repeat(4, 1fr); }
|
||||
}
|
||||
</style>
|
||||
243
src/lib/pages/print/store.svelte.js
Normal file
243
src/lib/pages/print/store.svelte.js
Normal 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user