Добавлен веб-личный кабинет: посты, контент-план, медиа-библиотека и генерация картинок
This commit is contained in:
1 parent
d8c086d3b4
commit
81625b2e6f
35 files changed
+2220
-64
No files matched your search
@@ -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)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Reference in new issue
Block a user