From db2cef41bb0ef6f36093e49aada897caf29620a9 Mon Sep 17 00:00:00 2001 From: test2 Date: Sat, 6 Jun 2026 22:30:00 +0300 Subject: [PATCH] Add folders with password sharing and email invites Co-authored-by: Cursor --- app/__init__.py | 6 +- app/auth.py | 7 + app/auth_utils.py | 12 +- app/folder_utils.py | 94 ++++++ app/folders.py | 316 ++++++++++++++++++++ app/models.py | 89 ++++++ app/routes.py | 21 +- app/static/css/style.css | 85 ++++++ app/templates/base.html | 1 + app/templates/cabinet/folders/list.html | 80 +++++ app/templates/cabinet/folders/password.html | 22 ++ app/templates/cabinet/folders/settings.html | 142 +++++++++ app/templates/cabinet/folders/view.html | 54 ++++ app/templates/cabinet/index.html | 22 ++ app/templates/share/folder.html | 47 +++ app/templates/share/password.html | 22 ++ 16 files changed, 1016 insertions(+), 4 deletions(-) create mode 100644 app/folder_utils.py create mode 100644 app/folders.py create mode 100644 app/templates/cabinet/folders/list.html create mode 100644 app/templates/cabinet/folders/password.html create mode 100644 app/templates/cabinet/folders/settings.html create mode 100644 app/templates/cabinet/folders/view.html create mode 100644 app/templates/share/folder.html create mode 100644 app/templates/share/password.html diff --git a/app/__init__.py b/app/__init__.py index 83ef06a..71ec558 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -42,21 +42,25 @@ def create_app(): from .routes import bp as main_bp, cabinet_bp from .auth import bp as auth_bp from .admin import bp as admin_bp + from .folders import bp as folders_bp app.register_blueprint(main_bp) app.register_blueprint(cabinet_bp) app.register_blueprint(auth_bp) app.register_blueprint(admin_bp) + app.register_blueprint(folders_bp) register_cli(app) 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() from app.bootstrap import create_first_admin, ensure_schema + from app.folders import ensure_folder_schema ensure_schema() + ensure_folder_schema() create_first_admin(app) return app diff --git a/app/auth.py b/app/auth.py index f3f71c1..7d3d618 100644 --- a/app/auth.py +++ b/app/auth.py @@ -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 app import db +from app.folder_utils import process_pending_invites from app.models import User bp = Blueprint("auth", __name__, url_prefix="/auth") @@ -34,7 +35,10 @@ def register(): db.session.add(user) db.session.commit() login_user(user) + accepted = process_pending_invites(user) flash("Регистрация успешна. Добро пожаловать!", "success") + if accepted: + flash(f"Вам открыт доступ к {accepted} общим папкам", "success") return redirect(url_for("cabinet.index")) return render_template("auth/register.html") @@ -60,7 +64,10 @@ def login(): flash("Аккаунт заблокирован", "error") else: login_user(user, remember=remember) + accepted = process_pending_invites(user) flash(f"Добро пожаловать, {user.username}!", "success") + if accepted: + flash(f"Вам открыт доступ к {accepted} общим папкам", "success") next_page = request.args.get("next") if next_page: return redirect(next_page) diff --git a/app/auth_utils.py b/app/auth_utils.py index cbe7f59..b22d7b8 100644 --- a/app/auth_utils.py +++ b/app/auth_utils.py @@ -3,6 +3,9 @@ from functools import wraps from flask import abort, flash, redirect, url_for 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): @wraps(f) @@ -20,7 +23,14 @@ def can_manage_photo(photo): return False if current_user.is_admin: 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): diff --git a/app/folder_utils.py b/app/folder_utils.py new file mode 100644 index 0000000..0b8a122 --- /dev/null +++ b/app/folder_utils.py @@ -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 diff --git a/app/folders.py b/app/folders.py new file mode 100644 index 0000000..3bef44e --- /dev/null +++ b/app/folders.py @@ -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/") +@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//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//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//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//members//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//invites//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/", 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() diff --git a/app/models.py b/app/models.py index 60eaf87..700adca 100644 --- a/app/models.py +++ b/app/models.py @@ -1,3 +1,4 @@ +import uuid from datetime import datetime, timezone from flask_login import UserMixin @@ -22,6 +23,7 @@ class User(UserMixin, db.Model): ) photos = db.relationship("Photo", backref="owner", lazy="dynamic") + folders = db.relationship("Folder", backref="owner", lazy="dynamic") def set_password(self, password): self.password_hash = generate_password_hash(password) @@ -43,6 +45,92 @@ class User(UserMixin, db.Model): 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): __tablename__ = "photos" @@ -52,6 +140,7 @@ class Photo(db.Model): file_size = db.Column(db.Integer, nullable=False, default=0) 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) + folder_id = db.Column(db.Integer, db.ForeignKey("folders.id"), nullable=True, index=True) created_at = db.Column( db.DateTime, nullable=False, diff --git a/app/routes.py b/app/routes.py index 6f7540d..2a7bd25 100644 --- a/app/routes.py +++ b/app/routes.py @@ -5,6 +5,7 @@ from datetime import datetime, timezone from flask import ( Blueprint, current_app, + abort, flash, jsonify, redirect, @@ -18,7 +19,8 @@ from werkzeug.utils import secure_filename from app import db 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__) @@ -60,6 +62,13 @@ def upload(): flash("Недопустимый формат. Разрешены: PNG, JPG, GIF, WEBP, BMP", "error") 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() stored_name = f"{uuid.uuid4().hex}.{ext}" safe_original = secure_filename(file.filename) or f"photo.{ext}" @@ -75,12 +84,15 @@ def upload(): file_size=file_size, mime_type=file.content_type or f"image/{ext}", user_id=current_user.id, + folder_id=folder.id if folder else None, created_at=datetime.now(timezone.utc), ) db.session.add(photo) db.session.commit() flash("Фото успешно загружено", "success") + if folder: + return redirect(url_for("folders.view_folder", folder_id=folder.id)) return redirect(url_for("cabinet.index")) @@ -129,15 +141,20 @@ cabinet_bp = Blueprint("cabinet", __name__, url_prefix="/cabinet") @cabinet_bp.route("/") @login_required def index(): + from app.folder_utils import process_pending_invites + + process_pending_invites(current_user) 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()) .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) return render_template( "cabinet/index.html", photos=photos, + folders=folders, total_photos=len(photos), total_size=total_size, max_upload_mb=current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024), diff --git a/app/static/css/style.css b/app/static/css/style.css index 7fcef93..0c111a4 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -881,3 +881,88 @@ body { color: #fca5a5; 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); +} diff --git a/app/templates/base.html b/app/templates/base.html index a0cf9ca..52421f5 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -24,6 +24,7 @@ Главная {% if current_user.is_authenticated %} Личный кабинет + Папки {% if current_user.is_admin %} Админка {% endif %} diff --git a/app/templates/cabinet/folders/list.html b/app/templates/cabinet/folders/list.html new file mode 100644 index 0000000..e509bd0 --- /dev/null +++ b/app/templates/cabinet/folders/list.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} + +{% block title %}Папки — PhotoHost{% endblock %} + +{% block content %} + + +{% include "partials/alerts.html" %} + +
+
+
+

Создать папку

+
+
+ + +
+
+ + +
+ + +
+
+ +

Мои папки

+ {% if owned_folders %} +
+ {% for folder in owned_folders %} +
+
📁
+

{{ folder.name }}

+

+ {{ folder.photo_count }} фото + {% if folder.is_private %} · приватная{% endif %} + {% if folder.has_password %} · с паролем{% endif %} +

+ +
+ {% endfor %} +
+ {% else %} +
+
📁
+

У вас пока нет папок

+

Создайте первую папку для организации фото

+
+ {% endif %} + + {% if shared_folders %} +

Общие со мной

+
+ {% for folder in shared_folders %} +
+
🤝
+

{{ folder.name }}

+

@{{ folder.owner.username }} · {{ folder.photo_count }} фото

+ +
+ {% endfor %} +
+ {% endif %} +
+
+{% endblock %} diff --git a/app/templates/cabinet/folders/password.html b/app/templates/cabinet/folders/password.html new file mode 100644 index 0000000..6861270 --- /dev/null +++ b/app/templates/cabinet/folders/password.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block title %}Пароль папки — {{ folder.name }}{% endblock %} + +{% block content %} +
+
+
+

Папка «{{ folder.name }}»

+

Введите пароль для доступа

+ {% include "partials/alerts.html" %} +
+
+ + +
+ +
+
+
+
+{% endblock %} diff --git a/app/templates/cabinet/folders/settings.html b/app/templates/cabinet/folders/settings.html new file mode 100644 index 0000000..0b49678 --- /dev/null +++ b/app/templates/cabinet/folders/settings.html @@ -0,0 +1,142 @@ +{% extends "base.html" %} + +{% block title %}Настройки — {{ folder.name }}{% endblock %} + +{% block content %} + + +
+
+ {% include "partials/alerts.html" %} + +
+
+

Основные настройки

+
+ +
+ + +
+ +
+ + +
+ {% if folder.has_password %} + + {% endif %} + +
+
+ +
+

Ссылка для sharing

+

{{ share_url }}

+
+ +
+ + +
+
+
+
+ +
+

Пригласить по email

+
+
+ + +
+
+ + +
+ +
+

Если пользователь ещё не зарегистрирован, доступ откроется автоматически после регистрации с этим email.

+
+ + {% if members %} +
+

Участники

+
+ + + + + + {% for member in members %} + + + + + + + {% endfor %} + +
ПользовательEmailРоль
{{ member.user.username }}{{ member.user.email }}{{ 'Редактор' if member.role == 'editor' else 'Просмотр' }} +
+ +
+
+
+
+ {% endif %} + + {% if invites %} +
+

Ожидают регистрации

+
+ + + + + + {% for invite in invites %} + + + + + + {% endfor %} + +
EmailРоль
{{ invite.email }}{{ 'Редактор' if invite.role == 'editor' else 'Просмотр' }} +
+ +
+
+
+
+ {% endif %} + +
+

Удалить папку

+

Все фото внутри папки будут удалены безвозвратно.

+
+ + +
+
+
+
+{% endblock %} diff --git a/app/templates/cabinet/folders/view.html b/app/templates/cabinet/folders/view.html new file mode 100644 index 0000000..4815a4c --- /dev/null +++ b/app/templates/cabinet/folders/view.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} + +{% block title %}{{ folder.name }} — PhotoHost{% endblock %} + +{% block content %} + + +{% include "partials/alerts.html" %} + +{% if can_edit %} +
+
+

Загрузить в папку

+
+ +
+ +

Перетащите фото сюда

+

или нажмите для выбора файла

+ +
+ +
+
+
+{% endif %} + + +{% endblock %} diff --git a/app/templates/cabinet/index.html b/app/templates/cabinet/index.html index 8793896..5a0d4d7 100644 --- a/app/templates/cabinet/index.html +++ b/app/templates/cabinet/index.html @@ -9,6 +9,7 @@

Личный кабинет

Привет, {{ current_user.username }}! Управляйте своими фотографиями.

@@ -57,6 +58,27 @@ +{% if folders %} + +{% endif %} +