diff --git a/README.md b/README.md
index 6c42852..d810771 100644
--- a/README.md
+++ b/README.md
@@ -323,6 +323,32 @@ docker compose up -d --build
---
+## Релиз v2.0
+
+**Загрузка по прямым ссылкам**
+
+- В форме загрузки вкладка «Ссылки»
+- Вставьте одну или несколько прямых URL на изображения (HTTP/HTTPS)
+- Поддерживаются те же форматы и лимиты, что и при обычной загрузке
+
+**Поделиться: BBCode, HTML, QR**
+
+- На каждой фотографии кнопки **Ссылка**, **BBCode** и **QR**
+- BBCode для форумов: `[img]https://...[/img]`
+- HTML для сайтов: `
`
+- QR-код открывается в модальном окне с быстрым копированием всех форматов
+
+**Обновление до v2.0 на сервере:**
+
+```bash
+cd ~/fotohost
+git fetch --tags
+git checkout v2.0
+docker compose up -d --build
+```
+
+---
+
## Релиз v1.4
**Лимиты групп пользователей**
@@ -493,7 +519,8 @@ python wsgi.py
| GET | `/cabinet/` | Личный кабинет |
| GET | `/admin/` | Админ-панель |
| GET | `/admin/banners` | Управление рекламными баннерами |
-| POST | `/upload` | Загрузка фото (auth) |
+| GET | `/photo//qr` | QR-код для прямой ссылки на фото |
+| POST | `/upload` | Загрузка фото или по URL (auth) |
| GET | `/uploads/` | Прямая ссылка на файл |
| GET | `/api/photos` | JSON-список всех фото |
| POST | `/delete/` | Удаление фото |
diff --git a/app/routes.py b/app/routes.py
index 66cb71c..1825c3c 100644
--- a/app/routes.py
+++ b/app/routes.py
@@ -21,7 +21,7 @@ from app.folder_utils import can_edit_folder
from app.models import Folder, Photo
from app.settings_service import get_settings
from app.storage_service import delete_photo_file, get_photo_stream
-from app.upload_service import process_uploads
+from app.upload_service import process_uploads, process_url_uploads
from sqlalchemy import text
bp = Blueprint("main", __name__)
@@ -64,12 +64,24 @@ def upload():
if not can_edit_folder(folder):
abort(403)
- result = process_uploads(
- request.files,
- current_user,
- folder,
- current_app.config["ALLOWED_EXTENSIONS"],
- )
+ image_urls = request.form.get("image_urls", "").strip()
+ max_upload_mb = current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024)
+
+ if image_urls:
+ result = process_url_uploads(
+ image_urls,
+ current_user,
+ folder,
+ current_app.config["ALLOWED_EXTENSIONS"],
+ max_upload_mb,
+ )
+ else:
+ result = process_uploads(
+ request.files,
+ current_user,
+ folder,
+ current_app.config["ALLOWED_EXTENSIONS"],
+ )
if result["uploaded"] == 0 and result["errors"]:
flash(result["errors"][0], "error")
@@ -115,6 +127,28 @@ def api_photos():
)
+@bp.route("/photo//qr")
+def photo_qr(photo_id):
+ import io
+
+ import qrcode
+
+ from app.share_utils import photo_absolute_url
+
+ photo = Photo.query.get_or_404(photo_id)
+ target = photo_absolute_url(photo, request.url_root)
+
+ qr = qrcode.QRCode(box_size=8, border=2)
+ qr.add_data(target)
+ qr.make(fit=True)
+ img = qr.make_image(fill_color="black", back_color="white")
+
+ buf = io.BytesIO()
+ img.save(buf, format="PNG")
+ buf.seek(0)
+ return send_file(buf, mimetype="image/png", download_name=f"photo-{photo.id}-qr.png")
+
+
@bp.route("/uploads/")
def uploaded_file(filename):
photo = Photo.query.filter_by(filename=filename).first()
diff --git a/app/share_utils.py b/app/share_utils.py
new file mode 100644
index 0000000..c1c67dd
--- /dev/null
+++ b/app/share_utils.py
@@ -0,0 +1,12 @@
+def photo_absolute_url(photo, base_url):
+ return f"{base_url.rstrip('/')}{photo.url}"
+
+
+def photo_bbcode(photo, base_url):
+ return f"[img]{photo_absolute_url(photo, base_url)}[/img]"
+
+
+def photo_html(photo, base_url):
+ url = photo_absolute_url(photo, base_url)
+ name = photo.original_name.replace('"', """)
+ return f'
'
diff --git a/app/static/css/style.css b/app/static/css/style.css
index 7f6cd04..0ee3893 100644
--- a/app/static/css/style.css
+++ b/app/static/css/style.css
@@ -420,7 +420,9 @@ body {
display: flex;
align-items: center;
justify-content: center;
- gap: 8px;
+ flex-wrap: wrap;
+ gap: 6px;
+ padding: 12px;
background: rgba(0, 0, 0, 0.6);
opacity: 0;
transition: opacity 0.2s;
@@ -1160,3 +1162,160 @@ body {
.admin-table--groups td {
vertical-align: top;
}
+
+.upload-tabs {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 16px;
+}
+
+.upload-tabs__btn {
+ border: 1px solid var(--border);
+ background: transparent;
+ color: var(--text-muted);
+ border-radius: 999px;
+ padding: 8px 16px;
+ cursor: pointer;
+ font: inherit;
+}
+
+.upload-tabs__btn--active {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: #fff;
+}
+
+.upload-panel {
+ display: none;
+}
+
+.upload-panel--active {
+ display: block;
+}
+
+.url-upload {
+ margin-bottom: 16px;
+}
+
+.url-upload__label {
+ display: block;
+ margin-bottom: 8px;
+ font-weight: 500;
+}
+
+.url-upload__input {
+ width: 100%;
+ min-height: 120px;
+ padding: 12px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-card);
+ color: inherit;
+ font: inherit;
+ resize: vertical;
+}
+
+.url-upload__hint {
+ margin-top: 8px;
+ font-size: 0.85rem;
+ color: var(--text-muted);
+}
+
+.share-modal[hidden] {
+ display: none;
+}
+
+.share-modal {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+}
+
+.share-modal__backdrop {
+ position: absolute;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.65);
+}
+
+.share-modal__dialog {
+ position: relative;
+ width: min(100%, 520px);
+ max-height: 90vh;
+ overflow-y: auto;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 24px;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
+}
+
+.share-modal__close {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ border: none;
+ background: transparent;
+ color: var(--text-muted);
+ font-size: 1.5rem;
+ cursor: pointer;
+}
+
+.share-modal__title {
+ margin-bottom: 8px;
+}
+
+.share-modal__name {
+ margin-bottom: 16px;
+ color: var(--text-muted);
+ font-size: 0.9rem;
+ word-break: break-all;
+}
+
+.share-modal__qr-wrap {
+ display: flex;
+ justify-content: center;
+ margin-bottom: 20px;
+}
+
+.share-modal__qr {
+ width: 180px;
+ height: 180px;
+ border-radius: var(--radius-sm);
+ background: #fff;
+ padding: 8px;
+}
+
+.share-field {
+ margin-bottom: 14px;
+}
+
+.share-field label {
+ display: block;
+ margin-bottom: 6px;
+ font-size: 0.85rem;
+ color: var(--text-muted);
+}
+
+.share-field__row {
+ display: flex;
+ gap: 8px;
+}
+
+.share-field__row input {
+ flex: 1;
+ min-width: 0;
+ padding: 10px 12px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: rgba(255, 255, 255, 0.03);
+ color: inherit;
+ font-size: 0.85rem;
+}
+
+body.modal-open {
+ overflow: hidden;
+}
diff --git a/app/static/js/main.js b/app/static/js/main.js
index d33e706..cc89913 100644
--- a/app/static/js/main.js
+++ b/app/static/js/main.js
@@ -1,10 +1,31 @@
document.addEventListener("DOMContentLoaded", () => {
+ initUploadForm();
+ initCopyButtons();
+ initShareModal();
+});
+
+function initUploadForm() {
const dropzone = document.getElementById("dropzone");
const photoInput = document.getElementById("photoInput");
const preview = document.getElementById("preview");
const previewImg = document.getElementById("previewImg");
const previewName = document.getElementById("previewName");
const submitBtn = document.getElementById("submitBtn");
+ const uploadForm = document.getElementById("uploadForm");
+ const tabButtons = document.querySelectorAll(".upload-tabs__btn");
+ const panels = document.querySelectorAll(".upload-panel");
+
+ if (!uploadForm) return;
+
+ tabButtons.forEach((btn) => {
+ btn.addEventListener("click", () => {
+ const tab = btn.dataset.tab;
+ tabButtons.forEach((item) => item.classList.toggle("upload-tabs__btn--active", item === btn));
+ panels.forEach((panel) => {
+ panel.classList.toggle("upload-panel--active", panel.dataset.panel === tab);
+ });
+ });
+ });
if (!dropzone || !photoInput) return;
@@ -40,6 +61,25 @@ document.addEventListener("DOMContentLoaded", () => {
}
});
+ uploadForm.addEventListener("submit", (e) => {
+ const activePanel = document.querySelector(".upload-panel--active");
+ if (!activePanel) return;
+
+ if (activePanel.dataset.panel === "urls") {
+ const urls = document.getElementById("imageUrls");
+ if (!urls || !urls.value.trim()) {
+ e.preventDefault();
+ showToast("Укажите хотя бы одну ссылку");
+ }
+ return;
+ }
+
+ if (activePanel.dataset.panel === "files" && photoInput.files.length === 0) {
+ e.preventDefault();
+ showToast("Выберите файлы для загрузки");
+ }
+ });
+
function assignFiles(fileList) {
const dt = new DataTransfer();
const limit = Math.min(fileList.length, maxFiles);
@@ -68,26 +108,81 @@ document.addEventListener("DOMContentLoaded", () => {
};
reader.readAsDataURL(first);
}
+}
+function initCopyButtons() {
document.querySelectorAll(".copy-btn").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.stopPropagation();
- const url = btn.dataset.url;
- try {
- await navigator.clipboard.writeText(url);
- showToast("Ссылка скопирована!");
- } catch {
- const input = document.createElement("input");
- input.value = url;
- document.body.appendChild(input);
- input.select();
- document.execCommand("copy");
- document.body.removeChild(input);
- showToast("Ссылка скопирована!");
+ const targetId = btn.dataset.target;
+ const url = targetId
+ ? document.getElementById(targetId)?.value
+ : btn.dataset.url;
+
+ if (!url) return;
+
+ const copied = await copyText(url);
+ if (copied) {
+ const label = btn.textContent.trim();
+ showToast(label === "BBCode" ? "BBCode скопирован!" : "Скопировано!");
}
});
});
-});
+}
+
+function initShareModal() {
+ const modal = document.getElementById("shareModal");
+ if (!modal) return;
+
+ const urlInput = document.getElementById("shareModalUrl");
+ const bbcodeInput = document.getElementById("shareModalBbcode");
+ const htmlInput = document.getElementById("shareModalHtml");
+ const qrImg = document.getElementById("shareModalQr");
+ const nameEl = document.getElementById("shareModalName");
+
+ document.querySelectorAll(".share-qr-btn").forEach((btn) => {
+ btn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ urlInput.value = btn.dataset.url || "";
+ bbcodeInput.value = btn.dataset.bbcode || "";
+ htmlInput.value = btn.dataset.html || "";
+ qrImg.src = btn.dataset.qr || "";
+ nameEl.textContent = btn.dataset.name || "";
+ modal.hidden = false;
+ document.body.classList.add("modal-open");
+ });
+ });
+
+ modal.querySelectorAll("[data-close-share]").forEach((el) => {
+ el.addEventListener("click", closeShareModal);
+ });
+
+ document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape" && !modal.hidden) {
+ closeShareModal();
+ }
+ });
+
+ function closeShareModal() {
+ modal.hidden = true;
+ document.body.classList.remove("modal-open");
+ }
+}
+
+async function copyText(text) {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch {
+ const input = document.createElement("input");
+ input.value = text;
+ document.body.appendChild(input);
+ input.select();
+ const ok = document.execCommand("copy");
+ document.body.removeChild(input);
+ return ok;
+ }
+}
function showToast(message) {
const existing = document.querySelector(".toast");
diff --git a/app/templates/base.html b/app/templates/base.html
index 249d4c6..6cbb839 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -54,6 +54,7 @@
+ {% include "partials/share_modal.html" %}
{% block scripts %}{% endblock %}