Добавлен веб-личный кабинет: посты, контент-план, медиа-библиотека и генерация картинок

This commit is contained in:
apuc committed 2026-08-07 09:35:44 +03:00
1 parent d8c086d3b4
commit 81625b2e6f
35 files changed
+2220 -64

No files matched your search

+435
View File
@@ -0,0 +1,435 @@
from pathlib import Path
import os
import uuid
import bcrypt
from fastapi import Depends, FastAPI, Form, HTTPException, Request
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from ai_core.image_generator import IMAGE_SIZES, STYLE_PRESETS, ImageGenerator, detect_ext
from ai_core.neural_network import ContentGenerator
from config import SECRET_KEY
from database import (
create_web_user,
delete_media_by_id,
delete_plan_item,
delete_post_by_id,
delete_user_by_id,
get_media_by_id_for_user,
get_media_for_user,
get_plan_for_user,
get_plan_stats_for_user,
get_post_by_id_for_user,
get_posts_for_user,
get_stats_for_user,
get_user_by_email,
get_user_by_id,
replace_plan_for_user,
save_media,
save_post_for_user,
update_plan_status,
update_post_content,
update_user_field_by_id,
)
from keyboards.reply_keyboards import BUSINESS_TYPES
BASE_DIR = Path(__file__).resolve().parent
MEDIA_DIR = BASE_DIR / "media"
MEDIA_DIR.mkdir(exist_ok=True)
app = FastAPI(title="Личный кабинет")
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY)
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
app.mount("/media", StaticFiles(directory=str(MEDIA_DIR)), name="media")
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
content_gen = ContentGenerator()
image_gen = ImageGenerator()
PLATFORMS = [
("instagram", "Instagram"),
("linkedin", "LinkedIn"),
("telegram", "Telegram"),
("twitter", "Twitter/X"),
("ad", "Рекламный пост"),
]
PLATFORM_LABELS = dict(PLATFORMS)
def platform_label(platform: str) -> str:
return PLATFORM_LABELS.get(platform, platform)
class NotAuthenticated(Exception):
pass
@app.exception_handler(NotAuthenticated)
async def not_authenticated_handler(request: Request, exc: NotAuthenticated):
return RedirectResponse(url="/login", status_code=303)
def current_user(request: Request) -> dict:
user_id = request.session.get("user_id")
if not user_id:
raise NotAuthenticated()
user = get_user_by_id(user_id)
if not user:
request.session.clear()
raise NotAuthenticated()
return user
def flash(request: Request, message: str):
request.session["flash"] = message
def render(request: Request, template: str, status_code: int = 200, **context):
context.setdefault("user", None)
context["flash_message"] = request.session.pop("flash", None)
return templates.TemplateResponse(request, template, context, status_code=status_code)
def business_name_of(user: dict) -> str:
return BUSINESS_TYPES.get(str(user["business_type"]), {}).get("name", "Не указан")
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/")
def root(request: Request):
if request.session.get("user_id"):
return RedirectResponse("/dashboard", status_code=303)
return RedirectResponse("/login", status_code=303)
# === РЕГИСТРАЦИЯ ===
@app.get("/register")
def register_form(request: Request):
if request.session.get("user_id"):
return RedirectResponse("/dashboard", status_code=303)
return render(request, "register.html", business_types=BUSINESS_TYPES)
@app.post("/register")
def register_submit(
request: Request,
name: str = Form(...),
phone: str = Form(...),
business_type: str = Form(...),
description: str = Form(...),
email: str = Form(...),
password: str = Form(...),
password_confirm: str = Form(...),
):
email = email.strip().lower()
errors = []
if business_type not in BUSINESS_TYPES:
errors.append("Выбери вид бизнеса из списка.")
if len(password) < 8:
errors.append("Пароль должен быть не короче 8 символов.")
if password != password_confirm:
errors.append("Пароли не совпадают.")
if "@" not in email or "." not in email.split("@")[-1]:
errors.append("Введи корректный email.")
if not errors and get_user_by_email(email):
errors.append("Пользователь с таким email уже зарегистрирован.")
if errors:
return render(
request, "register.html", status_code=400,
business_types=BUSINESS_TYPES, errors=errors,
form={
"name": name, "phone": phone, "business_type": business_type,
"description": description, "email": email,
},
)
password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
user_id = create_web_user(email, password_hash, name, phone, int(business_type), description)
request.session["user_id"] = user_id
flash(request, "Регистрация завершена! Добро пожаловать.")
return RedirectResponse("/dashboard", status_code=303)
# === ВХОД / ВЫХОД ===
@app.get("/login")
def login_form(request: Request):
if request.session.get("user_id"):
return RedirectResponse("/dashboard", status_code=303)
return render(request, "login.html")
@app.post("/login")
def login_submit(request: Request, email: str = Form(...), password: str = Form(...)):
email = email.strip().lower()
user = get_user_by_email(email)
valid = (
user is not None
and user["password_hash"]
and bcrypt.checkpw(password.encode("utf-8"), user["password_hash"].encode("utf-8"))
)
if not valid:
return render(
request, "login.html", status_code=400,
errors=["Неверный email или пароль."], form={"email": email},
)
request.session["user_id"] = user["id"]
return RedirectResponse("/dashboard", status_code=303)
@app.post("/logout")
def logout(request: Request):
request.session.clear()
return RedirectResponse("/login", status_code=303)
# === КАБИНЕТ ===
@app.get("/dashboard")
def dashboard(request: Request, user: dict = Depends(current_user)):
return render(request, "dashboard.html", user=user, business_name=business_name_of(user))
@app.get("/profile/edit")
def profile_edit_form(request: Request, user: dict = Depends(current_user)):
return render(request, "profile_edit.html", user=user, business_types=BUSINESS_TYPES)
@app.post("/profile/edit")
def profile_edit_submit(
request: Request,
name: str = Form(...),
phone: str = Form(...),
business_type: str = Form(...),
description: str = Form(...),
user: dict = Depends(current_user),
):
if business_type not in BUSINESS_TYPES:
return render(
request, "profile_edit.html", status_code=400,
user=user, business_types=BUSINESS_TYPES,
errors=["Выбери вид бизнеса из списка."],
)
update_user_field_by_id(user["id"], "name", name)
update_user_field_by_id(user["id"], "phone", phone)
update_user_field_by_id(user["id"], "business_type", int(business_type))
update_user_field_by_id(user["id"], "description", description)
flash(request, "Профиль обновлён.")
return RedirectResponse("/dashboard", status_code=303)
@app.get("/generate")
def generate_form(request: Request, user: dict = Depends(current_user)):
return render(request, "generate.html", user=user, platforms=PLATFORMS)
@app.post("/generate")
def generate_submit(
request: Request,
platform: str = Form(...),
style: str = Form(...),
wishes: str = Form(""),
user: dict = Depends(current_user),
):
description = user["description"] or "не указано"
content = content_gen.generate_post_with_style(
business_name_of(user), description, platform, style, wishes
)
save_post_for_user(user["id"], platform, content, style, wishes)
return render(
request, "generate.html", user=user, platforms=PLATFORMS, result=content,
form={"platform": platform, "style": style, "wishes": wishes},
)
@app.get("/history")
def history(request: Request, user: dict = Depends(current_user)):
posts = get_posts_for_user(user["id"], limit=50)
return render(request, "history.html", user=user, posts=posts, platform_label=platform_label)
@app.get("/posts/{post_id}")
def post_detail(request: Request, post_id: int, user: dict = Depends(current_user)):
post = get_post_by_id_for_user(post_id, user["id"])
if not post:
raise HTTPException(status_code=404, detail="Пост не найден")
return render(request, "post_detail.html", user=user, post=post, platform_label=platform_label)
@app.get("/posts/{post_id}/edit")
def post_edit_form(request: Request, post_id: int, user: dict = Depends(current_user)):
post = get_post_by_id_for_user(post_id, user["id"])
if not post:
raise HTTPException(status_code=404, detail="Пост не найден")
return render(request, "post_edit.html", user=user, post=post, platform_label=platform_label)
@app.post("/posts/{post_id}/edit")
def post_edit_submit(
request: Request,
post_id: int,
content: str = Form(...),
user: dict = Depends(current_user),
):
post = get_post_by_id_for_user(post_id, user["id"])
if not post:
raise HTTPException(status_code=404, detail="Пост не найден")
content = content.strip()
if not content:
return render(
request, "post_edit.html", status_code=400,
user=user, post=post, platform_label=platform_label,
errors=["Текст поста не может быть пустым."],
)
update_post_content(user["id"], post_id, content)
flash(request, "Пост обновлён.")
return RedirectResponse(f"/posts/{post_id}", status_code=303)
@app.post("/posts/{post_id}/delete")
def post_delete(request: Request, post_id: int, user: dict = Depends(current_user)):
post = get_post_by_id_for_user(post_id, user["id"])
if not post:
raise HTTPException(status_code=404, detail="Пост не найден")
delete_post_by_id(user["id"], post_id)
flash(request, "Пост удалён.")
return RedirectResponse("/history", status_code=303)
@app.get("/plan")
def plan_form(request: Request, user: dict = Depends(current_user)):
items = get_plan_for_user(user["id"])
status_counts = get_plan_stats_for_user(user["id"])
return render(
request, "plan.html",
user=user, plan=items, status_counts=status_counts, platform_label=platform_label,
)
@app.post("/plan")
def plan_submit(request: Request, days: int = Form(7), user: dict = Depends(current_user)):
days = max(1, min(days, 14))
description = user["description"] or "не указано"
plan = content_gen.generate_content_plan(business_name_of(user), description, days)
replace_plan_for_user(user["id"], plan)
flash(request, f"Контент-план на {days} дн. сохранён.")
return RedirectResponse("/plan", status_code=303)
@app.post("/plan/{item_id}/status")
def plan_status_submit(
request: Request,
item_id: int,
status: str = Form(...),
user: dict = Depends(current_user),
):
try:
update_plan_status(user["id"], item_id, status)
flash(request, "Статус пункта обновлён.")
except ValueError:
flash(request, "Недопустимый статус.")
return RedirectResponse("/plan", status_code=303)
@app.post("/plan/{item_id}/delete")
def plan_item_delete(request: Request, item_id: int, user: dict = Depends(current_user)):
delete_plan_item(user["id"], item_id)
flash(request, "Пункт плана удалён.")
return RedirectResponse("/plan", status_code=303)
@app.get("/stats")
def stats(request: Request, user: dict = Depends(current_user)):
return render(request, "stats.html", user=user, stats=get_stats_for_user(user["id"]))
@app.get("/delete-account")
def delete_confirm_form(request: Request, user: dict = Depends(current_user)):
return render(request, "delete_confirm.html", user=user)
@app.post("/delete-account")
def delete_account_submit(
request: Request, confirm: str = Form(""), user: dict = Depends(current_user)
):
if confirm != "yes":
return RedirectResponse("/dashboard", status_code=303)
delete_user_by_id(user["id"])
request.session.clear()
return RedirectResponse("/login", status_code=303)
# === ГЕНЕРАЦИЯ КАРТИНОК ===
@app.get("/images")
def images_gallery(request: Request, user: dict = Depends(current_user)):
items = get_media_for_user(user["id"])
return render(request, "images.html", user=user, images=items)
@app.get("/images/generate")
def image_generate_form(request: Request, user: dict = Depends(current_user)):
return render(
request, "image_generate.html",
user=user, sizes=IMAGE_SIZES, styles=STYLE_PRESETS,
)
@app.post("/images/generate")
def image_generate_submit(
request: Request,
prompt: str = Form(...),
size: str = Form("square"),
style: str = Form("none"),
user: dict = Depends(current_user),
):
prompt = prompt.strip()
if not prompt:
return render(
request, "image_generate.html", status_code=400,
user=user, sizes=IMAGE_SIZES, styles=STYLE_PRESETS,
form={"prompt": prompt, "size": size, "style": style},
errors=["Опиши, что нужно нарисовать."],
)
full_prompt = image_gen.build_prompt(prompt, style)
try:
image_bytes = image_gen.generate(full_prompt, size)
except Exception as e:
return render(
request, "image_generate.html", status_code=502,
user=user, sizes=IMAGE_SIZES, styles=STYLE_PRESETS,
form={"prompt": prompt, "size": size, "style": style},
errors=[f"Ошибка генерации картинки: {e}"],
)
filename = f"{uuid.uuid4().hex}{detect_ext(image_bytes)}"
(MEDIA_DIR / filename).write_bytes(image_bytes)
save_media(user["id"], prompt, style, filename)
flash(request, "Картинка сгенерирована и сохранена в библиотеку.")
return RedirectResponse("/images", status_code=303)
@app.post("/images/{media_id}/delete")
def image_delete(request: Request, media_id: int, user: dict = Depends(current_user)):
item = get_media_by_id_for_user(media_id, user["id"])
if not item:
raise HTTPException(status_code=404, detail="Картинка не найдена")
filename = delete_media_by_id(user["id"], media_id)
if filename:
file_path = MEDIA_DIR / filename
if file_path.exists():
file_path.unlink()
flash(request, "Картинка удалена.")
return RedirectResponse("/images", status_code=303)
+13
View File
@@ -0,0 +1,13 @@
import uvicorn
from config import CABINET_SERVICE_HOST, CABINET_SERVICE_PORT
from cabinet_service.app import app
def run():
uvicorn.run(
app,
host=CABINET_SERVICE_HOST,
port=CABINET_SERVICE_PORT,
log_level="warning"
)
+555
View File
@@ -0,0 +1,555 @@
:root {
--bg: #f4f5f7;
--surface: #ffffff;
--border: #e2e5ea;
--border-strong: #d1d5db;
--text: #1f2328;
--text-muted: #4b5563;
--text-faint: #6b7280;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--accent-soft: #eef2ff;
--accent-soft-border: #c7d2fe;
--accent-soft-hover: #e0e7ff;
--danger: #dc2626;
--danger-hover: #b91c1c;
--danger-text: #b91c1c;
--success-bg: #ecfdf3;
--success-border: #a7e3bb;
--success-text: #14532d;
--warn-bg: #fffbeb;
--warn-border: #fde68a;
--warn-text: #78350f;
--error-bg: #fef2f2;
--error-border: #f3b4b4;
--error-text: #7f1d1d;
--table-head: #f9fafb;
--badge-bg: #f3f4f6;
--badge-text: #374151;
--input-bg: #ffffff;
--published-surface: #fafefb;
color-scheme: light;
}
:root[data-theme="dark"] {
--bg: #0f172a;
--surface: #1e293b;
--border: #334155;
--border-strong: #475569;
--text: #e2e8f0;
--text-muted: #94a3b8;
--text-faint: #94a3b8;
--accent: #60a5fa;
--accent-hover: #93c5fd;
--accent-soft: #172554;
--accent-soft-border: #1e40af;
--accent-soft-hover: #1e3a8a;
--danger: #ef4444;
--danger-hover: #f87171;
--danger-text: #f87171;
--success-bg: #064e3b;
--success-border: #059669;
--success-text: #a7f3d0;
--warn-bg: #451a03;
--warn-border: #b45309;
--warn-text: #fde68a;
--error-bg: #450a0a;
--error-border: #991b1b;
--error-text: #fecaca;
--table-head: #243244;
--badge-bg: #334155;
--badge-text: #e2e8f0;
--input-bg: #0f172a;
--published-surface: #0b2e22;
color-scheme: dark;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--bg: #0f172a;
--surface: #1e293b;
--border: #334155;
--border-strong: #475569;
--text: #e2e8f0;
--text-muted: #94a3b8;
--text-faint: #94a3b8;
--accent: #60a5fa;
--accent-hover: #93c5fd;
--accent-soft: #172554;
--accent-soft-border: #1e40af;
--accent-soft-hover: #1e3a8a;
--danger: #ef4444;
--danger-hover: #f87171;
--danger-text: #f87171;
--success-bg: #064e3b;
--success-border: #059669;
--success-text: #a7f3d0;
--warn-bg: #451a03;
--warn-border: #b45309;
--warn-text: #fde68a;
--error-bg: #450a0a;
--error-border: #991b1b;
--error-text: #fecaca;
--table-head: #243244;
--badge-bg: #334155;
--badge-text: #e2e8f0;
--input-bg: #0f172a;
--published-surface: #0b2e22;
color-scheme: dark;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
transition: background-color 0.2s ease, color 0.2s ease;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.topbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 20px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.topbar .brand {
font-weight: 600;
font-size: 1.1rem;
color: var(--text);
}
.topbar nav {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 14px;
}
.theme-toggle {
background: none;
border: 1px solid var(--border);
border-radius: 8px;
padding: 4px 10px;
font-size: 1rem;
line-height: 1.4;
cursor: pointer;
}
.theme-toggle:hover {
background: var(--accent-soft);
border-color: var(--accent-soft-border);
}
.inline-form {
display: inline;
margin: 0;
}
.link-button {
background: none;
border: none;
padding: 0;
font: inherit;
color: var(--accent);
cursor: pointer;
}
.link-button:hover {
text-decoration: underline;
}
.container {
max-width: 720px;
margin: 0 auto;
padding: 24px 20px 60px;
}
h1 {
font-size: 1.5rem;
margin-bottom: 16px;
}
h2 {
font-size: 1.15rem;
margin-top: 28px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px 18px;
margin-bottom: 16px;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.card p {
margin: 6px 0;
}
.result {
white-space: pre-wrap;
}
.flash {
background: var(--success-bg);
border: 1px solid var(--success-border);
color: var(--success-text);
padding: 10px 14px;
border-radius: 8px;
margin-bottom: 16px;
}
.errors {
background: var(--error-bg);
border: 1px solid var(--error-border);
color: var(--error-text);
padding: 10px 14px;
border-radius: 8px;
margin-bottom: 16px;
}
.errors ul {
margin: 0;
padding-left: 18px;
}
.form {
display: flex;
flex-direction: column;
gap: 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.form label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.9rem;
color: var(--text-muted);
}
.form input,
.form select,
.form textarea {
font: inherit;
padding: 9px 11px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--input-bg);
color: var(--text);
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.form input:focus,
.form select:focus,
.form textarea:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.form button,
.button-danger {
align-self: flex-start;
background: var(--accent);
color: #fff;
border: none;
padding: 10px 18px;
border-radius: 8px;
font: inherit;
cursor: pointer;
}
.form button:hover {
background: var(--accent-hover);
}
.button-danger {
background: var(--danger);
}
.button-danger:hover {
background: var(--danger-hover);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.tile {
display: block;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px;
font-weight: 500;
color: var(--text);
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.tile:hover {
border-color: var(--accent);
text-decoration: none;
}
.tile-danger {
color: var(--danger-text);
}
.post-list {
list-style: none;
margin: 0;
padding: 0;
}
.post-meta {
font-size: 0.85rem;
color: var(--text-faint);
margin-bottom: 6px;
}
.post-content {
white-space: pre-wrap;
}
.table {
width: 100%;
border-collapse: collapse;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.table th,
.table td {
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.table th {
background: var(--table-head);
}
/* === Действия с постами / планом === */
.post-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 12px;
}
.copy-btn {
background: var(--accent-soft);
color: var(--accent);
border: 1px solid var(--accent-soft-border);
border-radius: 8px;
padding: 7px 12px;
font: inherit;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.copy-btn:hover {
background: var(--accent-soft-hover);
}
.button-link {
background: none;
border: none;
padding: 0;
font: inherit;
font-size: 0.9rem;
color: var(--accent);
cursor: pointer;
text-decoration: none;
}
.button-link:hover {
text-decoration: underline;
}
.button-link-danger {
color: var(--danger-text);
}
.post-notes {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--border);
font-size: 0.9rem;
color: var(--text-muted);
}
.form-actions {
display: flex;
align-items: center;
gap: 16px;
}
/* === Контент-план === */
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.chip {
padding: 6px 12px;
border-radius: 999px;
font-size: 0.85rem;
background: var(--surface);
border: 1px solid var(--border);
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.chip-published {
background: var(--success-bg);
border-color: var(--success-border);
color: var(--success-text);
}
.chip-postponed {
background: var(--warn-bg);
border-color: var(--warn-border);
color: var(--warn-text);
}
.plan-item-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.plan-day {
font-weight: 600;
}
.plan-platform {
font-size: 0.9rem;
color: var(--text-faint);
}
.plan-idea {
white-space: pre-wrap;
}
.status-badge {
font-size: 0.8rem;
padding: 3px 10px;
border-radius: 999px;
background: var(--badge-bg);
color: var(--badge-text);
}
.status-badge.status-published {
background: var(--success-bg);
color: var(--success-text);
}
.status-badge.status-postponed {
background: var(--warn-bg);
color: var(--warn-text);
}
.plan-item.status-published {
background: var(--published-surface);
}
.status-select {
font: inherit;
font-size: 0.9rem;
padding: 6px 8px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--input-bg);
color: var(--text);
}
/* === Медиа-библиотека === */
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.media-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.media-card a.media-link {
display: block;
line-height: 0;
}
.media-thumb {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
background: var(--table-head);
display: block;
}
.media-body {
padding: 10px 12px 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.media-prompt {
font-size: 0.9rem;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.form-hint {
font-size: 0.85rem;
color: var(--text-faint);
margin: 0;
}
+114
View File
@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Личный кабинет{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css?v=2">
<script>
(function () {
function isDark() {
return document.documentElement.getAttribute('data-theme') === 'dark';
}
function setIcon(btn) {
if (btn) btn.textContent = isDark() ? '☀️' : '🌙';
}
try {
var saved = localStorage.getItem('theme');
// Тёмная тема по умолчанию: dark включается всегда, кроме явного выбора 'light'
if (saved !== 'light') {
document.documentElement.setAttribute('data-theme', 'dark');
}
} catch (e) {}
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-theme-toggle]');
if (!btn) return;
if (isDark()) {
document.documentElement.removeAttribute('data-theme');
try { localStorage.setItem('theme', 'light'); } catch (e) {}
} else {
document.documentElement.setAttribute('data-theme', 'dark');
try { localStorage.setItem('theme', 'dark'); } catch (e) {}
}
setIcon(btn);
});
document.addEventListener('DOMContentLoaded', function () {
setIcon(document.querySelector('[data-theme-toggle]'));
});
})();
</script>
</head>
<body>
<header class="topbar">
<a class="brand" href="{{ '/dashboard' if user else '/login' }}">🗂 Личный кабинет</a>
<nav>
<button type="button" class="theme-toggle" data-theme-toggle aria-label="Переключить тему">🌙</button>
{% if user %}
<a href="/dashboard">Главная</a>
<a href="/generate">Пост</a>
<a href="/plan">Контент-план</a>
<a href="/history">История</a>
<a href="/images">Картинки</a>
<a href="/stats">Статистика</a>
<a href="/profile/edit">Профиль</a>
<form action="/logout" method="post" class="inline-form">
<button type="submit" class="link-button">Выйти</button>
</form>
</nav>
{% else %}
<a href="/login">Вход</a>
<a href="/register">Регистрация</a>
</nav>
{% endif %}
</header>
<main class="container">
{% if flash_message %}
<div class="flash">{{ flash_message }}</div>
{% endif %}
{% if errors %}
<div class="errors">
<ul>
{% for error in errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
<script>
function copyText(text, btn) {
function done(ok) {
if (!btn) return;
var old = btn.textContent;
btn.textContent = ok ? '✅ Скопировано' : '❌ Ошибка';
setTimeout(function () { btn.textContent = old; }, 1500);
}
function fallback() {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
var ok = false;
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
document.body.removeChild(ta);
done(ok);
}
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(function () { done(true); }, fallback);
} else {
fallback();
}
}
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-copy-target]');
if (!btn) return;
var el = document.getElementById(btn.getAttribute('data-copy-target'));
if (el) copyText(el.textContent, btn);
});
</script>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}Главная — Личный кабинет{% endblock %}
{% block content %}
<h1>Привет, {{ user.name }}!</h1>
<div class="card">
<p><strong>Телефон:</strong> {{ user.phone }}</p>
<p><strong>Вид бизнеса:</strong> {{ business_name }}</p>
<p><strong>Описание:</strong> {{ user.description }}</p>
<p><strong>В кабинете с:</strong> {{ user.created_at }}</p>
</div>
<div class="grid">
<a class="tile" href="/generate">💡 Сгенерировать пост</a>
<a class="tile" href="/plan">📅 Контент-план</a>
<a class="tile" href="/history">📜 История постов</a>
<a class="tile" href="/images">🖼 Медиа-библиотека</a>
<a class="tile" href="/stats">📈 Статистика</a>
<a class="tile" href="/profile/edit">✏️ Редактировать профиль</a>
<a class="tile tile-danger" href="/delete-account">🗑 Удалить аккаунт</a>
</div>
{% endblock %}
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Удаление аккаунта — Личный кабинет{% endblock %}
{% block content %}
<h1>Удалить аккаунт?</h1>
<p>Это безвозвратно удалит твой профиль и всю историю постов.</p>
<form method="post" action="/delete-account" class="form">
<input type="hidden" name="confirm" value="yes">
<button type="submit" class="button-danger">Да, удалить аккаунт</button>
</form>
<p><a href="/dashboard">Отмена, вернуться в кабинет</a></p>
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}Сгенерировать пост — Личный кабинет{% endblock %}
{% block content %}
<h1>Пост в своём стиле</h1>
<form method="post" action="/generate" class="form">
<label>Платформа
<select name="platform" required>
{% for key, label in platforms %}
<option value="{{ key }}" {% if form and form.platform == key %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>Стиль (например: дружелюбный, с юмором, экспертный)
<input type="text" name="style" value="{{ form.style if form else '' }}" required>
</label>
<label>Пожелания к посту
<textarea name="wishes" rows="3">{{ form.wishes if form else '' }}</textarea>
</label>
<button type="submit">Сгенерировать</button>
</form>
{% if result %}
<h2>Готовый пост</h2>
<div class="card result">{{ result }}</div>
{% endif %}
<p><a href="/dashboard">← Назад в кабинет</a></p>
{% endblock %}
+30
View File
@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block title %}История постов — Личный кабинет{% endblock %}
{% block content %}
<h1>История постов</h1>
{% if posts %}
<ul class="post-list">
{% for post in posts %}
<li class="card">
<div class="post-meta">{{ platform_label(post.platform) }} · {{ post.created_at }}</div>
<div class="post-content" id="post-content-{{ post.id }}">{{ post.content }}</div>
<div class="post-actions">
<button type="button" class="copy-btn" data-copy-target="post-content-{{ post.id }}">📋 Копировать</button>
<a class="button-link" href="/posts/{{ post.id }}">👁 Открыть</a>
<a class="button-link" href="/posts/{{ post.id }}/edit">✏️ Изменить</a>
<form method="post" action="/posts/{{ post.id }}/delete" class="inline-form"
onsubmit="return confirm('Удалить этот пост? Действие необратимо.');">
<button type="submit" class="button-link button-link-danger">🗑 Удалить</button>
</form>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="card">
<p>Постов пока нет.</p>
<p><a href="/generate">💡 Сгенерировать первый пост</a></p>
</div>
{% endif %}
<p><a href="/dashboard">← Назад в кабинет</a></p>
{% endblock %}
@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Сгенерировать картинку — Личный кабинет{% endblock %}
{% block content %}
<h1>Генерация картинки</h1>
<form method="post" action="/images/generate" class="form">
<label>Что нарисовать?
<textarea name="prompt" rows="4" required placeholder="Например: современный кофейный стакан на белом фоне, логотип для кофейни">{{ form.prompt if form else '' }}</textarea>
</label>
<label>Размер
<select name="size" required>
{% for key, (value, label) in sizes.items() %}
<option value="{{ key }}" {% if form and form.size == key %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>Стиль
<select name="style">
{% for key, (label, suffix) in styles.items() %}
<option value="{{ key }}" {% if form and form.style == key %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<button type="submit">🎨 Сгенерировать</button>
<p class="form-hint">Генерация занимает 10–60 секунд. Картинка сохранится в медиа-библиотеку.</p>
</form>
<p><a href="/images">← Назад в библиотеку</a></p>
{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}Картинки — Личный кабинет{% endblock %}
{% block content %}
<h1>Медиа-библиотека</h1>
<p><a class="copy-btn" href="/images/generate">🎨 Сгенерировать картинку</a></p>
{% if images %}
<div class="media-grid">
{% for img in images %}
<div class="media-card">
<a href="/media/{{ img.filename }}" target="_blank" title="Открыть в полном размере">
<img class="media-thumb" src="/media/{{ img.filename }}" alt="{{ img.prompt }}" loading="lazy">
</a>
<div class="media-body">
<div class="media-prompt" title="{{ img.prompt }}">{{ img.prompt }}</div>
<div class="post-meta">{{ img.created_at }}</div>
<div class="post-actions">
<a class="copy-btn" href="/media/{{ img.filename }}" download>⬇ Скачать</a>
<form method="post" action="/images/{{ img.id }}/delete" class="inline-form"
onsubmit="return confirm('Удалить эту картинку?');">
<button type="submit" class="button-link button-link-danger">🗑 Удалить</button>
</form>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="card">
<p>Картинок пока нет.</p>
<p><a href="/images/generate">🎨 Сгенерировать первую картинку</a></p>
</div>
{% endif %}
<p><a href="/dashboard">← Назад в кабинет</a></p>
{% endblock %}
+15
View File
@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}Вход — Личный кабинет{% endblock %}
{% block content %}
<h1>Вход</h1>
<form method="post" action="/login" class="form">
<label>Email
<input type="email" name="email" value="{{ form.email if form else '' }}" required>
</label>
<label>Пароль
<input type="password" name="password" required>
</label>
<button type="submit">Войти</button>
</form>
<p>Ещё нет аккаунта? <a href="/register">Зарегистрироваться</a></p>
{% endblock %}
+58
View File
@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}Контент-план — Личный кабинет{% endblock %}
{% block content %}
<h1>Контент-план</h1>
{% if plan %}
<div class="chips">
<span class="chip chip-planned">📝 В плане: {{ status_counts.get('planned', 0) }}</span>
<span class="chip chip-published">✅ Опубликовано: {{ status_counts.get('published', 0) }}</span>
<span class="chip chip-postponed">🕓 Отложено: {{ status_counts.get('postponed', 0) }}</span>
</div>
<div class="plan-list">
{% for item in plan %}
<div class="card plan-item status-{{ item.status }}">
<div class="plan-item-head">
<span class="plan-day">День {{ item.day }}</span>
<span class="plan-platform">{{ platform_label(item.platform) }}</span>
<span class="status-badge status-{{ item.status }}">
{% if item.status == 'published' %}✅ Опубликовано
{% elif item.status == 'postponed' %}🕓 Отложено
{% else %}📝 В плане{% endif %}
</span>
</div>
<div class="plan-idea">{{ item.idea }}</div>
<div class="post-actions">
<form method="post" action="/plan/{{ item.id }}/status" class="inline-form">
<select name="status" class="status-select">
<option value="planned" {% if item.status == 'planned' %}selected{% endif %}>📝 В плане</option>
<option value="published" {% if item.status == 'published' %}selected{% endif %}>✅ Опубликовано</option>
<option value="postponed" {% if item.status == 'postponed' %}selected{% endif %}>🕓 Отложено</option>
</select>
<button type="submit" class="copy-btn">Сохранить</button>
</form>
<form method="post" action="/plan/{{ item.id }}/delete" class="inline-form"
onsubmit="return confirm('Удалить этот пункт плана?');">
<button type="submit" class="button-link button-link-danger">🗑 Удалить</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="card">
<p>Контент-плана пока нет. Составь его на 1–14 дней — он будет сохранён, и ты сможешь отмечать опубликованные и отложенные посты.</p>
</div>
{% endif %}
<h2>{% if plan %}Пересоставить план{% else %}Составить план{% endif %}</h2>
<form method="post" action="/plan" class="form"
onsubmit="{% if plan %}return confirm('Новый план заменит текущий (отметки статусов сбросятся). Продолжить?');{% endif %}">
<label>На сколько дней (1-14)
<input type="number" name="days" min="1" max="14" value="7" required>
</label>
<button type="submit">📅 Составить план</button>
</form>
<p><a href="/dashboard">← Назад в кабинет</a></p>
{% endblock %}
@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}Пост — Личный кабинет{% endblock %}
{% block content %}
<h1>Пост</h1>
<div class="card">
<div class="post-meta">
{{ platform_label(post.platform) }} · {{ post.created_at }}
{% if post.style %} · Стиль: {{ post.style }}{% endif %}
</div>
<div class="post-content" id="post-content-{{ post.id }}">{{ post.content }}</div>
{% if post.wishes %}
<div class="post-notes"><strong>Пожелания:</strong> {{ post.wishes }}</div>
{% endif %}
</div>
<div class="post-actions">
<button type="button" class="copy-btn" data-copy-target="post-content-{{ post.id }}">📋 Копировать</button>
<a class="button-link" href="/posts/{{ post.id }}/edit">✏️ Изменить</a>
<form method="post" action="/posts/{{ post.id }}/delete" class="inline-form"
onsubmit="return confirm('Удалить этот пост? Действие необратимо.');">
<button type="submit" class="button-link button-link-danger">🗑 Удалить</button>
</form>
</div>
<p><a href="/history">← Назад к истории</a></p>
{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block title %}Изменить пост — Личный кабинет{% endblock %}
{% block content %}
<h1>Изменить пост</h1>
<div class="card">
<p class="post-meta">{{ platform_label(post.platform) }} · {{ post.created_at }}</p>
<form method="post" action="/posts/{{ post.id }}/edit" class="form">
<label>Текст поста
<textarea name="content" rows="12" required>{{ post.content }}</textarea>
</label>
<div class="form-actions">
<button type="submit">💾 Сохранить</button>
<a class="button-link" href="/posts/{{ post.id }}">Отмена</a>
</div>
</form>
</div>
<p><a href="/history">← Назад к истории</a></p>
{% endblock %}
@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Профиль — Личный кабинет{% endblock %}
{% block content %}
<h1>Редактировать профиль</h1>
<form method="post" action="/profile/edit" class="form">
<label>Имя
<input type="text" name="name" value="{{ user.name }}" required>
</label>
<label>Телефон
<input type="text" name="phone" value="{{ user.phone }}" required>
</label>
<label>Вид бизнеса
<select name="business_type" required>
{% for key, value in business_types.items() %}
<option value="{{ key }}" {% if key == user.business_type|string %}selected{% endif %}>{{ value.name }}</option>
{% endfor %}
</select>
</label>
<label>Описание бизнеса
<textarea name="description" rows="4" required>{{ user.description }}</textarea>
</label>
<button type="submit">Сохранить</button>
</form>
<p><a href="/dashboard">← Назад в кабинет</a></p>
{% endblock %}
+35
View File
@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Регистрация — Личный кабинет{% endblock %}
{% block content %}
<h1>Регистрация</h1>
<form method="post" action="/register" class="form">
<label>Имя
<input type="text" name="name" value="{{ form.name if form else '' }}" required>
</label>
<label>Телефон
<input type="text" name="phone" value="{{ form.phone if form else '' }}" required>
</label>
<label>Вид бизнеса
<select name="business_type" required>
<option value="" disabled {% if not form %}selected{% endif %}>Выбери вид бизнеса</option>
{% for key, value in business_types.items() %}
<option value="{{ key }}" {% if form and form.business_type == key %}selected{% endif %}>{{ value.name }}</option>
{% endfor %}
</select>
</label>
<label>Описание бизнеса
<textarea name="description" rows="4" required>{{ form.description if form else '' }}</textarea>
</label>
<label>Email
<input type="email" name="email" value="{{ form.email if form else '' }}" required>
</label>
<label>Пароль
<input type="password" name="password" minlength="8" required>
</label>
<label>Повтори пароль
<input type="password" name="password_confirm" minlength="8" required>
</label>
<button type="submit">Зарегистрироваться</button>
</form>
<p>Уже есть аккаунт? <a href="/login">Войти</a></p>
{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}Статистика — Личный кабинет{% endblock %}
{% block content %}
<h1>Статистика использования</h1>
<div class="card">
<p><strong>В кабинете с:</strong> {{ stats.created_at }}</p>
<p><strong>Всего постов:</strong> {{ stats.total_posts }}</p>
</div>
<h2>По платформам</h2>
{% if stats.by_platform %}
<ul>
{% for platform, count in stats.by_platform.items() %}
<li>{{ platform }}: {{ count }}</li>
{% endfor %}
</ul>
{% else %}
<p>Пока нет постов.</p>
{% endif %}
<p><a href="/dashboard">← Назад в кабинет</a></p>
{% endblock %}