diff --git a/projects/loft-photo/images/arrow-left.svg b/projects/loft-photo/images/arrow-left.svg new file mode 100644 index 000000000..a4e4c339a --- /dev/null +++ b/projects/loft-photo/images/arrow-left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/projects/loft-photo/images/button.svg b/projects/loft-photo/images/button.svg new file mode 100644 index 000000000..6ce85ea9f --- /dev/null +++ b/projects/loft-photo/images/button.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/projects/loft-photo/images/chat.svg b/projects/loft-photo/images/chat.svg new file mode 100644 index 000000000..fc47d01e1 --- /dev/null +++ b/projects/loft-photo/images/chat.svg @@ -0,0 +1,3 @@ + + + diff --git a/projects/loft-photo/images/exit.svg b/projects/loft-photo/images/exit.svg new file mode 100644 index 000000000..d28c122e1 --- /dev/null +++ b/projects/loft-photo/images/exit.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/projects/loft-photo/images/heart-red.svg b/projects/loft-photo/images/heart-red.svg new file mode 100644 index 000000000..e9985dca6 --- /dev/null +++ b/projects/loft-photo/images/heart-red.svg @@ -0,0 +1,3 @@ + + + diff --git a/projects/loft-photo/images/heart.svg b/projects/loft-photo/images/heart.svg new file mode 100644 index 000000000..4bcdacd80 --- /dev/null +++ b/projects/loft-photo/images/heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/projects/loft-photo/images/logo.svg b/projects/loft-photo/images/logo.svg new file mode 100644 index 000000000..12685673d --- /dev/null +++ b/projects/loft-photo/images/logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/projects/loft-photo/images/send.svg b/projects/loft-photo/images/send.svg new file mode 100644 index 000000000..5a55b025c --- /dev/null +++ b/projects/loft-photo/images/send.svg @@ -0,0 +1,3 @@ + + + diff --git a/projects/loft-photo/images/vert1.svg b/projects/loft-photo/images/vert1.svg new file mode 100644 index 000000000..d5d86e658 --- /dev/null +++ b/projects/loft-photo/images/vert1.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/loft-photo/images/vert2.svg b/projects/loft-photo/images/vert2.svg new file mode 100644 index 000000000..0f5e75ed2 --- /dev/null +++ b/projects/loft-photo/images/vert2.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/loft-photo/images/vert3.svg b/projects/loft-photo/images/vert3.svg new file mode 100644 index 000000000..7b481af03 --- /dev/null +++ b/projects/loft-photo/images/vert3.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/loft-photo/index.js b/projects/loft-photo/index.js new file mode 100644 index 000000000..c1aed211b --- /dev/null +++ b/projects/loft-photo/index.js @@ -0,0 +1,20 @@ +import mainPage from './mainPage'; +import loginPage from './loginPage'; +import profilePage from './profilePage'; // в верхней части + +pages.openPage('login'); +loginPage.handleEvents(); +mainPage.handleEvents(); + +document.addEventListener('click', () => { + const pages = pageNames; + + var randomIndex = Math.floor(Math.random() * pages.length); + var randomElem = pages[randomIndex] + + pages.OpenPage(randomElem) +}); + + +profilePage.handleEvents(); // в нижней части + diff --git a/projects/loft-photo/layout.html b/projects/loft-photo/layout.html new file mode 100644 index 000000000..555bd6fd3 --- /dev/null +++ b/projects/loft-photo/layout.html @@ -0,0 +1,65 @@ + + + + + + Loft Photo + + + + +
+
+ + + + + +
+ + +
+ + diff --git a/projects/loft-photo/loginPage.js b/projects/loft-photo/loginPage.js new file mode 100644 index 000000000..5edc92859 --- /dev/null +++ b/projects/loft-photo/loginPage.js @@ -0,0 +1,21 @@ +import model from './model'; +import pages from './pages'; +import mainPage from './mainPage'; + +export default { + handleEvents() { + document.querySelector('.page-login-button').addEventListener('click', async () => { + try { + await model.login(); + + await model.init(); + + pages.show('main'); + + mainPage.getNextPhoto(); + } catch (error) { + console.error('Ошибка при входе или инициализации:', error); + } + }); + }, +}; diff --git a/projects/loft-photo/mainPage.js b/projects/loft-photo/mainPage.js new file mode 100644 index 000000000..c0402c458 --- /dev/null +++ b/projects/loft-photo/mainPage.js @@ -0,0 +1,121 @@ +import pages from './pages'; +import model from './model'; +import profilePage from './profilePage'; + +export default { + async getNextPhoto(){ + const {friend, id, url } = await model.getNextPhoto(); + const photoStats = await model.photoStats(id); + this.setFriendAndPhoto(friend, id, url, photoStats); + }, + + setFriendAndPhoto(friend, id, url, stats){ + const photoomp = document.querySelector('.component-photo'); + const HeaderphotoComp = document.querySelector('.component-header-photo'); + const headerNameComp = document.querySelector('.component-header-name'); + const FooterPhotoComp = document.querySelector('.component-footer-photo'); + + this.friend = friend; + this.photoId = id; + + HeaderphotoComp.computedStyleMap.background = `url('${friend.photo_50}')`; + headerNameComp.innerText = `${friend.first_name ?? ''} ${friend.last_name ?? ''}`; + photoomp.style.backgroundImage = `url(${url})`; + FooterPhotoComp.style.backgroundImage = `url('$(model.me.photo_50)')`; + this.setLikes(stats.likes, stats.liked); + this.setComments(stats.comments); + }, + + + + handleEvents() { + let startFrom; + + document.querySelector('.component-photo').addEventListener('touchstart', (e) =>{ + e.preventDefault(); + startFrom = {y: e.changedTouches[0].pageY}; + }); + + document.querySelector('.component-photo').addEventListener('touchend', async(e) =>{ + const direction = e.changedTouches[0].pageY - startFrom.y; + + if(direction < 0){ + await this.getNextPhoto(); + } + }); + + document.querySelector('.component-header-profile-link').addEventListener('click', async () =>{ + await profilePage.setUser(this.friend); + pages.openPage('profile'); + }); + + + document.querySelector('.component-footer-container-profile-link').addEventListener('click', async () =>{ + await profilePage.setUser(model.me); + pages.openPage('profile'); + }); + + + document.querySelector('.component-footer-container-social-likes').addEventListener('click', async () => { + const{likes, liked} = await model.like(this.photoId); + this.setLikes(likes, liked); + }); + + document.querySelector('.component-footer-container-social-comments').addEventListener('click', async () =>{ + document.querySelector('.component-comments').classList.remove('hidden'); + await this.loadComments(this.photoId); + }); + + const input = document.querySelector('.component-comments-container-form-input'); + + document.querySelector('.component-comments').addEventListener('click', (e) =>{ + if (e.target === e.currentTarget){ + document.querySelector('.component-comments').classList.add('hidden'); + } + }) + + document.querySelector('component-comments-container-form-sens').addEventListener('click', async() => { + if(input.ariaValueMax.trim().length){ + await model.postComment(this.photoId, input.ariaValueMax.trim()); + input.value = ''; + await this.loadComments(this.photoId); + } + }); + + }, + + + async loadComments(photo) { + const comments = await model.getComments(photo); + const commentElement = commentsTemplate({ + list: comments.map((comment)=>{ + return { + name: `${comment.user.first_name ?? ''} ${comment.user.last_name ?? ''}`, + photo: comment.user.photo_50, + text: comment.text, + }; + }), + }); + + document.querySelector('.component-comments-container-lisst').innerHTML = ''; + document.querySelector('.component-comments-container-list').append(commentElement); + this.setComments(comments.lenght); + }, + + setLikes(total, liked) { + const likesElem = document.querySelector('.component-footer-container-social-likes'); + + likesElem.innerText = total; + + if(liked){ + likesElem.classList.add('liked'); + } else{ + likesElem.classList.remove('liked'); + } + }, + + setComments(total) { + const likesElem = document.querySelector('.component-footer-container-social-comments') ; + likesElem.innerText = total; + }, +}; diff --git a/projects/loft-photo/model.js b/projects/loft-photo/model.js new file mode 100644 index 000000000..936cfca4b --- /dev/null +++ b/projects/loft-photo/model.js @@ -0,0 +1,164 @@ +import { rejects } from "assert"; +import { resolve } from "path"; + +const APP_ID = 51838147; +const PERM_FRIENDS = 2; +const PERM_PHOTOS = 4; + +export default { + getRandomElement(array) { + if(!array.length){ + return null; + } + + const index = Math.round(Math.random() * (array.length - 1)); + + return array[index]; + }, + + findSize(){ + const size = photo.sizes.find((size) => size.width >= 360); + }, + + async getNextPhoto() { + const friend = this.getRandomElement(this.friend.item); + const photos = await this.getRandomElement(friend.id); + const photo = this.getRandomElement(photos.items); + const size = this.findSize(photo) + + return {friend, id: photo.id, url: size.url}; + }, + + login() { + return new Promise((resolve,rejects) =>{ + VK.init({ + apId: APP_ID, + }); + + VK.Auth.login((response) =>{ + if(response.session){ + this.token = response.session.sid + resolve(response) + }else{ + console.error(response); + rejects(response); + } + }, PERM_FRIENDS || PERM_PHOTOS) + }); + }, + + callApi(method, params){ + params.v = params.v || '5.120'; + + return new Promise((resolve,rejects) =>{ + VK.api(method, params, (response) =>{ + if(response.error){ + rejects(new Error(response.error.error.msg)); + } else{ + resolve(response.response); + }; + }); + }); + }, + + async init() { + this.photoCache = {}; + this.friend = await this.getFriends; + [this.me] = await this.getUsers(); + }, + + photoCache: { + + }, + + getPhotos(owner){ + const params = { + owner_id: owner, + }; + return this.callApi('photos.getAll', params) + }, + + async getFriendPhotos(id) { + const photos = this.photoCache[id]; + + if (photos) { + return photos; + } + + photos = await this.getPhotos(id); + + this.photoCache[id] = photos; + + return photos; + }, + + logout() { + return Promise((resolve) => VK.Auth.revokeGrants(resolve)) + }, + + getUsers(ids) { + const params = { + field : ['photo_50', 'photo_100'], + }; + + if (ids){ + params.user_ids = 100; + } + + return this.callApi('users.get', params); + }, + + getFriends(){ + const params ={ + field:['photo_50', 'photo_100'] + } + return this.callApi('friends.get', params) + }, + + async callServer(method, queryParams, body){ + queryParams ={ + ...queryParams, + method, + }; + + const query = Object.entries(queryParams) + .reduce((all, [name, value]) => { + all.push('${name}=${encodeURIComponent(value)}'); + return all; + }, []) + .join('&'); + const params = { + headers: { + vk_token: this.token, + }, + }; + + if (body){ + params.method = 'POST'; + params.body = JSON.sstringify(body); + } + + const response = await fetch(`/loft-photo/api/?${query}`, params); + + return response.json(); + }, + + async like(photo) { + return this.callServer('like', {photo}); + }, + + async photoStats(photo) { + return this.callServer('photoStats', {photo}); + }, + + async getComments(photo) { + return this.callServer('getCommets', {photo}); + }, + + async postComment(photo, text) { + return this.callServer('postComment', {photo}, {text}); + }, + +}; + + diff --git a/projects/loft-photo/pages.js b/projects/loft-photo/pages.js new file mode 100644 index 000000000..1db8985a0 --- /dev/null +++ b/projects/loft-photo/pages.js @@ -0,0 +1,19 @@ +const pagesMap = { + login: '.page-login', + main: '.page-main', + profile: '.page-profile', + }; + + let currentPage = null; + + export default { + openPage(name) { + const page = pagesMap[name]; + const element = document.querySelector(page); + + currentPage?.classList.add('hiden'); + currentPage = element; + currentPage.classList.remove('hiden'); + }, + }; + \ No newline at end of file diff --git a/projects/loft-photo/profilePage.js b/projects/loft-photo/profilePage.js new file mode 100644 index 000000000..9cb9fccbf --- /dev/null +++ b/projects/loft-photo/profilePage.js @@ -0,0 +1,53 @@ +import model from './model'; +import mainPage from './mainPage'; +import pages from './pages'; + +export default { + async setUser(user) { + const photoComp = document.querySelector('.component-user-info-photo'); + const nameComp = document.querySelector('.component-user-info-name'); + const photosComp = document.querySelector('.component-user-photos'); + const photos = await model.getPhotos(user.id); + + this.user = user; + + photoComp.style.backgroundImage = `url('$(user.photo_100)')`; + nameComp.innerText = `${user.first_name ?? ''} ${user.last_name ?? ''}`; + photosComp.innerHTML = ''; + + for(const photo of photos.items){ + const size = model.findSize(photo); + const element = document.createElement('div'); + + element.classList.add('component-user-photo'); + element.dataset.id = photo.id; + element.style.backgroundImage = `url($(size.url))`; + photosComp.append(element); + } + }, + + handleEvents() { + document.querySelector('.component-user-photos').addEventListener('click', async (e) =>{ + if(e.target.classList.contains('component-user-photo')){ + const photoId = e.target.dataset.id; + const friendsPhotos = await model.getFriendPhotos(this.user.id); + + const photo = friendsPhotos.items.find((photo) => photo.id == photoId); + const size = model.findSize(photo); + + mainPage.setFriendAndPhoto(this.user, parseInt(photoId), size.url); + pages.openPage('main'); + } + }); + + + document.querySelector('.page-profile-back').addEventListener('click', async () =>{ + pages.openPage('main'); + }); + + document.querySelector('.page-profile-exit').addEventListener('click', async () =>{ + await model.logout(); + pages.openPage('login'); + }); + }, +}; diff --git a/projects/loft-photo/server/index.js b/projects/loft-photo/server/index.js new file mode 100644 index 000000000..c8d7e4ada --- /dev/null +++ b/projects/loft-photo/server/index.js @@ -0,0 +1,123 @@ +const http = require('node:http'); +const https = require('node:https'); +const url = require('node:url'); + +const DB = { + tokens: new Map(), + likes: new Map(), + comments: new Map(), +}; + +const methods = { + like(req, res, url, vkUser) { + const photoId = url.searchParams.get('photo'); + let photoLikes = DB.likes.get(photoId); + + if(!photoLikes){ + photoLikes = new Map(); + DB.likes.get(photoId); + } + + if(photoLikes.get(vkUser.id)){ + photoLikes.delete(vkUser.id); + return {likes: photoLikes.size, linked: false}; + } + + photoLikes.set(vkUser.id, true); + return {likes: photoLikes.size, linked: true}; + }, + photoStats(req, res, url, vkUser) { + const photoId = url.searchParams.get('photo'); + const photoLikes = DB.likes.get(photoId); + const photoComments = DB.comments.get(photoId); + + return { + likes: photoLikes?.size ?? 0, + liked: photoLikes?.has(vkUser.id) ?? false, + comments: photoComments?.lenght ?? 0, + }; + }, + postComment(req, res, url, vkUser, body) { + const photoId = url.searchParams.get('photo'); + let photoComments = DB.comments.get(photoId); + + if(!photoComments){ + photoComments = []; + DB.comments.set(photoId, photoComments); + } + + photoComments.unshift({user: vkUser, text: body.text}); + }, + getComments(req, res, url) { + const photoId = url.searchParams.get('photo'); + return DB.comments.get(photoId) ?? []; + }, + +}; + +http + .createServer(async (req, res) => { + console.log('➡️ Поступил запрос:', req.method, req.url); + const token = req.headers['vk_token']; + const parsed = new url.URL(req.url, 'http://localhost'); + const vkUser = await getMe(token); + const body = await readBody(req); + const method = parsed.searchParams.get('method'); + const responseData = await methods[method]?.(req, res, parsed, vkUser, body); + + res.end(JSON.stringify(responseData ?? null)); + }) + .listen('8888', () => { + console.log('🚀 Сервер запущен'); + }); + +async function readBody(req) { + if (req.method === 'GET') { + return null; + } + + return new Promise((resolve) => { + let body = ''; + req + .on('data', (chunk) => { + body += chunk; + }) + .on('end', () => resolve(JSON.parse(body))); + }); +} + +async function getVKUser(token) { + const body = await new Promise((resolve, reject) => + https + .get( + `https://api.vk.com/method/users.get?access_token=${token}&fields=photo_50&v=5.120` + ) + .on('response', (res) => { + let body = ''; + + res.setEncoding('utf8'); + res + .on('data', (chunk) => { + body += chunk; + }) + .on('end', () => resolve(JSON.parse(body))); + }) + .on('error', reject) + ); + + return body.response[0]; +} + +async function getMe(token) { + const existing = DB.tokens.get(token); + + if (existing) { + return existing; + } + + const user = getVKUser(token); + + DB.tokens.set(token, user); + + return user; +} diff --git a/projects/loft-photo/settings.json b/projects/loft-photo/settings.json new file mode 100644 index 000000000..3d20b4405 --- /dev/null +++ b/projects/loft-photo/settings.json @@ -0,0 +1,7 @@ +{ + "proxy": { + "/loft-photo/api/": { + "target": "http://localhost:8888" + } + } +} diff --git a/projects/loft-photo/styles.css b/projects/loft-photo/styles.css new file mode 100644 index 000000000..62d5fba21 --- /dev/null +++ b/projects/loft-photo/styles.css @@ -0,0 +1,348 @@ +/* base */ + +body { + font-family: "Roboto Light", Geneva, Arial, Helvetica, sans-serif; +} + +.hidden { + display: none !important; +} + +a { + text-decoration: none; +} + +/* app */ + +#app { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: flex; + + align-items: center; + justify-content: center; +} + +.page { + height: 100%; + width: 360px; + position: relative; +} + +/* page login */ + +.page-login { + display: flex; + justify-content: center; + background: #1C1B1F; +} + +.page-login-button { + border: none; + background: url('images/button.svg'); + width: 219px; + height: 40px; + position: absolute; + bottom: 60px; + margin: 0 auto; +} + +.page-login-logo { + top: 429px; + position: absolute; + gap: 16px; + display: flex; + flex-direction: column; + align-items: center; +} + +.page-login-image { + width: 147px; + height: 24px; + background: url('images/logo.svg'); +} + +.page-login-text { + font-size: 14px; + line-height: 20px; + text-align: center; + width: 237px; + color: #B0B0B0; +} + +.page-login-vert1, .page-login-vert2, .page-login-vert3 { + width: 71px; + height: 333px; + position: absolute; +} + +.page-login-vert1 { + top: 59px; + left: 49px; + background: linear-gradient(180deg, rgba(28, 27, 31, 0) 80%, #1C1B1F 100%), url('images/vert1.svg'); +} + +.page-login-vert2 { + top: 81px; + left: 144px; + background: linear-gradient(180deg, rgba(28, 27, 31, 0) 80%, #1C1B1F 100%), url('images/vert2.svg'); +} + +.page-login-vert3 { + top: 59px; + left: 239px; + background: linear-gradient(180deg, rgba(28, 27, 31, 0) 80%, #1C1B1F 100%), url('images/vert3.svg'); +} + +/* page main */ + +.page-main .component-header { + position: absolute; + display: flex; + height: 80px; + top: 0; + left: 0; + right: 0; + background: rgba(0 0 0 / 25%); + padding: 0 24px; +} + +.page-main .component-header-profile-link { + display: flex; + align-items: center; +} + +.page-main .component-header-photo { + width: 40px; + height: 40px; + border-radius: 50%; + flex-shrink: 0; +} + +.page-main .component-header-name { + margin-left: 8px; + font-weight: 400; + font-size: 16px; + color: white; +} + +.page-main .component-footer { + position: absolute; + display: flex; + height: 80px; + bottom: 0; + left: 0; + right: 0; + background: rgba(0 0 0 / 25%); + padding: 0 24px; +} + +.page-main .component-footer-container { + display: flex; + align-items: center; + width: 100%; +} + +.page-main .component-footer-container-profile-link { + margin-left: auto; +} + +.page-main .component-footer-photo { + width: 40px; + height: 40px; + border-radius: 50%; +} + +.page-main .component-footer-container-social-comments, +.page-main .component-footer-container-social-likes { + color: white; + display: flex; + align-items: center; +} + +.page-main .component-footer-container-social-comments:before, +.page-main .component-footer-container-social-likes:before { + display: inline-block; + content: ''; + width: 20px; + height: 20px; + margin-right: 6px; +} + +.page-main .component-footer-container-social-comments:before { + background: url("images/chat.svg"); +} + +.page-main .component-footer-container-social-likes:before { + background: url("images/heart.svg"); + margin-left: 18px; +} + +.page-main .component-footer-container-social-likes.liked:before { + background: url("images/heart-red.svg"); + margin-left: 18px; +} + +.page-main .component-photo { + height: 100%; + width: 360px; + position: relative; + + background-size: cover; + background-position: center; +} + +.component-comments { + position: fixed; + bottom: 0; + left: 0; + right: 0; + top: 0; + background: rgba(0, 0, 0, 0.4); +} + +.component-comments-container { + position: absolute; + display: flex; + flex-direction: column; + top: 50vh; + bottom: 0; + left: 0; + right: 0; + padding: 16px; + border-radius: 28px 28px 0 0; + background: white; +} + +.component-comments-container-title { + font-size: 14px; + text-align: center; + width: 100%; +} + +.component-comments-container-list { + margin-top: 24px; + flex-grow: 1; + display: flex; + gap: 12px; + flex-direction: column; + overflow-y: auto; + margin-bottom: 14px +} + +.component-comments-container-form { + display: flex; + align-items: center; + gap: 16px; + height: 48px; +} + +.component-comments-container-form-input { + box-sizing: border-box; + border: 1px solid #E0E0E0; + border-radius: 32px; + flex-grow: 1; + height: 48px; +} + +.component-comments-container-form-input, +.component-comments-container-form-input, +.component-comments-container-form-input, +.component-comments-container-form-input { + padding: 14px 16px; +} + +.component-comments-container-form-send { + background: url('images/send.svg'); + width: 40px; + height: 40px; +} + +.component-comment { + display: flex; + gap: 8px +} + +.component-comment-photo { + width: 24px; + height: 24px; + border-radius: 50%; + background-position: center; + background-size: cover; +} + +.component-comment-content { + flex-direction: column; +} + +.component-comment-name { + font-size: 12px; +} + +.component-comment-text { + font-size: 14px; +} + +/* page profile */ + +.page-profile { + margin-top: 52px; +} + +.page-profile-back { + background: url('images/arrow-left.svg'); + width: 24px; + height: 24px; + + position: absolute; + left: 24px; +} + +.page-profile-exit { + background: url('images/exit.svg'); + width: 24px; + height: 24px; + + position: absolute; + right: 24px; +} + +.component-user-photos { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 24px 16px 16px 16px; +} + +.component-user-photo { + width: 104px; + height: 104px; + background-size: cover; + background-repeat: no-repeat; + background-position: center; +} + +.page-profile .component-user-info { + display: flex; + flex-direction: column; + align-items: center; +} + +.page-profile .component-user-info-photo { + height: 72px; + width: 72px; + border-radius: 50%; + + background-size: cover; + background-position: center; +} + +.page-profile .component-user-info-name { + font-weight: 400; + font-size: 18px; + line-height: 26px; + margin-top: 8px; +} \ No newline at end of file