Add user auth, personal cabinet, admin panel and first admin bootstrap
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8,3 +8,8 @@ SECRET_KEY=change_me_random_secret_key_min_32_chars
|
|||||||
MAX_UPLOAD_MB=10
|
MAX_UPLOAD_MB=10
|
||||||
UPLOAD_FOLDER=/app/uploads
|
UPLOAD_FOLDER=/app/uploads
|
||||||
APP_PORT=8080
|
APP_PORT=8080
|
||||||
|
|
||||||
|
# First admin (created automatically on first startup if no admin exists)
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_EMAIL=admin@example.com
|
||||||
|
ADMIN_PASSWORD=change_me_admin_password
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ RUN mkdir -p /app/uploads && adduser --disabled-password --gecos "" appuser \
|
|||||||
|
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|
||||||
|
ENV FLASK_APP=wsgi:app
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120", "wsgi:app"]
|
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120", "wsgi:app"]
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
Современный фото-хостинг на **Python (Flask)**, **PostgreSQL** и **Docker Compose**.
|
Современный фото-хостинг на **Python (Flask)**, **PostgreSQL** и **Docker Compose**.
|
||||||
|
|
||||||
- Красивая главная страница с drag-and-drop загрузкой
|
- Красивая главная страница с drag-and-drop загрузкой
|
||||||
|
- **Регистрация и авторизация** пользователей
|
||||||
|
- **Личный кабинет** — загрузка и управление своими фото
|
||||||
|
- **Админ-панель** — пользователи, фото, статистика
|
||||||
|
- **Автоматическое создание первого администратора** через `.env`
|
||||||
- Галерея загруженных фото
|
- Галерея загруженных фото
|
||||||
- Копирование прямых ссылок на изображения
|
- Копирование прямых ссылок на изображения
|
||||||
- Хранение метаданных в PostgreSQL, файлов — в Docker volume
|
- Хранение метаданных в PostgreSQL, файлов — в Docker volume
|
||||||
@@ -14,8 +18,13 @@
|
|||||||
```
|
```
|
||||||
fotohost/
|
fotohost/
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── __init__.py # Flask-приложение и модель Photo
|
│ ├── __init__.py # Flask-приложение, Flask-Login
|
||||||
│ ├── routes.py # Маршруты (загрузка, галерея, API)
|
│ ├── models.py # User, Photo
|
||||||
|
│ ├── auth.py # Регистрация, вход, выход
|
||||||
|
│ ├── routes.py # Главная, загрузка, личный кабинет
|
||||||
|
│ ├── admin.py # Админ-панель
|
||||||
|
│ ├── bootstrap.py # Миграция схемы, первый admin
|
||||||
|
│ ├── auth_utils.py # Декораторы доступа
|
||||||
│ ├── templates/ # HTML-шаблоны
|
│ ├── templates/ # HTML-шаблоны
|
||||||
│ └── static/ # CSS и JavaScript
|
│ └── static/ # CSS и JavaScript
|
||||||
├── uploads/ # Локальная папка (в Docker — volume)
|
├── uploads/ # Локальная папка (в Docker — volume)
|
||||||
@@ -132,6 +141,11 @@ DATABASE_URL=postgresql://photohost:YOUR_STRONG_DB_PASSWORD@db:5432/photohost
|
|||||||
SECRET_KEY=random_string_min_32_chars
|
SECRET_KEY=random_string_min_32_chars
|
||||||
MAX_UPLOAD_MB=10
|
MAX_UPLOAD_MB=10
|
||||||
APP_PORT=8080
|
APP_PORT=8080
|
||||||
|
|
||||||
|
# Первый администратор (создаётся автоматически при первом запуске)
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_EMAIL=admin@example.com
|
||||||
|
ADMIN_PASSWORD=YOUR_STRONG_ADMIN_PASSWORD
|
||||||
```
|
```
|
||||||
|
|
||||||
Сгенерировать случайный `SECRET_KEY`:
|
Сгенерировать случайный `SECRET_KEY`:
|
||||||
@@ -140,6 +154,14 @@ APP_PORT=8080
|
|||||||
python3 -c "import secrets; print(secrets.token_hex(32))"
|
python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Первый администратор:** при первом запуске, если в базе нет ни одного admin, создаётся пользователь из `ADMIN_USERNAME` / `ADMIN_EMAIL` / `ADMIN_PASSWORD`. Обязательно смените пароль в `.env` до деплоя.
|
||||||
|
|
||||||
|
Альтернатива — создать admin вручную через CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec web flask create-admin
|
||||||
|
```
|
||||||
|
|
||||||
### 8. Запуск приложения
|
### 8. Запуск приложения
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -175,6 +197,8 @@ http://YOUR_SERVER_IP:8080
|
|||||||
|
|
||||||
Загрузите тестовое изображение — оно должно появиться в галерее.
|
Загрузите тестовое изображение — оно должно появиться в галерее.
|
||||||
|
|
||||||
|
Войдите как admin (`/auth/login`) → откройте **Админку** (`/admin`).
|
||||||
|
|
||||||
### 10. Открытие порта в файрволе (UFW)
|
### 10. Открытие порта в файрволе (UFW)
|
||||||
|
|
||||||
Если включён UFW:
|
Если включён UFW:
|
||||||
@@ -197,6 +221,26 @@ sudo systemctl start docker
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Регистрация, авторизация и роли
|
||||||
|
|
||||||
|
| URL | Описание |
|
||||||
|
|-----|----------|
|
||||||
|
| `/auth/register` | Регистрация нового пользователя |
|
||||||
|
| `/auth/login` | Вход (логин или email) |
|
||||||
|
| `/auth/logout` | Выход |
|
||||||
|
| `/cabinet/` | Личный кабинет — мои фото |
|
||||||
|
| `/cabinet/profile` | Настройки профиля, смена пароля |
|
||||||
|
| `/admin/` | Панель администратора (только admin) |
|
||||||
|
| `/admin/users` | Управление пользователями |
|
||||||
|
| `/admin/photos` | Все фото на сервере |
|
||||||
|
|
||||||
|
**Права доступа:**
|
||||||
|
- Загрузка фото — только авторизованным пользователям
|
||||||
|
- Удаление фото — владелец или администратор
|
||||||
|
- Админка — только пользователи с `is_admin=True`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Полезные команды
|
## Полезные команды
|
||||||
|
|
||||||
| Действие | Команда |
|
| Действие | Команда |
|
||||||
@@ -321,7 +365,11 @@ python wsgi.py
|
|||||||
| Метод | URL | Описание |
|
| Метод | URL | Описание |
|
||||||
|-------|---------------|-----------------------------|
|
|-------|---------------|-----------------------------|
|
||||||
| GET | `/` | Главная страница |
|
| GET | `/` | Главная страница |
|
||||||
| POST | `/upload` | Загрузка фото (form-data) |
|
| POST | `/auth/register` | Регистрация |
|
||||||
|
| POST | `/auth/login` | Вход |
|
||||||
|
| GET | `/cabinet/` | Личный кабинет |
|
||||||
|
| GET | `/admin/` | Админ-панель |
|
||||||
|
| POST | `/upload` | Загрузка фото (auth) |
|
||||||
| GET | `/uploads/<filename>` | Прямая ссылка на файл |
|
| GET | `/uploads/<filename>` | Прямая ссылка на файл |
|
||||||
| GET | `/api/photos` | JSON-список всех фото |
|
| GET | `/api/photos` | JSON-список всех фото |
|
||||||
| POST | `/delete/<id>` | Удаление фото |
|
| POST | `/delete/<id>` | Удаление фото |
|
||||||
@@ -377,7 +425,7 @@ docker compose down && docker compose up -d
|
|||||||
|
|
||||||
## Технологии
|
## Технологии
|
||||||
|
|
||||||
- Python 3.12, Flask 3, Gunicorn
|
- Python 3.12, Flask 3, Flask-Login, Gunicorn
|
||||||
- PostgreSQL 16
|
- PostgreSQL 16
|
||||||
- SQLAlchemy, Pillow
|
- SQLAlchemy, Pillow
|
||||||
- Docker & Docker Compose
|
- Docker & Docker Compose
|
||||||
|
|||||||
+58
-26
@@ -1,13 +1,24 @@
|
|||||||
import os
|
import os
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
from flask_login import LoginManager
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
db = SQLAlchemy()
|
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():
|
def create_app():
|
||||||
@@ -26,40 +37,61 @@ def create_app():
|
|||||||
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
|
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
|
||||||
|
|
||||||
db.init_app(app)
|
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():
|
with app.app_context():
|
||||||
|
from app.models import Photo, User # noqa: F401
|
||||||
|
|
||||||
db.create_all()
|
db.create_all()
|
||||||
|
from app.bootstrap import create_first_admin, ensure_schema
|
||||||
|
|
||||||
|
ensure_schema()
|
||||||
|
create_first_admin(app)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
class Photo(db.Model):
|
def register_cli(app):
|
||||||
__tablename__ = "photos"
|
@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)
|
from app.models import User
|
||||||
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),
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
username = input("Username: ").strip()
|
||||||
def url(self):
|
email = input("Email: ").strip()
|
||||||
return f"/uploads/{self.filename}"
|
password = getpass("Password: ")
|
||||||
|
password2 = getpass("Confirm password: ")
|
||||||
|
|
||||||
@property
|
if not username or not email or not password:
|
||||||
def size_human(self):
|
print("All fields are required.")
|
||||||
size = self.file_size
|
return
|
||||||
for unit in ("Б", "КБ", "МБ", "ГБ"):
|
if password != password2:
|
||||||
if size < 1024:
|
print("Passwords do not match.")
|
||||||
return f"{size:.0f} {unit}" if unit == "Б" else f"{size:.1f} {unit}"
|
return
|
||||||
size /= 1024
|
|
||||||
return f"{size:.1f} ТБ"
|
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
@@ -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
@@ -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"))
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
@@ -13,9 +13,12 @@ from flask import (
|
|||||||
send_from_directory,
|
send_from_directory,
|
||||||
url_for,
|
url_for,
|
||||||
)
|
)
|
||||||
|
from flask_login import current_user, login_required
|
||||||
from werkzeug.utils import secure_filename
|
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__)
|
bp = Blueprint("main", __name__)
|
||||||
|
|
||||||
@@ -29,31 +32,33 @@ def allowed_file(filename):
|
|||||||
|
|
||||||
@bp.route("/")
|
@bp.route("/")
|
||||||
def index():
|
def index():
|
||||||
photos = Photo.query.order_by(Photo.created_at.desc()).all()
|
photos = Photo.query.order_by(Photo.created_at.desc()).limit(24).all()
|
||||||
total_size = sum(p.file_size for p in photos)
|
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(
|
return render_template(
|
||||||
"index.html",
|
"index.html",
|
||||||
photos=photos,
|
photos=photos,
|
||||||
total_photos=len(photos),
|
total_photos=total_photos,
|
||||||
total_size=total_size,
|
total_size=int(total_size),
|
||||||
max_upload_mb=current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024),
|
max_upload_mb=current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/upload", methods=["POST"])
|
@bp.route("/upload", methods=["POST"])
|
||||||
|
@login_required
|
||||||
def upload():
|
def upload():
|
||||||
if "photo" not in request.files:
|
if "photo" not in request.files:
|
||||||
flash("Файл не выбран", "error")
|
flash("Файл не выбран", "error")
|
||||||
return redirect(url_for("main.index"))
|
return redirect(request.referrer or url_for("main.index"))
|
||||||
|
|
||||||
file = request.files["photo"]
|
file = request.files["photo"]
|
||||||
if file.filename == "":
|
if file.filename == "":
|
||||||
flash("Файл не выбран", "error")
|
flash("Файл не выбран", "error")
|
||||||
return redirect(url_for("main.index"))
|
return redirect(request.referrer or url_for("main.index"))
|
||||||
|
|
||||||
if not allowed_file(file.filename):
|
if not allowed_file(file.filename):
|
||||||
flash("Недопустимый формат. Разрешены: PNG, JPG, GIF, WEBP, BMP", "error")
|
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()
|
ext = file.filename.rsplit(".", 1)[1].lower()
|
||||||
stored_name = f"{uuid.uuid4().hex}.{ext}"
|
stored_name = f"{uuid.uuid4().hex}.{ext}"
|
||||||
@@ -69,13 +74,14 @@ def upload():
|
|||||||
original_name=safe_original,
|
original_name=safe_original,
|
||||||
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,
|
||||||
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")
|
||||||
return redirect(url_for("main.index"))
|
return redirect(url_for("cabinet.index"))
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/photos")
|
@bp.route("/api/photos")
|
||||||
@@ -89,6 +95,7 @@ def api_photos():
|
|||||||
"original_name": p.original_name,
|
"original_name": p.original_name,
|
||||||
"file_size": p.file_size,
|
"file_size": p.file_size,
|
||||||
"size_human": p.size_human,
|
"size_human": p.size_human,
|
||||||
|
"user_id": p.user_id,
|
||||||
"created_at": p.created_at.isoformat(),
|
"created_at": p.created_at.isoformat(),
|
||||||
}
|
}
|
||||||
for p in photos
|
for p in photos
|
||||||
@@ -102,12 +109,67 @@ def uploaded_file(filename):
|
|||||||
|
|
||||||
|
|
||||||
@bp.route("/delete/<int:photo_id>", methods=["POST"])
|
@bp.route("/delete/<int:photo_id>", methods=["POST"])
|
||||||
|
@login_required
|
||||||
def delete_photo(photo_id):
|
def delete_photo(photo_id):
|
||||||
photo = Photo.query.get_or_404(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)
|
filepath = os.path.join(current_app.config["UPLOAD_FOLDER"], photo.filename)
|
||||||
if os.path.exists(filepath):
|
if os.path.exists(filepath):
|
||||||
os.remove(filepath)
|
os.remove(filepath)
|
||||||
db.session.delete(photo)
|
db.session.delete(photo)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash("Фото удалено", "success")
|
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")
|
||||||
|
|||||||
@@ -561,4 +561,323 @@ body {
|
|||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
padding-bottom: 12px;
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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
@@ -21,8 +21,18 @@
|
|||||||
<span class="logo__text">PhotoHost</span>
|
<span class="logo__text">PhotoHost</span>
|
||||||
</a>
|
</a>
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a href="#upload" class="nav__link">Загрузить</a>
|
<a href="{{ url_for('main.index') }}" class="nav__link">Главная</a>
|
||||||
<a href="#gallery" 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>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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
@@ -1,29 +1,21 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% from "macros.html" import format_size %}
|
||||||
{% 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 %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div class="container hero__inner">
|
<div class="container hero__inner">
|
||||||
<div class="hero__badge">Бесплатно · Без регистрации</div>
|
<div class="hero__badge">Регистрация · Личный кабинет · Админка</div>
|
||||||
<h1 class="hero__title">
|
<h1 class="hero__title">
|
||||||
Загружайте фото<br>
|
Загружайте фото<br>
|
||||||
<span class="hero__accent">мгновенно</span>
|
<span class="hero__accent">мгновенно</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="hero__subtitle">
|
<p class="hero__subtitle">
|
||||||
Современный фото-хостинг на Python и PostgreSQL.
|
Современный фото-хостинг на Python и PostgreSQL.
|
||||||
Перетащите изображение — получите прямую ссылку за секунды.
|
{% if current_user.is_authenticated %}
|
||||||
|
Загружайте изображения в личном кабинете и делитесь ссылками.
|
||||||
|
{% else %}
|
||||||
|
Зарегистрируйтесь, чтобы загружать фото и управлять галереей.
|
||||||
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
@@ -39,19 +31,18 @@
|
|||||||
<span class="stat-card__label">на файл</span>
|
<span class="stat-card__label">на файл</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{% include "partials/alerts.html" %}
|
||||||
{% if messages %}
|
|
||||||
<section class="container alerts">
|
|
||||||
{% for category, message in messages %}
|
|
||||||
<div class="alert alert--{{ category }}">{{ message }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
<section id="upload" class="upload-section">
|
<section id="upload" class="upload-section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="section-title">Загрузить фото</h2>
|
<h2 class="section-title">Загрузить фото</h2>
|
||||||
@@ -81,53 +72,17 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<section id="gallery" class="gallery-section">
|
<section id="gallery" class="gallery-section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="gallery-header">
|
<div class="gallery-header">
|
||||||
<h2 class="section-title">Галерея</h2>
|
<h2 class="section-title">Последние фото</h2>
|
||||||
<span class="gallery-count">{{ total_photos }} {{ 'фото' if total_photos != 1 else 'фото' }}</span>
|
<span class="gallery-count">{{ total_photos }} фото</span>
|
||||||
</div>
|
</div>
|
||||||
|
{% 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='Загрузить фото' %}
|
||||||
{% if photos %}
|
{% include "partials/photo_gallery.html" %}
|
||||||
<div class="gallery">
|
{% endwith %}
|
||||||
{% 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 %}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -26,6 +26,9 @@ services:
|
|||||||
SECRET_KEY: ${SECRET_KEY:-dev-secret-change-in-production}
|
SECRET_KEY: ${SECRET_KEY:-dev-secret-change-in-production}
|
||||||
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-10}
|
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-10}
|
||||||
UPLOAD_FOLDER: /app/uploads
|
UPLOAD_FOLDER: /app/uploads
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-}
|
||||||
|
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
||||||
volumes:
|
volumes:
|
||||||
- uploads_data:/app/uploads
|
- uploads_data:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
Flask-Login==0.6.3
|
||||||
Flask==3.1.0
|
Flask==3.1.0
|
||||||
Flask-SQLAlchemy==3.1.1
|
Flask-SQLAlchemy==3.1.1
|
||||||
psycopg2-binary==2.9.10
|
psycopg2-binary==2.9.10
|
||||||
|
|||||||
Reference in New Issue
Block a user