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
+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 %}