diff --git a/projects/gp-3/friendsFilter.js b/projects/gp-3/friendsFilter.js
new file mode 100644
index 0000000..4b58a3b
--- /dev/null
+++ b/projects/gp-3/friendsFilter.js
@@ -0,0 +1,173 @@
+import VKAPI from './vkAPI';
+import FriendsList from './friendsList';
+import { LocalStorage, VKStorage } from './storage';
+
+export default class FriendsFilter {
+ constructor() {
+ this.lsKey = 'LS_FRIENDS_FILTER';
+ this.allFriendsDOMFilter = document.querySelector(
+ '[data-role=filter-input][data-list=all]'
+ );
+ this.allFriendsDOMList = document.querySelector(
+ '[data-role=list-items][data-list=all]'
+ );
+ this.bestFriendsDOMFilter = document.querySelector(
+ '[data-role=filter-input][data-list=best]'
+ );
+ this.bestFriendsDOMList = document.querySelector(
+ '[data-role=list-items][data-list=best]'
+ );
+
+ this.api = new VKAPI(6789124, 2);
+ this.allFriends = new FriendsList(new VKStorage(this.api));
+ this.bestFriends = new FriendsList(new LocalStorage(this.api, this.lsKey));
+
+ this.init();
+ }
+
+ async init() {
+ await this.api.init();
+ await this.api.login();
+ await this.allFriends.load();
+ await this.bestFriends.load();
+
+ for (const item of this.bestFriends.valuesIterable()) {
+ await this.allFriends.delete(item.id);
+ }
+
+ this.reloadList(this.allFriendsDOMList, this.allFriends);
+ this.reloadList(this.bestFriendsDOMList, this.bestFriends);
+
+ document.addEventListener('mousedown', this.onMouseDown.bind(this));
+ document.addEventListener('mousemove', this.onMouseMove.bind(this));
+ document.addEventListener('mouseup', this.onMouseUp.bind(this));
+ document.addEventListener('click', this.onClick.bind(this));
+
+ this.allFriendsDOMFilter.addEventListener('input', (e) => {
+ this.allFriendsFilter = e.target.value;
+ this.reloadList(this.allFriendsDOMList, this.allFriends, this.allFriendsFilter);
+ });
+ this.bestFriendsDOMFilter.addEventListener('input', (e) => {
+ this.bestFriendsFilter = e.target.value;
+ this.reloadList(this.bestFriendsDOMList, this.bestFriends, this.bestFriendsFilter);
+ });
+ }
+
+ isMatchingFilter(source, filter) {
+ return source.toLowerCase().includes(filter.toLowerCase());
+ }
+
+ reloadList(listDOM, friendsList, filter) {
+ const fragment = document.createDocumentFragment();
+
+ listDOM.innerHTML = '';
+
+ for (const friend of friendsList.valuesIterable()) {
+ const fullName = `${friend.first_name} ${friend.last_name}`;
+
+ if (!filter || this.isMatchingFilter(fullName, filter)) {
+ const friendDOM = this.createFriendDOM(friend);
+ fragment.append(friendDOM);
+ }
+ }
+
+ listDOM.append(fragment);
+ }
+
+ createFriendDOM(data) {
+ const root = document.createElement('div');
+
+ root.dataset.role = 'list-item';
+ root.dataset.friendId = data.id;
+ root.classList.add('list-item');
+ root.innerHTML = `
+
+
${data.first_name} ${data.last_name}
+
+ `;
+
+ return root;
+ }
+
+ move(friendId, from, to) {
+ if (from === 'all' && to === 'best') {
+ const friend = this.allFriends.delete(friendId);
+ this.bestFriends.add(friend);
+ } else if (from === 'best' && to === 'all') {
+ const friend = this.bestFriends.delete(friendId);
+ this.allFriends.add(friend);
+ }
+
+ this.bestFriends.save();
+ this.reloadList(this.allFriendsDOMList, this.allFriends, this.allFriendsFilter);
+ this.reloadList(this.bestFriendsDOMList, this.bestFriends, this.bestFriendsFilter);
+ }
+
+ onMouseDown(e) {
+ const sourceItem = e.target.closest('[data-role=list-item]');
+
+ if (!sourceItem) {
+ return;
+ }
+
+ const friendId = sourceItem.dataset.friendId;
+ const sourceList = e.target.closest('[data-role=list-items]').dataset.list;
+
+ this.dragging = {
+ offsetX: e.offsetX,
+ offsetY: e.offsetY,
+ sourceItem,
+ friendId,
+ sourceList,
+ pending: true,
+ };
+ }
+
+ onMouseMove(e) {
+ if (!this.dragging) {
+ return;
+ }
+
+ e.preventDefault();
+
+ if (this.dragging.pending) {
+ const rect = this.dragging.sourceItem.getBoundingClientRect();
+ const clone = this.dragging.sourceItem.cloneNode(true);
+ clone.classList.add('list-item-clone');
+ clone.style.width = `${rect.width}px`;
+ clone.style.height = `${rect.height}px`;
+ document.body.append(clone);
+ this.dragging.pending = false;
+ this.dragging.clone = clone;
+ }
+
+ this.dragging.clone.style.left = `${e.clientX - this.dragging.offsetX}px`;
+ this.dragging.clone.style.top = `${e.clientY - this.dragging.offsetY}px`;
+ }
+
+ onMouseUp(e) {
+ if (!this.dragging || this.dragging.pending) {
+ this.dragging = null;
+ return;
+ }
+
+ const targetList = e.target.closest('[data-role=list-items]');
+
+ if (targetList) {
+ const moveTo = targetList.dataset.list;
+ this.move(this.dragging.friendId, this.dragging.sourceList, moveTo);
+ }
+
+ this.dragging.clone.remove();
+ this.dragging = null;
+ }
+
+ onClick(e) {
+ if (e.target.dataset.role === 'list-item-swap') {
+ const sourceList = e.target.closest('[data-role=list-items]').dataset.list;
+ const friendId = e.target.dataset.friendId;
+
+ this.move(friendId, sourceList, sourceList === 'all' ? 'best' : 'all');
+ }
+ }
+}
diff --git a/projects/gp-3/friendsList.js b/projects/gp-3/friendsList.js
new file mode 100644
index 0000000..849fb24
--- /dev/null
+++ b/projects/gp-3/friendsList.js
@@ -0,0 +1,40 @@
+export default class FriendsList {
+ constructor(storage) {
+ this.storage = storage;
+ this.data = new Map();
+ }
+
+ async load() {
+ this.data.clear();
+ const list = await this.storage.load();
+
+ for (const item of list) {
+ this.add(item);
+ }
+ }
+
+ save() {
+ this.storage.save([...this.data.values()]);
+ }
+
+ add(friend) {
+ if (!this.data.has(friend.id)) {
+ this.data.set(friend.id, friend);
+ }
+ }
+
+ delete(friendId) {
+ friendId = Number(friendId);
+ const friend = this.data.get(friendId);
+
+ if (friend) {
+ this.data.delete(friendId);
+ }
+
+ return friend;
+ }
+
+ valuesIterable() {
+ return this.data.values();
+ }
+}
diff --git a/projects/gp-3/index.html b/projects/gp-3/index.html
new file mode 100644
index 0000000..302e344
--- /dev/null
+++ b/projects/gp-3/index.html
@@ -0,0 +1,131 @@
+
+
+
diff --git a/projects/gp-3/index.js b/projects/gp-3/index.js
new file mode 100644
index 0000000..ea2d9a8
--- /dev/null
+++ b/projects/gp-3/index.js
@@ -0,0 +1,7 @@
+import './index.html';
+import FriendsFilter from './friendsFilter';
+
+new FriendsFilter(
+ document.querySelector('.list.all-friends'),
+ document.querySelector('.list.best-friends')
+);
diff --git a/projects/gp-3/storage.js b/projects/gp-3/storage.js
new file mode 100644
index 0000000..c1e453f
--- /dev/null
+++ b/projects/gp-3/storage.js
@@ -0,0 +1,30 @@
+export class LocalStorage {
+ constructor(api, key) {
+ this.api = api;
+ this.key = key;
+ }
+
+ async load() {
+ const friendsIds = JSON.parse(localStorage.getItem(this.key) || '[]');
+ return await this.api.getUsers(friendsIds);
+ }
+
+ save(data) {
+ localStorage.setItem(this.key, JSON.stringify(data.map((item) => item.id)));
+ }
+}
+
+export class VKStorage {
+ constructor(api) {
+ this.api = api;
+ }
+
+ async load() {
+ const response = await this.api.getFriends();
+ return response.items;
+ }
+
+ save() {
+ throw new Error('not supported');
+ }
+}
diff --git a/projects/gp-3/vkAPI.js b/projects/gp-3/vkAPI.js
new file mode 100644
index 0000000..c932add
--- /dev/null
+++ b/projects/gp-3/vkAPI.js
@@ -0,0 +1,64 @@
+/* global VK */
+
+export default class VKAPI {
+ constructor(appId, perms) {
+ this.appId = appId;
+ this.perms = perms;
+ }
+
+ init() {
+ return new Promise((resolve) => {
+ const script = document.createElement('script');
+ script.src = 'http://vk.com/js/api/openapi.js';
+ document.body.appendChild(script);
+ script.addEventListener('load', resolve);
+ });
+ }
+
+ login() {
+ return new Promise((resolve, reject) => {
+ VK.init({
+ apiId: this.appId,
+ });
+
+ VK.Auth.login((response) => {
+ if (response.session) {
+ resolve(response);
+ } else {
+ reject(new Error('Не удалось авторизоваться'));
+ }
+ }, this.perms);
+ });
+ }
+
+ callApi(method, params) {
+ params.v = params.v || '5.120';
+
+ return new Promise((resolve, reject) => {
+ VK.api(method, params, (response) => {
+ if (response.error) {
+ reject(new Error(response.error.error_msg));
+ } else {
+ resolve(response.response);
+ }
+ });
+ });
+ }
+
+ getFriends() {
+ const params = {
+ fields: ['photo_50'],
+ };
+
+ return this.callApi('friends.get', params);
+ }
+
+ getUsers(ids) {
+ const params = {
+ fields: ['photo_50'],
+ user_ids: ids,
+ };
+
+ return this.callApi('users.get', params);
+ }
+}