Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db2cef41bb | |||
| a375ad330a |
@@ -221,6 +221,86 @@ sudo systemctl start docker
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Обновление до новой версии на сервере
|
||||||
|
|
||||||
|
Когда выходит новая версия в Git, обновите проект на сервере без потери данных (БД и фото хранятся в Docker volumes, файл `.env` не перезаписывается).
|
||||||
|
|
||||||
|
### Быстрое обновление (последняя версия из `main`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/fotohost
|
||||||
|
git pull origin main
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs --tail=50 web
|
||||||
|
```
|
||||||
|
|
||||||
|
### Обновление до конкретного релиза (рекомендуется)
|
||||||
|
|
||||||
|
Список доступных версий:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/fotohost
|
||||||
|
git fetch --tags
|
||||||
|
git tag -l
|
||||||
|
```
|
||||||
|
|
||||||
|
Пример — установить релиз **v1.0-beta**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/fotohost
|
||||||
|
git fetch --tags
|
||||||
|
git checkout v1.0-beta
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Вернуться на последнюю dev-версию из `main`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/fotohost
|
||||||
|
git checkout main
|
||||||
|
git pull origin main
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Перед обновлением (рекомендуется)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/fotohost
|
||||||
|
|
||||||
|
# Бэкап базы данных
|
||||||
|
docker compose exec db pg_dump -U photohost photohost > backup_$(date +%Y%m%d_%H%M).sql
|
||||||
|
|
||||||
|
# Проверить, не появились ли новые переменные в .env.example
|
||||||
|
diff .env .env.example || true
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Если в `.env.example` появились новые строки — добавьте их в свой `.env` вручную.
|
||||||
|
|
||||||
|
### После обновления — проверка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
curl -I http://127.0.0.1:8080
|
||||||
|
docker compose logs --tail=100 web
|
||||||
|
```
|
||||||
|
|
||||||
|
Откройте сайт в браузере и проверьте вход, загрузку фото и админку.
|
||||||
|
|
||||||
|
### Если что-то пошло не так — откат на предыдущий тег
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/fotohost
|
||||||
|
git checkout v1.0-beta
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Важно:** команда `docker compose up -d --build` пересобирает контейнер `web`, но **не удаляет** volumes с PostgreSQL и загруженными фото.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Регистрация, авторизация и роли
|
## Регистрация, авторизация и роли
|
||||||
|
|
||||||
| URL | Описание |
|
| URL | Описание |
|
||||||
|
|||||||
+5
-1
@@ -42,21 +42,25 @@ def create_app():
|
|||||||
from .routes import bp as main_bp, cabinet_bp
|
from .routes import bp as main_bp, cabinet_bp
|
||||||
from .auth import bp as auth_bp
|
from .auth import bp as auth_bp
|
||||||
from .admin import bp as admin_bp
|
from .admin import bp as admin_bp
|
||||||
|
from .folders import bp as folders_bp
|
||||||
|
|
||||||
app.register_blueprint(main_bp)
|
app.register_blueprint(main_bp)
|
||||||
app.register_blueprint(cabinet_bp)
|
app.register_blueprint(cabinet_bp)
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(admin_bp)
|
app.register_blueprint(admin_bp)
|
||||||
|
app.register_blueprint(folders_bp)
|
||||||
|
|
||||||
register_cli(app)
|
register_cli(app)
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from app.models import Photo, User # noqa: F401
|
from app.models import Folder, FolderInvite, FolderMember, Photo, User # noqa: F401
|
||||||
|
|
||||||
db.create_all()
|
db.create_all()
|
||||||
from app.bootstrap import create_first_admin, ensure_schema
|
from app.bootstrap import create_first_admin, ensure_schema
|
||||||
|
from app.folders import ensure_folder_schema
|
||||||
|
|
||||||
ensure_schema()
|
ensure_schema()
|
||||||
|
ensure_folder_schema()
|
||||||
create_first_admin(app)
|
create_first_admin(app)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from flask import Blueprint, flash, redirect, render_template, request, url_for
|
|||||||
from flask_login import current_user, login_user, logout_user
|
from flask_login import current_user, login_user, logout_user
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
|
from app.folder_utils import process_pending_invites
|
||||||
from app.models import User
|
from app.models import User
|
||||||
|
|
||||||
bp = Blueprint("auth", __name__, url_prefix="/auth")
|
bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||||
@@ -34,7 +35,10 @@ def register():
|
|||||||
db.session.add(user)
|
db.session.add(user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
login_user(user)
|
login_user(user)
|
||||||
|
accepted = process_pending_invites(user)
|
||||||
flash("Регистрация успешна. Добро пожаловать!", "success")
|
flash("Регистрация успешна. Добро пожаловать!", "success")
|
||||||
|
if accepted:
|
||||||
|
flash(f"Вам открыт доступ к {accepted} общим папкам", "success")
|
||||||
return redirect(url_for("cabinet.index"))
|
return redirect(url_for("cabinet.index"))
|
||||||
|
|
||||||
return render_template("auth/register.html")
|
return render_template("auth/register.html")
|
||||||
@@ -60,7 +64,10 @@ def login():
|
|||||||
flash("Аккаунт заблокирован", "error")
|
flash("Аккаунт заблокирован", "error")
|
||||||
else:
|
else:
|
||||||
login_user(user, remember=remember)
|
login_user(user, remember=remember)
|
||||||
|
accepted = process_pending_invites(user)
|
||||||
flash(f"Добро пожаловать, {user.username}!", "success")
|
flash(f"Добро пожаловать, {user.username}!", "success")
|
||||||
|
if accepted:
|
||||||
|
flash(f"Вам открыт доступ к {accepted} общим папкам", "success")
|
||||||
next_page = request.args.get("next")
|
next_page = request.args.get("next")
|
||||||
if next_page:
|
if next_page:
|
||||||
return redirect(next_page)
|
return redirect(next_page)
|
||||||
|
|||||||
+11
-1
@@ -3,6 +3,9 @@ from functools import wraps
|
|||||||
from flask import abort, flash, redirect, url_for
|
from flask import abort, flash, redirect, url_for
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
|
|
||||||
|
from app.folder_utils import can_edit_folder, is_folder_owner
|
||||||
|
from app.models import FolderMember
|
||||||
|
|
||||||
|
|
||||||
def admin_required(f):
|
def admin_required(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
@@ -20,7 +23,14 @@ def can_manage_photo(photo):
|
|||||||
return False
|
return False
|
||||||
if current_user.is_admin:
|
if current_user.is_admin:
|
||||||
return True
|
return True
|
||||||
return photo.user_id == current_user.id
|
if photo.user_id == current_user.id:
|
||||||
|
return True
|
||||||
|
if photo.folder_id and photo.folder:
|
||||||
|
if is_folder_owner(photo.folder, current_user):
|
||||||
|
return True
|
||||||
|
if can_edit_folder(photo.folder, current_user):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def photo_owner_or_admin(photo):
|
def photo_owner_or_admin(photo):
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
from flask import session
|
||||||
|
|
||||||
|
from app.models import Folder, FolderMember
|
||||||
|
|
||||||
|
|
||||||
|
def get_unlocked_folder_ids():
|
||||||
|
return session.get("unlocked_folders", [])
|
||||||
|
|
||||||
|
|
||||||
|
def unlock_folder(folder_id):
|
||||||
|
unlocked = session.get("unlocked_folders", [])
|
||||||
|
if folder_id not in unlocked:
|
||||||
|
unlocked.append(folder_id)
|
||||||
|
session["unlocked_folders"] = unlocked
|
||||||
|
session.modified = True
|
||||||
|
|
||||||
|
|
||||||
|
def is_folder_unlocked(folder):
|
||||||
|
if not folder.has_password:
|
||||||
|
return True
|
||||||
|
return folder.id in get_unlocked_folder_ids()
|
||||||
|
|
||||||
|
|
||||||
|
def get_folder_member(folder, user):
|
||||||
|
if not user or not user.is_authenticated:
|
||||||
|
return None
|
||||||
|
return FolderMember.query.filter_by(folder_id=folder.id, user_id=user.id).first()
|
||||||
|
|
||||||
|
|
||||||
|
def is_folder_owner(folder, user):
|
||||||
|
return user and user.is_authenticated and folder.owner_id == user.id
|
||||||
|
|
||||||
|
|
||||||
|
def can_view_folder(folder, user=None):
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
|
user = user or current_user
|
||||||
|
if is_folder_owner(folder, user):
|
||||||
|
return True
|
||||||
|
if user and user.is_authenticated and user.is_admin:
|
||||||
|
return True
|
||||||
|
member = get_folder_member(folder, user)
|
||||||
|
if member:
|
||||||
|
return True
|
||||||
|
if not folder.is_private and is_folder_unlocked(folder):
|
||||||
|
return True
|
||||||
|
if is_folder_unlocked(folder):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def can_edit_folder(folder, user=None):
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
|
user = user or current_user
|
||||||
|
if is_folder_owner(folder, user):
|
||||||
|
return True
|
||||||
|
if user and user.is_authenticated and user.is_admin:
|
||||||
|
return True
|
||||||
|
member = get_folder_member(folder, user)
|
||||||
|
return member is not None and member.role == "editor"
|
||||||
|
|
||||||
|
|
||||||
|
def can_manage_folder_settings(folder, user=None):
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
|
user = user or current_user
|
||||||
|
return is_folder_owner(folder, user) or (user and user.is_authenticated and user.is_admin)
|
||||||
|
|
||||||
|
|
||||||
|
def process_pending_invites(user):
|
||||||
|
from app import db
|
||||||
|
from app.models import FolderInvite
|
||||||
|
|
||||||
|
invites = FolderInvite.query.filter_by(email=user.email.lower()).all()
|
||||||
|
accepted = 0
|
||||||
|
for invite in invites:
|
||||||
|
existing = FolderMember.query.filter_by(
|
||||||
|
folder_id=invite.folder_id, user_id=user.id
|
||||||
|
).first()
|
||||||
|
if not existing:
|
||||||
|
db.session.add(
|
||||||
|
FolderMember(
|
||||||
|
folder_id=invite.folder_id,
|
||||||
|
user_id=user.id,
|
||||||
|
role=invite.role,
|
||||||
|
added_by_id=invite.invited_by_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
accepted += 1
|
||||||
|
db.session.delete(invite)
|
||||||
|
if invites:
|
||||||
|
db.session.commit()
|
||||||
|
return accepted
|
||||||
+316
@@ -0,0 +1,316 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from flask import (
|
||||||
|
Blueprint,
|
||||||
|
abort,
|
||||||
|
flash,
|
||||||
|
redirect,
|
||||||
|
render_template,
|
||||||
|
request,
|
||||||
|
url_for,
|
||||||
|
)
|
||||||
|
from flask_login import current_user, login_required
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.folder_utils import (
|
||||||
|
can_edit_folder,
|
||||||
|
can_manage_folder_settings,
|
||||||
|
can_view_folder,
|
||||||
|
is_folder_unlocked,
|
||||||
|
process_pending_invites,
|
||||||
|
unlock_folder,
|
||||||
|
)
|
||||||
|
from app.models import Folder, FolderInvite, FolderMember, Photo, User
|
||||||
|
|
||||||
|
bp = Blueprint("folders", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/cabinet/folders")
|
||||||
|
@login_required
|
||||||
|
def list_folders():
|
||||||
|
process_pending_invites(current_user)
|
||||||
|
owned = Folder.query.filter_by(owner_id=current_user.id).order_by(Folder.created_at.desc()).all()
|
||||||
|
shared = (
|
||||||
|
Folder.query.join(FolderMember)
|
||||||
|
.filter(
|
||||||
|
FolderMember.user_id == current_user.id,
|
||||||
|
Folder.owner_id != current_user.id,
|
||||||
|
)
|
||||||
|
.order_by(Folder.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return render_template("cabinet/folders/list.html", owned_folders=owned, shared_folders=shared)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/cabinet/folders/create", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def create_folder():
|
||||||
|
name = request.form.get("name", "").strip()
|
||||||
|
is_private = request.form.get("is_private") == "on"
|
||||||
|
access_password = request.form.get("access_password", "").strip()
|
||||||
|
|
||||||
|
if len(name) < 2:
|
||||||
|
flash("Название папки — минимум 2 символа", "error")
|
||||||
|
return redirect(url_for("folders.list_folders"))
|
||||||
|
|
||||||
|
folder = Folder(name=name, owner_id=current_user.id, is_private=is_private)
|
||||||
|
if access_password:
|
||||||
|
if len(access_password) < 4:
|
||||||
|
flash("Пароль папки — минимум 4 символа", "error")
|
||||||
|
return redirect(url_for("folders.list_folders"))
|
||||||
|
folder.set_access_password(access_password)
|
||||||
|
|
||||||
|
db.session.add(folder)
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Папка «{folder.name}» создана", "success")
|
||||||
|
return redirect(url_for("folders.view_folder", folder_id=folder.id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/cabinet/folders/<int:folder_id>")
|
||||||
|
@login_required
|
||||||
|
def view_folder(folder_id):
|
||||||
|
folder = Folder.query.get_or_404(folder_id)
|
||||||
|
if not can_view_folder(folder):
|
||||||
|
if folder.has_password:
|
||||||
|
return redirect(url_for("folders.folder_password", folder_id=folder.id))
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
photos = folder.photos.order_by(Photo.created_at.desc()).all()
|
||||||
|
can_edit = can_edit_folder(folder)
|
||||||
|
return render_template(
|
||||||
|
"cabinet/folders/view.html",
|
||||||
|
folder=folder,
|
||||||
|
photos=photos,
|
||||||
|
can_edit=can_edit,
|
||||||
|
share_url=_share_url(folder),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/cabinet/folders/<int:folder_id>/password", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def folder_password(folder_id):
|
||||||
|
folder = Folder.query.get_or_404(folder_id)
|
||||||
|
if is_folder_owner_or_member(folder):
|
||||||
|
return redirect(url_for("folders.view_folder", folder_id=folder.id))
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
if folder.check_access_password(password):
|
||||||
|
unlock_folder(folder.id)
|
||||||
|
flash("Доступ к папке открыт", "success")
|
||||||
|
return redirect(url_for("folders.view_folder", folder_id=folder.id))
|
||||||
|
flash("Неверный пароль", "error")
|
||||||
|
|
||||||
|
return render_template("cabinet/folders/password.html", folder=folder)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/cabinet/folders/<int:folder_id>/settings", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def folder_settings(folder_id):
|
||||||
|
folder = Folder.query.get_or_404(folder_id)
|
||||||
|
if not can_manage_folder_settings(folder):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
action = request.form.get("action", "save")
|
||||||
|
|
||||||
|
if action == "regenerate_link":
|
||||||
|
folder.regenerate_share_token()
|
||||||
|
db.session.commit()
|
||||||
|
flash("Ссылка для sharing обновлена", "success")
|
||||||
|
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
|
||||||
|
|
||||||
|
if action == "delete":
|
||||||
|
_delete_folder(folder)
|
||||||
|
flash(f"Папка «{folder.name}» удалена", "success")
|
||||||
|
return redirect(url_for("folders.list_folders"))
|
||||||
|
|
||||||
|
name = request.form.get("name", "").strip()
|
||||||
|
is_private = request.form.get("is_private") == "on"
|
||||||
|
access_password = request.form.get("access_password", "").strip()
|
||||||
|
remove_password = request.form.get("remove_password") == "on"
|
||||||
|
|
||||||
|
if len(name) < 2:
|
||||||
|
flash("Название папки — минимум 2 символа", "error")
|
||||||
|
else:
|
||||||
|
folder.name = name
|
||||||
|
folder.is_private = is_private
|
||||||
|
if remove_password:
|
||||||
|
folder.set_access_password(None)
|
||||||
|
elif access_password:
|
||||||
|
if len(access_password) < 4:
|
||||||
|
flash("Пароль папки — минимум 4 символа", "error")
|
||||||
|
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
|
||||||
|
folder.set_access_password(access_password)
|
||||||
|
db.session.commit()
|
||||||
|
flash("Настройки папки сохранены", "success")
|
||||||
|
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
|
||||||
|
|
||||||
|
members = FolderMember.query.filter_by(folder_id=folder.id).all()
|
||||||
|
invites = FolderInvite.query.filter_by(folder_id=folder.id).all()
|
||||||
|
return render_template(
|
||||||
|
"cabinet/folders/settings.html",
|
||||||
|
folder=folder,
|
||||||
|
members=members,
|
||||||
|
invites=invites,
|
||||||
|
share_url=_share_url(folder),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/cabinet/folders/<int:folder_id>/invite", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def invite_member(folder_id):
|
||||||
|
folder = Folder.query.get_or_404(folder_id)
|
||||||
|
if not can_manage_folder_settings(folder):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
email = request.form.get("email", "").strip().lower()
|
||||||
|
role = request.form.get("role", "viewer")
|
||||||
|
if role not in ("viewer", "editor"):
|
||||||
|
role = "viewer"
|
||||||
|
|
||||||
|
if not email or "@" not in email:
|
||||||
|
flash("Укажите корректный email", "error")
|
||||||
|
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
|
||||||
|
|
||||||
|
user = User.query.filter_by(email=email).first()
|
||||||
|
if user:
|
||||||
|
if user.id == folder.owner_id:
|
||||||
|
flash("Владелец папки уже имеет доступ", "error")
|
||||||
|
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
|
||||||
|
|
||||||
|
existing = FolderMember.query.filter_by(folder_id=folder.id, user_id=user.id).first()
|
||||||
|
if existing:
|
||||||
|
existing.role = role
|
||||||
|
flash(f"Пользователь {user.username} уже в папке — роль обновлена", "success")
|
||||||
|
else:
|
||||||
|
db.session.add(
|
||||||
|
FolderMember(
|
||||||
|
folder_id=folder.id,
|
||||||
|
user_id=user.id,
|
||||||
|
role=role,
|
||||||
|
added_by_id=current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
FolderInvite.query.filter_by(folder_id=folder.id, email=email).delete()
|
||||||
|
flash(f"Пользователь {user.username} добавлен в папку", "success")
|
||||||
|
else:
|
||||||
|
invite = FolderInvite.query.filter_by(folder_id=folder.id, email=email).first()
|
||||||
|
if invite:
|
||||||
|
invite.role = role
|
||||||
|
flash("Приглашение обновлено", "success")
|
||||||
|
else:
|
||||||
|
db.session.add(
|
||||||
|
FolderInvite(
|
||||||
|
folder_id=folder.id,
|
||||||
|
email=email,
|
||||||
|
role=role,
|
||||||
|
invited_by_id=current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
flash(f"Приглашение отправлено на {email}. Доступ откроется после регистрации.", "success")
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/cabinet/folders/<int:folder_id>/members/<int:user_id>/remove", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def remove_member(folder_id, user_id):
|
||||||
|
folder = Folder.query.get_or_404(folder_id)
|
||||||
|
if not can_manage_folder_settings(folder):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
member = FolderMember.query.filter_by(folder_id=folder.id, user_id=user_id).first_or_404()
|
||||||
|
db.session.delete(member)
|
||||||
|
db.session.commit()
|
||||||
|
flash("Пользователь удалён из папки", "success")
|
||||||
|
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/cabinet/folders/<int:folder_id>/invites/<int:invite_id>/remove", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def remove_invite(folder_id, invite_id):
|
||||||
|
folder = Folder.query.get_or_404(folder_id)
|
||||||
|
if not can_manage_folder_settings(folder):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
invite = FolderInvite.query.filter_by(folder_id=folder.id, id=invite_id).first_or_404()
|
||||||
|
db.session.delete(invite)
|
||||||
|
db.session.commit()
|
||||||
|
flash("Приглашение отменено", "success")
|
||||||
|
return redirect(url_for("folders.folder_settings", folder_id=folder.id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/share/f/<share_token>", methods=["GET", "POST"])
|
||||||
|
def share_folder(share_token):
|
||||||
|
folder = Folder.query.filter_by(share_token=share_token).first_or_404()
|
||||||
|
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
process_pending_invites(current_user)
|
||||||
|
|
||||||
|
if is_folder_owner_or_member(folder) or (can_view_folder(folder) and not folder.has_password):
|
||||||
|
return _render_share_folder(folder)
|
||||||
|
|
||||||
|
if folder.has_password and not is_folder_unlocked(folder):
|
||||||
|
if request.method == "POST":
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
if folder.check_access_password(password):
|
||||||
|
unlock_folder(folder.id)
|
||||||
|
flash("Доступ к папке открыт", "success")
|
||||||
|
return redirect(url_for("folders.share_folder", share_token=share_token))
|
||||||
|
flash("Неверный пароль", "error")
|
||||||
|
return render_template("share/password.html", folder=folder, share_token=share_token)
|
||||||
|
|
||||||
|
return _render_share_folder(folder)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_share_folder(folder):
|
||||||
|
photos = folder.photos.order_by(Photo.created_at.desc()).all()
|
||||||
|
return render_template(
|
||||||
|
"share/folder.html",
|
||||||
|
folder=folder,
|
||||||
|
photos=photos,
|
||||||
|
can_edit=can_edit_folder(folder),
|
||||||
|
share_url=_share_url(folder),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _share_url(folder):
|
||||||
|
return url_for("folders.share_folder", share_token=folder.share_token, _external=True)
|
||||||
|
|
||||||
|
|
||||||
|
def is_folder_owner_or_member(folder):
|
||||||
|
if not current_user.is_authenticated:
|
||||||
|
return False
|
||||||
|
if folder.owner_id == current_user.id:
|
||||||
|
return True
|
||||||
|
return FolderMember.query.filter_by(folder_id=folder.id, user_id=current_user.id).first() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_folder(folder):
|
||||||
|
upload_dir = None
|
||||||
|
for photo in folder.photos.all():
|
||||||
|
if upload_dir is None:
|
||||||
|
from flask import current_app
|
||||||
|
upload_dir = current_app.config["UPLOAD_FOLDER"]
|
||||||
|
filepath = os.path.join(upload_dir, photo.filename)
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
os.remove(filepath)
|
||||||
|
db.session.delete(photo)
|
||||||
|
db.session.delete(folder)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_folder_schema():
|
||||||
|
inspector = inspect(db.engine)
|
||||||
|
tables = inspector.get_table_names()
|
||||||
|
|
||||||
|
if "photos" in tables:
|
||||||
|
columns = {col["name"] for col in inspector.get_columns("photos")}
|
||||||
|
if "folder_id" not in columns:
|
||||||
|
db.session.execute(
|
||||||
|
text("ALTER TABLE photos ADD COLUMN folder_id INTEGER REFERENCES folders(id)")
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from flask_login import UserMixin
|
from flask_login import UserMixin
|
||||||
@@ -22,6 +23,7 @@ class User(UserMixin, db.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
photos = db.relationship("Photo", backref="owner", lazy="dynamic")
|
photos = db.relationship("Photo", backref="owner", lazy="dynamic")
|
||||||
|
folders = db.relationship("Folder", backref="owner", lazy="dynamic")
|
||||||
|
|
||||||
def set_password(self, password):
|
def set_password(self, password):
|
||||||
self.password_hash = generate_password_hash(password)
|
self.password_hash = generate_password_hash(password)
|
||||||
@@ -43,6 +45,92 @@ class User(UserMixin, db.Model):
|
|||||||
return int(result or 0)
|
return int(result or 0)
|
||||||
|
|
||||||
|
|
||||||
|
class Folder(db.Model):
|
||||||
|
__tablename__ = "folders"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(120), nullable=False)
|
||||||
|
owner_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
share_token = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||||
|
is_private = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
|
password_hash = db.Column(db.String(256), nullable=True)
|
||||||
|
created_at = db.Column(
|
||||||
|
db.DateTime,
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
photos = db.relationship("Photo", backref="folder", lazy="dynamic")
|
||||||
|
members = db.relationship("FolderMember", backref="folder", lazy="dynamic", cascade="all, delete-orphan")
|
||||||
|
invites = db.relationship("FolderInvite", backref="folder", lazy="dynamic", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
if not self.share_token:
|
||||||
|
self.share_token = uuid.uuid4().hex
|
||||||
|
|
||||||
|
def set_access_password(self, password):
|
||||||
|
if password:
|
||||||
|
self.password_hash = generate_password_hash(password)
|
||||||
|
else:
|
||||||
|
self.password_hash = None
|
||||||
|
|
||||||
|
def check_access_password(self, password):
|
||||||
|
if not self.password_hash:
|
||||||
|
return True
|
||||||
|
return check_password_hash(self.password_hash, password)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_password(self):
|
||||||
|
return bool(self.password_hash)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def photo_count(self):
|
||||||
|
return self.photos.count()
|
||||||
|
|
||||||
|
def regenerate_share_token(self):
|
||||||
|
self.share_token = uuid.uuid4().hex
|
||||||
|
|
||||||
|
|
||||||
|
class FolderMember(db.Model):
|
||||||
|
__tablename__ = "folder_members"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
folder_id = db.Column(db.Integer, db.ForeignKey("folders.id"), nullable=False, index=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
role = db.Column(db.String(20), nullable=False, default="viewer")
|
||||||
|
added_at = db.Column(
|
||||||
|
db.DateTime,
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
added_by_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||||||
|
|
||||||
|
user = db.relationship("User", foreign_keys=[user_id])
|
||||||
|
added_by = db.relationship("User", foreign_keys=[added_by_id])
|
||||||
|
|
||||||
|
__table_args__ = (db.UniqueConstraint("folder_id", "user_id", name="uq_folder_member"),)
|
||||||
|
|
||||||
|
|
||||||
|
class FolderInvite(db.Model):
|
||||||
|
__tablename__ = "folder_invites"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
folder_id = db.Column(db.Integer, db.ForeignKey("folders.id"), nullable=False, index=True)
|
||||||
|
email = db.Column(db.String(120), nullable=False, index=True)
|
||||||
|
role = db.Column(db.String(20), nullable=False, default="viewer")
|
||||||
|
invited_by_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
|
||||||
|
created_at = db.Column(
|
||||||
|
db.DateTime,
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
invited_by = db.relationship("User", foreign_keys=[invited_by_id])
|
||||||
|
|
||||||
|
__table_args__ = (db.UniqueConstraint("folder_id", "email", name="uq_folder_invite"),)
|
||||||
|
|
||||||
|
|
||||||
class Photo(db.Model):
|
class Photo(db.Model):
|
||||||
__tablename__ = "photos"
|
__tablename__ = "photos"
|
||||||
|
|
||||||
@@ -52,6 +140,7 @@ class Photo(db.Model):
|
|||||||
file_size = db.Column(db.Integer, nullable=False, default=0)
|
file_size = db.Column(db.Integer, nullable=False, default=0)
|
||||||
mime_type = db.Column(db.String(100), nullable=False, default="image/jpeg")
|
mime_type = db.Column(db.String(100), nullable=False, default="image/jpeg")
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True)
|
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True)
|
||||||
|
folder_id = db.Column(db.Integer, db.ForeignKey("folders.id"), nullable=True, index=True)
|
||||||
created_at = db.Column(
|
created_at = db.Column(
|
||||||
db.DateTime,
|
db.DateTime,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|||||||
+19
-2
@@ -5,6 +5,7 @@ from datetime import datetime, timezone
|
|||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
current_app,
|
current_app,
|
||||||
|
abort,
|
||||||
flash,
|
flash,
|
||||||
jsonify,
|
jsonify,
|
||||||
redirect,
|
redirect,
|
||||||
@@ -18,7 +19,8 @@ from werkzeug.utils import secure_filename
|
|||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
from app.auth_utils import photo_owner_or_admin
|
from app.auth_utils import photo_owner_or_admin
|
||||||
from app.models import Photo
|
from app.folder_utils import can_edit_folder
|
||||||
|
from app.models import Folder, Photo
|
||||||
|
|
||||||
bp = Blueprint("main", __name__)
|
bp = Blueprint("main", __name__)
|
||||||
|
|
||||||
@@ -60,6 +62,13 @@ def upload():
|
|||||||
flash("Недопустимый формат. Разрешены: PNG, JPG, GIF, WEBP, BMP", "error")
|
flash("Недопустимый формат. Разрешены: PNG, JPG, GIF, WEBP, BMP", "error")
|
||||||
return redirect(request.referrer or url_for("main.index"))
|
return redirect(request.referrer or url_for("main.index"))
|
||||||
|
|
||||||
|
folder_id = request.form.get("folder_id", type=int)
|
||||||
|
folder = None
|
||||||
|
if folder_id:
|
||||||
|
folder = Folder.query.get_or_404(folder_id)
|
||||||
|
if not can_edit_folder(folder):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
ext = file.filename.rsplit(".", 1)[1].lower()
|
ext = file.filename.rsplit(".", 1)[1].lower()
|
||||||
stored_name = f"{uuid.uuid4().hex}.{ext}"
|
stored_name = f"{uuid.uuid4().hex}.{ext}"
|
||||||
safe_original = secure_filename(file.filename) or f"photo.{ext}"
|
safe_original = secure_filename(file.filename) or f"photo.{ext}"
|
||||||
@@ -75,12 +84,15 @@ def upload():
|
|||||||
file_size=file_size,
|
file_size=file_size,
|
||||||
mime_type=file.content_type or f"image/{ext}",
|
mime_type=file.content_type or f"image/{ext}",
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
|
folder_id=folder.id if folder else None,
|
||||||
created_at=datetime.now(timezone.utc),
|
created_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
db.session.add(photo)
|
db.session.add(photo)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
flash("Фото успешно загружено", "success")
|
flash("Фото успешно загружено", "success")
|
||||||
|
if folder:
|
||||||
|
return redirect(url_for("folders.view_folder", folder_id=folder.id))
|
||||||
return redirect(url_for("cabinet.index"))
|
return redirect(url_for("cabinet.index"))
|
||||||
|
|
||||||
|
|
||||||
@@ -129,15 +141,20 @@ cabinet_bp = Blueprint("cabinet", __name__, url_prefix="/cabinet")
|
|||||||
@cabinet_bp.route("/")
|
@cabinet_bp.route("/")
|
||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
|
from app.folder_utils import process_pending_invites
|
||||||
|
|
||||||
|
process_pending_invites(current_user)
|
||||||
photos = (
|
photos = (
|
||||||
Photo.query.filter_by(user_id=current_user.id)
|
Photo.query.filter_by(user_id=current_user.id, folder_id=None)
|
||||||
.order_by(Photo.created_at.desc())
|
.order_by(Photo.created_at.desc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
folders = Folder.query.filter_by(owner_id=current_user.id).order_by(Folder.created_at.desc()).limit(6).all()
|
||||||
total_size = sum(p.file_size for p in photos)
|
total_size = sum(p.file_size for p in photos)
|
||||||
return render_template(
|
return render_template(
|
||||||
"cabinet/index.html",
|
"cabinet/index.html",
|
||||||
photos=photos,
|
photos=photos,
|
||||||
|
folders=folders,
|
||||||
total_photos=len(photos),
|
total_photos=len(photos),
|
||||||
total_size=total_size,
|
total_size=total_size,
|
||||||
max_upload_mb=current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024),
|
max_upload_mb=current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024),
|
||||||
|
|||||||
@@ -881,3 +881,88 @@ body {
|
|||||||
color: #fca5a5;
|
color: #fca5a5;
|
||||||
border-color: rgba(239, 68, 68, 0.3);
|
border-color: rgba(239, 68, 68, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Folders */
|
||||||
|
.folder-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 24px;
|
||||||
|
transition: transform 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
border-color: rgba(99, 102, 241, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-card--shared {
|
||||||
|
border-color: rgba(34, 197, 94, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-card__icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-card__title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-card__meta {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-card__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-create {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-create__form {
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-share-url {
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--accent-light);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-hint {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel--danger {
|
||||||
|
border-color: rgba(239, 68, 68, 0.25);
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
<a href="{{ url_for('main.index') }}" class="nav__link">Главная</a>
|
<a href="{{ url_for('main.index') }}" class="nav__link">Главная</a>
|
||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
<a href="{{ url_for('cabinet.index') }}" class="nav__link">Личный кабинет</a>
|
<a href="{{ url_for('cabinet.index') }}" class="nav__link">Личный кабинет</a>
|
||||||
|
<a href="{{ url_for('folders.list_folders') }}" class="nav__link">Папки</a>
|
||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
<a href="{{ url_for('admin.dashboard') }}" class="nav__link nav__link--admin">Админка</a>
|
<a href="{{ url_for('admin.dashboard') }}" class="nav__link nav__link--admin">Админка</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Папки — PhotoHost{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-header">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="page-header__title">Мои папки</h1>
|
||||||
|
<p class="page-header__subtitle">Создавайте папки, делитесь ссылками, защищайте паролем и приглашайте пользователей по email.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% include "partials/alerts.html" %}
|
||||||
|
|
||||||
|
<section class="admin-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="admin-panel folder-create">
|
||||||
|
<h2 class="admin-panel__title">Создать папку</h2>
|
||||||
|
<form method="post" action="{{ url_for('folders.create_folder') }}" class="auth-form folder-create__form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Название</label>
|
||||||
|
<input type="text" id="name" name="name" required minlength="2" placeholder="Отпуск 2025">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="access_password">Пароль доступа (необязательно)</label>
|
||||||
|
<input type="password" id="access_password" name="access_password" minlength="4" placeholder="для приватной папки">
|
||||||
|
</div>
|
||||||
|
<label class="form-checkbox">
|
||||||
|
<input type="checkbox" name="is_private" checked>
|
||||||
|
<span>Приватная папка (доступ по ссылке, паролю или приглашению)</span>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn btn--primary">Создать папку</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title">Мои папки</h2>
|
||||||
|
{% if owned_folders %}
|
||||||
|
<div class="folder-grid">
|
||||||
|
{% for folder in owned_folders %}
|
||||||
|
<article class="folder-card">
|
||||||
|
<div class="folder-card__icon">📁</div>
|
||||||
|
<h3 class="folder-card__title">{{ folder.name }}</h3>
|
||||||
|
<p class="folder-card__meta">
|
||||||
|
{{ folder.photo_count }} фото
|
||||||
|
{% if folder.is_private %} · приватная{% endif %}
|
||||||
|
{% if folder.has_password %} · с паролем{% endif %}
|
||||||
|
</p>
|
||||||
|
<div class="folder-card__actions">
|
||||||
|
<a href="{{ url_for('folders.view_folder', folder_id=folder.id) }}" class="btn btn--ghost btn--sm">Открыть</a>
|
||||||
|
<a href="{{ url_for('folders.folder_settings', folder_id=folder.id) }}" class="btn btn--ghost btn--sm">Настройки</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state__icon">📁</div>
|
||||||
|
<h3>У вас пока нет папок</h3>
|
||||||
|
<p>Создайте первую папку для организации фото</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if shared_folders %}
|
||||||
|
<h2 class="section-title" style="margin-top: 40px;">Общие со мной</h2>
|
||||||
|
<div class="folder-grid">
|
||||||
|
{% for folder in shared_folders %}
|
||||||
|
<article class="folder-card folder-card--shared">
|
||||||
|
<div class="folder-card__icon">🤝</div>
|
||||||
|
<h3 class="folder-card__title">{{ folder.name }}</h3>
|
||||||
|
<p class="folder-card__meta">@{{ folder.owner.username }} · {{ folder.photo_count }} фото</p>
|
||||||
|
<div class="folder-card__actions">
|
||||||
|
<a href="{{ url_for('folders.view_folder', folder_id=folder.id) }}" class="btn btn--ghost btn--sm">Открыть</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Пароль папки — {{ folder.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="auth-section">
|
||||||
|
<div class="container auth-container">
|
||||||
|
<div class="auth-card">
|
||||||
|
<h1 class="auth-card__title">Папка «{{ folder.name }}»</h1>
|
||||||
|
<p class="auth-card__subtitle">Введите пароль для доступа</p>
|
||||||
|
{% include "partials/alerts.html" %}
|
||||||
|
<form method="post" class="auth-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Пароль папки</label>
|
||||||
|
<input type="password" id="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn--primary btn--full">Открыть</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Настройки — {{ folder.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-header">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="page-header__title">Настройки папки</h1>
|
||||||
|
<p class="page-header__subtitle">{{ folder.name }}</p>
|
||||||
|
<div class="page-header__actions">
|
||||||
|
<a href="{{ url_for('folders.view_folder', folder_id=folder.id) }}" class="btn btn--ghost">← К папке</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-section">
|
||||||
|
<div class="container">
|
||||||
|
{% include "partials/alerts.html" %}
|
||||||
|
|
||||||
|
<div class="admin-grid">
|
||||||
|
<div class="admin-panel">
|
||||||
|
<h2 class="admin-panel__title">Основные настройки</h2>
|
||||||
|
<form method="post" class="auth-form">
|
||||||
|
<input type="hidden" name="action" value="save">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Название</label>
|
||||||
|
<input type="text" id="name" name="name" value="{{ folder.name }}" required minlength="2">
|
||||||
|
</div>
|
||||||
|
<label class="form-checkbox">
|
||||||
|
<input type="checkbox" name="is_private" {% if folder.is_private %}checked{% endif %}>
|
||||||
|
<span>Приватная папка</span>
|
||||||
|
</label>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="access_password">Новый пароль доступа</label>
|
||||||
|
<input type="password" id="access_password" name="access_password" minlength="4" placeholder="оставьте пустым, если не меняете">
|
||||||
|
</div>
|
||||||
|
{% if folder.has_password %}
|
||||||
|
<label class="form-checkbox">
|
||||||
|
<input type="checkbox" name="remove_password">
|
||||||
|
<span>Убрать пароль</span>
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit" class="btn btn--primary">Сохранить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-panel">
|
||||||
|
<h2 class="admin-panel__title">Ссылка для sharing</h2>
|
||||||
|
<p class="folder-share-url">{{ share_url }}</p>
|
||||||
|
<div class="folder-card__actions">
|
||||||
|
<button type="button" class="btn btn--ghost btn--sm copy-btn" data-url="{{ share_url }}">Копировать</button>
|
||||||
|
<form method="post" style="display:inline">
|
||||||
|
<input type="hidden" name="action" value="regenerate_link">
|
||||||
|
<button type="submit" class="btn btn--ghost btn--sm">Обновить ссылку</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-panel" style="margin-top: 24px;">
|
||||||
|
<h2 class="admin-panel__title">Пригласить по email</h2>
|
||||||
|
<form method="post" action="{{ url_for('folders.invite_member', folder_id=folder.id) }}" class="auth-form folder-create__form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email пользователя</label>
|
||||||
|
<input type="email" id="email" name="email" required placeholder="user@example.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="role">Роль</label>
|
||||||
|
<select id="role" name="role" class="form-select">
|
||||||
|
<option value="viewer">Просмотр</option>
|
||||||
|
<option value="editor">Редактор (загрузка и удаление)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn--primary">Добавить / пригласить</button>
|
||||||
|
</form>
|
||||||
|
<p class="folder-hint">Если пользователь ещё не зарегистрирован, доступ откроется автоматически после регистрации с этим email.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if members %}
|
||||||
|
<div class="admin-panel" style="margin-top: 24px;">
|
||||||
|
<h2 class="admin-panel__title">Участники</h2>
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Пользователь</th><th>Email</th><th>Роль</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for member in members %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ member.user.username }}</td>
|
||||||
|
<td>{{ member.user.email }}</td>
|
||||||
|
<td>{{ 'Редактор' if member.role == 'editor' else 'Просмотр' }}</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="{{ url_for('folders.remove_member', folder_id=folder.id, user_id=member.user_id) }}">
|
||||||
|
<button type="submit" class="btn btn--danger btn--sm">Удалить</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if invites %}
|
||||||
|
<div class="admin-panel" style="margin-top: 24px;">
|
||||||
|
<h2 class="admin-panel__title">Ожидают регистрации</h2>
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Email</th><th>Роль</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for invite in invites %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ invite.email }}</td>
|
||||||
|
<td>{{ 'Редактор' if invite.role == 'editor' else 'Просмотр' }}</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="{{ url_for('folders.remove_invite', folder_id=folder.id, invite_id=invite.id) }}">
|
||||||
|
<button type="submit" class="btn btn--danger btn--sm">Отменить</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="admin-panel admin-panel--danger" style="margin-top: 24px;">
|
||||||
|
<h2 class="admin-panel__title">Удалить папку</h2>
|
||||||
|
<p class="folder-hint">Все фото внутри папки будут удалены безвозвратно.</p>
|
||||||
|
<form method="post" onsubmit="return confirm('Удалить папку и все фото в ней?');">
|
||||||
|
<input type="hidden" name="action" value="delete">
|
||||||
|
<button type="submit" class="btn btn--danger">Удалить папку</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ folder.name }} — PhotoHost{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-header">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="page-header__title">📁 {{ folder.name }}</h1>
|
||||||
|
<p class="page-header__subtitle">
|
||||||
|
{% if folder.is_private %}Приватная папка{% else %}Публичная папка{% endif %}
|
||||||
|
{% if folder.has_password %} · защищена паролем{% endif %}
|
||||||
|
· {{ photos|length }} фото
|
||||||
|
</p>
|
||||||
|
<div class="page-header__actions">
|
||||||
|
<a href="{{ url_for('folders.list_folders') }}" class="btn btn--ghost">← Все папки</a>
|
||||||
|
{% if folder.owner_id == current_user.id %}
|
||||||
|
<a href="{{ url_for('folders.folder_settings', folder_id=folder.id) }}" class="btn btn--ghost">Настройки</a>
|
||||||
|
{% endif %}
|
||||||
|
<button type="button" class="btn btn--ghost copy-btn" data-url="{{ share_url }}">Копировать ссылку</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% include "partials/alerts.html" %}
|
||||||
|
|
||||||
|
{% if can_edit %}
|
||||||
|
<section id="upload" class="upload-section">
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="section-title">Загрузить в папку</h2>
|
||||||
|
<form action="{{ url_for('main.upload') }}" method="post" enctype="multipart/form-data" class="upload-form" id="uploadForm">
|
||||||
|
<input type="hidden" name="folder_id" value="{{ folder.id }}">
|
||||||
|
<div class="dropzone" id="dropzone">
|
||||||
|
<input type="file" name="photo" id="photoInput" accept="image/png,image/jpeg,image/gif,image/webp,image/bmp" hidden>
|
||||||
|
<p class="dropzone__title">Перетащите фото сюда</p>
|
||||||
|
<p class="dropzone__hint">или нажмите для выбора файла</p>
|
||||||
|
<div class="dropzone__preview" id="preview" hidden>
|
||||||
|
<img id="previewImg" alt="Предпросмотр">
|
||||||
|
<span id="previewName"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn--primary" id="submitBtn" disabled>Загрузить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="gallery-section">
|
||||||
|
<div class="container">
|
||||||
|
{% with photos=photos, empty_title='В папке пока нет фото', empty_text='Загрузите первое изображение' %}
|
||||||
|
{% include "partials/photo_gallery.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
<h1 class="page-header__title">Личный кабинет</h1>
|
<h1 class="page-header__title">Личный кабинет</h1>
|
||||||
<p class="page-header__subtitle">Привет, {{ current_user.username }}! Управляйте своими фотографиями.</p>
|
<p class="page-header__subtitle">Привет, {{ current_user.username }}! Управляйте своими фотографиями.</p>
|
||||||
<div class="page-header__actions">
|
<div class="page-header__actions">
|
||||||
|
<a href="{{ url_for('folders.list_folders') }}" class="btn btn--primary">Мои папки</a>
|
||||||
<a href="{{ url_for('cabinet.profile') }}" class="btn btn--ghost">Настройки профиля</a>
|
<a href="{{ url_for('cabinet.profile') }}" class="btn btn--ghost">Настройки профиля</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,6 +58,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% if folders %}
|
||||||
|
<section class="gallery-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="gallery-header">
|
||||||
|
<h2 class="section-title">Недавние папки</h2>
|
||||||
|
<a href="{{ url_for('folders.list_folders') }}" class="btn btn--ghost btn--sm">Все папки</a>
|
||||||
|
</div>
|
||||||
|
<div class="folder-grid">
|
||||||
|
{% for folder in folders %}
|
||||||
|
<article class="folder-card">
|
||||||
|
<div class="folder-card__icon">📁</div>
|
||||||
|
<h3 class="folder-card__title">{{ folder.name }}</h3>
|
||||||
|
<p class="folder-card__meta">{{ folder.photo_count }} фото</p>
|
||||||
|
<a href="{{ url_for('folders.view_folder', folder_id=folder.id) }}" class="btn btn--ghost btn--sm">Открыть</a>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<section class="gallery-section">
|
<section class="gallery-section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="gallery-header">
|
<div class="gallery-header">
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ folder.name }} — Shared folder{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-header">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="page-header__title">📁 {{ folder.name }}</h1>
|
||||||
|
<p class="page-header__subtitle">Общая папка · {{ photos|length }} фото</p>
|
||||||
|
{% if share_url %}
|
||||||
|
<div class="page-header__actions">
|
||||||
|
<button type="button" class="btn btn--ghost copy-btn" data-url="{{ share_url }}">Копировать ссылку</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% include "partials/alerts.html" %}
|
||||||
|
|
||||||
|
{% if can_edit %}
|
||||||
|
<section id="upload" class="upload-section">
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="section-title">Загрузить в папку</h2>
|
||||||
|
<form action="{{ url_for('main.upload') }}" method="post" enctype="multipart/form-data" class="upload-form" id="uploadForm">
|
||||||
|
<input type="hidden" name="folder_id" value="{{ folder.id }}">
|
||||||
|
<div class="dropzone" id="dropzone">
|
||||||
|
<input type="file" name="photo" id="photoInput" accept="image/png,image/jpeg,image/gif,image/webp,image/bmp" hidden>
|
||||||
|
<p class="dropzone__title">Перетащите фото сюда</p>
|
||||||
|
<div class="dropzone__preview" id="preview" hidden>
|
||||||
|
<img id="previewImg" alt="Предпросмотр">
|
||||||
|
<span id="previewName"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn--primary" id="submitBtn" disabled>Загрузить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="gallery-section">
|
||||||
|
<div class="container">
|
||||||
|
{% with photos=photos, empty_title='В папке пока нет фото' %}
|
||||||
|
{% include "partials/photo_gallery.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Пароль — {{ folder.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="auth-section">
|
||||||
|
<div class="container auth-container">
|
||||||
|
<div class="auth-card">
|
||||||
|
<h1 class="auth-card__title">Папка «{{ folder.name }}»</h1>
|
||||||
|
<p class="auth-card__subtitle">Эта папка защищена паролем</p>
|
||||||
|
{% include "partials/alerts.html" %}
|
||||||
|
<form method="post" class="auth-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Пароль</label>
|
||||||
|
<input type="password" id="password" name="password" required autofocus>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn--primary btn--full">Открыть папку</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user