Добавлен веб-личный кабинет: посты, контент-план, медиа-библиотека и генерация картинок
This commit is contained in:
1 parent
d8c086d3b4
commit
81625b2e6f
35 files changed
+2207
-51
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
users.db
|
||||||
|
venv/
|
||||||
|
cabinet_service/media/
|
||||||
|
.idea/
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,89 @@
|
|||||||
|
import base64
|
||||||
|
import struct
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from config import PIAPI_API_KEY, PIAPI_IMAGE_MODEL, get_proxies
|
||||||
|
|
||||||
|
PIAPI_IMAGE_URL = "https://api.piapi.ai/v1/images/generations"
|
||||||
|
|
||||||
|
# Доступные размеры (gpt-image-1)
|
||||||
|
IMAGE_SIZES = {
|
||||||
|
"square": ("1024x1024", "Квадрат 1024x1024"),
|
||||||
|
"wide": ("1536x1024", "Горизонтальный 1536x1024"),
|
||||||
|
"tall": ("1024x1536", "Вертикальный 1024x1536"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Пресеты стилей - дописываются к промпту
|
||||||
|
STYLE_PRESETS = {
|
||||||
|
"none": ("Без стиля", ""),
|
||||||
|
"photo": ("Фотореализм", ", ultra realistic photo, professional photography, natural lighting"),
|
||||||
|
"illustration": ("Иллюстрация", ", digital illustration, vibrant colors, detailed"),
|
||||||
|
"minimal": ("Минимализм", ", minimalist style, clean background, simple composition"),
|
||||||
|
"3d": ("3D-рендер", ", 3D render, soft shadows, modern design"),
|
||||||
|
"flat": ("Плоская графика", ", flat vector illustration, bold shapes, social media style"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def detect_ext(data: bytes) -> str:
|
||||||
|
"""Определяет расширение файла по magic-байтам."""
|
||||||
|
if data[:8] == b"\x89PNG\r\n\x1a\n":
|
||||||
|
return ".png"
|
||||||
|
if data[:2] == b"\xff\xd8":
|
||||||
|
return ".jpg"
|
||||||
|
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
||||||
|
return ".webp"
|
||||||
|
if data[:4] == b"GIF8":
|
||||||
|
return ".gif"
|
||||||
|
return ".png"
|
||||||
|
|
||||||
|
|
||||||
|
class ImageGenerator:
|
||||||
|
"""Генерация изображений через PiAPI (OpenAI-совместимый /images/generations)."""
|
||||||
|
|
||||||
|
def __init__(self, model: str | None = None):
|
||||||
|
self.model = model or PIAPI_IMAGE_MODEL
|
||||||
|
self.proxies = get_proxies()
|
||||||
|
|
||||||
|
def generate(self, prompt: str, size: str = "square") -> bytes:
|
||||||
|
"""Генерирует картинку и возвращает её байты. Поднимает исключение при ошибке."""
|
||||||
|
if size not in IMAGE_SIZES:
|
||||||
|
size = "square"
|
||||||
|
size_value = IMAGE_SIZES[size][0]
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
PIAPI_IMAGE_URL,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {PIAPI_API_KEY}",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": self.model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"n": 1,
|
||||||
|
"size": size_value,
|
||||||
|
},
|
||||||
|
proxies=self.proxies,
|
||||||
|
timeout=240,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json().get("data") or []
|
||||||
|
if not data:
|
||||||
|
raise RuntimeError("Пустой ответ от API картинок")
|
||||||
|
|
||||||
|
item = data[0]
|
||||||
|
if item.get("b64_json"):
|
||||||
|
return base64.b64decode(item["b64_json"])
|
||||||
|
if item.get("url"):
|
||||||
|
img_response = requests.get(item["url"], proxies=self.proxies, timeout=120)
|
||||||
|
img_response.raise_for_status()
|
||||||
|
return img_response.content
|
||||||
|
|
||||||
|
raise RuntimeError("Неожиданный формат ответа от API картинок")
|
||||||
|
|
||||||
|
def build_prompt(self, prompt: str, style: str = "none") -> str:
|
||||||
|
"""Дополняет промпт пресетом стиля."""
|
||||||
|
if style in STYLE_PRESETS:
|
||||||
|
return prompt.strip() + STYLE_PRESETS[style][1]
|
||||||
|
return prompt.strip()
|
||||||
+175
-32
@@ -1,28 +1,26 @@
|
|||||||
import random
|
import random
|
||||||
import os
|
import os
|
||||||
from groq import Groq
|
import json
|
||||||
from config import GROQ_API_KEY
|
import requests
|
||||||
|
from config import PIAPI_API_KEY, PIAPI_MODEL, get_proxies
|
||||||
|
|
||||||
|
PIAPI_URL = "https://api.piapi.ai/v1/chat/completions"
|
||||||
|
|
||||||
class ContentGenerator:
|
class ContentGenerator:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Инициализация генератора контента"""
|
"""Инициализация генератора контента"""
|
||||||
print("🔄 Инициализация ContentGenerator...")
|
print("🔄 Инициализация ContentGenerator...")
|
||||||
|
|
||||||
# Проверяем, есть ли ключ Groq
|
# Проверяем, есть ли ключ PiAPI
|
||||||
if GROQ_API_KEY:
|
if PIAPI_API_KEY:
|
||||||
try:
|
self.model = PIAPI_MODEL
|
||||||
self.client = Groq(api_key=GROQ_API_KEY)
|
self.use_piapi = True
|
||||||
self.model = "llama-3.3-70b-versatile"
|
print("✅ PiAPI подключен!")
|
||||||
self.use_groq = True
|
|
||||||
print("✅ Groq API подключен!")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"⚠️ Ошибка подключения Groq: {e}")
|
|
||||||
self.use_groq = False
|
|
||||||
else:
|
else:
|
||||||
print("⚠️ Groq API ключ не найден, использую шаблоны")
|
print("⚠️ PiAPI ключ не найден, использую шаблоны")
|
||||||
self.use_groq = False
|
self.use_piapi = False
|
||||||
|
|
||||||
# Шаблоны для запасного варианта (если Groq не работает)
|
# Шаблоны для запасного варианта (если PiAPI не работает)
|
||||||
self.templates = {
|
self.templates = {
|
||||||
'instagram': [
|
'instagram': [
|
||||||
"🔥 {business_name} — это не просто бизнес, это миссия!\n\n"
|
"🔥 {business_name} — это не просто бизнес, это миссия!\n\n"
|
||||||
@@ -116,22 +114,22 @@ class ContentGenerator:
|
|||||||
"""
|
"""
|
||||||
print(f"🔄 Генерация поста для {platform}...")
|
print(f"🔄 Генерация поста для {platform}...")
|
||||||
|
|
||||||
# Пробуем сгенерировать через Groq
|
# Пробуем сгенерировать через PiAPI
|
||||||
if self.use_groq:
|
if self.use_piapi:
|
||||||
try:
|
try:
|
||||||
post = self._generate_with_groq(business_type, business_description, platform)
|
post = self._generate_with_piapi(business_type, business_description, platform)
|
||||||
if post and len(post) > 20:
|
if post and len(post) > 20:
|
||||||
print("✅ Пост сгенерирован через Groq!")
|
print("✅ Пост сгенерирован через PiAPI!")
|
||||||
return post
|
return post
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"⚠️ Ошибка Groq: {e}, использую шаблоны")
|
print(f"⚠️ Ошибка PiAPI: {e}, использую шаблоны")
|
||||||
|
|
||||||
# Если Groq не работает - используем шаблоны
|
# Если PiAPI не работает - используем шаблоны
|
||||||
print("🔄 Использую шаблоны...")
|
print("🔄 Использую шаблоны...")
|
||||||
return self._generate_with_templates(business_type, business_description, platform)
|
return self._generate_with_templates(business_type, business_description, platform)
|
||||||
|
|
||||||
def _generate_with_groq(self, business_type, business_description, platform):
|
def _generate_with_piapi(self, business_type, business_description, platform):
|
||||||
"""Генерация через Groq API"""
|
"""Генерация через PiAPI (OpenAI-совместимый chat/completions)"""
|
||||||
|
|
||||||
platform_names = {
|
platform_names = {
|
||||||
'instagram': 'Instagram',
|
'instagram': 'Instagram',
|
||||||
@@ -160,17 +158,162 @@ class ContentGenerator:
|
|||||||
Пост должен быть уникальным и интересным.
|
Пост должен быть уникальным и интересным.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
response = self.client.chat.completions.create(
|
response = requests.post(
|
||||||
model=self.model,
|
PIAPI_URL,
|
||||||
messages=[
|
headers={
|
||||||
{"role": "system", "content": "Ты профессиональный копирайтер. Пиши ярко, полезно и вовлекающе."},
|
"Content-Type": "application/json",
|
||||||
{"role": "user", "content": prompt}
|
"Authorization": f"Bearer {PIAPI_API_KEY}"
|
||||||
],
|
},
|
||||||
temperature=0.9,
|
json={
|
||||||
max_tokens=500
|
"model": self.model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "Ты профессиональный копирайтер. Пиши ярко, полезно и вовлекающе."},
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
],
|
||||||
|
"temperature": 0.9,
|
||||||
|
"max_tokens": 500
|
||||||
|
},
|
||||||
|
proxies=get_proxies(),
|
||||||
|
timeout=30
|
||||||
)
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
return response.choices[0].message.content.strip()
|
return data["choices"][0]["message"]["content"].strip()
|
||||||
|
|
||||||
|
def generate_post_with_style(self, business_type, business_description, platform, style, wishes):
|
||||||
|
"""Генерирует пост с учётом стиля и пожеланий пользователя"""
|
||||||
|
print(f"🔄 Генерация поста в своём стиле для {platform}...")
|
||||||
|
|
||||||
|
if self.use_piapi:
|
||||||
|
try:
|
||||||
|
platform_names = {
|
||||||
|
'instagram': 'Instagram',
|
||||||
|
'linkedin': 'LinkedIn',
|
||||||
|
'telegram': 'Telegram',
|
||||||
|
'twitter': 'Twitter/X',
|
||||||
|
'ad': 'рекламный пост'
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt = f"""
|
||||||
|
Ты — профессиональный SMM-специалист и копирайтер.
|
||||||
|
|
||||||
|
Напиши пост для {platform_names.get(platform, 'соцсетей')} о бизнесе.
|
||||||
|
|
||||||
|
Тип бизнеса: {business_type}
|
||||||
|
Описание бизнеса: {business_description}
|
||||||
|
Стиль поста: {style}
|
||||||
|
Пожелания к посту: {wishes}
|
||||||
|
|
||||||
|
Требования к посту:
|
||||||
|
- Длина: 100-200 слов
|
||||||
|
- Строго придерживайся указанного стиля и пожеланий
|
||||||
|
- Призыв к действию в конце
|
||||||
|
- Используй 2-3 эмодзи
|
||||||
|
|
||||||
|
Напиши только текст поста, без лишних пояснений.
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
PIAPI_URL,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {PIAPI_API_KEY}"
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": self.model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "Ты профессиональный копирайтер. Точно следуешь стилю и пожеланиям клиента."},
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
],
|
||||||
|
"temperature": 0.9,
|
||||||
|
"max_tokens": 500
|
||||||
|
},
|
||||||
|
proxies=get_proxies(),
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
post = response.json()["choices"][0]["message"]["content"].strip()
|
||||||
|
if post and len(post) > 20:
|
||||||
|
print("✅ Пост в стиле сгенерирован через PiAPI!")
|
||||||
|
return post
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Ошибка PiAPI: {e}, использую шаблоны")
|
||||||
|
|
||||||
|
print("🔄 Использую шаблоны...")
|
||||||
|
post = self._generate_with_templates(business_type, business_description, platform)
|
||||||
|
return f"{post}\n\n💭 Пожелания учтены частично (без ИИ): {wishes}"
|
||||||
|
|
||||||
|
def generate_content_plan(self, business_type, business_description, days):
|
||||||
|
"""Генерирует контент-план на несколько дней вперёд"""
|
||||||
|
print(f"🔄 Генерация контент-плана на {days} дн...")
|
||||||
|
|
||||||
|
if self.use_piapi:
|
||||||
|
try:
|
||||||
|
prompt = f"""
|
||||||
|
Ты — SMM-стратег. Составь контент-план на {days} дней для бизнеса.
|
||||||
|
|
||||||
|
Тип бизнеса: {business_type}
|
||||||
|
Описание бизнеса: {business_description}
|
||||||
|
|
||||||
|
Для каждого дня укажи платформу (instagram, linkedin, telegram, twitter или ad) и короткую идею поста (1 предложение).
|
||||||
|
|
||||||
|
Верни ТОЛЬКО валидный JSON без пояснений в формате:
|
||||||
|
{{"plan": [{{"day": 1, "platform": "instagram", "idea": "..."}}, ...]}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
PIAPI_URL,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {PIAPI_API_KEY}"
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": self.model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "Ты отвечаешь только валидным JSON, без markdown и пояснений."},
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
],
|
||||||
|
"temperature": 0.7,
|
||||||
|
"max_tokens": 1000,
|
||||||
|
"response_format": {"type": "json_object"}
|
||||||
|
},
|
||||||
|
proxies=get_proxies(),
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
content = response.json()["choices"][0]["message"]["content"].strip()
|
||||||
|
parsed = json.loads(content)
|
||||||
|
plan = parsed.get("plan")
|
||||||
|
if plan:
|
||||||
|
print("✅ Контент-план сгенерирован через PiAPI!")
|
||||||
|
return plan
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Ошибка PiAPI: {e}, использую шаблонный план")
|
||||||
|
|
||||||
|
print("🔄 Использую шаблонный план...")
|
||||||
|
return self._generate_template_plan(business_type, days)
|
||||||
|
|
||||||
|
def _generate_template_plan(self, business_type, days):
|
||||||
|
"""Простой контент-план без обращения к ИИ (запасной вариант)"""
|
||||||
|
platforms = ['instagram', 'linkedin', 'telegram', 'twitter', 'ad']
|
||||||
|
ideas = [
|
||||||
|
f"Расскажи историю успеха {business_type}",
|
||||||
|
f"Покажи закулисье работы {business_type}",
|
||||||
|
"Поделись отзывом клиента",
|
||||||
|
"Развенчай миф из твоей ниши",
|
||||||
|
"Дай практический совет подписчикам",
|
||||||
|
"Проведи опрос среди аудитории",
|
||||||
|
"Расскажи о новинке или акции"
|
||||||
|
]
|
||||||
|
plan = []
|
||||||
|
for day in range(1, days + 1):
|
||||||
|
plan.append({
|
||||||
|
'day': day,
|
||||||
|
'platform': platforms[(day - 1) % len(platforms)],
|
||||||
|
'idea': ideas[(day - 1) % len(ideas)]
|
||||||
|
})
|
||||||
|
return plan
|
||||||
|
|
||||||
def _generate_with_templates(self, business_type, business_description, platform):
|
def _generate_with_templates(self, business_type, business_description, platform):
|
||||||
"""Генерация через шаблоны (запасной вариант)"""
|
"""Генерация через шаблоны (запасной вариант)"""
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -1,19 +1,59 @@
|
|||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
# Загружаем переменные из .env
|
# Загружаем переменные из .env
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
# Токен бота
|
# Токен бота
|
||||||
TOKEN = "8876908400:AAGb4_ayjfhWuUI_evP8vvc5YUUiG8jxepE"
|
TOKEN = os.getenv("BOT_TOKEN")
|
||||||
|
|
||||||
# Ключ Groq
|
if not TOKEN:
|
||||||
GROQ_API_KEY = os.getenv("GROQ_API_KEY")
|
print("❌ BOT_TOKEN не найден в .env файле!")
|
||||||
|
print("👉 Создай файл .env и добавь: BOT_TOKEN=твой_токен")
|
||||||
|
|
||||||
|
# Ключ PiAPI
|
||||||
|
PIAPI_API_KEY = os.getenv("PIAPI_API_KEY")
|
||||||
|
PIAPI_MODEL = os.getenv("PIAPI_MODEL", "gpt-4o")
|
||||||
|
|
||||||
|
# Модель генерации картинок через PiAPI (список доступных: /v1/models)
|
||||||
|
PIAPI_IMAGE_MODEL = os.getenv("PIAPI_IMAGE_MODEL", "gpt-image-1")
|
||||||
|
|
||||||
|
# Прокси для доступа к внешним API (PiAPI и др.).
|
||||||
|
# Формат в .env: PROXY_HOST, PROXY_PORT, PROXY_USER, PROXY_PASS
|
||||||
|
PROXY_HOST = os.getenv("PROXY_HOST")
|
||||||
|
PROXY_PORT = os.getenv("PROXY_PORT")
|
||||||
|
PROXY_USER = os.getenv("PROXY_USER")
|
||||||
|
PROXY_PASS = os.getenv("PROXY_PASS")
|
||||||
|
|
||||||
|
def get_proxies():
|
||||||
|
"""Возвращает dict прокси для requests, или None, если прокси не настроен."""
|
||||||
|
if not (PROXY_HOST and PROXY_PORT):
|
||||||
|
return None
|
||||||
|
auth = f"{PROXY_USER}:{PROXY_PASS}@" if (PROXY_USER and PROXY_PASS) else ""
|
||||||
|
proxy = f"http://{auth}{PROXY_HOST}:{PROXY_PORT}"
|
||||||
|
return {"http": proxy, "https": proxy}
|
||||||
|
|
||||||
|
# Сервис "Личный кабинет" (отдельный FastAPI-процесс, веб-интерфейс на браузере)
|
||||||
|
CABINET_SERVICE_HOST = os.getenv("CABINET_SERVICE_HOST", "127.0.0.1")
|
||||||
|
CABINET_SERVICE_PORT = int(os.getenv("CABINET_SERVICE_PORT", "8001"))
|
||||||
|
|
||||||
|
# Публичный адрес кабинета - именно эту ссылку бот присылает пользователю.
|
||||||
|
# Для реального использования (не localhost) нужно задать в .env настоящий
|
||||||
|
# домен/IP, доступный из браузера пользователя.
|
||||||
|
CABINET_WEB_URL = os.getenv("CABINET_WEB_URL", f"http://{CABINET_SERVICE_HOST}:{CABINET_SERVICE_PORT}")
|
||||||
|
|
||||||
|
# Ключ подписи cookie-сессий веб-кабинета
|
||||||
|
SECRET_KEY = os.getenv("SECRET_KEY")
|
||||||
|
if not SECRET_KEY:
|
||||||
|
SECRET_KEY = secrets.token_hex(32)
|
||||||
|
print("⚠️ SECRET_KEY не найден в .env - сгенерирован временный ключ.")
|
||||||
|
print("👉 Добавь SECRET_KEY в .env, иначе все сессии слетают при каждом перезапуске.")
|
||||||
|
|
||||||
# Проверяем, что ключ загрузился
|
# Проверяем, что ключ загрузился
|
||||||
if not GROQ_API_KEY:
|
if not PIAPI_API_KEY:
|
||||||
print("❌ GROQ_API_KEY не найден в .env файле!")
|
print("❌ PIAPI_API_KEY не найден в .env файле!")
|
||||||
print("👉 Создай файл .env и добавь: GROQ_API_KEY=твой_ключ")
|
print("👉 Создай файл .env и добавь: PIAPI_API_KEY=твой_ключ")
|
||||||
else:
|
else:
|
||||||
print("✅ GROQ_API_KEY загружен!")
|
print("✅ PIAPI_API_KEY загружен!")
|
||||||
|
|
||||||
+388
@@ -1,5 +1,8 @@
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
|
# Поля профиля, которые разрешено менять через "личный кабинет"
|
||||||
|
EDITABLE_FIELDS = {'name', 'phone', 'business_type', 'description'}
|
||||||
|
|
||||||
def create_db():
|
def create_db():
|
||||||
conn = sqlite3.connect('users.db')
|
conn = sqlite3.connect('users.db')
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
@@ -16,6 +19,67 @@ def create_db():
|
|||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
|
cursor.execute("PRAGMA table_info(users)")
|
||||||
|
columns = [row[1] for row in cursor.fetchall()]
|
||||||
|
if 'registered' not in columns:
|
||||||
|
cursor.execute('ALTER TABLE users ADD COLUMN registered BOOLEAN DEFAULT 0')
|
||||||
|
print("✅ Колонка 'registered' добавлена в таблицу users")
|
||||||
|
if 'created_at' not in columns:
|
||||||
|
# SQLite не разрешает non-constant DEFAULT в ALTER TABLE ADD COLUMN
|
||||||
|
cursor.execute('ALTER TABLE users ADD COLUMN created_at TIMESTAMP')
|
||||||
|
cursor.execute('UPDATE users SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL')
|
||||||
|
print("✅ Колонка 'created_at' добавлена в таблицу users")
|
||||||
|
if 'email' not in columns:
|
||||||
|
# Без UNIQUE - SQLite не позволяет добавить UNIQUE через ALTER TABLE,
|
||||||
|
# уникальность email проверяется на уровне приложения (get_user_by_email)
|
||||||
|
cursor.execute('ALTER TABLE users ADD COLUMN email TEXT')
|
||||||
|
print("✅ Колонка 'email' добавлена в таблицу users")
|
||||||
|
if 'password_hash' not in columns:
|
||||||
|
cursor.execute('ALTER TABLE users ADD COLUMN password_hash TEXT')
|
||||||
|
print("✅ Колонка 'password_hash' добавлена в таблицу users")
|
||||||
|
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
telegram_id INTEGER,
|
||||||
|
platform TEXT,
|
||||||
|
content TEXT,
|
||||||
|
style TEXT,
|
||||||
|
wishes TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
cursor.execute("PRAGMA table_info(posts)")
|
||||||
|
posts_columns = [row[1] for row in cursor.fetchall()]
|
||||||
|
if 'user_id' not in posts_columns:
|
||||||
|
# Посты веб-кабинета привязаны к users.id, посты из бота - к telegram_id
|
||||||
|
cursor.execute('ALTER TABLE posts ADD COLUMN user_id INTEGER')
|
||||||
|
print("✅ Колонка 'user_id' добавлена в таблицу posts")
|
||||||
|
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS content_plan (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER,
|
||||||
|
day INTEGER,
|
||||||
|
platform TEXT,
|
||||||
|
idea TEXT,
|
||||||
|
status TEXT DEFAULT 'planned',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS media (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER,
|
||||||
|
prompt TEXT,
|
||||||
|
style TEXT,
|
||||||
|
filename TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
print("✅ База данных создана!")
|
print("✅ База данных создана!")
|
||||||
@@ -50,5 +114,329 @@ def update_user_description(telegram_id, description):
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
def update_user_field(telegram_id, field, value):
|
||||||
|
"""Обновляет одно поле профиля. field обязан быть из EDITABLE_FIELDS."""
|
||||||
|
if field not in EDITABLE_FIELDS:
|
||||||
|
raise ValueError(f"Поле '{field}' нельзя редактировать")
|
||||||
|
|
||||||
|
conn = sqlite3.connect('users.db')
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(f'UPDATE users SET {field} = ? WHERE telegram_id = ?', (value, telegram_id))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def delete_user(telegram_id):
|
||||||
|
"""Удаляет пользователя и всю его историю постов."""
|
||||||
|
conn = sqlite3.connect('users.db')
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('DELETE FROM users WHERE telegram_id = ?', (telegram_id,))
|
||||||
|
cursor.execute('DELETE FROM posts WHERE telegram_id = ?', (telegram_id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def save_post(telegram_id, platform, content, style=None, wishes=None):
|
||||||
|
conn = sqlite3.connect('users.db')
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT INTO posts (telegram_id, platform, content, style, wishes)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
''', (telegram_id, platform, content, style, wishes))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def get_posts(telegram_id, limit=10):
|
||||||
|
conn = sqlite3.connect('users.db')
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT platform, content, style, wishes, created_at
|
||||||
|
FROM posts
|
||||||
|
WHERE telegram_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
''', (telegram_id, limit))
|
||||||
|
posts = cursor.fetchall()
|
||||||
|
conn.close()
|
||||||
|
return posts
|
||||||
|
|
||||||
|
def get_stats(telegram_id):
|
||||||
|
conn = sqlite3.connect('users.db')
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute('SELECT created_at FROM users WHERE telegram_id = ?', (telegram_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
created_at = row[0] if row else None
|
||||||
|
|
||||||
|
cursor.execute('SELECT COUNT(*) FROM posts WHERE telegram_id = ?', (telegram_id,))
|
||||||
|
total_posts = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT platform, COUNT(*) FROM posts
|
||||||
|
WHERE telegram_id = ?
|
||||||
|
GROUP BY platform
|
||||||
|
''', (telegram_id,))
|
||||||
|
by_platform = dict(cursor.fetchall())
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return {
|
||||||
|
'created_at': created_at,
|
||||||
|
'total_posts': total_posts,
|
||||||
|
'by_platform': by_platform
|
||||||
|
}
|
||||||
|
|
||||||
|
# === ФУНКЦИИ ДЛЯ ВЕБ-КАБИНЕТА (ключ - внутренний users.id, не telegram_id) ===
|
||||||
|
|
||||||
|
def _row_conn():
|
||||||
|
conn = sqlite3.connect('users.db')
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_email(email):
|
||||||
|
conn = _row_conn()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('SELECT * FROM users WHERE email = ?', (email,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_id(user_id):
|
||||||
|
conn = _row_conn()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('SELECT * FROM users WHERE id = ?', (user_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def create_web_user(email, password_hash, name, phone, business_type, description):
|
||||||
|
"""Создаёт пользователя веб-кабинета (без telegram_id). Возвращает id нового пользователя."""
|
||||||
|
conn = sqlite3.connect('users.db')
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT INTO users (name, phone, business_type, description, registered, created_at, email, password_hash)
|
||||||
|
VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP, ?, ?)
|
||||||
|
''', (name, phone, business_type, description, email, password_hash))
|
||||||
|
conn.commit()
|
||||||
|
user_id = cursor.lastrowid
|
||||||
|
conn.close()
|
||||||
|
return user_id
|
||||||
|
|
||||||
|
|
||||||
|
def update_user_field_by_id(user_id, field, value):
|
||||||
|
"""Обновляет одно поле профиля по внутреннему id. field обязан быть из EDITABLE_FIELDS."""
|
||||||
|
if field not in EDITABLE_FIELDS:
|
||||||
|
raise ValueError(f"Поле '{field}' нельзя редактировать")
|
||||||
|
|
||||||
|
conn = sqlite3.connect('users.db')
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(f'UPDATE users SET {field} = ? WHERE id = ?', (value, user_id))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_user_by_id(user_id):
|
||||||
|
"""Удаляет пользователя веб-кабинета и всю его историю постов."""
|
||||||
|
conn = sqlite3.connect('users.db')
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('DELETE FROM users WHERE id = ?', (user_id,))
|
||||||
|
cursor.execute('DELETE FROM posts WHERE user_id = ?', (user_id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def save_post_for_user(user_id, platform, content, style=None, wishes=None):
|
||||||
|
conn = sqlite3.connect('users.db')
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT INTO posts (user_id, platform, content, style, wishes)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
''', (user_id, platform, content, style, wishes))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_posts_for_user(user_id, limit=10):
|
||||||
|
conn = _row_conn()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT id, platform, content, style, wishes, created_at
|
||||||
|
FROM posts
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
''', (user_id, limit))
|
||||||
|
posts = [dict(row) for row in cursor.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
return posts
|
||||||
|
|
||||||
|
|
||||||
|
def get_stats_for_user(user_id):
|
||||||
|
conn = sqlite3.connect('users.db')
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute('SELECT created_at FROM users WHERE id = ?', (user_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
created_at = row[0] if row else None
|
||||||
|
|
||||||
|
cursor.execute('SELECT COUNT(*) FROM posts WHERE user_id = ?', (user_id,))
|
||||||
|
total_posts = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT platform, COUNT(*) FROM posts
|
||||||
|
WHERE user_id = ?
|
||||||
|
GROUP BY platform
|
||||||
|
''', (user_id,))
|
||||||
|
by_platform = dict(cursor.fetchall())
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return {
|
||||||
|
'created_at': created_at,
|
||||||
|
'total_posts': total_posts,
|
||||||
|
'by_platform': by_platform
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# === УПРАВЛЕНИЕ ПОСТАМИ (веб-кабинет) ===
|
||||||
|
|
||||||
|
def get_post_by_id_for_user(post_id, user_id):
|
||||||
|
"""Возвращает один пост веб-пользователя (только если он принадлежит user_id)."""
|
||||||
|
conn = _row_conn()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('SELECT * FROM posts WHERE id = ? AND user_id = ?', (post_id, user_id))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def update_post_content(user_id, post_id, content):
|
||||||
|
"""Редактирует текст поста. Меняет только посты, принадлежащие user_id."""
|
||||||
|
conn = sqlite3.connect('users.db')
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('UPDATE posts SET content = ? WHERE id = ? AND user_id = ?', (content, post_id, user_id))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_post_by_id(user_id, post_id):
|
||||||
|
"""Удаляет пост. Удаляет только посты, принадлежащие user_id."""
|
||||||
|
conn = sqlite3.connect('users.db')
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('DELETE FROM posts WHERE id = ? AND user_id = ?', (post_id, user_id))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# === КОНТЕНТ-ПЛАН (веб-кабинет) ===
|
||||||
|
|
||||||
|
PLAN_STATUSES = ('planned', 'published', 'postponed')
|
||||||
|
|
||||||
|
def replace_plan_for_user(user_id, plan_items):
|
||||||
|
"""Полностью заменяет контент-план пользователя новыми пунктами.
|
||||||
|
plan_items: список словарей {'day': int, 'platform': str, 'idea': str}"""
|
||||||
|
conn = sqlite3.connect('users.db')
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('DELETE FROM content_plan WHERE user_id = ?', (user_id,))
|
||||||
|
cursor.executemany(
|
||||||
|
'INSERT INTO content_plan (user_id, day, platform, idea) VALUES (?, ?, ?, ?)',
|
||||||
|
[(user_id, item['day'], item['platform'], item['idea']) for item in plan_items]
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_plan_for_user(user_id):
|
||||||
|
conn = _row_conn()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('SELECT * FROM content_plan WHERE user_id = ? ORDER BY day, id', (user_id,))
|
||||||
|
rows = [dict(row) for row in cursor.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def update_plan_status(user_id, item_id, status):
|
||||||
|
if status not in PLAN_STATUSES:
|
||||||
|
raise ValueError(f"Недопустимый статус '{status}'")
|
||||||
|
conn = sqlite3.connect('users.db')
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
'UPDATE content_plan SET status = ? WHERE id = ? AND user_id = ?',
|
||||||
|
(status, item_id, user_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_plan_item(user_id, item_id):
|
||||||
|
conn = sqlite3.connect('users.db')
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('DELETE FROM content_plan WHERE id = ? AND user_id = ?', (item_id, user_id))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_plan_stats_for_user(user_id):
|
||||||
|
"""Считает пункты плана по статусам: {'planned': n, 'published': n, ...}"""
|
||||||
|
conn = sqlite3.connect('users.db')
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('SELECT status, COUNT(*) FROM content_plan WHERE user_id = ? GROUP BY status', (user_id,))
|
||||||
|
result = dict(cursor.fetchall())
|
||||||
|
conn.close()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# === МЕДИА-БИБЛИОТЕКА (веб-кабинет) ===
|
||||||
|
|
||||||
|
def save_media(user_id, prompt, style, filename):
|
||||||
|
"""Сохраняет запись о сгенерированной картинке. Возвращает id записи."""
|
||||||
|
conn = sqlite3.connect('users.db')
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
'INSERT INTO media (user_id, prompt, style, filename) VALUES (?, ?, ?, ?)',
|
||||||
|
(user_id, prompt, style, filename)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
media_id = cursor.lastrowid
|
||||||
|
conn.close()
|
||||||
|
return media_id
|
||||||
|
|
||||||
|
|
||||||
|
def get_media_for_user(user_id, limit=50):
|
||||||
|
conn = _row_conn()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
'SELECT id, prompt, style, filename, created_at FROM media '
|
||||||
|
'WHERE user_id = ? ORDER BY created_at DESC, id DESC LIMIT ?',
|
||||||
|
(user_id, limit)
|
||||||
|
)
|
||||||
|
rows = [dict(row) for row in cursor.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def get_media_by_id_for_user(media_id, user_id):
|
||||||
|
conn = _row_conn()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('SELECT * FROM media WHERE id = ? AND user_id = ?', (media_id, user_id))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def delete_media_by_id(user_id, media_id):
|
||||||
|
"""Удаляет запись о картинке. Возвращает filename, чтобы удалить и сам файл."""
|
||||||
|
conn = sqlite3.connect('users.db')
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('SELECT filename FROM media WHERE id = ? AND user_id = ?', (media_id, user_id))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
cursor.execute('DELETE FROM media WHERE id = ? AND user_id = ?', (media_id, user_id))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return row[0]
|
||||||
|
conn.close()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Создаем базу при первом импорте
|
# Создаем базу при первом импорте
|
||||||
create_db()
|
create_db()
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11
-1
@@ -1,6 +1,7 @@
|
|||||||
from database import get_user, update_user_description
|
from database import get_user, update_user_description
|
||||||
from keyboards.reply_keyboards import get_main_menu_keyboard, get_settings_keyboard, get_content_keyboard, BUSINESS_TYPES
|
from keyboards.reply_keyboards import get_main_menu_keyboard, get_settings_keyboard, get_content_keyboard, get_cabinet_link_keyboard, BUSINESS_TYPES
|
||||||
from handlers.content import handle_content_generation
|
from handlers.content import handle_content_generation
|
||||||
|
from config import CABINET_WEB_URL
|
||||||
|
|
||||||
def show_main_menu(user_id, bot):
|
def show_main_menu(user_id, bot):
|
||||||
"""Показывает главное меню"""
|
"""Показывает главное меню"""
|
||||||
@@ -87,6 +88,15 @@ def handle_menu_choice(message, bot):
|
|||||||
handle_content_generation(message, bot, text)
|
handle_content_generation(message, bot, text)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
elif text == "🗂 Личный кабинет":
|
||||||
|
bot.send_message(
|
||||||
|
user_id,
|
||||||
|
"🗂 Полная версия личного кабинета теперь в браузере: профиль, "
|
||||||
|
"посты в своём стиле, контент-план, история и статистика.",
|
||||||
|
reply_markup=get_cabinet_link_keyboard(CABINET_WEB_URL)
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def show_profile(message, bot):
|
def show_profile(message, bot):
|
||||||
|
|||||||
Binary file not shown.
@@ -1,4 +1,4 @@
|
|||||||
from telebot.types import ReplyKeyboardMarkup, KeyboardButton
|
from telebot.types import ReplyKeyboardMarkup, KeyboardButton, InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
|
||||||
# === 9 ВИДОВ ПРЕДПРИНИМАТЕЛЕЙ ===
|
# === 9 ВИДОВ ПРЕДПРИНИМАТЕЛЕЙ ===
|
||||||
BUSINESS_TYPES = {
|
BUSINESS_TYPES = {
|
||||||
@@ -21,10 +21,17 @@ def get_main_menu_keyboard():
|
|||||||
KeyboardButton("✏️ Обновить описание"),
|
KeyboardButton("✏️ Обновить описание"),
|
||||||
KeyboardButton("📊 Статистика"),
|
KeyboardButton("📊 Статистика"),
|
||||||
KeyboardButton("💡 Предложить контент"),
|
KeyboardButton("💡 Предложить контент"),
|
||||||
KeyboardButton("⚙️ Настройки")
|
KeyboardButton("⚙️ Настройки"),
|
||||||
|
KeyboardButton("🗂 Личный кабинет")
|
||||||
)
|
)
|
||||||
return keyboard
|
return keyboard
|
||||||
|
|
||||||
|
def get_cabinet_link_keyboard(cabinet_web_url):
|
||||||
|
"""Инлайн-кнопка со ссылкой на веб-версию личного кабинета"""
|
||||||
|
keyboard = InlineKeyboardMarkup()
|
||||||
|
keyboard.add(InlineKeyboardButton("🌐 Открыть личный кабинет", url=cabinet_web_url))
|
||||||
|
return keyboard
|
||||||
|
|
||||||
def get_business_types_keyboard():
|
def get_business_types_keyboard():
|
||||||
"""Клавиатура с 9 видами предпринимателей"""
|
"""Клавиатура с 9 видами предпринимателей"""
|
||||||
keyboard = ReplyKeyboardMarkup(row_width=3, resize_keyboard=True)
|
keyboard = ReplyKeyboardMarkup(row_width=3, resize_keyboard=True)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import telebot
|
|||||||
import requests
|
import requests
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
from requests.adapters import HTTPAdapter
|
from requests.adapters import HTTPAdapter
|
||||||
from urllib3.util.retry import Retry
|
from urllib3.util.retry import Retry
|
||||||
from telebot import apihelper
|
from telebot import apihelper
|
||||||
@@ -87,12 +88,12 @@ def status_bot(message):
|
|||||||
import datetime
|
import datetime
|
||||||
now = datetime.datetime.now().strftime("%d.%m.%Y %H:%M:%S")
|
now = datetime.datetime.now().strftime("%d.%m.%Y %H:%M:%S")
|
||||||
|
|
||||||
# Проверяем, есть ли GROQ_API_KEY
|
# Проверяем, есть ли PIAPI_API_KEY
|
||||||
try:
|
try:
|
||||||
from config import GROQ_API_KEY
|
from config import PIAPI_API_KEY
|
||||||
groq_status = "подключен ✅" if GROQ_API_KEY else "не подключен ⚠️"
|
piapi_status = "подключен ✅" if PIAPI_API_KEY else "не подключен ⚠️"
|
||||||
except:
|
except:
|
||||||
groq_status = "не подключен ⚠️"
|
piapi_status = "не подключен ⚠️"
|
||||||
|
|
||||||
status_text = f"""
|
status_text = f"""
|
||||||
📊 **СТАТУС БОТА**
|
📊 **СТАТУС БОТА**
|
||||||
@@ -103,7 +104,7 @@ def status_bot(message):
|
|||||||
📋 **Информация:**
|
📋 **Информация:**
|
||||||
• Бот запущен и работает
|
• Бот запущен и работает
|
||||||
• База данных: подключена ✅
|
• База данных: подключена ✅
|
||||||
• Groq API: {groq_status}
|
• PiAPI: {piapi_status}
|
||||||
|
|
||||||
💡 **Команды управления:**
|
💡 **Команды управления:**
|
||||||
/stop - остановить бота
|
/stop - остановить бота
|
||||||
@@ -133,6 +134,7 @@ def help_command(message):
|
|||||||
📊 Статистика - статистика
|
📊 Статистика - статистика
|
||||||
💡 Предложить контент - сгенерировать пост для соцсетей
|
💡 Предложить контент - сгенерировать пост для соцсетей
|
||||||
⚙️ Настройки - настройки бота
|
⚙️ Настройки - настройки бота
|
||||||
|
🗂 Личный кабинет - расширенный профиль, пост в своём стиле, контент-план, история постов и статистика
|
||||||
|
|
||||||
💡 **Как использовать:**
|
💡 **Как использовать:**
|
||||||
1. Напиши /start
|
1. Напиши /start
|
||||||
@@ -194,6 +196,11 @@ def handle_messages(message):
|
|||||||
|
|
||||||
# === ЗАПУСК БОТА ===
|
# === ЗАПУСК БОТА ===
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
from cabinet_service.runner import run as run_cabinet_service
|
||||||
|
cabinet_thread = threading.Thread(target=run_cabinet_service, daemon=True)
|
||||||
|
cabinet_thread.start()
|
||||||
|
print("🗂 Сервис личного кабинета запущен в фоне")
|
||||||
|
|
||||||
print("🤖 БОТ ЗАПУЩЕН!")
|
print("🤖 БОТ ЗАПУЩЕН!")
|
||||||
print("📋 Команды:")
|
print("📋 Команды:")
|
||||||
print(" /start - начать работу")
|
print(" /start - начать работу")
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json"
|
||||||
|
}
|
||||||
+6
-4
@@ -1,8 +1,10 @@
|
|||||||
pytelegrambotapi>=4.16.0
|
pytelegrambotapi>=4.16.0
|
||||||
sqlalchemy>=2.0.23
|
|
||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
urllib3>=2.1.0
|
urllib3>=2.1.0
|
||||||
groq>=0.5.0
|
fastapi>=0.110.0
|
||||||
transformers>=4.36.0 # (опционально, для локальных моделей)
|
uvicorn>=0.29.0
|
||||||
torch>=2.1.0 # (опционально, для локальных моделей)
|
bcrypt>=4.1.0
|
||||||
|
itsdangerous>=2.1.0
|
||||||
|
jinja2>=3.1.0
|
||||||
|
python-multipart>=0.0.9
|
||||||
Reference in new issue
Block a user