From 14d5caaf9f5e0a4517bda5ba71165890b087c8a3 Mon Sep 17 00:00:00 2001
From: lalka2018 <99399973+lalka2016@users.noreply.github.com>
Date: Thu, 14 Sep 2023 20:36:29 +0300
Subject: [PATCH] Photos: AJAX support (#980)

* aj

* Drag'n'drop

* add good view
---
 Web/Presenters/PhotosPresenter.php            |  94 ++++++---
 Web/Presenters/templates/Photos/EditAlbum.xml |   9 +
 .../templates/Photos/UploadPhoto.xml          |  77 +++++---
 Web/static/css/main.css                       |  98 +++++++++-
 Web/static/js/al_photos.js                    | 179 ++++++++++++++++++
 locales/en.strings                            |  21 ++
 locales/ru.strings                            |  21 ++
 7 files changed, 442 insertions(+), 57 deletions(-)
 create mode 100644 Web/static/js/al_photos.js

diff --git a/Web/Presenters/PhotosPresenter.php b/Web/Presenters/PhotosPresenter.php
index 9d64ba20..11026d00 100644
--- a/Web/Presenters/PhotosPresenter.php
+++ b/Web/Presenters/PhotosPresenter.php
@@ -27,7 +27,7 @@ final class PhotosPresenter extends OpenVKPresenter
             if(!$user) $this->notFound();
             if (!$user->getPrivacyPermission('photos.read', $this->user->identity ?? NULL))
                 $this->flashFail("err", tr("forbidden"), tr("forbidden_comment"));
-            $this->template->albums  = $this->albums->getUserAlbums($user, $this->queryParam("p") ?? 1);
+            $this->template->albums  = $this->albums->getUserAlbums($user, (int)($this->queryParam("p") ?? 1));
             $this->template->count   = $this->albums->getUserAlbumsCount($user);
             $this->template->owner   = $user;
             $this->template->canEdit = false;
@@ -36,7 +36,7 @@ final class PhotosPresenter extends OpenVKPresenter
         } else {
             $club = (new Clubs)->get(abs($owner));
             if(!$club) $this->notFound();
-            $this->template->albums  = $this->albums->getClubAlbums($club, $this->queryParam("p") ?? 1);
+            $this->template->albums  = $this->albums->getClubAlbums($club, (int)($this->queryParam("p") ?? 1));
             $this->template->count   = $this->albums->getClubAlbumsCount($club);
             $this->template->owner   = $club;
             $this->template->canEdit = false;
@@ -46,7 +46,7 @@ final class PhotosPresenter extends OpenVKPresenter
         
         $this->template->paginatorConf = (object) [
             "count"   => $this->template->count,
-            "page"    => $this->queryParam("p") ?? 1,
+            "page"    => (int)($this->queryParam("p") ?? 1),
             "amount"  => NULL,
             "perPage" => OPENVK_DEFAULT_PER_PAGE,
         ];
@@ -147,7 +147,7 @@ final class PhotosPresenter extends OpenVKPresenter
         $this->template->photos = iterator_to_array( $album->getPhotos( (int) ($this->queryParam("p") ?? 1), 20) );
         $this->template->paginatorConf = (object) [
             "count"   => $album->getPhotosCount(),
-            "page"    => $this->queryParam("p") ?? 1,
+            "page"    => (int)($this->queryParam("p") ?? 1),
             "amount"  => sizeof($this->template->photos),
             "perPage" => 20,
             "atBottom" => true
@@ -221,39 +221,74 @@ final class PhotosPresenter extends OpenVKPresenter
     function renderUploadPhoto(): void
     {
         $this->assertUserLoggedIn();
-        $this->willExecuteWriteAction();
+        $this->willExecuteWriteAction(true);
         
         if(is_null($this->queryParam("album")))
-            $this->flashFail("err", "Неизвестная ошибка", "Не удалось сохранить фотографию в <b>DELETED</b>.");
+            $this->flashFail("err", "Неизвестная ошибка", "Не удалось сохранить фотографию в DELETED.", 500, true);
         
         [$owner, $id] = explode("_", $this->queryParam("album"));
         $album = $this->albums->get((int) $id);
         if(!$album)
-            $this->flashFail("err", "Неизвестная ошибка", "Не удалось сохранить фотографию в <b>DELETED</b>.");
+            $this->flashFail("err", "Неизвестная ошибка", "Не удалось сохранить фотографию в DELETED.", 500, true);
         if(is_null($this->user) || !$album->canBeModifiedBy($this->user->identity))
-            $this->flashFail("err", "Ошибка доступа", "Недостаточно прав для модификации данного ресурса.");
+            $this->flashFail("err", "Ошибка доступа", "Недостаточно прав для модификации данного ресурса.", 500, true);
         
         if($_SERVER["REQUEST_METHOD"] === "POST") {
-            if(!isset($_FILES["blob"]))
-                $this->flashFail("err", "Нету фотографии", "Выберите файл.");
-            
-            try {
-                $photo = new Photo;
-                $photo->setOwner($this->user->id);
-                $photo->setDescription($this->postParam("desc"));
-                $photo->setFile($_FILES["blob"]);
-                $photo->setCreated(time());
-                $photo->save();
-            } catch(ISE $ex) {
-                $name = $album->getName();
-                $this->flashFail("err", "Неизвестная ошибка", "Не удалось сохранить фотографию в <b>$name</b>.");
-            }
-            
-            $album->addPhoto($photo);
-            $album->setEdited(time());
-            $album->save();
+            if($this->queryParam("act") == "finish") {
+                $result = json_decode($this->postParam("photos"), true);
+                
+                foreach($result as $photoId => $description) {
+                    $phot = $this->photos->get($photoId);
 
-            $this->redirect("/photo" . $photo->getPrettyId() . "?from=album" . $album->getId());
+                    if(!$phot || $phot->isDeleted() || $phot->getOwner()->getId() != $this->user->id)
+                        continue;
+                    
+                    if(iconv_strlen($description) > 255)
+                        $this->flashFail("err", tr("error"), tr("description_too_long"), 500, true);
+
+                    $phot->setDescription($description);
+                    $phot->save();
+
+                    $album = $phot->getAlbum();
+                }
+
+                $this->returnJson(["success" => true,
+                                    "album"  => $album->getId(),
+                                    "owner"  => $album->getOwner() instanceof User ? $album->getOwner()->getId() : $album->getOwner()->getId() * -1]);
+            }
+
+            if(!isset($_FILES))
+                $this->flashFail("err", "Нету фотографии", "Выберите файл.", 500, true);
+            
+            $photos = [];
+            for($i = 0; $i < $this->postParam("count"); $i++) {
+                try {
+                    $photo = new Photo;
+                    $photo->setOwner($this->user->id);
+                    $photo->setDescription("");
+                    $photo->setFile($_FILES["photo_".$i]);
+                    $photo->setCreated(time());
+                    $photo->save();
+
+                    $photos[] = [
+                        "url"   => $photo->getURLBySizeId("tiny"),
+                        "id"    => $photo->getId(),
+                        "vid"   => $photo->getVirtualId(),
+                        "owner" => $photo->getOwner()->getId(),
+                        "link"  => $photo->getURL()
+                    ];
+                } catch(ISE $ex) {
+                    $name = $album->getName();
+                    $this->flashFail("err", "Неизвестная ошибка", "Не удалось сохранить фотографию в $name.", 500, true);
+                }
+
+                $album->addPhoto($photo);
+                $album->setEdited(time());
+                $album->save();
+            }
+
+            $this->returnJson(["success" => true,
+                "photos" => $photos]);
         } else {
             $this->template->album = $album;
         }
@@ -285,7 +320,7 @@ final class PhotosPresenter extends OpenVKPresenter
     function renderDeletePhoto(int $ownerId, int $photoId): void
     {
         $this->assertUserLoggedIn();
-        $this->willExecuteWriteAction();
+        $this->willExecuteWriteAction($_SERVER["REQUEST_METHOD"] === "POST");
         $this->assertNoCSRF();
         
         $photo = $this->photos->getByOwnerAndVID($ownerId, $photoId);
@@ -298,6 +333,9 @@ final class PhotosPresenter extends OpenVKPresenter
         $photo->isolate();
         $photo->delete();
         
+        if($_SERVER["REQUEST_METHOD"] === "POST")
+            $this->returnJson(["success" => true]);
+
         $this->flash("succ", "Фотография удалена", "Эта фотография была успешно удалена.");
         $this->redirect($redirect);
     }
diff --git a/Web/Presenters/templates/Photos/EditAlbum.xml b/Web/Presenters/templates/Photos/EditAlbum.xml
index 271d1034..a10b5c7f 100644
--- a/Web/Presenters/templates/Photos/EditAlbum.xml
+++ b/Web/Presenters/templates/Photos/EditAlbum.xml
@@ -14,6 +14,15 @@
 {/block}
 
 {block content}
+    <div class="tabs">
+        <div id="activetabs" class="tab">
+            <a id="act_tab_a" href="/album{$album->getPrettyId()}/edit">{_edit_album}</a>
+        </div>
+        <div class="tab">
+            <a href="/photos/upload?album={$album->getPrettyId()}">{_add_photos}</a>
+        </div>
+    </div>
+
     <form method="post" enctype="multipart/form-data">
       <table cellspacing="6">
         <tbody>
diff --git a/Web/Presenters/templates/Photos/UploadPhoto.xml b/Web/Presenters/templates/Photos/UploadPhoto.xml
index 9876e5b9..6ea987cb 100644
--- a/Web/Presenters/templates/Photos/UploadPhoto.xml
+++ b/Web/Presenters/templates/Photos/UploadPhoto.xml
@@ -12,32 +12,53 @@
 {/block}
 
 {block content}
-    <form action="/photos/upload?album={$album->getPrettyId()}" method="post" enctype="multipart/form-data">
-      <table cellspacing="6">
-        <tbody>
-          <tr>
-            <td width="120" valign="top"><span class="nobold">{_description}:</span></td>
-            <td><textarea style="margin: 0px; height: 50px; width: 159px; resize: none;" name="desc"></textarea></td>
-          </tr>
-          <tr>
-            <td width="120" valign="top"><span class="nobold">{_photo}:</span></td>
-            <td>
-              <label class="button" style="">{_browse}
-                <input type="file" id="blob" name="blob" style="display: none;" onchange="filename.innerHTML=blob.files[0].name" />
-              </label>
-              <div id="filename" style="margin-top: 10px;"></div>
-            </td>
-          </tr>
-          <tr>
-            <td width="120" valign="top"></td>
-            <td>
-                <input type="hidden" name="hash" value="{$csrfToken}" />
-                <input type="submit" class="button" name="submit" value="Загрузить" />
-            </td>
-          </tr>
-        </tbody>
-      </table>
-      
-      <input n:ifset="$_GET['album']" type="hidden" name="album" value="{$_GET['album']}" />
-    </form>
+    <div class="tabs">
+        <div class="tab">
+            <a href="/album{$album->getPrettyId()}/edit">{_edit_album}</a>
+        </div>
+        <div id="activetabs" class="tab">
+            <a id="act_tab_a" href="#">{_add_photos}</a>
+        </div>
+    </div>
+
+    <input type="file" accept=".jpg,.png,.gif" name="files[]" multiple class="button" id="uploadButton" style="display:none">
+
+    <div class="container_gray" style="height: 344px;">
+        <div class="insertThere"></div>
+        <div class="whiteBox" style="display: block;">
+            <div class="boxContent">
+                <h4>{_uploading_photos_from_computer}</h4>
+
+                <div class="limits" style="margin-top:17px">
+                    <b style="color:#45688E">{_admin_limits}</b>
+                    <ul class="blueList" style="margin-left: -25px;margin-top: 1px;">
+                        <li>{_supported_formats}</li>
+                        <li>{_max_load_photos}</li>
+                    </ul>
+
+                    <div style="text-align: center;padding-top: 4px;" class="insertAgain">
+                        <input type="button" class="button" id="fakeButton" onclick="uploadButton.click()" value="{_upload_picts}">
+                    </div>
+
+                    <div class="tipping" style="margin-top: 19px;">
+                        <span style="line-height: 15px"><b>{_tip}</b>: {_tip_ctrl}</span>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <div class="insertPhotos" id="photos" style="margin-top: 9px;padding-bottom: 12px;"></div>
+
+        <input type="button" class="button" style="display:none;margin-left: auto;margin-right: auto;" id="endUploading" value="{_end_uploading}">
+    </div>
+
+    <input n:ifset="$_GET['album']" type="hidden" id="album" value="{$_GET['album']}" />
+
+    <script>
+        uploadButton.value = ''
+    </script>
+{/block}
+
+{block bodyScripts}
+    {script "js/al_photos.js"}
 {/block}
diff --git a/Web/static/css/main.css b/Web/static/css/main.css
index 55484f13..f05d0a2b 100644
--- a/Web/static/css/main.css
+++ b/Web/static/css/main.css
@@ -2700,4 +2700,100 @@ body.article .floating_sidebar, body.article .page_content {
     position: absolute;
     right: 22px;
     font-size: 12px;
-}
\ No newline at end of file
+}
+
+.uploadedImage img {
+    max-height: 76px;
+    object-fit: cover;
+}
+
+.lagged {
+    filter: opacity(0.5);
+    cursor: progress;
+    user-select: none;
+}
+
+.lagged * {
+    pointer-events: none;
+}
+
+.button.dragged {
+    background: #c4c4c4 !important;
+    border-color: #c4c4c4 !important;
+    color: black !important;
+}
+
+.whiteBox {
+    background: white;
+    width: 421px;
+    margin-left: auto;
+    margin-right: auto;
+    border: 1px solid #E8E8E8;
+    margin-top: 7%;
+    height: 231px;
+}
+
+.boxContent {
+    padding: 24px 38px;
+}
+
+.blueList {
+    list-style-type: none;
+}
+
+.blueList li {
+    color: black;
+    font-size: 11px;
+    padding-top: 7px;
+}
+
+.blueList li::before {
+    content: " ";
+    width: 5px;
+    height: 5px;
+    display: inline-block;
+    vertical-align: bottom;
+    background-color: #73889C;
+    margin: 3px;
+    margin-left: 2px;
+    margin-right: 7px;
+}
+
+.insertedPhoto {
+    background: white;
+    border: 1px solid #E8E7EA;
+    padding: 10px;
+    height: 100px;
+    margin-top: 6px;
+}
+
+.uploadedImage {
+    float: right;
+    display: flex;
+    flex-direction: column;
+}
+
+.uploadedImageDescription {
+    width: 449px;
+}
+
+.uploadedImageDescription textarea {
+    width: 84%;
+    height: 86px;
+}
+
+.smallFrame {
+    border: 1px solid #E1E3E5;
+    background: #F0F0F0;
+    height: 33px;
+    text-align: center;
+    cursor: pointer;
+}
+
+.smallFrame .smallBtn {
+    margin-top: 10px;
+}
+
+.smallFrame:hover {
+    background: #E9F0F1 !important;
+}
diff --git a/Web/static/js/al_photos.js b/Web/static/js/al_photos.js
new file mode 100644
index 00000000..59965c09
--- /dev/null
+++ b/Web/static/js/al_photos.js
@@ -0,0 +1,179 @@
+$(document).on("change", "#uploadButton", (e) => {
+    let iterator = 0
+
+    if(e.currentTarget.files.length > 10) {
+        MessageBox(tr("error"), tr("too_many_pictures"), [tr("ok")], [() => {Function.noop}])
+        return;
+    }
+
+    if(document.querySelector(".whiteBox").style.display == "block") {
+        document.querySelector(".whiteBox").style.display = "none"
+        document.querySelector(".insertThere").append(document.getElementById("fakeButton"));
+    }
+
+    let photos = new FormData()
+    for(file of e.currentTarget.files) {
+        photos.append("photo_"+iterator, file)
+        iterator += 1
+    }
+
+    photos.append("count", e.currentTarget.files.length)
+    photos.append("hash", u("meta[name=csrf]").attr("value"))
+
+    let xhr = new XMLHttpRequest()
+    xhr.open("POST", "/photos/upload?album="+document.getElementById("album").value)
+
+    xhr.onloadstart = () => {
+        document.querySelector(".insertPhotos").insertAdjacentHTML("beforeend", `<img id="loader" src="/assets/packages/static/openvk/img/loading_mini.gif">`)
+    }
+
+    xhr.onload = () => {
+        let result = JSON.parse(xhr.responseText)
+
+        if(result.success) {
+            u("#loader").remove()
+            let photosArr = result.photos
+
+            for(photo of photosArr) {
+                let table = document.querySelector(".insertPhotos")
+
+                table.insertAdjacentHTML("beforeend", `
+                <div id="photo" class="insertedPhoto" data-id="${photo.id}">
+                    <div class="uploadedImageDescription" style="float: left;">
+                        <span style="color: #464646;position: absolute;">${tr("description")}:</span>
+                        <textarea style="margin-left: 62px; resize: none;" maxlength="255"></textarea>
+                    </div>
+                    <div class="uploadedImage">
+                        <a href="${photo.link}" target="_blank"><img width="125" src="${photo.url}"></a>
+                        <a class="profile_link" style="width: 125px;" id="deletePhoto" data-id="${photo.vid}" data-owner="${photo.owner}">${tr("delete")}</a>
+                        <!--<div class="smallFrame" style="margin-top: 6px;">
+                            <div class="smallBtn">${tr("album_poster")}</div>
+                        </div>-->
+                    </div>
+                </div>
+                `)
+            }
+
+            document.getElementById("endUploading").style.display = "block"
+        } else {
+            u("#loader").remove()
+            MessageBox(tr("error"), escapeHtml(result.flash.message) ?? tr("error_uploading_photo"), [tr("ok")], [() => {Function.noop}])
+        }
+    }
+
+    xhr.send(photos)
+})
+
+$(document).on("click", "#endUploading", (e) => {
+    let table = document.querySelector("#photos")
+    let data  = new FormData()
+    let arr   = new Map();
+    for(el of table.querySelectorAll("div#photo")) {
+        arr.set(el.dataset.id, el.querySelector("textarea").value)
+    }
+
+    data.append("photos", JSON.stringify(Object.fromEntries(arr)))
+    data.append("hash", u("meta[name=csrf]").attr("value"))
+
+    let xhr = new XMLHttpRequest()
+    // в самом вк на каждое изменение описания отправляется свой запрос, но тут мы экономим запросы
+    xhr.open("POST", "/photos/upload?act=finish&album="+document.getElementById("album").value)
+
+    xhr.onloadstart = () => {
+        e.currentTarget.setAttribute("disabled", "disabled")
+    }
+
+    xhr.onerror = () => {
+        MessageBox(tr("error"), tr("error_uploading_photo"), [tr("ok")], [() => {Function.noop}])
+    }
+
+    xhr.onload = () => {
+        let result = JSON.parse(xhr.responseText)
+
+        if(!result.success) {
+            MessageBox(tr("error"), escapeHtml(result.flash.message), [tr("ok")], [() => {Function.noop}])
+        } else {
+            document.querySelector(".page_content .insertPhotos").innerHTML = ""
+            document.getElementById("endUploading").style.display = "none"
+    
+            NewNotification(tr("photos_successfully_uploaded"), tr("click_to_go_to_album"), null, () => {window.location.assign(`/album${result.owner}_${result.album}`)})
+            
+            document.querySelector(".whiteBox").style.display = "block"
+            document.querySelector(".insertAgain").append(document.getElementById("fakeButton"))
+        }
+
+        e.currentTarget.removeAttribute("disabled")
+    }
+
+    xhr.send(data)
+})
+
+$(document).on("click", "#deletePhoto", (e) => {
+    let data  = new FormData()
+    data.append("hash", u("meta[name=csrf]").attr("value"))
+
+    let xhr = new XMLHttpRequest()
+    xhr.open("POST", `/photo${e.currentTarget.dataset.owner}_${e.currentTarget.dataset.id}/delete`)
+
+    xhr.onloadstart = () => {
+        e.currentTarget.closest("div#photo").classList.add("lagged")
+    }
+
+    xhr.onerror = () => {
+        MessageBox(tr("error"), tr("unknown_error"), [tr("ok")], [() => {Function.noop}])
+    }
+
+    xhr.onload = () => {
+        u(e.currentTarget.closest("div#photo")).remove()
+
+        if(document.querySelectorAll("div#photo").length < 1) {
+            document.getElementById("endUploading").style.display = "none"
+            document.querySelector(".whiteBox").style.display = "block"
+            document.querySelector(".insertAgain").append(document.getElementById("fakeButton"))
+        }
+    }
+
+    xhr.send(data)
+})
+
+$(document).on("dragover drop", (e) => {
+    e.preventDefault()
+
+    return false;
+})
+
+$(document).on("dragover", (e) => {
+    e.preventDefault()
+    document.querySelector("#fakeButton").classList.add("dragged")
+    document.querySelector("#fakeButton").value = tr("drag_files_here")
+})
+
+$(document).on("dragleave", (e) => {
+    e.preventDefault()
+    document.querySelector("#fakeButton").classList.remove("dragged")
+    document.querySelector("#fakeButton").value = tr("upload_picts")
+})
+
+$("#fakeButton").on("drop", (e) => {
+    e.originalEvent.dataTransfer.dropEffect = 'move';
+    e.preventDefault()
+
+    $(document).trigger("dragleave")
+
+    let files = e.originalEvent.dataTransfer.files
+
+    for(const file of files) {
+        if(!file.type.startsWith('image/')) {
+            MessageBox(tr("error"), tr("only_images_accepted", escapeHtml(file.name)), [tr("ok")], [() => {Function.noop}])
+            return;
+        }
+
+        if(file.size > 5 * 1024 * 1024) {
+            MessageBox(tr("error"), tr("max_filesize", 5), [tr("ok")], [() => {Function.noop}])
+            return;
+        }
+    }
+
+    document.getElementById("uploadButton").files = files
+    u("#uploadButton").trigger("change")
+})
diff --git a/locales/en.strings b/locales/en.strings
index 487d2156..780da8b3 100644
--- a/locales/en.strings
+++ b/locales/en.strings
@@ -381,6 +381,25 @@
 "upd_f" = "updated her profile picture";
 "upd_g" = "updated group's picture";
 
+"add_photos" = "Add photos";
+"upload_picts" = "Upload photos";
+"end_uploading" = "Finish uploading";
+"photos_successfully_uploaded" = "Photos successfully uploaded";
+"click_to_go_to_album" = "Click here to go to album.";
+"error_uploading_photo" = "Error when uploading photo";
+"too_many_pictures" = "No more than 10 pictures";
+
+"drag_files_here" = "Drag files here";
+"only_images_accepted" = "File \"$1\" is not an image";
+"max_filesize" = "Max filesize is $1 MB";
+
+"uploading_photos_from_computer" = "Uploading photos from Your computer";
+"supported_formats" = "Supported file formats: JPG, PNG and GIF.";
+"max_load_photos" = "You can upload up to 10 photos at a time.";
+"tip" = "Tip";
+"tip_ctrl" = "to select multiple photos at once, hold down the Ctrl key when selecting files in Windows or the CMD key in Mac OS.";
+"album_poster" = "Album poster";
+
 /* Notes */
 
 "notes" = "Notes";
@@ -1066,6 +1085,7 @@
 "error_data_too_big" = "Attribute '$1' must be at most $2 $3 long";
 
 "forbidden" = "Access error";
+"unknown_error" = "Unknown error";
 "forbidden_comment" = "This user's privacy settings do not allow you to look at his page.";
 
 "changes_saved" = "Changes saved";
@@ -1117,6 +1137,7 @@
 "media_file_corrupted_or_too_large" = "The media content file is corrupted or too large.";
 "post_is_empty_or_too_big" = "The post is empty or too big.";
 "post_is_too_big" = "The post is too big.";
+"description_too_long" = "Description is too long.";
 
 /* Admin actions */
 
diff --git a/locales/ru.strings b/locales/ru.strings
index dc101e2b..2364175a 100644
--- a/locales/ru.strings
+++ b/locales/ru.strings
@@ -364,6 +364,25 @@
 "upd_f" = "обновила фотографию на своей странице";
 "upd_g" = "обновило фотографию группы";
 
+"add_photos" = "Добавить фотографии";
+"upload_picts" = "Загрузить фотографии";
+"end_uploading" = "Завершить загрузку";
+"photos_successfully_uploaded" = "Фотографии успешно загружены";
+"click_to_go_to_album" = "Нажмите, чтобы перейти к альбому.";
+"error_uploading_photo" = "Не удалось загрузить фотографию";
+"too_many_pictures" = "Не больше 10 фотографий";
+
+"drag_files_here" = "Перетащите файлы сюда";
+"only_images_accepted" = "Файл \"$1\" не является изображением";
+"max_filesize" = "Максимальный размер файла — $1 мегабайт";
+
+"uploading_photos_from_computer" = "Загрузка фотографий с Вашего компьютера";
+"supported_formats" = "Поддерживаемые форматы файлов: JPG, PNG и GIF.";
+"max_load_photos" = "Вы можете загружать до 10 фотографий за один раз.";
+"tip" = "Подсказка";
+"tip_ctrl" = "для того, чтобы выбрать сразу несколько фотографий, удерживайте клавишу Ctrl при выборе файлов в ОС Windows или клавишу CMD в Mac OS.";
+"album_poster" = "Обложка альбома";
+
 /* Notes */
 
 "notes" = "Заметки";
@@ -982,6 +1001,7 @@
 "error_repost_fail" = "Не удалось поделиться записью";
 "error_data_too_big" = "Аттрибут '$1' не может быть длиннее $2 $3";
 "forbidden" = "Ошибка доступа";
+"unknown_error" = "Неизвестная ошибка";
 "forbidden_comment" = "Настройки приватности этого пользователя не разрешают вам смотреть на его страницу.";
 "changes_saved" = "Изменения сохранены";
 "changes_saved_comment" = "Новые данные появятся на вашей странице";
@@ -1017,6 +1037,7 @@
 "media_file_corrupted_or_too_large" = "Файл медиаконтента повреждён или слишком велик.";
 "post_is_empty_or_too_big" = "Пост пустой или слишком большой.";
 "post_is_too_big" = "Пост слишком большой.";
+"description_too_long" = "Описание слишком длинное.";
 
 /* Admin actions */