diff --git a/projects/loft-photo/friends.json b/projects/loft-photo/friends.json
new file mode 100644
index 000000000..b861e0c00
--- /dev/null
+++ b/projects/loft-photo/friends.json
@@ -0,0 +1,32 @@
+[
+ {
+ "id": 0,
+ "avatar": "https://via.placeholder.com/100?text=avatar",
+ "firstName": "Adrian",
+ "lastName": "Norman"
+ },
+ {
+ "id": 1,
+ "avatar": "https://via.placeholder.com/100?text=avatar",
+ "firstName": "Gail",
+ "lastName": "Norton"
+ },
+ {
+ "id": 2,
+ "avatar": "https://via.placeholder.com/100?text=avatar",
+ "firstName": "Molina",
+ "lastName": "Rodgers"
+ },
+ {
+ "id": 3,
+ "avatar": "https://via.placeholder.com/100?text=avatar",
+ "firstName": "Adams",
+ "lastName": "Parrish"
+ },
+ {
+ "id": 4,
+ "avatar": "https://via.placeholder.com/100?text=avatar",
+ "firstName": "Mercer",
+ "lastName": "Wiggins"
+ }
+]
\ No newline at end of file
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..9afdce1ad
--- /dev/null
+++ b/projects/loft-photo/index.js
@@ -0,0 +1,14 @@
+import pages from './pages';
+import model from './model';
+
+import mainPage from './mainPage';
+import loginPage from './loginPage';
+
+import('./styles.css');
+
+const pageNames = ['login', 'main', 'profile'];
+
+pages.openPage('login');
+loginPage.handleEvents();
+mainPage.handleEvents();
+
diff --git a/projects/loft-photo/layout.html b/projects/loft-photo/layout.html
new file mode 100644
index 000000000..e72d67b61
--- /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..80cb0d661
--- /dev/null
+++ b/projects/loft-photo/loginPage.js
@@ -0,0 +1,16 @@
+
+import model from './model';
+import pages from './pages';
+import mainPage from './mainPage';
+
+export default {
+ handleEvents() {
+ document.querySelector('.page-login-button').addEventListener('click', async () => {
+ await model.login();
+ await model.init();
+
+ pages.openPage('main');
+ await mainPage.getNextPhoto();
+ });
+ },
+};
diff --git a/projects/loft-photo/mainPage.js b/projects/loft-photo/mainPage.js
new file mode 100644
index 000000000..c6adcdaf4
--- /dev/null
+++ b/projects/loft-photo/mainPage.js
@@ -0,0 +1,35 @@
+import model from './model';
+
+export default {
+ async getNextPhoto() {
+ const { friend, id, url } = await model.getNextPhoto();
+ this.setFriendAndPhoto(friend, id, url);
+ },
+
+ setFriendAndPhoto(friend, id, url) {
+ const photoComp = document.querySelector('.component-photo');
+ const headerPhotoComp = document.querySelector('.component-header-photo');
+ const headerNameComp = document.querySelector('.component-header-name');
+
+ headerPhotoComp.style.backgroundImage = `url('${friend.photo_50}')`;
+ headerNameComp.innerText = `${friend.first_name ?? ''} ${friend.last_name ?? ''}`;
+ photoComp.style.backgroundImage = `url(${url})`;
+ },
+
+ 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();
+ }
+ });
+ },
+};
diff --git a/projects/loft-photo/model.js b/projects/loft-photo/model.js
new file mode 100644
index 000000000..5e0a59e78
--- /dev/null
+++ b/projects/loft-photo/model.js
@@ -0,0 +1,92 @@
+const PERM_FRIENDS = 2;
+const PERM_PHOTOS = 4;
+const APP_ID = 51710464;
+
+export default {
+ getRandomElement(array) {
+ if (!array.length) {
+ return null;
+ }
+
+ const index = Math.round(Math.random() * (array.length - 1));
+
+ return array[index];
+ },
+
+ async getNextPhoto() {
+ const friend = this.getRandomElement(this.friends.items);
+ const photos = await this.getFriendPhotos(friend.id);
+ const photo = this.getRandomElement(photos.items);
+ const size = this.findSize(photo);
+
+ return { friend, id: photo, url: size.url }
+ },
+
+ findSize(photo) {
+ const size = photo.sizes.find((size) => size >= 360);
+
+ if (!size) {
+ return photo.sizes.reduce((biggest, current) => {
+ if (current.width > biggest.width) {
+ return curent;
+ }
+
+ return biggest;
+ }, photo.sizes[0]);
+ }
+
+ return size;
+ },
+
+ login() {
+ return new Promise((resolve, reject) => {
+ VK.init({
+ apiId: APP_ID,
+ });
+
+ VK.Auth.login((response) => {
+ if (response.session) {
+ resolve(response);
+ } else {
+ console.error(response);
+ reject(response);
+ }
+ }, PERM_FRIENDS | PERM_PHOTOS);
+ });
+ },
+
+ async init() {
+ this.photoCache = {};
+ this.friends = await this.getFriends();
+ },
+
+ getFriends() {
+ const params = {
+ fields: ['photo_50', 'photo_100'],
+ };
+
+ return this.callApi('friends.get', params);
+ },
+
+ 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;
+ },
+};
\ No newline at end of file
diff --git a/projects/loft-photo/pages.js b/projects/loft-photo/pages.js
new file mode 100644
index 000000000..6a60eee1e
--- /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 selector = pagesMap[name];
+ const element = document.querySelector(selector);
+
+ currentPage?.classList.add('hidden');
+ currentPage = element;
+ currentPage.classList.remove('hidden');
+ },
+ };
+
\ No newline at end of file
diff --git a/projects/loft-photo/photos.json b/projects/loft-photo/photos.json
new file mode 100644
index 000000000..a299e6057
--- /dev/null
+++ b/projects/loft-photo/photos.json
@@ -0,0 +1,72 @@
+{
+ "0": [
+ {
+ "id": 10,
+ "url": "https://via.placeholder.com/360x680?text=photo 1 for Adrian Norman"
+ },
+ {
+ "id": 11,
+ "url": "https://via.placeholder.com/360x680?text=photo 2 for Adrian Norman"
+ },
+ {
+ "id": 12,
+ "url": "https://via.placeholder.com/360x680?text=photo 3 for Adrian Norman"
+ }
+ ],
+ "1": [
+ {
+ "id": 20,
+ "url": "https://via.placeholder.com/360x680?text=photo 1 for Gail Norton"
+ },
+ {
+ "id": 21,
+ "url": "https://via.placeholder.com/360x680?text=photo 2 for Gail Norton"
+ },
+ {
+ "id": 22,
+ "url": "https://via.placeholder.com/360x680?text=photo 3 for Gail Norton"
+ }
+ ],
+ "2": [
+ {
+ "id": 30,
+ "url": "https://via.placeholder.com/360x680?text=photo 1 for Molina Rodgers"
+ },
+ {
+ "id": 31,
+ "url": "https://via.placeholder.com/360x680?text=photo 2 for Molina Rodgers"
+ },
+ {
+ "id": 32,
+ "url": "https://via.placeholder.com/360x680?text=photo 3 for Molina Rodgers"
+ }
+ ],
+ "3": [
+ {
+ "id": 40,
+ "url": "https://via.placeholder.com/360x680?text=photo 1 for Adams Parrish"
+ },
+ {
+ "id": 41,
+ "url": "https://via.placeholder.com/360x680?text=photo 2 for Adams Parrish"
+ },
+ {
+ "id": 42,
+ "url": "https://via.placeholder.com/360x680?text=photo 3 for Adams Parrish"
+ }
+ ],
+ "4": [
+ {
+ "id": 50,
+ "url": "https://via.placeholder.com/360x680?text=photo 1 for Mercer Wiggins"
+ },
+ {
+ "id": 51,
+ "url": "https://via.placeholder.com/360x680?text=photo 2 for Mercer Wiggins"
+ },
+ {
+ "id": 52,
+ "url": "https://via.placeholder.com/360x680?text=photo 3 for Mercer Wiggins"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/projects/loft-photo/server/index.js b/projects/loft-photo/server/index.js
new file mode 100644
index 000000000..e9057c6c4
--- /dev/null
+++ b/projects/loft-photo/server/index.js
@@ -0,0 +1,91 @@
+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) {
+ // todo
+ },
+ photoStats(req, res, url, vkUser) {
+ // todo
+ },
+ postComment(req, res, url, vkUser, body) {
+ // todo
+ },
+ getComments(req, res, url) {
+ // todo
+ },
+};
+
+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