# Виджет оценки файлов: лайк/дизлайк с живым рейтингом

Часто на файлообменниках, форумах и каталогах файлов хочется дать пользователям возможность быстро оценить материал — не звёздочками и не длинной анкетой, а простым «нравится / не нравится». Собрал именно такой виджет: компактный блок с карточкой автора, кнопками 👍/👎 и рейтингом, который обновляется сразу после клика, без перезагрузки страницы.

## Что он умеет


  • - **Карточка автора файла** — аватар, имя и ссылка на профиль.
  • - **Название и краткое описание** материала со ссылкой на страницу файла.
  • - **Кнопки «Хорошо» и «Ужасно»** — по сути лайк и дизлайк.
  • - **Итоговый счёт** в виде цифры со знаком: `+3`, `-2`, `0` — сразу видно, нравится людям файл или нет, даже не читая точный процент.
  • - **Статистика голосов** — сколько всего человек оценили и какая средняя оценка.
  • - **Уведомление внизу экрана** — короткое всплывающее сообщение о результате клика: засчитан голос, уже голосовали или произошла ошибка соединения.
  • - Всё это **без перезагрузки страницы** — счётчик и статус меняются на лету через AJAX.


## Как это выглядит для пользователя

1. Человек заходит на страницу файла и видит блок с карточкой автора и текущим рейтингом.
2. Нажимает 👍 или 👎.
3. Пока идёт запрос, кнопки на секунду становятся полупрозрачными — как будто «задумались».
4. Приходит ответ от сервера — и счёт, количество голосов и средняя оценка тут же обновляются, а внизу экрана выскакивает уведомление с результатом.
5. Если человек уже голосовал за этот файл раньше, вместо повторного зачёта голоса он увидит сообщение «Вы уже оценивали этот файл».
6. Если вдруг с соединением проблема, виджет не «зависает» молча, а честно сообщает об ошибке и предлагает попробовать позже.

## Как это устроено технически (в двух словах)

Виджет состоит из трёх частей:

- **Разметка (HTML)** — карточка с автором, кнопками и статистикой. Подставляется автоматически, если использовать его как шаблон для файлов на движке (переменные вроде имени пользователя, названия файла и текущего рейтинга заполняются самим движком).

- **Стили (CSS)** — аккуратный, адаптивный вид блока: на мобильных элементы выстраиваются в столбик, кнопки растягиваются на всю ширину.

- **Скрипт (JavaScript, на jQuery)** — при клике отправляет запрос на сервер, разбирает ответ и обновляет цифры на странице. Написан с расчётом на совместимость со старыми версиями jQuery, которые нередко используются на бесплатных движках сайтов.

Все три части лежат в одном файле — можно скопировать целиком и вставить в шаблон карточки файла.

## Кому пригодится

Подойдёт для любого сайта, где есть файлы, публикации или материалы от разных авторов и хочется дать посетителям простой способ оценить их одним кликом: файлообменники, каталоги, доски объявлений, блоги с загружаемыми материалами.

---

Если будете использовать у себя — под свой движок наверняка придётся немного поправить адрес запроса и формат ответа сервера (в моём случае это связка `a=65`, `mark`, `mod` под конкретную систему рейтинга), но общая логика — карточка, кнопки, живое обновление счёта — останется той же.

Код
<div id="file-rating-$ID$" class="file-rating" data-id="$ID$">  
  <div class="fr-user">  
  <a class="fr-avatar" href="$PROFILE_URL$">  
  <?if($AVATAR_URL$)?>  
  <img src="$AVATAR_URL$" alt="$USER$">  
  <?else?>  
  <img src="/images/noavatar.png" alt="">  
  <?endif?>  
  </a>  
  <div class="fr-user-info">  
  <a href="$PROFILE_URL$" class="fr-username">$USER$</a>  
  <div class="fr-label">Автор файла</div>  
  </div>  
  </div>  

  <div class="fr-content">  
  <div class="fr-title">  
  <a href="$ENTRY_URL$"><?if(len($ENTRY_TITLE$)>40)?><?substr($ENTRY_TITLE$,0,40)?>..<?else?>$ENTRY_TITLE$<?endif?></a>  
  </div>  
  <div class="fr-desc"><?if(len($BRIEF$)>80)?><?substr($BRIEF$,0,80)?>..<?else?>$BRIEF$<?endif?></div>  
  <div class="fr-rating">  
  <a href="#" class="fr-btn fr-bad" onclick="return fileRating.vote($ID$,-1,'load')">👎 Ужасно</a>  
  <div class="fr-result">  
  <span class="fr-score">  
  <script>(function(){var t=Math.round($RATING$*$RATED$)-2*$RATED$;document.write(t>0?'+'+t:t);})();</script>  
  </span>  
  </div>  
  <a href="#" class="fr-btn fr-good" onclick="return fileRating.vote($ID$,1,'load')">👍 Хорошо</a>  
  </div>  
  <div class="fr-stat">  
  <span>  
  Голосов: <b class="fr-votes-count">$RATED$</b>  
  </span>  
  <span>  
  Средняя: <b class="fr-avg-rating">$RATING$</b>/ из 3  
  </span>  
  </div>  
  </div>  
  </div>  

  <style>  
  .file-rating{  
  display:flex;  
  align-items:center;  
  gap:18px;  
  padding:15px;  
  background:#fff;  
  border:1px solid #e5e5e5;  
  border-radius:14px;  
  margin:10px 0  
  }  
  .fr-user{  
  display:flex;  
  align-items:center;  
  min-width:180px  
  }  
  .fr-avatar{  
  width:55px;  
  height:55px;  
  border-radius:50%;  
  overflow:hidden;  
  display:block;  
  margin-right:10px;  
  background:#eee  
  }  
  .fr-avatar img{  
  width:100%;  
  height:100%;  
  object-fit:cover  
  }  
  .fr-user-info{  
  line-height:18px  
  }  
  .fr-username{  
  font-size:14px;  
  font-weight:700;  
  color:#333;  
  text-decoration:none  
  }  
  .fr-label{  
  font-size:11px;  
  color:#999  
  }  
  .fr-content{  
  flex:1  
  }  
  .fr-title a{  
  font-size:18px;  
  font-weight:700;  
  color:#333;  
  text-decoration:none  
  }  
  .fr-desc{  
  margin-top:5px;  
  font-size:13px;  
  color:#777  
  }  
  .fr-rating{  
  display:flex;  
  align-items:center;  
  gap:8px;  
  margin-top:12px  
  }  
  .fr-btn{  
  height:38px;  
  padding:0 14px;  
  display:flex;  
  align-items:center;  
  justify-content:center;  
  border:1px solid #ddd;  
  border-radius:8px;  
  background:#fff;  
  color:#555;  
  font-size:13px;  
  font-weight:600;  
  text-decoration:none;  
  cursor:pointer;  
  transition:.2s  
  }  
  .fr-good:hover{  
  background:#edfff4;  
  border-color:#35b76b;  
  color:#168447  
  }  
  .fr-bad:hover{  
  background:#fff1f1;  
  border-color:#df6666;  
  color:#d04444  
  }  

  .fr-result{  
  width:48px;  
  height:38px;  
  display:flex;  
  align-items:center;  
  justify-content:center;  
  border-radius:8px;  
  background:#f2f2f2;  
  font-size:18px;  
  font-weight:800;  
  color:#888  
  }  
  .fr-positive{  
  background:#e9f9ef;  
  color:#168447  
  }  
  .fr-negative{  
  background:#fff0f0;  
  color:#d04444  
  }  
  .fr-zero{  
  background:#f2f2f2;  
  color:#999  
  }  
  .fr-stat{  
  display:flex;  
  gap:12px;  
  margin-top:8px;  
  font-size:12px;  
  color:#999  
  }  
  .fr-stat b{  
  color:#555  
  }  
  .fr-loading{  
  opacity:.5;  
  pointer-events:none  
  }  
  .fr-voted{  
  opacity:.75  
  }  
  #file-rating-message{  
  display:none;  
  position:fixed;  
  left:50%;  
  bottom:30px;  
  transform:translateX(-50%);  
  padding:12px 20px;  
  border-radius:10px;  
  background:#222;  
  color:#fff;  
  font-size:14px;  
  font-weight:600;  
  z-index:99999  
  }  
  @media(max-width:700px){  
  .file-rating{  
  display:block  
  }  
  .fr-user{  
  margin-bottom:12px  
  }  
  .fr-rating{  
  flex-wrap:wrap  
  }  
  .fr-btn{  
  flex:1  
  }  
  .fr-stat{  
  flex-wrap:wrap  
  }  
  }  
  </style>  

  <script>  
  (function ($) {  
'use strict';  
window.fileRating = {  
vote: function (id, mark, module) {  
if (!id || (mark !== 1 && mark !== -1)) {  
return false;  
}  
var box = $('#file-rating-' + id);  
if (!box.length) {  
return false;  
}  
var buttons = box.find('.fr-btn');  
var result = box.find('.fr-result');  
var ratingMark = mark + 2;  
buttons.addClass('fr-loading');  
$.ajax({  
url: '/' + module + '/',  
type: 'POST',  
dataType: 'text',
data: {  
a: '65',  
id: id,  
mark: ratingMark,  
mod: module,  
ajax: '2'  
},  
success: function (response) {  
buttons.removeClass('fr-loading');  
var data = $('<div>').html(response).text();  
var match = data.match(/рейтинг:\s*([\d.]+)\/(\d+)/i);  
if (!match) {  
if (data.indexOf('уже оценивали') !== -1) {  
buttons.addClass('fr-voted');  
fileRating.message('Вы уже оценивали этот файл.');  
} else {  
fileRating.message('Не удалось засчитать оценку.');  
}  
return;  
}  

var average = parseFloat(match[1]);  
var votes = parseInt(match[2], 10);  
var score = Math.round(average * votes) - (2 * votes);  
box.find('.fr-score').text(score > 0 ? '+' + score : score);  
box.find('.fr-votes-count').text(votes);  
box.find('.fr-avg-rating').text(average.toFixed(1));  
result  
.removeClass('fr-positive fr-negative fr-zero')  
.addClass(  
score > 0  
? 'fr-positive'  
: score < 0  
? 'fr-negative'  
: 'fr-zero'  
);  
buttons.addClass('fr-voted');  
fileRating.message(  
mark === 1  
? 'Ваш лайк засчитан.'  
: 'Ваш дизлайк засчитан.'  
);  
},  
error: function () {  
buttons.removeClass('fr-loading');  
fileRating.message('Ошибка соединения. Попробуйте позже.');  
}  
});  
return false;  
},  
message: function (text) {  
var msg = $('#file-rating-message');  
if (!msg.length) {  
$('body').append('<div id="file-rating-message"></div>');  
msg = $('#file-rating-message');  
}  
msg.stop(true, true)  
.text(text)  
.fadeIn(200);  
clearTimeout(window.fileRatingMessageTimer);  
window.fileRatingMessageTimer = setTimeout(function () {  
msg.fadeOut(300);  
}, 2500);  
}  
};  
  })(jQuery);  
  </script>