From d4f0eaa7d958d35c080d27a3e73939be27bc6fc9 Mon Sep 17 00:00:00 2001 From: test2 Date: Sun, 7 Jun 2026 02:36:59 +0300 Subject: [PATCH] Release v2.0: URL upload, BBCode sharing, QR codes Co-authored-by: Cursor --- README.md | 29 +++- app/routes.py | 48 +++++- app/share_utils.py | 12 ++ app/static/css/style.css | 161 +++++++++++++++++- app/static/js/main.js | 121 +++++++++++-- app/templates/base.html | 1 + app/templates/partials/photo_gallery.html | 21 ++- app/templates/partials/share_modal.html | 36 ++++ app/templates/partials/upload_form.html | 61 +++++-- app/upload_service.py | 196 ++++++++++++++++++++++ requirements.txt | 2 + 11 files changed, 646 insertions(+), 42 deletions(-) create mode 100644 app/share_utils.py create mode 100644 app/templates/partials/share_modal.html 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'{name}' 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 %} diff --git a/app/templates/partials/photo_gallery.html b/app/templates/partials/photo_gallery.html index 7d23a0d..66d9803 100644 --- a/app/templates/partials/photo_gallery.html +++ b/app/templates/partials/photo_gallery.html @@ -1,6 +1,9 @@ {% if photos %}