@@ -54,6 +54,7 @@
. review__rating label { min-width : 0 ; padding : .45 rem .25 rem ; border : 1 px solid #c5d0d8 ; border-radius : .5 rem ; color : var ( - - muted ) ; text-align : center ; font-size : .8 rem ; cursor : pointer ; }
. review__rating label : has ( input : checked ) { border-color : var ( - - brand ) ; background : var ( - - brand ) ; color : #fff ; }
. review__rating label : hover { border-color : #7d9aaa ; }
. review__rating label : has ( input : focus-visible ) { outline : 3 px solid var ( - - focus ) ; outline-offset : 2 px ; }
. review__rating input { position : absolute ; opacity : 0 ; inline-size : 1 px ; block-size : 1 px ; }
. review__comment { min-height : 4 rem ; resize : vertical ; background : var ( - - surface ) ; }
. review__document { position : sticky ; inset-block-start : 1 rem ; min-height : 24 rem ; }
@@ -69,9 +70,9 @@
. review__dialog-close { float : inline - end ; }
. review__empty { padding : 3 rem 1 rem ; color : var ( - - muted ) ; text-align : center ; }
. review__empty strong { display : block ; margin-block-end : .35 rem ; color : var ( - - ink ) ; }
. review__tour-targe t { position : relative ; z-index : 3 ; box-shadow : 0 0 0 5 px var ( - - accent ) , 0 0 0 9 px rgb ( 231 184 75 / .25 ) !important ; }
. review__tour-spotligh t { position : fixed ; z-index : 1 ; pointer-events : none ; border : 4 px solid var ( - - accent ) ; border-radius : .75 rem ; box-shadow : 0 0 0 100 vmax rgb ( 10 30 40 / .58 ) , 0 0 0 8 px rgb ( 231 184 75 / .25 ) ; }
. review__tour { width : min ( 34 rem , calc ( 100 % - 2 rem ) ) ; padding : 0 ; color : var ( - - ink ) ; }
. review__tour :: backdrop { background : rgb ( 10 30 40 / .58 ) ; }
. review__tour :: backdrop { background : transparent ; }
. review__tour-content { padding : clamp ( 1.25 rem , 4 vw , 2 rem ) ; }
. review__tour-kicker { margin : 0 0 .5 rem ; color : var ( - - brand ) ; font-size : .75 rem ; font-weight : 800 ; letter-spacing : .1 em ; text-transform : uppercase ; }
. review__tour h2 { margin : 0 0 .65 rem ; font-size : clamp ( 1.35 rem , 3 vw , 1.75 rem ) ; line-height : 1.1 ; letter-spacing : -.02 em ; }
@@ -83,6 +84,7 @@
. review__search { grid-template-columns : 1 fr 1 fr ; }
. review__search . review__field : first-child { grid-column : 1 / -1 ; }
. review__workspace { grid-template-columns : 1 fr ; }
. review__main { padding-block-end : 14 rem ; }
. review__document { display : none ; }
. review__search button { grid-column : 1 / -1 ; }
. review__actions { padding-inline : 1 rem ; }
@@ -115,7 +117,7 @@
< / main >
< div class = "review__actions" > < div class = "review__actions-inner" > < label > Ваше имя или псевдоним< input id = "reviewer" maxlength = "120" autocomplete = "name" placeholder = "Например, Айжан К ." > < / label > < label > Комментарий к выдаче < input id = "overall-comment" maxlength = "4000" placeholder = "Что важно учесть? (необязательно)" > < / label > < button class = "review__button--primary" id = "save" type = "button" > Сохранить разметку< / button > < / div > < / div >
< dialog id = "document-dialog" > < button class = "review__dialog-close" id = "dialog-close" type = "button" > Закрыть< / button > < h2 id = "dialog-heading" > Документ< / h2 > < div class = "review__document-body" id = "dialog-body" > < / div > < / dialog >
< dialog class = "review__tour" id = "tutorial-dialog" aria-labelledby = "tutorial-heading" aria-describedby = "tutorial-text" > < div class = "review__tour-content" > < p class = "review__tour-kicker" > Короткое знакомство< / p > < h2 id = "tutorial-heading" > < / h2 > < p id = "tutorial-text" > < / p > < div class = "review__tour-footer" > < span class = "review__tour-progress" id = "tutorial-progress" > < / span > < div class = "review__tour-actions" > < button id = "tutorial-skip" type = "button" > Пропустить< / button > < button class = "review__button--primary" id = "tutorial-next" type = "button" > Далее< / button > < / div > < / div > < / div > < / dialog >
< div class = "review__tour-spotlight" id = "tutorial-spotlight" hidden aria-hidden = "true" > < / div > < dialog class = "review__tour" id = "tutorial-dialog" aria-labelledby = "tutorial-heading" aria-describedby = "tutorial-text" > < div class = "review__tour-content" > < p class = "review__tour-kicker" > Короткое знакомство< / p > < h2 id = "tutorial-heading" > < / h2 > < p id = "tutorial-text" > < / p > < div class = "review__tour-footer" > < span class = "review__tour-progress" id = "tutorial-progress" > < / span > < div class = "review__tour-actions" > < button id = "tutorial-skip" type = "button" > Пропустить< / button > < button class = "review__button--primary" id = "tutorial-next" type = "button" > Далее< / button > < / div > < / div > < / div > < / dialog >
< div class = "review__status review__status--error" id = "error" role = "alert" aria-live = "assertive" > < / div >
< footer class = "review__header" > Акылдаш · Backend v0.9.2 · Внутренняя лаборатория релевантности< / footer >
< script >
@@ -133,6 +135,8 @@
const setError = ( text ) => { $ ( 'error' ) . textContent = text ; } ;
const escapeText = ( value ) => value == null ? '' : String ( value ) ;
const readDraft = ( ) => { try { return JSON . parse ( localStorage . getItem ( DRAFT _KEY ) || 'null' ) ; } catch ( _ ) { return null ; } } ;
const hasSeenTour = ( ) => { try { return localStorage . getItem ( TOUR _KEY ) === '1' ; } catch ( _ ) { return false ; } } ;
const markTourSeen = ( ) => { try { localStorage . setItem ( TOUR _KEY , '1' ) ; } catch ( _ ) { } } ;
const saveDraft = ( ) => { if ( ! state . token ) return ; const results = [ ... $ ( 'results' ) . children ] . map ( ( item , index ) => ( { rank : index + 1 , code : item . dataset . code , rating : item . querySelector ( 'input:checked' ) ? . value ? ? null , comment : item . querySelector ( 'textarea' ) . value } ) ) ; try { localStorage . setItem ( DRAFT _KEY , JSON . stringify ( { query : state . query , language : $ ( 'language' ) . value , pageSize : $ ( 'page-size' ) . value , reviewer : $ ( 'reviewer' ) . value , overallComment : $ ( 'overall-comment' ) . value , results } ) ) ; } catch ( _ ) { } } ;
const applyDraft = ( ) => { const draft = readDraft ( ) ; if ( ! draft || draft . query !== state . query || draft . language !== $ ( 'language' ) . value || draft . pageSize !== $ ( 'page-size' ) . value ) return ; $ ( 'reviewer' ) . value = draft . reviewer || '' ; $ ( 'overall-comment' ) . value = draft . overallComment || '' ; ( draft . results || [ ] ) . forEach ( ( saved ) => { const item = [ ... $ ( 'results' ) . children ] . find ( ( candidate ) => candidate . dataset . rank === String ( saved . rank ) && candidate . dataset . code === saved . code ) ; if ( ! item ) return ; if ( saved . rating != null ) { const input = item . querySelector ( ` input[value=" ${ CSS . escape ( String ( saved . rating ) ) } "] ` ) ; if ( input ) input . checked = true ; } item . querySelector ( 'textarea' ) . value = saved . comment || '' ; } ) ; } ;
function renderResults ( ) {
@@ -156,16 +160,16 @@
}
$ ( 'dialog-close' ) . addEventListener ( 'click' , ( ) => { $ ( 'document-dialog' ) . close ( ) ; if ( state . selected ) state . selected . focus ( ) ; } ) ;
let tutorialStep = 0 ; let tutorialReturnFocus = null ;
function clearTourTarget ( ) { document . querySelector ( '.review__tour-targe t') ? . classList . remove ( 'review__tour-target' ) ; }
function renderTutorial ( ) { const step = TOUR _STEPS [ tutorialStep ] ; clearTourTarget ( ) ; $ ( step . target ) . classList . add ( 'review__tour-target' ) ; $ ( 'tutorial-heading' ) . textContent = step . title ; $ ( 'tutorial-text' ) . textContent = step . text ; $ ( 'tutorial-progress' ) . textContent = step . label ; $ ( 'tutorial-next' ) . textContent = tutorialStep === TOUR _STEPS . length - 1 ? 'Понятно' : 'Далее' ; }
function closeTutorial ( ) { clearTourTarget ( ) ; $ ( 'tutorial-dialog' ) . close ( ) ; localStorage . setItem ( TOUR _KEY , '1' ) ; tutorialReturnFocus ? . focus ( ) ; }
function clearTourTarget ( ) { $ ( 'tutorial-spotligh t') . hidden = true ; }
function renderTutorial ( ) { const step = TOUR _STEPS [ tutorialStep ] ; const target = $ ( step . target ) ; target . scrollIntoView ( { block : 'nearest' , inline : 'nearest' } ) ; const bounds = target . getBoundingClientRect ( ) ; $ ( 'tutorial-spotlight' ) . style . cssText = ` inset-block-start: ${ Math . max ( 8 , bounds . top - 4 ) } px; inset-inline-start: ${ Math . max ( 8 , bounds . left - 4 ) } px; inline-size: ${ bounds . width + 8 } px; block-size: ${ bounds . height + 8 } px; ` ; $ ( 'tutorial-spotlight' ) . hidden = false ; $ ( 'tutorial-heading' ) . textContent = step . title ; $ ( 'tutorial-text' ) . textContent = step . text ; $ ( 'tutorial-progress' ) . textContent = step . label ; $ ( 'tutorial-next' ) . textContent = tutorialStep === TOUR _STEPS . length - 1 ? 'Понятно' : 'Далее' ; }
function closeTutorial ( ) { clearTourTarget ( ) ; $ ( 'tutorial-dialog' ) . close ( ) ; markTourSeen ( ) ; tutorialReturnFocus ? . focus ( ) ; }
function openTutorial ( ) { tutorialReturnFocus = document . activeElement ; tutorialStep = 0 ; renderTutorial ( ) ; $ ( 'tutorial-dialog' ) . showModal ( ) ; $ ( 'tutorial-next' ) . focus ( ) ; }
$ ( 'tutorial-open' ) . addEventListener ( 'click' , openTutorial ) ; $ ( 'tutorial-next' ) . addEventListener ( 'click' , ( ) => { if ( tutorialStep === TOUR _STEPS . length - 1 ) closeTutorial ( ) ; else { tutorialStep += 1 ; renderTutorial ( ) ; } } ) ; $ ( 'tutorial-skip' ) . addEventListener ( 'click' , closeTutorial ) ; $ ( 'tutorial-dialog' ) . addEventListener ( 'cancel' , ( event ) => { event . preventDefault ( ) ; closeTutorial ( ) ; } ) ;
$ ( 'search-form' ) . addEventListener ( 'submit' , async ( event ) => { event . preventDefault ( ) ; const query = $ ( 'query' ) . value . trim ( ) ; if ( ! query ) return ; state . query = query ; setStatus ( 'Поиск выполняется…' ) ; $ ( 'save' ) . disabled = true ; try { const params = new URLSearchParams ( { q : query , language : $ ( 'language' ) . value , page _size : $ ( 'page-size' ) . value } ) ; const response = await fetch ( ` /search? ${ params } ` ) ; if ( ! response . ok ) throw new Error ( ) ; const payload = await response . json ( ) ; state . results = payload . results ; state . token = payload . review _token ; renderResults ( ) ; setStatus ( state . results . length ? ` Найдено результатов: ${ state . results . length } . ` : ` По запросу « ${ query } » ничего не найдено. Измените запрос. ` ) ; } catch ( error ) { state . results = [ ] ; state . token = '' ; renderResults ( ) ; setError ( 'Н е удалось выполнить поиск. Повторите поиск.' ) ; } finally { $ ( 'save' ) . disabled = false ; } } ) ;
document . addEventListener ( 'input' , saveDraft ) ; document . addEventListener ( 'change' , saveDraft ) ;
$ ( 'save' ) . addEventListener ( 'click' , async ( ) => { if ( ! state . token ) { setError ( 'Сначала выполните поиск.' ) ; return ; } const reviewer = $ ( 'reviewer' ) . value . trim ( ) ; if ( ! reviewer ) { $ ( 'reviewer' ) . focus ( ) ; setError ( 'Укажите проверяющего.' ) ; return ; } const results = [ ... $ ( 'results' ) . children ] . map ( ( item , index ) => ( { rank : index + 1 , code : item . dataset . code , rating : item . querySelector ( 'input:checked' ) ? Number ( item . querySelector ( 'input:checked' ) . value ) : null , comment : item . querySelector ( 'textarea' ) . value } ) ) ; $ ( 'save' ) . disabled = true ; setStatus ( 'Сохранение выполняется…' ) ; try { const response = await fetch ( '/search-reviews' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' } , body : JSON . stringify ( { review _token : state . token , reviewer , overall _comment : $ ( 'overall-comment' ) . value , results } ) } ) ; if ( ! response . ok ) throw new Error ( ) ; const payload = await response . json ( ) ; localStorage . removeItem ( DRAFT _KEY ) ; setStatus ( ` Оценка сохранена · № ${ payload . id } . ` ) ; } catch ( error ) { setError ( 'Н е удалось сохранить. Проверьте подключение и повторите.' ) ; } finally { $ ( 'save' ) . disabled = false ; } } ) ;
const draft = readDraft ( ) ; if ( draft ) { $ ( 'query' ) . value = draft . query || '' ; $ ( 'language' ) . value = draft . language || 'ru' ; $ ( 'page-size' ) . value = draft . pageSize || '20' ; $ ( 'reviewer' ) . value = draft . reviewer || '' ; $ ( 'overall-comment' ) . value = draft . overallComment || '' ; }
if ( ! localStorage . getItem ( TOUR _KEY ) ) setTimeout ( openTutorial , 350 ) ;
if ( ! hasSeenTour ( ) ) setTimeout ( openTutorial , 350 ) ;
< / script >
< / body >
< / html >