Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules/
2 changes: 2 additions & 0 deletions .prettierrc.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
tabWidth: 4
singleQuote: true
53 changes: 53 additions & 0 deletions JS modules/addComment.js
Original file line number Diff line number Diff line change
@@ -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());
}
25 changes: 25 additions & 0 deletions JS modules/likeHandler.js
Original file line number Diff line number Diff line change
@@ -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());
});
});
}
16 changes: 16 additions & 0 deletions JS modules/loginView.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export function renderLogin(root, onLogin) {
root.innerHTML = `
<div class="add-form">
<input id="login" placeholder="Логин" />
<input id="password" type="password" placeholder="Пароль" />
<button id="login-btn">Войти</button>
</div>
`;

document.getElementById('login-btn').addEventListener('click', () => {
const login = document.getElementById('login').value;
const password = document.getElementById('password').value;

onLogin({ login, password });
});
}
58 changes: 58 additions & 0 deletions JS modules/main.js
Original file line number Diff line number Diff line change
@@ -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 = '<div class="loading">Загружаем...</div>';

loadComments()
.then(() => {
renderComments(listRoot, getComments());

if (!getUser()) {
form.style.display = 'none';

const link = document.createElement('div');
link.innerHTML =
'<a href="#">Чтобы добавить комментарий, авторизуйтесь</a>';

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();
21 changes: 21 additions & 0 deletions JS modules/quoteHandler.js
Original file line number Diff line number Diff line change
@@ -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,
);
});
}
73 changes: 73 additions & 0 deletions JS modules/state.js
Original file line number Diff line number Diff line change
@@ -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;
}
31 changes: 31 additions & 0 deletions JS modules/utils.js
Original file line number Diff line number Diff line change
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.trim();
}

export function delay(interval = 300) {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, interval);
});
}
24 changes: 24 additions & 0 deletions JS modules/view.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Рендер списка
export function renderComments(rootEl, comments) {
const items = comments.map(
(item, index) => `
<li class="comment">
<div class="comment-header">
<div>${item.name}</div>
<div>${item.date}</div>
</div>
<div class="comment-body">
<div class="comment-text">${item.text}</div>
</div>
<div class="comment-footer">
<div class="likes">
<span class="likes-counter">${item.likes}</span>
<button class="like-button${
item.isLiked ? ' -active-like' : ''
}${item.isLikeLoading ? ' -loading-like' : ''}" data-index="${index}"></button>
</div>
</div>
</li>`,
);
rootEl.innerHTML = items.join('');
}
12 changes: 12 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -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,
];
Loading