@ -1,127 +1,76 @@
<!--
lib/common/ui/NetworkGate.svelte
Экран-блокиратор: не пускает дальше, пока устройство не окажется в нужной сети.
mode:
- 'need-internet' — нужен интернет (домен → 95.165.135.233). Используется в скане .
- 'need-local' — нужна локальная сеть принтера (домен → 192.168.20.1). Перед печатью.
Как только сеть появилась — показывает «Подключено» и вызывает onReady().
lib/common/ui/NetworkGate.svelte
Экран-блокиратор: не пускает дальше, пока устройство не окажется в нужной сети.
mode:
- 'need-internet' — нужен интернет (/api/is_server отвечает true). Используется в скане.
- 'need-local' — нужна локальная сеть принтера (local.unitprint.ru/api/is_local отвечает true). Перед печатью.
Сам опрашивает подключение; как только оно появилось — показывает «Подключено» и вызывает onReady() .
-->
< script >
import { onMount } from 'svelte' ;
import { detectNetwork , LOCAL_WIFI_NAME } from '$lib/common/services/network.service.js' ;
import { onMount } from 'svelte' ;
import { isLocalNetwork , isServerReachable , LOCAL_WIFI_NAME } from '$lib/common/services/network.service.js' ;
let {
/** 'need-internet' | 'need-local' */
mode ,
onReady = () => {},
onBack = () => {},
pollMs = 3000 ,
} = $props ();
let {
/** 'need-internet' | 'need-local' */
mode ,
onReady = () => {},
onBack = () => {},
pollMs = 3000 ,
} = $props ();
/** 'checking' — первая проверка, 'waiting' — ждём сеть, 'success' — сеть появилась */
let stat us = $state ( 'checking' ) ;
let copySuccess = $state ( false ) ;
let success = $state ( false );
let b usy = false ;
let finished = false ;
let timer = null ;
let busy = false ;
let finished = false ;
let timer = null ;
const isLocalMode = $derived ( mode === 'need-local' ) ;
const isLocalMode = $derived ( mode === 'need-local' );
async function check () {
if ( busy || finished ) return ;
busy = tru e;
try {
const net = await detectNetwork () ;
const ok = isLocalMode ? net === 'local' : net === 'internet' ;
if ( ok ) {
finished = true ;
status = 'success' ;
stopPoll ();
setTimeout (() => onReady ? .(), 900 ) ;
} else if ( status === 'checking' ) {
status = 'waiting' ;
startPoll ();
async function check () {
if ( busy || finished ) return ;
busy = true ;
try {
const ok = isLocalMode ? await isLocalNetwork () : await isServerReachabl e () ;
if ( ok ) {
finished = true ;
success = true ;
stopPoll ();
setTimeout (() => onReady ? .(), 900 ) ;
}
} finally {
busy = false ;
}
} finally {
busy = false ;
}
}
function startPoll () {
stopPoll ();
timer = setInterval ( check , pollMs );
}
function stopPoll () {
if ( timer ) clearInterval ( timer );
timer = null ;
}
onMount (() => {
check ();
const onVis = () => {
if ( document . visibilityState === 'visible' ) check ();
};
document . addEventListener ( 'visibilitychange' , onVis );
return () => {
document . removeEventListener ( 'visibilitychange' , onVis );
function startPoll () {
stopPoll ();
} ;
});
timer = setInterval ( check , pollMs ) ;
}
function stopPoll () {
if ( timer ) clearInterval ( timer );
timer = null ;
}
/* ── копирование имени сети ── */
async function copyWifiName () {
let success = false ;
if ( navigator . clipboard && window . isSecureContext ) {
try {
await navigator . clipboard . writeText ( LOCAL_WIFI_NAME ) ;
success = true ;
} catch ( err ) {
console . warn ( 'Clipboard API failed: ', err );
}
}
if ( ! success ) {
try {
const textArea = document . createElement ( 'textarea' );
textArea . value = LOCAL_WIFI_NAME ;
textArea . style . position = 'fixed' ;
textArea . style . left = '-9999px' ;
textArea . style . opacity = '0' ;
document . body . appendChild ( textArea );
textArea . focus ();
textArea . select ();
const deprecated = /** @type {any} */ ( document ). execCommand ;
if ( deprecated && deprecated . call ( document , 'copy' )) success = true ;
document . body . removeChild ( textArea );
} catch ( err ) {
console . error ( 'Fallback copy failed:' , err );
}
}
if ( success ) {
copySuccess = true ;
setTimeout (() => ( copySuccess = false ), 2000 );
}
}
onMount (() => {
check ();
startPoll () ;
const onVis = () => {
if ( document . visibilityState === 'visible' ) check ();
} ;
document . addEventListener ( 'visibilitychange' , onVis ) ;
return () => {
document . removeEventListener ( 'visibilitychange ', onVis );
stopPoll ();
};
});
</ script >
< div class = "gate-container" >
< header class = "gate-header" >
< button type = "button" class = "back-btn" onclick = {() => onBack ? .()} > ←</button >
< h1 > { isLocalMode ? 'Подключение к принтеру' : 'Нет интернета' } </ h1 >
</ header >
< main class = "gate-content" >
{ #if status === 'checking' }
< div class = "state-block" >
< div class = "spinner big" ></ div >
< p class = "state-title" > Проверка подключения…</ p >
< p class = "state-sub" > Определяем, в какой вы сети</ p >
</ div >
{ :else if status === 'success' }
{ #if success }
< div class = "state-block" >
< div class = "success-icon" >
< svg width = "42" height = "42" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "3" stroke-linecap = "round" stroke-linejoin = "round" aria-hidden = "true" >< polyline points = "20 6 9 17 4 12" ></ polyline ></ svg >
@ -149,41 +98,29 @@ async function copyWifiName() {
< div class = "gate-info" >
{ #if isLocalMode }
< h2 > Нет связи с принтером </ h2 >
< h2 > Подключитесь к принтеру </ h2 >
< p > Печать доступна только в локальной сети принтера. Подключите устройство к сети:</ p >
{ : else }
< h2 > Необходимо подключение к интернету</ h2 >
< p > Убедитесь, что вы отключены от локальной сети Wi-Fi принтера. </ p >
< p > Сканирование доступно только онлайн </ p >
{ /if }
</ div >
< div class = "steps" >
{ #if isLocalMode }
{ #if isLocalMode }
< div class = "steps-card" >
< div class = "step" >
< span class = "step-num" > 1</ span >
< div class = "step-body" >
< span class = "step-body" >
< span class = "step-text" > Подключитесь к Wi-Fi</ span >
< div class = "wifi-chip-row " >
< span class = "wifi-chip " >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" aria-hidden = "true" >
< path d = "M5 12.55a11 11 0 0 1 14.08 0" ></ path >
< path d = "M8.53 16.11a6 6 0 0 1 6.95 0" ></ path >
< line x1 = "12" y1 = "20" x2 = "12.01" y2 = "20" ></ line >
</ svg >
{ LOCAL_WIFI_NAME }
</ span >
< button
type = "button"
class = "copy-btn"
class:success = { copySuccess }
onclick= { copyWifiName }
title = { copySuccess ? 'Скопировано' : 'Скопировать название сети' }
aria-label= { copySuccess ? 'Скопировано' : 'Скопировать название сети' }
>
{ copySuccess ? '✓' : '📋' }
</ button >
</ div >
</ div >
< span class = "wifi-name " >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" aria-hidden = "true " >
< path d = "M5 12.55a11 11 0 0 1 14.08 0" ></ path >
< path d = "M8.53 16.11a6 6 0 0 1 6.95 0" ></ path >
< line x1 = "12" y1 = "20" x2 = "12.01" y2 = "2 0" ></ line >
</ svg >
{ LOCAL_WIFI_NAME }
</ span >
</ span >
</ div >
< div class = "step" >
< span class = "step-num" > 2</ span >
@ -193,252 +130,166 @@ async function copyWifiName() {
< span class = "step-num" > 3</ span >
< span class = "step-text" > Отключите VPN</ span >
</ div >
{ : else }
< div class = "step" >
< span class = "step-num" > 1</ span >
< span class = "step-text" > Отключитесь от сети < strong > { LOCAL_WIFI_NAME } </ strong ></ span >
</ div >
< div class = "step" >
< span class = "step-num" > 2</ span >
< span class = "step-text" > Включите мобильный интернет или подключитесь к другому Wi-Fi</ span >
</ div >
{ /if }
</ div >
</ div >
{ /if }
< div class = "waiting-row" >
< div class = "spinner small" ></ div >
< span > { isLocalMode ? 'Ожидание подключения к принтеру…' : 'Ожидание интернета…' } </ span >
< span > Ожидание подключения… </ span >
</ div >
< p class = "auto-hint" > Как только подключение появится — продолжим автоматически</ p >
< button type = "button" class = "retry-btn" onclick = { check } > Проверить снова </ button >
{ /if }
</ main >
</ div >
< style >
. gate-container {
width : 100 % ;
min-height : 100 dvh ;
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 ;
}
. gate-header {
padding : 24 px 20 px 8 px ;
display : flex ;
align-items : center ;
position : relative ;
}
. back-btn {
background : none ;
border : none ;
color : #6366f1 ;
font-size : 15 px ;
font-weight : 600 ;
cursor : pointer ;
padding : 8 px 4px ;
margin-right : auto ;
min-height : 44 px ;
touch-action : manipulation ;
-webkit- tap-highlight-color : transpar ent;
}
. gate-header h1 {
margin : 0 ;
font-size : 28 px ;
font-weight : 700 ;
position : absolute ;
left : 50 % ;
transform : translateX ( -50 % ) ;
white-space : nowrap ;
}
. gate-content {
flex : 1 ;
width : 100 % ;
max-width : 520 px ;
margin : 0 auto ;
box-sizing : border-box ;
padding : 28 px 20 px 40 px ;
display : flex ;
flex-direction : column ;
align-items : center ;
gap : 22 px ;
}
/* ── состояния checking / success ── */
. state-block {
flex : 1 ;
display : fle x;
flex-direction : column ;
align-items : center ;
justify-content : center ;
gap : 14 px ;
text-align : center ;
padding : 4 0px 0 ;
}
. state-title { margin : 0 ; font-size : 19 px ; font-weight : 700 ; color : #f5f7fa ; }
. state-title . ok { color : #4ade80 ; }
. state-sub { margin : 0 ; font-size : 14 px ; color : #8b93a1 ; }
. success-icon {
width : 88 px ;
height : 88 px ;
border-radius : 50 % ;
display : grid ;
place-items : center ;
background : linear-gradient ( 135 deg , #22c55e 0 % , #16a34a 100 % ) ;
box-shadow : 0 16 px 38 px -12 px rgba ( 34 , 197 , 94 , 0.55 ) ;
color : #fff ;
animation : pop-in 0.4 s cubic-bezier ( 0.34 , 1.56 , 0.64 , 1 ) ;
}
@ keyframes pop-in {
from { transform : scale ( 0.5 ); opacity : 0 ; }
to { transform : scale ( 1 ); opacity : 1 ; }
}
/* ── состояние waiting ── */
. icon-wrap {
width : 92 px ;
height : 92 px ;
border-radius : 28 px ;
display : grid ;
place-items : center ;
color : #ff f ;
margin-top : 6 px ;
}
. icon-wrap . local {
background : linear-gradient ( 135 deg , #5b8cff 0 % , #38c6ff 100 % ) ;
box-shadow : 0 16 px 38 px -12 px rgba ( 72 , 150 , 255 , 0.55 ) ;
}
. icon-wrap . internet {
background : linea r-g radient ( 135 deg , #34d399 0 % , #2dd4bf 100 % ) ;
box-shadow : 0 16 px 38 px -12 px rgba ( 5 2, 211 , 153 , 0.5 5 );
}
. gate-info { text-align : center ; display : flex ; flex-direction : column ; gap : 8 px ; }
. gate-info h2 { margin : 0 ; font-size : 21 px ; font-weight : 700 ; letter-spacing : -0.01 em ; }
. gate-info p { margin : 0 ; font-size : 14 p x; line-height : 1.5 ; color : #9ca3af ; }
/* ── шаги ── */
. steps { width : 100 % ; display : flex ; flex-direction : column ; gap : 10 px ; }
. step {
display : flex ;
align-items : center ;
gap : 14 px ;
padding : 14 px 16 px ;
border-radius : 16 px ;
background : rgba ( 255 , 255 , 255 , 0.05 );
borde r: 1 px solid rgba ( 255 , 255 , 255 , 0.08 ) ;
text-align : left ;
}
. step-num {
width : 2 6px ;
height : 26 px ;
border-radius : 50 % ;
flex-shrink : 0 ;
display : flex ;
align-items : center ;
justify-content : center ;
background : rgba ( 99 , 102 , 241 , 0.18 ) ;
border : 1 px solid rgba ( 99 , 102 , 241 , 0.4 ) ;
color : #a5b4fc ;
font-size : 13 px ;
font-weight : 700 ;
}
. step-text { font-size : 15 px ; line-height : 1.45 ; color : #e5e9f0 ; }
. step-text strong { color : #fff ; }
. step-body { flex : 1 ; min-width : 0 ; display : flex ; flex-direction : column ; gap : 10 px ; }
/* ── плашка Wi-Fi + копирование ── */
. wifi-chip-row { display : flex ; align-items : center ; ga p: 10 px ; }
. wifi-chip {
flex : 1 ;
min-width : 0 ;
display : flex ;
align-items : center ;
justify-content : center ;
gap : 8 px ;
padding : 12 px 14 px ;
border-radius : 12 px ;
background : rgba ( 91 , 140 , 255 , 0.14 ) ;
border : 1 px solid rgba ( 91 , 140 , 255 , 0.45 );
color : #fff ;
font-size : 16 px ;
font-weight : 700 ;
letter-spacing : 0.01 em ;
overflow : hidden ;
text-overflow : ellipsis ;
white-space : nowrap ;
}
. wifi-chip svg { flex-shrink : 0 ; color : #7db4ff ; }
. copy-btn {
width : 46 px ;
height : 46 px ;
flex-shrink : 0 ;
display : flex ;
align-items : center ;
justify-content : center ;
background : rgba ( 99 , 102 , 241 , 0.2 );
border : 1 px solid rgba ( 99 , 102 , 241 , 0.4 );
border-radius : 12 px ;
color : #fff ;
font-size : 20 px ;
cursor : pointer ;
transition : all 0.2 s ease ;
padding : 0 ;
touch-action : manipulation ;
-webkit- tap-highlight-color : transparent ;
}
. copy-btn : hover { background : rgba ( 99 , 102 , 241 , 0.35 ); border-color : #6366f1 ; }
. copy-btn : active { transform : scale ( 0.95 ); }
. copy-btn . success { background : rgba ( 34 , 197 , 94 , 0.25 ); border-color : rgba ( 34 , 197 , 94 , 0.5 ); }
/* ── ожидание ── */
. waiting-row {
display : flex ;
align-items : center ;
gap : 10 px ;
margin-top : 4 px ;
color : #8b93a1 ;
font-size : 14 px ;
font-weight : 600 ;
}
. auto-hint {
margin : -12 px 0 0 ;
text-align : center ;
color : rgba ( 139 , 147 , 161 , 0.6 );
font-size : 12 px ;
line-height : 1.4 ;
}
. retry-btn {
width : 100 % ;
padding : 16 px 24 px ;
border-radius : 16 px ;
border : 1 px solid rgba ( 255 , 255 , 255 , 0.14 );
background : rgba ( 255 , 255 , 255 , 0.06 );
color : #f5f7fa ;
font-size : 16 px ;
font-weight : 700 ;
cursor : pointer ;
transition : background 0.2 s ease , border-color 0.2 s ease ;
touch-action : manipulation ;
-webkit- tap-highlight-color : transparent ;
}
. retry-btn : hover { background : rgba ( 255 , 255 , 255 , 0.1 ); border-color : rgba ( 255 , 255 , 255 , 0.24 ); }
/* ── спиннер ── */
. spinner {
border-radius : 50 % ;
border : 3 px solid rgba ( 255 , 255 , 255 , 0.12 );
border-top-color : #6366f1 ;
animation : gate-spin 0.8 s linear infinite ;
}
. spinner . big { width : 46 px ; height : 46 px ; }
. spinner . small { width : 18 px ; height : 18 px ; border-width : 2 px ; }
@ keyframes gate-spin { to { transform : rotate ( 360 deg ); } }
@ media ( max-width : 380px ) {
. gate-header h1 { font-size : 22 px ; }
. gate-info h2 { font-size : 19 px ; }
}
. gate-container {
width : 100 % ;
min-height : 100 dvh ;
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 ;
}
. gate-header {
padding : 24 px 20 px 8 px ;
display : flex ;
align-items : center ;
}
. back-btn {
background : none ;
border : none ;
color : #6366f1 ;
font-size : 15 px ;
font-weight : 600 ;
cursor : pointer ;
padding : 8 px 4 px ;
min-height : 4 4px ;
touch-action : manipulation ;
-webkit- tap-highlight-color : transparent ;
}
. gate-cont ent {
flex : 1 ;
width : 100 % ;
max-width : 520 px ;
margin : 0 auto ;
box-sizing : border-box ;
padding : 28 px 20 px 40 px ;
display : flex ;
flex-direction : column ;
align-items : center ;
gap : 22 px ;
}
/* ── состояние success ── */
. state-block {
flex : 1 ;
display : flex ;
flex-direction : column ;
align-items : center ;
justify-content : center ;
gap : 14 px ;
text- align: center ;
padding : 40 px 0 ;
}
. state-title { margin : 0 ; font-size : 19 px ; font-weight : 700 ; color : #f5f7fa ; }
. state-title . ok { color : #4ade80 ; }
. state-sub { margin : 0 ; font-size : 14 px ; color : #8b93a1 ; }
. success-icon {
width : 88 p x;
height : 88 px ;
border-radius : 50 % ;
display : grid ;
place-items : center ;
background : linear-gradient ( 135 deg , #22c55e 0 % , #16a34a 100 % ) ;
box-shadow : 0 16 px 38 px -12 px rgba ( 34 , 197 , 94 , 0.55 ) ;
color : #fff ;
animation : pop-in 0.4 s cubic-bezier ( 0.34 , 1.56 , 0.64 , 1 );
}
@ keyframes pop-in {
from { transform : scale ( 0.5 ); opacity : 0 ; }
to { transform : scale ( 1 ); opacity : 1 ; }
}
/* ── состояние ожидания ── */
. icon-wrap {
width : 92 px ;
height : 92 px ;
border-radius : 28 px ;
display : grid ;
place-items : center ;
color : #fff ;
margin-top : 6 px ;
}
. icon-wrap . local {
background : linear-gradient ( 135 deg , #5b8cff 0 % , #38c6ff 100 % );
box-shadow : 0 16 px 38 px -12 px rgba ( 72 , 150 , 255 , 0.55 );
}
. icon-wrap . internet {
background : linear-gradient ( 135 deg , #34d399 0 % , #2dd4bf 100 % ) ;
box-shadow : 0 16 px 38 px -12 px rgba ( 52 , 211 , 153 , 0.55 ) ;
}
. gate-info { text-align : center ; display : flex ; flex-direction : column ; gap : 8 px ; }
. gate-info h2 { margin : 0 ; font-size : 21 px ; font-weight : 700 ; letter-spacing : -0.01 em ; }
. gate-info p { margin : 0 ; font-size : 14 px ; line-height : 1.5 ; color : #9ca3a f ; }
/* ── единый блок шагов ── */
. steps-card {
width : 100 % ;
display : flex ;
flex-direction : column ;
gap : 12 px ;
padding : 14 px 16 px ;
borde r-radius : 16 px ;
background : rgba ( 255 , 255 , 255 , 0.0 5 );
border : 1 px solid rgba ( 255 , 255 , 255 , 0.08 );
text-align : left ;
}
. step { display : fle x; a lign-items : center ; gap : 14 px ; }
. step-num {
width : 26 px ;
height : 26 px ;
border-radius : 50 % ;
flex-shrink : 0 ;
display : flex ;
align-items : center ;
justify-content : center ;
background : rgba ( 99 , 102 , 241 , 0.18 ) ;
border : 1 px solid rgba ( 99 , 102 , 241 , 0.4 );
colo r: #a5b4fc ;
font-size : 13 px ;
font-weight : 700 ;
}
. step-body { flex : 1 ; min-width : 0 ; display : flex ; flex-direction : column ; gap : 6px ; }
. step-text { font-size : 15 px ; line-height : 1.45 ; color : #e5e9f0 ; }
/* имя Wi-Fi без рамки, просто иконка + текст */
. wifi-name {
display : inline- flex;
align-items : center ;
gap : 8 px ;
color : #7db4ff ;
font-size : 15 px ;
font-weight : 700 ;
letter-spacing : 0.01 em ;
}
. wifi-name svg { flex-shrink : 0 ; }
/* ── ожидание ── */
. waiting-row {
display : flex ;
align-items : center ;
gap : 10 px ;
margin-to p : 4 px ;
color : #8b93a1 ;
font-size : 14 px ;
font-weight : 60 0;
}
/* ── спиннер ── */
. spinner {
border-radius : 50 % ;
border : 3 px solid rgba ( 255 , 255 , 255 , 0.12 ) ;
border-top-color : #6366f1 ;
animation : gate-spin 0.8 s linear infinite ;
}
. spinner . small { width : 18 px ; height : 18 px ; border-width : 2 px ; }
@ keyframes gate-spin { to { transform : rotate ( 360 deg ) ; } }
@ media ( max-width : 380px ) {
. gate-info h2 { font-size : 19 px ; }
}
</ style >