diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000..40b878db5b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+node_modules/
\ No newline at end of file
diff --git a/.prettierrc.yaml b/.prettierrc.yaml
new file mode 100644
index 0000000000..0fe5deb5b9
--- /dev/null
+++ b/.prettierrc.yaml
@@ -0,0 +1,2 @@
+tabWidth: 4
+singleQuote: true
diff --git a/JS modules/addComment.js b/JS modules/addComment.js
new file mode 100644
index 0000000000..9014733e46
--- /dev/null
+++ b/JS modules/addComment.js
@@ -0,0 +1,53 @@
+import { saveComment, loadComments, getComments } from './state.js';
+import { renderComments } from './view.js';
+
+export function initAddComment({ nameInput, textInput, button, listRoot }) {
+ const handlePostClick = (retryCount = 0) => {
+ const textValue = textInput.value.trim();
+
+ if (textValue.length < 3) {
+ alert('Комментарий должен быть не короче 3 символов');
+ return;
+ }
+
+ button.disabled = true;
+ button.textContent = 'Отправляем...';
+
+ saveComment({ text: textValue })
+ .then(() => loadComments())
+ .then(() => {
+ renderComments(listRoot, getComments());
+ textInput.value = '';
+ })
+ .catch((e) => {
+ if (e.message === 'Failed to fetch') {
+ alert('Кажется, у вас сломался интернет, попробуйте позже');
+ return;
+ }
+
+ if (e.message === 'Ошибка сервера') {
+ if (retryCount < 3) {
+ setTimeout(() => {
+ handlePostClick(retryCount + 1);
+ }, 1000);
+ } else {
+ alert('Сервер сломался, попробуй позже');
+ }
+ return;
+ }
+
+ if (e.message === 'Ошибка запроса') {
+ alert('Ошибка запроса');
+ return;
+ }
+
+ alert(e.message);
+ })
+ .finally(() => {
+ button.disabled = false;
+ button.textContent = 'Написать';
+ });
+ };
+
+ button.addEventListener('click', () => handlePostClick());
+}
diff --git a/JS modules/likeHandler.js b/JS modules/likeHandler.js
new file mode 100644
index 0000000000..f3c74f594c
--- /dev/null
+++ b/JS modules/likeHandler.js
@@ -0,0 +1,25 @@
+// Лайки
+import { toggleLike, setLikeLoading, getComments } from './state.js';
+import { renderComments } from './view.js';
+import { delay } from './utils.js';
+
+export function initLikeHandler({ listRoot }) {
+ listRoot.addEventListener('click', (e) => {
+ const likeBtn = e.target.closest('.like-button');
+ if (!likeBtn) return;
+ const i = Number(likeBtn.dataset.index);
+ if (Number.isNaN(i)) return;
+
+ const comments = getComments();
+ if (comments[i].isLikeLoading) return;
+
+ setLikeLoading(i, true);
+ renderComments(listRoot, getComments());
+
+ delay(2000).then(() => {
+ toggleLike(i);
+ setLikeLoading(i, false);
+ renderComments(listRoot, getComments());
+ });
+ });
+}
diff --git a/JS modules/loginView.js b/JS modules/loginView.js
new file mode 100644
index 0000000000..56e2d66b6a
--- /dev/null
+++ b/JS modules/loginView.js
@@ -0,0 +1,16 @@
+export function renderLogin(root, onLogin) {
+ root.innerHTML = `
+
+
+
+
+
+ `;
+
+ document.getElementById('login-btn').addEventListener('click', () => {
+ const login = document.getElementById('login').value;
+ const password = document.getElementById('password').value;
+
+ onLogin({ login, password });
+ });
+}
diff --git a/JS modules/main.js b/JS modules/main.js
new file mode 100644
index 0000000000..ad304ba27c
--- /dev/null
+++ b/JS modules/main.js
@@ -0,0 +1,58 @@
+import { getComments, loadComments, login, getUser } from './state.js';
+import { renderComments } from './view.js';
+import { initAddComment } from './addComment.js';
+import { renderLogin } from './loginView.js';
+
+const listRoot = document.querySelector('.comments');
+const appRoot = document.querySelector('.container');
+const form = document.querySelector('.add-form');
+
+function renderApp() {
+ listRoot.innerHTML = 'Загружаем...
';
+
+ loadComments()
+ .then(() => {
+ renderComments(listRoot, getComments());
+
+ if (!getUser()) {
+ form.style.display = 'none';
+
+ const link = document.createElement('div');
+ link.innerHTML =
+ 'Чтобы добавить комментарий, авторизуйтесь';
+
+ link.addEventListener('click', (e) => {
+ e.preventDefault();
+ renderLogin(appRoot, handleLogin);
+ });
+
+ appRoot.appendChild(link);
+ } else {
+ form.style.display = 'block';
+
+ const nameInput = document.getElementById('name');
+ const textInput = document.getElementById('comment');
+ const button = document.getElementById('button');
+
+ nameInput.value = getUser().name;
+ nameInput.setAttribute('readonly', true);
+
+ initAddComment({ nameInput, textInput, button, listRoot });
+ }
+ })
+ .catch(() => {
+ alert('Ошибка загрузки');
+ });
+}
+
+function handleLogin({ login: userLogin, password }) {
+ login({ login: userLogin, password })
+ .then(() => {
+ location.reload();
+ })
+ .catch(() => {
+ alert('Неверные данные');
+ });
+}
+
+renderApp();
diff --git a/JS modules/quoteHandler.js b/JS modules/quoteHandler.js
new file mode 100644
index 0000000000..f0866709b3
--- /dev/null
+++ b/JS modules/quoteHandler.js
@@ -0,0 +1,21 @@
+// Цитирование по клику
+export function initQuoteHandler({ listRoot, textInput }) {
+ listRoot.addEventListener('click', (e) => {
+ const item = e.target.closest('.comment');
+ if (!item || !listRoot.contains(item)) return;
+
+ const author =
+ item
+ .querySelector('.comment-header > div:first-child')
+ ?.textContent.trim() ?? '';
+ const text =
+ item.querySelector('.comment-text')?.textContent.trim() ?? '';
+ const quote = `@${author}: "${text}" `;
+ textInput.value = quote;
+ textInput.focus();
+ textInput.setSelectionRange(
+ textInput.value.length,
+ textInput.value.length,
+ );
+ });
+}
diff --git a/JS modules/state.js b/JS modules/state.js
new file mode 100644
index 0000000000..f702451e6b
--- /dev/null
+++ b/JS modules/state.js
@@ -0,0 +1,73 @@
+const API_URL = 'https://wedev-api.sky.pro/api/v2/origami/comments';
+
+let comments = [];
+let token = null;
+let user = null;
+
+export const getComments = () => comments.slice();
+export const getUser = () => user;
+
+export function loadComments() {
+ return fetch(API_URL)
+ .then((res) => {
+ if (!res.ok) {
+ if (res.status >= 500) throw new Error('Ошибка сервера');
+ throw new Error('Ошибка запроса');
+ }
+ return res.json();
+ })
+ .then((data) => {
+ comments = data.comments.map((c) => ({
+ name: c.author.name,
+ text: c.text,
+ date: new Date(c.date).toLocaleString('ru-RU'),
+ likes: c.likes,
+ isLiked: false,
+ isLikeLoading: false,
+ }));
+ });
+}
+
+export function saveComment({ text }) {
+ return fetch(API_URL, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({ text }),
+ }).then((res) => {
+ if (!res.ok) {
+ if (res.status >= 500) throw new Error('Ошибка сервера');
+ if (res.status === 400) throw new Error('Ошибка запроса');
+ throw new Error('Ошибка');
+ }
+ });
+}
+
+export function login({ login, password }) {
+ return fetch('https://wedev-api.sky.pro/api/v2/origami/login', {
+ method: 'POST',
+ body: JSON.stringify({ login, password }),
+ })
+ .then((res) => {
+ if (!res.ok) throw new Error('Неверные данные');
+ return res.json();
+ })
+ .then((data) => {
+ token = data.user.token;
+ user = data.user;
+ });
+}
+
+export function toggleLike(index) {
+ const c = comments[index];
+ if (!c) return;
+ c.isLiked = !c.isLiked;
+ c.likes += c.isLiked ? 1 : -1;
+}
+
+export function setLikeLoading(index, value) {
+ const c = comments[index];
+ if (!c) return;
+ c.isLikeLoading = value;
+}
\ No newline at end of file
diff --git a/JS modules/utils.js b/JS modules/utils.js
new file mode 100644
index 0000000000..608b2b18b0
--- /dev/null
+++ b/JS modules/utils.js
@@ -0,0 +1,31 @@
+// Время и экранирование
+export function nowRu() {
+ const now = new Date();
+ const dateStr = now.toLocaleDateString('ru-RU', {
+ day: '2-digit',
+ month: '2-digit',
+ year: '2-digit',
+ });
+ const timeStr = now.toLocaleTimeString('ru-RU', {
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ });
+ return `${dateStr} ${timeStr}`;
+}
+
+export function sanitize(str) {
+ return String(str)
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .trim();
+}
+
+export function delay(interval = 300) {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve();
+ }, interval);
+ });
+}
diff --git a/JS modules/view.js b/JS modules/view.js
new file mode 100644
index 0000000000..e0e3526f3f
--- /dev/null
+++ b/JS modules/view.js
@@ -0,0 +1,24 @@
+// Рендер списка
+export function renderComments(rootEl, comments) {
+ const items = comments.map(
+ (item, index) => `
+ `,
+ );
+ rootEl.innerHTML = items.join('');
+}
diff --git a/eslint.config.mjs b/eslint.config.mjs
new file mode 100644
index 0000000000..97a6dae97f
--- /dev/null
+++ b/eslint.config.mjs
@@ -0,0 +1,12 @@
+import globals from 'globals';
+import pluginJs from '@eslint/js';
+import config from 'eslint-config-prettier';
+import plugin from 'eslint-plugin-prettier/recommended';
+
+/** @type {import('eslint').Linter.Config[]} */
+export default [
+ { languageOptions: { globals: globals.browser } },
+ pluginJs.configs.recommended,
+ config,
+ plugin,
+];
diff --git a/index.html b/index.html
index 6f14ae14aa..bd62f52857 100644
--- a/index.html
+++ b/index.html
@@ -1,71 +1,34 @@
-
+
-
- Проект "Комменты"
-
-
-
-
-
-
-