Add user auth, personal cabinet, admin panel and first admin bootstrap

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-06 22:20:09 +03:00
parent c6a7ecfc4c
commit 61e7290ce8
26 changed files with 1351 additions and 108 deletions
+58 -26
View File
@@ -1,13 +1,24 @@
import os
from datetime import datetime, timezone
from flask import Flask
from flask_login import LoginManager
from flask_sqlalchemy import SQLAlchemy
from dotenv import load_dotenv
load_dotenv()
db = SQLAlchemy()
login_manager = LoginManager()
login_manager.login_view = "auth.login"
login_manager.login_message = "Войдите для доступа к этой странице."
login_manager.login_message_category = "error"
@login_manager.user_loader
def load_user(user_id):
from app.models import User
return db.session.get(User, int(user_id))
def create_app():
@@ -26,40 +37,61 @@ def create_app():
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
db.init_app(app)
login_manager.init_app(app)
from .routes import bp
from .routes import bp as main_bp, cabinet_bp
from .auth import bp as auth_bp
from .admin import bp as admin_bp
app.register_blueprint(bp)
app.register_blueprint(main_bp)
app.register_blueprint(cabinet_bp)
app.register_blueprint(auth_bp)
app.register_blueprint(admin_bp)
register_cli(app)
with app.app_context():
from app.models import Photo, User # noqa: F401
db.create_all()
from app.bootstrap import create_first_admin, ensure_schema
ensure_schema()
create_first_admin(app)
return app
class Photo(db.Model):
__tablename__ = "photos"
def register_cli(app):
@app.cli.command("create-admin")
def create_admin_command():
"""Create or update admin user interactively."""
from getpass import getpass
id = db.Column(db.Integer, primary_key=True)
filename = db.Column(db.String(255), nullable=False)
original_name = db.Column(db.String(255), nullable=False)
file_size = db.Column(db.Integer, nullable=False, default=0)
mime_type = db.Column(db.String(100), nullable=False, default="image/jpeg")
created_at = db.Column(
db.DateTime,
nullable=False,
default=lambda: datetime.now(timezone.utc),
)
from app.models import User
@property
def url(self):
return f"/uploads/{self.filename}"
username = input("Username: ").strip()
email = input("Email: ").strip()
password = getpass("Password: ")
password2 = getpass("Confirm password: ")
@property
def size_human(self):
size = self.file_size
for unit in ("Б", "КБ", "МБ", "ГБ"):
if size < 1024:
return f"{size:.0f} {unit}" if unit == "Б" else f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} ТБ"
if not username or not email or not password:
print("All fields are required.")
return
if password != password2:
print("Passwords do not match.")
return
user = User.query.filter_by(username=username).first()
if user:
user.email = email
user.is_admin = True
user.set_password(password)
print(f"User '{username}' updated and promoted to admin.")
else:
user = User(username=username, email=email, is_admin=True)
user.set_password(password)
db.session.add(user)
print(f"Admin '{username}' created.")
db.session.commit()
+118
View File
@@ -0,0 +1,118 @@
import os
from flask import Blueprint, current_app, flash, redirect, render_template, request, url_for
from flask_login import current_user
from sqlalchemy import func
from app import db
from app.auth_utils import admin_required
from app.models import Photo, User
bp = Blueprint("admin", __name__, url_prefix="/admin")
@bp.route("/")
@admin_required
def dashboard():
stats = {
"users": User.query.count(),
"photos": Photo.query.count(),
"admins": User.query.filter_by(is_admin=True).count(),
"storage": int(
db.session.query(func.coalesce(func.sum(Photo.file_size), 0)).scalar() or 0
),
}
recent_users = User.query.order_by(User.created_at.desc()).limit(5).all()
recent_photos = Photo.query.order_by(Photo.created_at.desc()).limit(8).all()
return render_template(
"admin/dashboard.html",
stats=stats,
recent_users=recent_users,
recent_photos=recent_photos,
)
@bp.route("/users")
@admin_required
def users():
all_users = User.query.order_by(User.created_at.desc()).all()
return render_template("admin/users.html", users=all_users)
@bp.route("/users/<int:user_id>/toggle-admin", methods=["POST"])
@admin_required
def toggle_admin(user_id):
user = User.query.get_or_404(user_id)
if user.id == current_user.id:
flash("Нельзя снять права администратора с самого себя", "error")
return redirect(url_for("admin.users"))
admin_count = User.query.filter_by(is_admin=True).count()
if user.is_admin and admin_count <= 1:
flash("Нельзя удалить последнего администратора", "error")
return redirect(url_for("admin.users"))
user.is_admin = not user.is_admin
db.session.commit()
action = "назначен администратором" if user.is_admin else "лишён прав администратора"
flash(f"Пользователь {user.username} {action}", "success")
return redirect(url_for("admin.users"))
@bp.route("/users/<int:user_id>/toggle-active", methods=["POST"])
@admin_required
def toggle_active(user_id):
user = User.query.get_or_404(user_id)
if user.id == current_user.id:
flash("Нельзя заблокировать самого себя", "error")
return redirect(url_for("admin.users"))
user.is_active = not user.is_active
db.session.commit()
action = "разблокирован" if user.is_active else "заблокирован"
flash(f"Пользователь {user.username} {action}", "success")
return redirect(url_for("admin.users"))
@bp.route("/users/<int:user_id>/delete", methods=["POST"])
@admin_required
def delete_user(user_id):
user = User.query.get_or_404(user_id)
if user.id == current_user.id:
flash("Нельзя удалить самого себя", "error")
return redirect(url_for("admin.users"))
if user.is_admin and User.query.filter_by(is_admin=True).count() <= 1:
flash("Нельзя удалить последнего администратора", "error")
return redirect(url_for("admin.users"))
for photo in user.photos.all():
filepath = os.path.join(current_app.config["UPLOAD_FOLDER"], photo.filename)
if os.path.exists(filepath):
os.remove(filepath)
db.session.delete(photo)
db.session.delete(user)
db.session.commit()
flash(f"Пользователь {user.username} удалён", "success")
return redirect(url_for("admin.users"))
@bp.route("/photos")
@admin_required
def photos():
all_photos = Photo.query.order_by(Photo.created_at.desc()).all()
return render_template("admin/photos.html", photos=all_photos)
@bp.route("/photos/<int:photo_id>/delete", methods=["POST"])
@admin_required
def delete_photo(photo_id):
photo = Photo.query.get_or_404(photo_id)
filepath = os.path.join(current_app.config["UPLOAD_FOLDER"], photo.filename)
if os.path.exists(filepath):
os.remove(filepath)
db.session.delete(photo)
db.session.commit()
flash("Фото удалено", "success")
return redirect(url_for("admin.photos"))
+78
View File
@@ -0,0 +1,78 @@
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.models import User
bp = Blueprint("auth", __name__, url_prefix="/auth")
@bp.route("/register", methods=["GET", "POST"])
def register():
if current_user.is_authenticated:
return redirect(url_for("cabinet.index"))
if request.method == "POST":
username = request.form.get("username", "").strip()
email = request.form.get("email", "").strip().lower()
password = request.form.get("password", "")
password2 = request.form.get("password2", "")
if len(username) < 3:
flash("Имя пользователя — минимум 3 символа", "error")
elif len(password) < 6:
flash("Пароль — минимум 6 символов", "error")
elif password != password2:
flash("Пароли не совпадают", "error")
elif User.query.filter_by(username=username).first():
flash("Это имя пользователя уже занято", "error")
elif User.query.filter_by(email=email).first():
flash("Этот email уже зарегистрирован", "error")
else:
user = User(username=username, email=email)
user.set_password(password)
db.session.add(user)
db.session.commit()
login_user(user)
flash("Регистрация успешна. Добро пожаловать!", "success")
return redirect(url_for("cabinet.index"))
return render_template("auth/register.html")
@bp.route("/login", methods=["GET", "POST"])
def login():
if current_user.is_authenticated:
return redirect(url_for("cabinet.index"))
if request.method == "POST":
login = request.form.get("login", "").strip()
password = request.form.get("password", "")
remember = request.form.get("remember") == "on"
user = User.query.filter(
(User.username == login) | (User.email == login.lower())
).first()
if user is None or not user.check_password(password):
flash("Неверный логин или пароль", "error")
elif not user.is_active:
flash("Аккаунт заблокирован", "error")
else:
login_user(user, remember=remember)
flash(f"Добро пожаловать, {user.username}!", "success")
next_page = request.args.get("next")
if next_page:
return redirect(next_page)
if user.is_admin:
return redirect(url_for("admin.dashboard"))
return redirect(url_for("cabinet.index"))
return render_template("auth/login.html")
@bp.route("/logout")
def logout():
logout_user()
flash("Вы вышли из аккаунта", "success")
return redirect(url_for("main.index"))
+28
View File
@@ -0,0 +1,28 @@
from functools import wraps
from flask import abort, flash, redirect, url_for
from flask_login import current_user
def admin_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not current_user.is_authenticated or not current_user.is_admin:
flash("Доступ только для администраторов", "error")
return redirect(url_for("main.index"))
return f(*args, **kwargs)
return decorated
def can_manage_photo(photo):
if not current_user.is_authenticated:
return False
if current_user.is_admin:
return True
return photo.user_id == current_user.id
def photo_owner_or_admin(photo):
if not can_manage_photo(photo):
abort(403)
+47
View File
@@ -0,0 +1,47 @@
import os
from sqlalchemy import inspect, text
from app import db
from app.models import User
def ensure_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 "user_id" not in columns:
db.session.execute(
text("ALTER TABLE photos ADD COLUMN user_id INTEGER REFERENCES users(id)")
)
db.session.commit()
def create_first_admin(app):
username = os.getenv("ADMIN_USERNAME", "").strip()
email = os.getenv("ADMIN_EMAIL", "").strip()
password = os.getenv("ADMIN_PASSWORD", "").strip()
if not username or not email or not password:
app.logger.info("ADMIN_* env vars not set — first admin was not created automatically")
return None
if User.query.filter_by(is_admin=True).first():
return None
if User.query.filter_by(username=username).first():
user = User.query.filter_by(username=username).first()
user.is_admin = True
user.set_password(password)
db.session.commit()
app.logger.info("Existing user '%s' promoted to admin", username)
return user
user = User(username=username, email=email, is_admin=True)
user.set_password(password)
db.session.add(user)
db.session.commit()
app.logger.info("First admin '%s' created", username)
return user
+72
View File
@@ -0,0 +1,72 @@
from datetime import datetime, timezone
from flask_login import UserMixin
from werkzeug.security import check_password_hash, generate_password_hash
from app import db
class User(UserMixin, db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
email = db.Column(db.String(120), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(256), nullable=False)
is_admin = db.Column(db.Boolean, nullable=False, default=False)
is_active = db.Column(db.Boolean, nullable=False, default=True)
created_at = db.Column(
db.DateTime,
nullable=False,
default=lambda: datetime.now(timezone.utc),
)
photos = db.relationship("Photo", backref="owner", lazy="dynamic")
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
@property
def photo_count(self):
return self.photos.count()
@property
def total_size(self):
from sqlalchemy import func
result = db.session.query(func.coalesce(func.sum(Photo.file_size), 0)).filter(
Photo.user_id == self.id
).scalar()
return int(result or 0)
class Photo(db.Model):
__tablename__ = "photos"
id = db.Column(db.Integer, primary_key=True)
filename = db.Column(db.String(255), nullable=False)
original_name = db.Column(db.String(255), nullable=False)
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)
created_at = db.Column(
db.DateTime,
nullable=False,
default=lambda: datetime.now(timezone.utc),
)
@property
def url(self):
return f"/uploads/{self.filename}"
@property
def size_human(self):
size = self.file_size
for unit in ("Б", "КБ", "МБ", "ГБ"):
if size < 1024:
return f"{size:.0f} {unit}" if unit == "Б" else f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} ТБ"
+72 -10
View File
@@ -13,9 +13,12 @@ from flask import (
send_from_directory,
url_for,
)
from flask_login import current_user, login_required
from werkzeug.utils import secure_filename
from app import Photo, db
from app import db
from app.auth_utils import photo_owner_or_admin
from app.models import Photo
bp = Blueprint("main", __name__)
@@ -29,31 +32,33 @@ def allowed_file(filename):
@bp.route("/")
def index():
photos = Photo.query.order_by(Photo.created_at.desc()).all()
total_size = sum(p.file_size for p in photos)
photos = Photo.query.order_by(Photo.created_at.desc()).limit(24).all()
total_photos = Photo.query.count()
total_size = db.session.query(db.func.coalesce(db.func.sum(Photo.file_size), 0)).scalar() or 0
return render_template(
"index.html",
photos=photos,
total_photos=len(photos),
total_size=total_size,
total_photos=total_photos,
total_size=int(total_size),
max_upload_mb=current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024),
)
@bp.route("/upload", methods=["POST"])
@login_required
def upload():
if "photo" not in request.files:
flash("Файл не выбран", "error")
return redirect(url_for("main.index"))
return redirect(request.referrer or url_for("main.index"))
file = request.files["photo"]
if file.filename == "":
flash("Файл не выбран", "error")
return redirect(url_for("main.index"))
return redirect(request.referrer or url_for("main.index"))
if not allowed_file(file.filename):
flash("Недопустимый формат. Разрешены: PNG, JPG, GIF, WEBP, BMP", "error")
return redirect(url_for("main.index"))
return redirect(request.referrer or url_for("main.index"))
ext = file.filename.rsplit(".", 1)[1].lower()
stored_name = f"{uuid.uuid4().hex}.{ext}"
@@ -69,13 +74,14 @@ def upload():
original_name=safe_original,
file_size=file_size,
mime_type=file.content_type or f"image/{ext}",
user_id=current_user.id,
created_at=datetime.now(timezone.utc),
)
db.session.add(photo)
db.session.commit()
flash("Фото успешно загружено", "success")
return redirect(url_for("main.index"))
return redirect(url_for("cabinet.index"))
@bp.route("/api/photos")
@@ -89,6 +95,7 @@ def api_photos():
"original_name": p.original_name,
"file_size": p.file_size,
"size_human": p.size_human,
"user_id": p.user_id,
"created_at": p.created_at.isoformat(),
}
for p in photos
@@ -102,12 +109,67 @@ def uploaded_file(filename):
@bp.route("/delete/<int:photo_id>", methods=["POST"])
@login_required
def delete_photo(photo_id):
photo = Photo.query.get_or_404(photo_id)
photo_owner_or_admin(photo)
filepath = os.path.join(current_app.config["UPLOAD_FOLDER"], photo.filename)
if os.path.exists(filepath):
os.remove(filepath)
db.session.delete(photo)
db.session.commit()
flash("Фото удалено", "success")
return redirect(url_for("main.index"))
return redirect(request.referrer or url_for("main.index"))
cabinet_bp = Blueprint("cabinet", __name__, url_prefix="/cabinet")
@cabinet_bp.route("/")
@login_required
def index():
photos = (
Photo.query.filter_by(user_id=current_user.id)
.order_by(Photo.created_at.desc())
.all()
)
total_size = sum(p.file_size for p in photos)
return render_template(
"cabinet/index.html",
photos=photos,
total_photos=len(photos),
total_size=total_size,
max_upload_mb=current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024),
)
@cabinet_bp.route("/profile", methods=["GET", "POST"])
@login_required
def profile():
from app.models import User
if request.method == "POST":
email = request.form.get("email", "").strip().lower()
current_password = request.form.get("current_password", "")
new_password = request.form.get("new_password", "")
new_password2 = request.form.get("new_password2", "")
other = User.query.filter(User.email == email, User.id != current_user.id).first()
if other:
flash("Этот email уже используется", "error")
elif not current_user.check_password(current_password):
flash("Неверный текущий пароль", "error")
elif new_password and len(new_password) < 6:
flash("Новый пароль — минимум 6 символов", "error")
elif new_password and new_password != new_password2:
flash("Новые пароли не совпадают", "error")
else:
current_user.email = email
if new_password:
current_user.set_password(new_password)
db.session.commit()
flash("Профиль обновлён", "success")
return redirect(url_for("cabinet.profile"))
return render_template("cabinet/profile.html")
+319
View File
@@ -561,4 +561,323 @@ body {
align-items: flex-end;
padding-bottom: 12px;
}
.nav {
flex-wrap: wrap;
justify-content: flex-end;
}
.admin-table-wrap {
overflow-x: auto;
}
}
/* Auth */
.auth-section {
position: relative;
z-index: 1;
padding: 48px 0 80px;
}
.auth-container {
max-width: 440px;
}
.auth-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 32px;
backdrop-filter: blur(10px);
}
.auth-card--wide {
max-width: 520px;
margin: 0 auto;
}
.auth-card__title {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 8px;
}
.auth-card__subtitle {
color: var(--text-muted);
margin-bottom: 24px;
}
.auth-card__footer {
margin-top: 20px;
text-align: center;
color: var(--text-muted);
font-size: 0.9rem;
}
.auth-card__footer a {
color: var(--accent-light);
text-decoration: none;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group label {
font-size: 0.85rem;
font-weight: 500;
color: var(--text-muted);
}
.form-group input {
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;
transition: border-color 0.2s;
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
}
.form-checkbox {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
color: var(--text-muted);
cursor: pointer;
}
.btn--full {
width: 100%;
}
.hero__actions {
display: flex;
gap: 12px;
justify-content: center;
margin-top: 24px;
}
.nav__user {
color: var(--accent-light);
font-size: 0.85rem;
font-weight: 600;
padding: 8px 12px;
}
.nav__link--accent {
color: var(--accent-light);
border: 1px solid rgba(99, 102, 241, 0.4);
}
.nav__link--admin {
color: #fbbf24;
}
.photo-card__owner {
font-size: 0.75rem;
color: var(--accent-light);
margin-bottom: 8px;
}
/* Page header */
.page-header {
position: relative;
z-index: 1;
padding: 48px 0 24px;
}
.page-header--admin .page-header__title {
color: #fbbf24;
}
.page-header__title {
font-size: 2rem;
font-weight: 700;
margin-bottom: 8px;
}
.page-header__subtitle {
color: var(--text-muted);
}
.page-header__actions {
margin-top: 16px;
}
.stats-bar {
position: relative;
z-index: 1;
padding-bottom: 24px;
}
.profile-info {
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border);
}
.profile-info__row {
display: flex;
justify-content: space-between;
padding: 8px 0;
font-size: 0.9rem;
}
.profile-info__row span {
color: var(--text-muted);
}
/* Admin */
.admin-section {
position: relative;
z-index: 1;
padding-bottom: 80px;
}
.admin-nav {
display: flex;
gap: 8px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.admin-nav__link {
padding: 10px 18px;
border-radius: var(--radius-sm);
text-decoration: none;
color: var(--text-muted);
font-size: 0.9rem;
font-weight: 500;
border: 1px solid var(--border);
background: var(--bg-card);
transition: all 0.2s;
}
.admin-nav__link:hover,
.admin-nav__link--active {
color: #fbbf24;
border-color: rgba(251, 191, 36, 0.4);
background: rgba(251, 191, 36, 0.08);
}
.admin-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.stat-card--admin {
border-color: rgba(251, 191, 36, 0.2);
}
.admin-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 24px;
}
.admin-panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
}
.admin-panel__title {
font-size: 1.1rem;
margin-bottom: 16px;
}
.admin-table-wrap {
overflow-x: auto;
}
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.admin-table th,
.admin-table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.admin-table th {
color: var(--text-muted);
font-weight: 500;
}
.admin-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.admin-mini-gallery {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.admin-mini-gallery__item {
aspect-ratio: 1;
border-radius: var(--radius-sm);
overflow: hidden;
}
.admin-mini-gallery__item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.admin-empty,
.text-muted {
color: var(--text-muted);
font-size: 0.9rem;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 600;
background: var(--bg-card);
border: 1px solid var(--border);
}
.badge--admin {
color: #fbbf24;
border-color: rgba(251, 191, 36, 0.4);
}
.badge--success {
color: #86efac;
border-color: rgba(34, 197, 94, 0.3);
}
.badge--danger {
color: #fca5a5;
border-color: rgba(239, 68, 68, 0.3);
}
+5
View File
@@ -0,0 +1,5 @@
<nav class="admin-nav">
<a href="{{ url_for('admin.dashboard') }}" class="admin-nav__link {% if request.endpoint == 'admin.dashboard' %}admin-nav__link--active{% endif %}">Обзор</a>
<a href="{{ url_for('admin.users') }}" class="admin-nav__link {% if request.endpoint == 'admin.users' %}admin-nav__link--active{% endif %}">Пользователи</a>
<a href="{{ url_for('admin.photos') }}" class="admin-nav__link {% if request.endpoint == 'admin.photos' %}admin-nav__link--active{% endif %}">Фото</a>
</nav>
+80
View File
@@ -0,0 +1,80 @@
{% extends "base.html" %}
{% from "macros.html" import format_size %}
{% block title %}Админка — PhotoHost{% endblock %}
{% block content %}
<section class="page-header page-header--admin">
<div class="container">
<h1 class="page-header__title">Панель администратора</h1>
<p class="page-header__subtitle">Управление пользователями и контентом</p>
</div>
</section>
<section class="admin-section">
<div class="container">
{% include "admin/_nav.html" %}
{% include "partials/alerts.html" %}
<div class="admin-stats">
<div class="stat-card stat-card--admin">
<span class="stat-card__value">{{ stats.users }}</span>
<span class="stat-card__label">пользователей</span>
</div>
<div class="stat-card stat-card--admin">
<span class="stat-card__value">{{ stats.photos }}</span>
<span class="stat-card__label">фотографий</span>
</div>
<div class="stat-card stat-card--admin">
<span class="stat-card__value">{{ stats.admins }}</span>
<span class="stat-card__label">администраторов</span>
</div>
<div class="stat-card stat-card--admin">
<span class="stat-card__value">{{ format_size(stats.storage) }}</span>
<span class="stat-card__label">хранилище</span>
</div>
</div>
<div class="admin-grid">
<div class="admin-panel">
<h2 class="admin-panel__title">Новые пользователи</h2>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>Логин</th>
<th>Email</th>
<th>Дата</th>
</tr>
</thead>
<tbody>
{% for user in recent_users %}
<tr>
<td>{{ user.username }}{% if user.is_admin %} <span class="badge badge--admin">admin</span>{% endif %}</td>
<td>{{ user.email }}</td>
<td>{{ user.created_at.strftime('%d.%m.%Y') }}</td>
</tr>
{% else %}
<tr><td colspan="3">Нет пользователей</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="admin-panel">
<h2 class="admin-panel__title">Последние фото</h2>
<div class="admin-mini-gallery">
{% for photo in recent_photos %}
<a href="{{ photo.url }}" target="_blank" class="admin-mini-gallery__item">
<img src="{{ photo.url }}" alt="{{ photo.original_name }}">
</a>
{% else %}
<p class="admin-empty">Нет фотографий</p>
{% endfor %}
</div>
</div>
</div>
</div>
</section>
{% endblock %}
+22
View File
@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Фото — Админка{% endblock %}
{% block content %}
<section class="page-header page-header--admin">
<div class="container">
<h1 class="page-header__title">Все фотографии</h1>
</div>
</section>
<section class="admin-section">
<div class="container">
{% include "admin/_nav.html" %}
{% include "partials/alerts.html" %}
{% with photos=photos, show_owner=true, delete_mode='admin', empty_title='Нет фотографий', empty_text='Пользователи ещё не загружали фото' %}
{% include "partials/photo_gallery.html" %}
{% endwith %}
</div>
</section>
{% endblock %}
+79
View File
@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}Пользователи — Админка{% endblock %}
{% block content %}
<section class="page-header page-header--admin">
<div class="container">
<h1 class="page-header__title">Пользователи</h1>
</div>
</section>
<section class="admin-section">
<div class="container">
{% include "admin/_nav.html" %}
{% include "partials/alerts.html" %}
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>Логин</th>
<th>Email</th>
<th>Фото</th>
<th>Роль</th>
<th>Статус</th>
<th>Дата</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.id }}</td>
<td>{{ user.username }}</td>
<td>{{ user.email }}</td>
<td>{{ user.photo_count }}</td>
<td>
{% if user.is_admin %}
<span class="badge badge--admin">Админ</span>
{% else %}
<span class="badge">User</span>
{% endif %}
</td>
<td>
{% if user.is_active %}
<span class="badge badge--success">Активен</span>
{% else %}
<span class="badge badge--danger">Заблокирован</span>
{% endif %}
</td>
<td>{{ user.created_at.strftime('%d.%m.%Y') }}</td>
<td class="admin-actions">
{% if user.id != current_user.id %}
<form action="{{ url_for('admin.toggle_admin', user_id=user.id) }}" method="post">
<button type="submit" class="btn btn--ghost btn--sm">
{% if user.is_admin %}Снять admin{% else %}Сделать admin{% endif %}
</button>
</form>
<form action="{{ url_for('admin.toggle_active', user_id=user.id) }}" method="post">
<button type="submit" class="btn btn--ghost btn--sm">
{% if user.is_active %}Блок{% else %}Разблок{% endif %}
</button>
</form>
<form action="{{ url_for('admin.delete_user', user_id=user.id) }}" method="post" onsubmit="return confirm('Удалить пользователя и все его фото?');">
<button type="submit" class="btn btn--danger btn--sm">Удалить</button>
</form>
{% else %}
<span class="text-muted">Вы</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% endblock %}
+36
View File
@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title %}Вход — PhotoHost{% endblock %}
{% block content %}
<section class="auth-section">
<div class="container auth-container">
<div class="auth-card">
<h1 class="auth-card__title">Вход</h1>
<p class="auth-card__subtitle">Войдите в аккаунт для загрузки фото</p>
{% include "partials/alerts.html" %}
<form method="post" class="auth-form">
<div class="form-group">
<label for="login">Логин или email</label>
<input type="text" id="login" name="login" required autocomplete="username" placeholder="username или email@example.com">
</div>
<div class="form-group">
<label for="password">Пароль</label>
<input type="password" id="password" name="password" required autocomplete="current-password" placeholder="••••••••">
</div>
<label class="form-checkbox">
<input type="checkbox" name="remember">
<span>Запомнить меня</span>
</label>
<button type="submit" class="btn btn--primary btn--full">Войти</button>
</form>
<p class="auth-card__footer">
Нет аккаунта? <a href="{{ url_for('auth.register') }}">Зарегистрироваться</a>
</p>
</div>
</div>
</section>
{% endblock %}
+40
View File
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Регистрация — PhotoHost{% endblock %}
{% block content %}
<section class="auth-section">
<div class="container auth-container">
<div class="auth-card">
<h1 class="auth-card__title">Регистрация</h1>
<p class="auth-card__subtitle">Создайте аккаунт для загрузки и управления фото</p>
{% include "partials/alerts.html" %}
<form method="post" class="auth-form">
<div class="form-group">
<label for="username">Имя пользователя</label>
<input type="text" id="username" name="username" required minlength="3" autocomplete="username" placeholder="min 3 символа">
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" required autocomplete="email" placeholder="you@example.com">
</div>
<div class="form-group">
<label for="password">Пароль</label>
<input type="password" id="password" name="password" required minlength="6" autocomplete="new-password" placeholder="min 6 символов">
</div>
<div class="form-group">
<label for="password2">Подтверждение пароля</label>
<input type="password" id="password2" name="password2" required minlength="6" autocomplete="new-password" placeholder="повторите пароль">
</div>
<button type="submit" class="btn btn--primary btn--full">Создать аккаунт</button>
</form>
<p class="auth-card__footer">
Уже есть аккаунт? <a href="{{ url_for('auth.login') }}">Войти</a>
</p>
</div>
</div>
</section>
{% endblock %}
+12 -2
View File
@@ -21,8 +21,18 @@
<span class="logo__text">PhotoHost</span>
</a>
<nav class="nav">
<a href="#upload" class="nav__link">Загрузить</a>
<a href="#gallery" class="nav__link">Галерея</a>
<a href="{{ url_for('main.index') }}" class="nav__link">Главная</a>
{% if current_user.is_authenticated %}
<a href="{{ url_for('cabinet.index') }}" class="nav__link">Личный кабинет</a>
{% if current_user.is_admin %}
<a href="{{ url_for('admin.dashboard') }}" class="nav__link nav__link--admin">Админка</a>
{% endif %}
<span class="nav__user">{{ current_user.username }}</span>
<a href="{{ url_for('auth.logout') }}" class="nav__link">Выйти</a>
{% else %}
<a href="{{ url_for('auth.login') }}" class="nav__link">Вход</a>
<a href="{{ url_for('auth.register') }}" class="nav__link nav__link--accent">Регистрация</a>
{% endif %}
</nav>
</div>
</header>
+71
View File
@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% from "macros.html" import format_size %}
{% block title %}Личный кабинет — PhotoHost{% endblock %}
{% block content %}
<section class="page-header">
<div class="container">
<h1 class="page-header__title">Личный кабинет</h1>
<p class="page-header__subtitle">Привет, {{ current_user.username }}! Управляйте своими фотографиями.</p>
<div class="page-header__actions">
<a href="{{ url_for('cabinet.profile') }}" class="btn btn--ghost">Настройки профиля</a>
</div>
</div>
</section>
{% include "partials/alerts.html" %}
<section class="stats-bar">
<div class="container stats">
<div class="stat-card">
<span class="stat-card__value">{{ total_photos }}</span>
<span class="stat-card__label">ваших фото</span>
</div>
<div class="stat-card">
<span class="stat-card__value">{{ format_size(total_size) }}</span>
<span class="stat-card__label">занято места</span>
</div>
<div class="stat-card">
<span class="stat-card__value">до {{ max_upload_mb }} МБ</span>
<span class="stat-card__label">на файл</span>
</div>
</div>
</section>
<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">
<div class="dropzone" id="dropzone">
<input type="file" name="photo" id="photoInput" accept="image/png,image/jpeg,image/gif,image/webp,image/bmp" hidden>
<div class="dropzone__icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 16V4m0 0L8 8m4-4l4 4"/>
<path d="M20 16.5v1a2.5 2.5 0 01-2.5 2.5h-11A2.5 2.5 0 014 17.5v-1"/>
</svg>
</div>
<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>
<section class="gallery-section">
<div class="container">
<div class="gallery-header">
<h2 class="section-title">Мои фото</h2>
<span class="gallery-count">{{ total_photos }} фото</span>
</div>
{% with photos=photos, empty_title='У вас пока нет фото', empty_text='Загрузите первое изображение выше' %}
{% include "partials/photo_gallery.html" %}
{% endwith %}
</div>
</section>
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% 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>
<section class="auth-section">
<div class="container auth-container">
<div class="auth-card auth-card--wide">
{% include "partials/alerts.html" %}
<div class="profile-info">
<div class="profile-info__row">
<span>Имя пользователя</span>
<strong>{{ current_user.username }}</strong>
</div>
<div class="profile-info__row">
<span>Роль</span>
<strong>{% if current_user.is_admin %}Администратор{% else %}Пользователь{% endif %}</strong>
</div>
<div class="profile-info__row">
<span>Дата регистрации</span>
<strong>{{ current_user.created_at.strftime('%d.%m.%Y') }}</strong>
</div>
</div>
<form method="post" class="auth-form">
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" required value="{{ current_user.email }}">
</div>
<div class="form-group">
<label for="current_password">Текущий пароль</label>
<input type="password" id="current_password" name="current_password" required placeholder="для подтверждения изменений">
</div>
<div class="form-group">
<label for="new_password">Новый пароль (необязательно)</label>
<input type="password" id="new_password" name="new_password" minlength="6" placeholder="оставьте пустым, если не меняете">
</div>
<div class="form-group">
<label for="new_password2">Подтверждение нового пароля</label>
<input type="password" id="new_password2" name="new_password2" minlength="6">
</div>
<button type="submit" class="btn btn--primary btn--full">Сохранить</button>
</form>
<p class="auth-card__footer">
<a href="{{ url_for('cabinet.index') }}">← Вернуться в кабинет</a>
</p>
</div>
</div>
</section>
{% endblock %}
+21 -66
View File
@@ -1,29 +1,21 @@
{% extends "base.html" %}
{% macro format_size(bytes) %}
{% set size = bytes|float %}
{% if size < 1024 %}
{{ size|int }} Б
{% elif size < 1048576 %}
{{ "%.1f"|format(size / 1024) }} КБ
{% elif size < 1073741824 %}
{{ "%.1f"|format(size / 1048576) }} МБ
{% else %}
{{ "%.1f"|format(size / 1073741824) }} ГБ
{% endif %}
{% endmacro %}
{% from "macros.html" import format_size %}
{% block content %}
<section class="hero">
<div class="container hero__inner">
<div class="hero__badge">Бесплатно · Без регистрации</div>
<div class="hero__badge">Регистрация · Личный кабинет · Админка</div>
<h1 class="hero__title">
Загружайте фото<br>
<span class="hero__accent">мгновенно</span>
</h1>
<p class="hero__subtitle">
Современный фото-хостинг на Python и PostgreSQL.
Перетащите изображение — получите прямую ссылку за секунды.
{% if current_user.is_authenticated %}
Загружайте изображения в личном кабинете и делитесь ссылками.
{% else %}
Зарегистрируйтесь, чтобы загружать фото и управлять галереей.
{% endif %}
</p>
<div class="stats">
<div class="stat-card">
@@ -39,19 +31,18 @@
<span class="stat-card__label">на файл</span>
</div>
</div>
{% if not current_user.is_authenticated %}
<div class="hero__actions">
<a href="{{ url_for('auth.register') }}" class="btn btn--primary">Создать аккаунт</a>
<a href="{{ url_for('auth.login') }}" class="btn btn--ghost">Войти</a>
</div>
{% endif %}
</div>
</section>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section class="container alerts">
{% for category, message in messages %}
<div class="alert alert--{{ category }}">{{ message }}</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
{% include "partials/alerts.html" %}
{% if current_user.is_authenticated %}
<section id="upload" class="upload-section">
<div class="container">
<h2 class="section-title">Загрузить фото</h2>
@@ -81,53 +72,17 @@
</form>
</div>
</section>
{% endif %}
<section id="gallery" class="gallery-section">
<div class="container">
<div class="gallery-header">
<h2 class="section-title">Галерея</h2>
<span class="gallery-count">{{ total_photos }} {{ 'фото' if total_photos != 1 else 'фото' }}</span>
<h2 class="section-title">Последние фото</h2>
<span class="gallery-count">{{ total_photos }} фото</span>
</div>
{% if photos %}
<div class="gallery">
{% for photo in photos %}
<article class="photo-card" data-id="{{ photo.id }}">
<div class="photo-card__image-wrap">
<img
src="{{ photo.url }}"
alt="{{ photo.original_name }}"
class="photo-card__image"
loading="lazy"
>
<div class="photo-card__overlay">
<button type="button" class="btn btn--ghost btn--sm copy-btn" data-url="{{ request.url_root.rstrip('/') }}{{ photo.url }}">
Копировать ссылку
</button>
<a href="{{ photo.url }}" target="_blank" class="btn btn--ghost btn--sm">Открыть</a>
</div>
</div>
<div class="photo-card__info">
<span class="photo-card__name" title="{{ photo.original_name }}">{{ photo.original_name }}</span>
<div class="photo-card__meta">
<span>{{ photo.size_human }}</span>
<span>{{ photo.created_at.strftime('%d.%m.%Y %H:%M') }}</span>
</div>
<form action="{{ url_for('main.delete_photo', photo_id=photo.id) }}" method="post" class="photo-card__delete" onsubmit="return confirm('Удалить это фото?');">
<button type="submit" class="btn btn--danger btn--sm">Удалить</button>
</form>
</div>
</article>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-state__icon">🖼️</div>
<h3>Пока нет фотографий</h3>
<p>Загрузите первое изображение — оно появится здесь</p>
<a href="#upload" class="btn btn--primary">Загрузить фото</a>
</div>
{% endif %}
{% with photos=photos, show_owner=true, empty_title='Пока нет фотографий', empty_text='Будьте первым — зарегистрируйтесь и загрузите фото', empty_link=url_for('auth.register') if not current_user.is_authenticated else url_for('cabinet.index'), empty_link_text='Загрузить фото' %}
{% include "partials/photo_gallery.html" %}
{% endwith %}
</div>
</section>
{% endblock %}
+12
View File
@@ -0,0 +1,12 @@
{% macro format_size(bytes) %}
{% set size = bytes|float %}
{% if size < 1024 %}
{{ size|int }} Б
{% elif size < 1048576 %}
{{ "%.1f"|format(size / 1024) }} КБ
{% elif size < 1073741824 %}
{{ "%.1f"|format(size / 1048576) }} МБ
{% else %}
{{ "%.1f"|format(size / 1073741824) }} ГБ
{% endif %}
{% endmacro %}
+9
View File
@@ -0,0 +1,9 @@
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section class="container alerts">
{% for category, message in messages %}
<div class="alert alert--{{ category }}">{{ message }}</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
+50
View File
@@ -0,0 +1,50 @@
{% if photos %}
<div class="gallery">
{% for photo in photos %}
<article class="photo-card" data-id="{{ photo.id }}">
<div class="photo-card__image-wrap">
<img
src="{{ photo.url }}"
alt="{{ photo.original_name }}"
class="photo-card__image"
loading="lazy"
>
<div class="photo-card__overlay">
<button type="button" class="btn btn--ghost btn--sm copy-btn" data-url="{{ request.url_root.rstrip('/') }}{{ photo.url }}">
Копировать ссылку
</button>
<a href="{{ photo.url }}" target="_blank" class="btn btn--ghost btn--sm">Открыть</a>
</div>
</div>
<div class="photo-card__info">
<span class="photo-card__name" title="{{ photo.original_name }}">{{ photo.original_name }}</span>
<div class="photo-card__meta">
<span>{{ photo.size_human }}</span>
<span>{{ photo.created_at.strftime('%d.%m.%Y %H:%M') }}</span>
</div>
{% if show_owner and photo.owner %}
<div class="photo-card__owner">@{{ photo.owner.username }}</div>
{% endif %}
{% if delete_mode == 'admin' %}
<form action="{{ url_for('admin.delete_photo', photo_id=photo.id) }}" method="post" class="photo-card__delete" onsubmit="return confirm('Удалить это фото?');">
<button type="submit" class="btn btn--danger btn--sm">Удалить</button>
</form>
{% elif current_user.is_authenticated and (current_user.is_admin or photo.user_id == current_user.id) %}
<form action="{{ url_for('main.delete_photo', photo_id=photo.id) }}" method="post" class="photo-card__delete" onsubmit="return confirm('Удалить это фото?');">
<button type="submit" class="btn btn--danger btn--sm">Удалить</button>
</form>
{% endif %}
</div>
</article>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-state__icon">🖼️</div>
<h3>{{ empty_title or 'Пока нет фотографий' }}</h3>
<p>{{ empty_text or 'Загрузите первое изображение — оно появится здесь' }}</p>
{% if empty_link %}
<a href="{{ empty_link }}" class="btn btn--primary">{{ empty_link_text or 'Загрузить фото' }}</a>
{% endif %}
</div>
{% endif %}