From 4bc0eaa8abdc5fb8ff6c78eef57b0f4c31d85cb3 Mon Sep 17 00:00:00 2001 From: Dastan Medetbekov Date: Sat, 4 Apr 2026 05:49:51 +0600 Subject: [PATCH 01/41] ADMIN --- README.md | 20 ++ backend_python/auth.py | 51 ++++- backend_python/database.py | 76 ++++++++ backend_python/main.py | 12 +- backend_python/routers/auth_router.py | 85 ++++++++- backend_python/routers/cards.py | 95 +++++++-- backend_python/routers/rag.py | 4 +- backend_python/routers/suggestions.py | 228 ++++++++++++++++++++++ docker-compose.yml | 4 + front/about-script.js | 132 +++---------- front/about-style.css | 21 -- front/about.html | 10 +- front/admin-script.js | 265 ++++++++++++++++---------- front/admin.html | 25 ++- front/chat-script.js | 99 ++++------ front/chat.html | 1 + front/index.html | 4 +- front/list-script.js | 236 ++++++++++------------- front/list.html | 14 +- front/nginx.conf | 48 +++++ front/script-back.js | 30 +++ front/script.js | 144 +++++++------- front/style.css | 222 ++++++++++----------- front/suggestions-script.js | 199 +++++++++++++++++++ front/suggestions.html | 90 +++++++++ scripts/start.sh | 41 ++++ 26 files changed, 1492 insertions(+), 664 deletions(-) create mode 100644 backend_python/routers/suggestions.py create mode 100644 front/script-back.js create mode 100644 front/suggestions-script.js create mode 100644 front/suggestions.html diff --git a/README.md b/README.md index 6acb7ec..4339053 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,26 @@ chmod +x scripts/*.sh - Не используй `docker compose down -v` в проде, если нужно сохранить БД. +## Авторизация и роли + +В `.env` задаются учетные данные админа, которые будут автоматически созданы при старте: + +- `ADMIN_USERNAME=admin` +- `ADMIN_PASSWORD=AdminSecure123` +- `SECRET_KEY=<случайная длинная строка>` +- `COOKIE_SECURE=true` (в проде с HTTPS) + +Роли: + +- `admin`: управление карточками, импорт, модерация предложений. +- `user`: чат и отправка предложений на модерацию. + +Поток предложений: + +1. Пользователь отправляет запись на странице `suggestions.html`. +2. Админ рассматривает на `admin.html`. +3. Админ одобряет (запись попадает в `person_cards`) или отклоняет. + ## RAG: Gemini или Claude В `.env` можно выбрать провайдер генерации для RAG: diff --git a/backend_python/auth.py b/backend_python/auth.py index 4d173a7..8eea1f5 100644 --- a/backend_python/auth.py +++ b/backend_python/auth.py @@ -1,8 +1,8 @@ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Optional from jose import JWTError, jwt from passlib.context import CryptContext -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, Request, status from fastapi.security import OAuth2PasswordBearer from sqlalchemy.orm import Session from database import get_db, User @@ -13,10 +13,12 @@ raise RuntimeError("SECRET_KEY must be set to a non-default value") ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 hours +ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "120")) +SESSION_COOKIE_NAME = os.getenv("SESSION_COOKIE_NAME", "archive_session") +COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").strip().lower() in {"1", "true", "yes"} pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login", auto_error=False) def verify_password(plain_password: str, hashed_password: str) -> bool: @@ -30,15 +32,15 @@ def get_password_hash(password: str) -> str: def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): to_encode = data.copy() if expires_delta: - expire = datetime.utcnow() + expires_delta + expire = datetime.now(timezone.utc) + expires_delta else: - expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) to_encode.update({"exp": expire}) encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt -def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)): +def _decode_user_from_token(token: str, db: Session) -> User: credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", @@ -51,8 +53,41 @@ def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends( raise credentials_exception except JWTError: raise credentials_exception - + user = db.query(User).filter(User.username == username).first() if user is None: raise credentials_exception return user + + +def _extract_token(request: Request, bearer_token: Optional[str]) -> Optional[str]: + if bearer_token: + return bearer_token + cookie_token = request.cookies.get(SESSION_COOKIE_NAME) + if cookie_token: + return cookie_token + return None + + +def get_current_user( + request: Request, + bearer_token: Optional[str] = Depends(oauth2_scheme), + db: Session = Depends(get_db), +): + token = _extract_token(request, bearer_token) + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + return _decode_user_from_token(token, db) + + +def require_admin(current_user: User = Depends(get_current_user)) -> User: + if current_user.role != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin permissions required", + ) + return current_user diff --git a/backend_python/database.py b/backend_python/database.py index d701890..6fc07ae 100644 --- a/backend_python/database.py +++ b/backend_python/database.py @@ -9,8 +9,10 @@ Text, UniqueConstraint, Index, + Date, ) from sqlalchemy.orm import sessionmaker, relationship, declarative_base +from sqlalchemy import inspect, text from datetime import datetime import os @@ -27,9 +29,11 @@ class User(Base): id = Column(Integer, primary_key=True, index=True) username = Column(String, unique=True, index=True, nullable=False) password_hash = Column(String, nullable=False) + role = Column(String, nullable=False, default="user", server_default="user") chat_history = relationship("ChatHistory", back_populates="user") revisions = relationship("PersonRevision", back_populates="author") + suggestions = relationship("PersonSuggestion", back_populates="author", foreign_keys="PersonSuggestion.author_id") class PersonCard(Base): @@ -46,7 +50,14 @@ class PersonCard(Base): death_year = Column(Integer, nullable=True) region = Column(String, index=True, nullable=False) category = Column(String, index=True, nullable=True) + nationality = Column(String, nullable=True) + district = Column(String, nullable=True) charge = Column(Text, nullable=False) + sentence = Column(Text, nullable=True) + arrest_date = Column(Date, nullable=True) + sentence_date = Column(Date, nullable=True) + rehabilitation_date = Column(Date, nullable=True) + status = Column(String, nullable=True) description = Column(Text, nullable=False) source = Column(Text, nullable=True) lat = Column(Float, nullable=True) @@ -116,6 +127,41 @@ class ChatHistory(Base): user = relationship("User", back_populates="chat_history") +class PersonSuggestion(Base): + __tablename__ = "person_suggestions" + __table_args__ = ( + Index("ix_person_suggestions_state_created", "state", "created_at"), + ) + + id = Column(Integer, primary_key=True, index=True) + author_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + + full_name = Column(String, nullable=False, index=True) + birth_year = Column(Integer, nullable=False, default=1900) + death_year = Column(Integer, nullable=True) + nationality = Column(String, nullable=True) + region = Column(String, nullable=False, default="Unknown") + district = Column(String, nullable=True) + occupation = Column(String, nullable=True) + charge = Column(Text, nullable=False, default="Unknown") + sentence = Column(Text, nullable=True) + arrest_date = Column(Date, nullable=True) + sentence_date = Column(Date, nullable=True) + rehabilitation_date = Column(Date, nullable=True) + biography = Column(Text, nullable=False, default="") + source = Column(Text, nullable=True) + status = Column(String, nullable=True) + + state = Column(String, nullable=False, default="pending", server_default="pending") + moderation_comment = Column(Text, nullable=True) + + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + moderated_at = Column(DateTime, nullable=True) + moderated_by = Column(Integer, ForeignKey("users.id"), nullable=True) + + author = relationship("User", foreign_keys=[author_id], back_populates="suggestions") + + def get_db(): db = SessionLocal() try: @@ -126,3 +172,33 @@ def get_db(): def init_db(): Base.metadata.create_all(bind=engine) + _apply_lightweight_migrations() + + +def _apply_lightweight_migrations() -> None: + """Best-effort additive migrations for already existing deployments.""" + inspector = inspect(engine) + + if inspector.has_table("users"): + _ensure_column("users", "role", "VARCHAR", default="'user'") + + if inspector.has_table("person_cards"): + _ensure_column("person_cards", "nationality", "VARCHAR") + _ensure_column("person_cards", "district", "VARCHAR") + _ensure_column("person_cards", "sentence", "TEXT") + _ensure_column("person_cards", "arrest_date", "DATE") + _ensure_column("person_cards", "sentence_date", "DATE") + _ensure_column("person_cards", "rehabilitation_date", "DATE") + _ensure_column("person_cards", "status", "VARCHAR") + + +def _ensure_column(table_name: str, column_name: str, sql_type: str, default: str | None = None) -> None: + inspector = inspect(engine) + existing = {c["name"] for c in inspector.get_columns(table_name)} + if column_name in existing: + return + + default_clause = f" DEFAULT {default}" if default is not None else "" + statement = f"ALTER TABLE {table_name} ADD COLUMN {column_name} {sql_type}{default_clause}" + with engine.begin() as conn: + conn.execute(text(statement)) diff --git a/backend_python/main.py b/backend_python/main.py index 9e6e690..2d72a11 100644 --- a/backend_python/main.py +++ b/backend_python/main.py @@ -7,8 +7,9 @@ from sqlalchemy.orm import Session import httpx -from database import init_db, SessionLocal, PersonCard, Document, DocumentChunk, get_db -from routers import auth_router, cards, rag +from database import init_db, SessionLocal, PersonCard, Document, DocumentChunk, get_db, User +from auth import require_admin +from routers import auth_router, cards, rag, suggestions from rag_engine import add_documents_to_vector_db @@ -35,11 +36,17 @@ def _parse_cors_origins() -> list[str]: app.include_router(auth_router.router) app.include_router(cards.router) app.include_router(rag.router) +app.include_router(suggestions.router) @app.on_event("startup") def startup_event() -> None: init_db() + db = SessionLocal() + try: + auth_router.ensure_admin_user(db) + finally: + db.close() @app.get("/") @@ -111,6 +118,7 @@ async def upload_card_with_document( lat: float | None = Form(None), lon: float | None = Form(None), file: UploadFile = File(...), + current_user: User = Depends(require_admin), ) -> dict: if not file.filename: raise HTTPException(status_code=400, detail="File name is required") diff --git a/backend_python/routers/auth_router.py b/backend_python/routers/auth_router.py index 344418b..1f5e92e 100644 --- a/backend_python/routers/auth_router.py +++ b/backend_python/routers/auth_router.py @@ -1,9 +1,18 @@ -from fastapi import APIRouter, Depends, HTTPException, status +import os + +from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.orm import Session from pydantic import BaseModel from database import get_db, User -from auth import get_password_hash, verify_password, create_access_token +from auth import ( + get_password_hash, + verify_password, + create_access_token, + get_current_user, + SESSION_COOKIE_NAME, + COOKIE_SECURE, +) router = APIRouter() @@ -18,8 +27,51 @@ class Token(BaseModel): token_type: str +class UserPublic(BaseModel): + id: int + username: str + role: str + + +def _validate_password_strength(password: str) -> None: + if len(password) < 6: + raise HTTPException(status_code=400, detail="Password must contain at least 6 characters") + has_upper = any(ch.isupper() for ch in password) + has_lower = any(ch.islower() for ch in password) + has_digit = any(ch.isdigit() for ch in password) + if not ((has_upper and has_lower and has_digit) or (has_lower and has_digit)): + raise HTTPException( + status_code=400, + detail="Password must include letters and digits", + ) + + +def ensure_admin_user(db: Session) -> None: + admin_username = os.getenv("ADMIN_USERNAME", "").strip() + admin_password = os.getenv("ADMIN_PASSWORD", "").strip() + if not admin_username or not admin_password: + return + + admin = db.query(User).filter(User.username == admin_username).first() + if admin: + if admin.role != "admin": + admin.role = "admin" + db.commit() + return + + db.add( + User( + username=admin_username, + password_hash=get_password_hash(admin_password), + role="admin", + ) + ) + db.commit() + + @router.post("/register", status_code=status.HTTP_201_CREATED) def register(user_data: UserRegister, db: Session = Depends(get_db)): + _validate_password_strength(user_data.password) # Check if user exists existing_user = db.query(User).filter(User.username == user_data.username).first() if existing_user: @@ -30,7 +82,7 @@ def register(user_data: UserRegister, db: Session = Depends(get_db)): # Create new user hashed_password = get_password_hash(user_data.password) - new_user = User(username=user_data.username, password_hash=hashed_password) + new_user = User(username=user_data.username, password_hash=hashed_password, role="user") db.add(new_user) db.commit() db.refresh(new_user) @@ -39,7 +91,7 @@ def register(user_data: UserRegister, db: Session = Depends(get_db)): @router.post("/login", response_model=Token) -def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): +def login(response: Response, form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): # Find user user = db.query(User).filter(User.username == form_data.username).first() if not user or not verify_password(form_data.password, user.password_hash): @@ -50,6 +102,27 @@ def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depend ) # Create access token - access_token = create_access_token(data={"sub": user.username}) - + access_token = create_access_token(data={"sub": user.username, "role": user.role}) + + response.set_cookie( + key=SESSION_COOKIE_NAME, + value=access_token, + httponly=True, + secure=COOKIE_SECURE, + samesite="lax", + max_age=60 * 60 * 2, + path="/", + ) + return {"access_token": access_token, "token_type": "bearer"} + + +@router.post("/logout") +def logout(response: Response) -> dict: + response.delete_cookie(key=SESSION_COOKIE_NAME, path="/") + return {"message": "Logged out"} + + +@router.get("/me", response_model=UserPublic) +def me(current_user: User = Depends(get_current_user)): + return UserPublic(id=current_user.id, username=current_user.username, role=current_user.role) diff --git a/backend_python/routers/cards.py b/backend_python/routers/cards.py index 0d52883..2333675 100644 --- a/backend_python/routers/cards.py +++ b/backend_python/routers/cards.py @@ -1,12 +1,14 @@ import json from collections import defaultdict +from datetime import date from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File +from sqlalchemy import or_ from sqlalchemy.orm import Session from pydantic import BaseModel from typing import Optional, List from database import get_db, PersonCard, User -from auth import get_current_user +from auth import require_admin router = APIRouter() @@ -15,11 +17,18 @@ class PersonCardCreate(BaseModel): name: str birth_year: Optional[int] = 1900 death_year: Optional[int] = None + nationality: Optional[str] = None region: Optional[str] = "Unknown" + district: Optional[str] = None category: Optional[str] = None charge: Optional[str] = "Unknown" + sentence: Optional[str] = None + arrest_date: Optional[date] = None + sentence_date: Optional[date] = None + rehabilitation_date: Optional[date] = None description: Optional[str] = "" source: Optional[str] = None + status: Optional[str] = None lat: Optional[float] = None lon: Optional[float] = None @@ -29,11 +38,18 @@ class PersonCardResponse(BaseModel): name: str birth_year: int death_year: Optional[int] + nationality: Optional[str] region: str + district: Optional[str] category: Optional[str] charge: str + sentence: Optional[str] + arrest_date: Optional[date] + sentence_date: Optional[date] + rehabilitation_date: Optional[date] description: str source: Optional[str] + status: Optional[str] lat: Optional[float] lon: Optional[float] @@ -46,11 +62,18 @@ class SeedPersonCard(BaseModel): full_name: str birth_year: Optional[int] = None death_year: Optional[int] = None + nationality: Optional[str] = None region: Optional[str] = None + district: Optional[str] = None occupation: Optional[str] = None charge: Optional[str] = None + sentence: Optional[str] = None + arrest_date: Optional[date] = None + sentence_date: Optional[date] = None + rehabilitation_date: Optional[date] = None biography: Optional[str] = None source: Optional[str] = None + status: Optional[str] = None def _normalize_card_payload(card_data: PersonCardCreate) -> dict: @@ -68,14 +91,14 @@ def _to_public_person(card: PersonCard) -> dict: "full_name": card.name, "birth_year": card.birth_year, "death_year": card.death_year, - "nationality": None, + "nationality": card.nationality, "occupation": card.category, - "arrest_date": None, - "sentence": card.charge, - "sentence_date": None, - "rehabilitation_date": None, + "arrest_date": card.arrest_date, + "sentence": card.sentence or card.charge, + "sentence_date": card.sentence_date, + "rehabilitation_date": card.rehabilitation_date, "biography": card.description or card.content or "", - "photo_url": "https://via.placeholder.com/250x350.png?text=Portrait", + "photo_url": "https://via.placeholder.com/250x350.png?text=Archive", "documents": [], } @@ -97,9 +120,17 @@ def search_public_persons( limit: int = Query(20, ge=1, le=100), db: Session = Depends(get_db), ) -> list[dict]: + term = q.strip() rows = ( db.query(PersonCard) - .filter(PersonCard.name.ilike(f"%{q}%")) + .filter( + or_( + PersonCard.name.ilike(f"%{term}%"), + PersonCard.region.ilike(f"%{term}%"), + PersonCard.category.ilike(f"%{term}%"), + PersonCard.charge.ilike(f"%{term}%"), + ) + ) .order_by(PersonCard.name.asc()) .limit(limit) .all() @@ -125,7 +156,15 @@ def list_public_persons( ) -> list[dict]: query = db.query(PersonCard) if q: - query = query.filter(PersonCard.name.ilike(f"%{q}%")) + term = q.strip() + query = query.filter( + or_( + PersonCard.name.ilike(f"%{term}%"), + PersonCard.region.ilike(f"%{term}%"), + PersonCard.category.ilike(f"%{term}%"), + PersonCard.charge.ilike(f"%{term}%"), + ) + ) rows = ( query.order_by(PersonCard.name.asc()) @@ -140,8 +179,11 @@ def list_public_persons( "full_name": row.name, "birth_year": row.birth_year, "death_year": row.death_year, + "nationality": row.nationality, "occupation": row.category, "region": row.region, + "district": row.district, + "charge": row.charge, } for row in rows ] @@ -154,7 +196,7 @@ def list_cards( region: Optional[str] = Query(None), birth_year: Optional[int] = Query(None), db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(require_admin) ): query = db.query(PersonCard) @@ -175,7 +217,7 @@ def list_cards( def get_card( card_id: int, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(require_admin) ): card = db.query(PersonCard).filter(PersonCard.id == card_id).first() if not card: @@ -190,7 +232,7 @@ def get_card( def create_card( card_data: PersonCardCreate, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(require_admin) ): payload = _normalize_card_payload(card_data) duplicate = db.query(PersonCard).filter( @@ -215,7 +257,7 @@ def update_card( card_id: int, card_data: PersonCardCreate, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(require_admin) ): payload = _normalize_card_payload(card_data) card = db.query(PersonCard).filter(PersonCard.id == card_id).first() @@ -248,7 +290,7 @@ def update_card( def delete_card( card_id: int, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(require_admin) ): card = db.query(PersonCard).filter(PersonCard.id == card_id).first() if not card: @@ -266,7 +308,7 @@ def delete_card( def import_cards( cards_data: List[PersonCardCreate], db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(require_admin) ): created = 0 skipped_duplicates = 0 @@ -303,7 +345,7 @@ def import_cards( def import_seed_cards( cards_data: List[SeedPersonCard], db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(require_admin) ): created = 0 skipped_duplicates = 0 @@ -325,13 +367,20 @@ def import_seed_cards( db.add(PersonCard( name=item.full_name, - birth_year=item.birth_year, + birth_year=item.birth_year or 1900, death_year=item.death_year, + nationality=item.nationality, region=item.region, + district=item.district, category=item.occupation, charge=item.charge, + sentence=item.sentence, + arrest_date=item.arrest_date, + sentence_date=item.sentence_date, + rehabilitation_date=item.rehabilitation_date, description=item.biography, source=item.source, + status=item.status, lat=None, lon=None, )) @@ -350,7 +399,7 @@ def import_seed_cards( async def import_persons_file( file: UploadFile = File(...), db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(require_admin) ): content = await file.read() try: @@ -384,11 +433,18 @@ async def import_persons_file( name=seed.full_name, birth_year=seed.birth_year or 1900, death_year=seed.death_year, + nationality=seed.nationality, region=seed.region or "Unknown", + district=seed.district, category=seed.occupation, charge=seed.charge or "Unknown", + sentence=seed.sentence, + arrest_date=seed.arrest_date, + sentence_date=seed.sentence_date, + rehabilitation_date=seed.rehabilitation_date, description=seed.biography or "", source=seed.source, + status=seed.status, lat=None, lon=None, )) @@ -401,8 +457,7 @@ async def import_persons_file( @router.get("/api/persons/alphabetical") def persons_alphabetical( - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + db: Session = Depends(get_db) ): rows = db.query(PersonCard).order_by(PersonCard.name.asc()).all() index = defaultdict(list) diff --git a/backend_python/routers/rag.py b/backend_python/routers/rag.py index 58585d9..caeb496 100644 --- a/backend_python/routers/rag.py +++ b/backend_python/routers/rag.py @@ -6,7 +6,7 @@ import httpx import os from database import get_db, User, ChatHistory, PersonCard, Document, DocumentChunk -from auth import get_current_user +from auth import get_current_user, require_admin from rag_engine import add_documents_to_vector_db, answer_with_rag import json @@ -60,7 +60,7 @@ async def upload_document( file: UploadFile = File(...), person_id: int = Form(...), db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(require_admin) ): if not file.filename: raise HTTPException( diff --git a/backend_python/routers/suggestions.py b/backend_python/routers/suggestions.py new file mode 100644 index 0000000..3942f6c --- /dev/null +++ b/backend_python/routers/suggestions.py @@ -0,0 +1,228 @@ +from datetime import date, datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from auth import get_current_user, require_admin +from database import PersonCard, PersonSuggestion, User, get_db + +router = APIRouter() + + +class SuggestionCreate(BaseModel): + full_name: str + birth_year: Optional[int] = 1900 + death_year: Optional[int] = None + nationality: Optional[str] = None + region: Optional[str] = "Unknown" + district: Optional[str] = None + occupation: Optional[str] = None + charge: Optional[str] = "Unknown" + sentence: Optional[str] = None + arrest_date: Optional[date] = None + sentence_date: Optional[date] = None + rehabilitation_date: Optional[date] = None + biography: Optional[str] = "" + source: Optional[str] = None + status: Optional[str] = None + + +class SuggestionResponse(BaseModel): + id: int + author_id: int + state: str + moderation_comment: Optional[str] + full_name: str + birth_year: int + death_year: Optional[int] + nationality: Optional[str] + region: str + district: Optional[str] + occupation: Optional[str] + charge: str + sentence: Optional[str] + arrest_date: Optional[date] + sentence_date: Optional[date] + rehabilitation_date: Optional[date] + biography: str + source: Optional[str] + status: Optional[str] + created_at: datetime + moderated_at: Optional[datetime] + + class Config: + from_attributes = True + + +class ModerationAction(BaseModel): + comment: Optional[str] = None + + +def _validate_logic(data: SuggestionCreate) -> None: + by = data.birth_year or 1900 + if by < 1800 or by > 2100: + raise HTTPException(status_code=422, detail="birth_year is out of allowed range") + if data.death_year is not None and data.death_year < by: + raise HTTPException(status_code=422, detail="death_year must be >= birth_year") + + if data.sentence_date and data.arrest_date and data.sentence_date < data.arrest_date: + raise HTTPException(status_code=422, detail="sentence_date must be after arrest_date") + + if data.rehabilitation_date and data.sentence_date and data.rehabilitation_date < data.sentence_date: + raise HTTPException(status_code=422, detail="rehabilitation_date must be after sentence_date") + + +@router.post("/suggestions", response_model=SuggestionResponse, status_code=status.HTTP_201_CREATED) +def create_suggestion( + payload: SuggestionCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _validate_logic(payload) + data = payload.model_dump() + + suggestion = PersonSuggestion( + author_id=current_user.id, + full_name=data["full_name"], + birth_year=data.get("birth_year") or 1900, + death_year=data.get("death_year"), + nationality=data.get("nationality"), + region=data.get("region") or "Unknown", + district=data.get("district"), + occupation=data.get("occupation"), + charge=data.get("charge") or "Unknown", + sentence=data.get("sentence"), + arrest_date=data.get("arrest_date"), + sentence_date=data.get("sentence_date"), + rehabilitation_date=data.get("rehabilitation_date"), + biography=data.get("biography") or "", + source=data.get("source"), + status=data.get("status"), + state="pending", + ) + + db.add(suggestion) + db.commit() + db.refresh(suggestion) + return suggestion + + +@router.get("/suggestions/my", response_model=list[SuggestionResponse]) +def my_suggestions( + limit: int = Query(100, ge=1, le=300), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + rows = ( + db.query(PersonSuggestion) + .filter(PersonSuggestion.author_id == current_user.id) + .order_by(PersonSuggestion.created_at.desc()) + .limit(limit) + .all() + ) + return rows + + +@router.get("/admin/suggestions", response_model=list[SuggestionResponse]) +def list_suggestions_admin( + state: Optional[str] = Query(None), + limit: int = Query(200, ge=1, le=1000), + db: Session = Depends(get_db), + current_user: User = Depends(require_admin), +): + query = db.query(PersonSuggestion) + if state: + query = query.filter(PersonSuggestion.state == state) + + return query.order_by(PersonSuggestion.created_at.desc()).limit(limit).all() + + +@router.post("/admin/suggestions/{suggestion_id}/approve") +def approve_suggestion( + suggestion_id: int, + action: ModerationAction, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin), +): + suggestion = db.query(PersonSuggestion).filter(PersonSuggestion.id == suggestion_id).first() + if not suggestion: + raise HTTPException(status_code=404, detail="Suggestion not found") + if suggestion.state != "pending": + raise HTTPException(status_code=400, detail="Only pending suggestions can be approved") + + duplicate = db.query(PersonCard).filter( + PersonCard.name.ilike(suggestion.full_name), + PersonCard.birth_year == suggestion.birth_year, + ).first() + if duplicate: + raise HTTPException(status_code=409, detail="Duplicate card already exists") + + card = PersonCard( + name=suggestion.full_name, + birth_year=suggestion.birth_year, + death_year=suggestion.death_year, + nationality=suggestion.nationality, + region=suggestion.region, + district=suggestion.district, + category=suggestion.occupation, + charge=suggestion.charge, + sentence=suggestion.sentence, + arrest_date=suggestion.arrest_date, + sentence_date=suggestion.sentence_date, + rehabilitation_date=suggestion.rehabilitation_date, + description=suggestion.biography, + source=suggestion.source, + status=suggestion.status, + lat=None, + lon=None, + content=suggestion.biography or "", + ) + db.add(card) + + suggestion.state = "approved" + suggestion.moderation_comment = action.comment + suggestion.moderated_at = datetime.utcnow() + suggestion.moderated_by = current_user.id + + db.commit() + db.refresh(card) + + return {"message": "Suggestion approved", "card_id": card.id, "suggestion_id": suggestion.id} + + +@router.post("/admin/suggestions/{suggestion_id}/reject") +def reject_suggestion( + suggestion_id: int, + action: ModerationAction, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin), +): + suggestion = db.query(PersonSuggestion).filter(PersonSuggestion.id == suggestion_id).first() + if not suggestion: + raise HTTPException(status_code=404, detail="Suggestion not found") + if suggestion.state != "pending": + raise HTTPException(status_code=400, detail="Only pending suggestions can be rejected") + + suggestion.state = "rejected" + suggestion.moderation_comment = action.comment + suggestion.moderated_at = datetime.utcnow() + suggestion.moderated_by = current_user.id + db.commit() + return {"message": "Suggestion rejected", "suggestion_id": suggestion.id} + + +@router.delete("/admin/suggestions/{suggestion_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_suggestion_admin( + suggestion_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin), +): + suggestion = db.query(PersonSuggestion).filter(PersonSuggestion.id == suggestion_id).first() + if not suggestion: + raise HTTPException(status_code=404, detail="Suggestion not found") + + db.delete(suggestion) + db.commit() + return None diff --git a/docker-compose.yml b/docker-compose.yml index 994863c..1e27dc1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,6 +74,10 @@ services: - RAG_OPENAI_EMBEDDING_MODEL=${RAG_OPENAI_EMBEDDING_MODEL:-text-embedding-3-large} - CPP_BACKEND_URL=http://cpp_backend:8080 - SECRET_KEY=${SECRET_KEY:-hackathon-secret-change-me} + - ADMIN_USERNAME=${ADMIN_USERNAME:-admin} + - ADMIN_PASSWORD=${ADMIN_PASSWORD:-AdminSecure123} + - COOKIE_SECURE=${COOKIE_SECURE:-false} + - ACCESS_TOKEN_EXPIRE_MINUTES=${ACCESS_TOKEN_EXPIRE_MINUTES:-120} - DATABASE_URL=${DATABASE_URL:-postgresql://hackathon:hackathon@db:5432/hackathon} - CHROMA_HOST=vector_db - CHROMA_PORT=8000 diff --git a/front/about-script.js b/front/about-script.js index 4f5f427..4eb5bc0 100644 --- a/front/about-script.js +++ b/front/about-script.js @@ -1,104 +1,28 @@ -function resolveApiBase() { - const params = new URLSearchParams(window.location.search); - const fromQuery = params.get("api"); - if (fromQuery) { - return fromQuery.replace(/\/$/, ""); - } - return ""; -} - -const API_BASE = resolveApiBase(); - -function apiUrl(path) { - return API_BASE ? `${API_BASE}${path}` : path; -} - -function renderPartners() { - const partnersGrid = document.getElementById("partners-grid"); - if (!partnersGrid) return; - - const partners = [ - "Государственный архив РФ", - "Мемориал", - "Сахаровский центр", - "РГАСПИ", - "Яд Вашем", - "Национальный архив КР" - ]; - - partnersGrid.innerHTML = ""; - partners.forEach((p) => { - partnersGrid.innerHTML += ` -
-
🏛
-
${p}
-
`; - }); -} - -async function renderStats() { - const statsGrid = document.getElementById("stats-grid"); - if (!statsGrid) return; - - statsGrid.innerHTML = "Загрузка..."; - - try { - const response = await fetch(apiUrl("/api/stats")); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - - const stats = await response.json(); - const rows = [ - { n: String(stats.persons ?? 0), l: "Записей в базе" }, - { n: String(stats.documents ?? 0), l: "Документов" }, - { n: String(stats.chunks_in_db ?? 0), l: "Чанков в векторной базе" } - ]; - - statsGrid.innerHTML = ""; - rows.forEach((s) => { - statsGrid.innerHTML += ` -
-
${s.n}
-
${s.l}
-
`; - }); - } catch (err) { - statsGrid.innerHTML = `Ошибка загрузки статистики: ${err.message}`; - } -} - -async function renderRegions() { - const regionsGrid = document.getElementById("regions-grid"); - if (!regionsGrid) return; - - regionsGrid.innerHTML = "Загрузка..."; - - try { - const response = await fetch(apiUrl("/api/stats")); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - - const stats = await response.json(); - const regions = Array.isArray(stats.by_region) ? stats.by_region : []; - - if (!regions.length) { - regionsGrid.innerHTML = "Нет данных по регионам"; - return; - } - - regionsGrid.innerHTML = ""; - regions.slice(0, 24).forEach((row) => { - regionsGrid.innerHTML += ` -
-
${row.region || "Unknown"}
-
${row.cnt || 0}
-
`; - }); - } catch (err) { - regionsGrid.innerHTML = `Ошибка загрузки регионов: ${err.message}`; - } -} - -document.addEventListener("DOMContentLoaded", () => { - renderPartners(); - renderStats(); - renderRegions(); -}); +const partnersGrid = document.getElementById('partners-grid'); + const statsGrid = document.getElementById('stats-grid'); + + if (partnersGrid && statsGrid) { + const partners = ["Государственный архив РФ", "Мемориал", "Сахаровский центр", "РГАСПИ", "Яд Вашем", "Национальный архив РК"]; + //Добавьте статистику в массив stats из бекенда + const stats = [ + { n: "48 291", l: "Записей в базе" }, + { n: "12 750", l: "Документов оцифровано" }, + { n: "37", l: "Регионов охвачено" } + ]; + + partners.forEach(p => { + partnersGrid.innerHTML += ` +
+
🏛
+
${p}
+
`; + }); + + stats.forEach(s => { + statsGrid.innerHTML += ` +
+
${s.n}
+
${s.l}
+
`; + }); + } \ No newline at end of file diff --git a/front/about-style.css b/front/about-style.css index 4e3c496..adda799 100644 --- a/front/about-style.css +++ b/front/about-style.css @@ -95,25 +95,4 @@ .stat-label { font-size: 0.85rem; color: #666; -} - -.region-card { - text-align: left; -} - -.region-name { - font-weight: bold; - color: #333; - margin-bottom: 8px; -} - -.region-count { - display: inline-block; - min-width: 42px; - padding: 4px 8px; - border-radius: 999px; - background: #f1ece1; - color: #800000; - font-weight: bold; - text-align: center; } \ No newline at end of file diff --git a/front/about.html b/front/about.html index 86be58d..34b3667 100644 --- a/front/about.html +++ b/front/about.html @@ -23,11 +23,10 @@ +
@@ -51,9 +50,6 @@

Партнёры

Статистика

- -

Регионы

-
+ \ No newline at end of file diff --git a/front/admin-script.js b/front/admin-script.js index b57cb58..6ad7808 100644 --- a/front/admin-script.js +++ b/front/admin-script.js @@ -18,6 +18,12 @@ function setAdminStatus(text) { document.getElementById("adminStatus").textContent = text; } +function setAdminProtectedVisible(visible) { + document.querySelectorAll(".admin-protected").forEach((panel) => { + panel.hidden = !visible; + }); +} + function parseIntOrNull(value) { if (!value || value.trim() === "") return null; const n = Number(value); @@ -38,62 +44,37 @@ async function apiFetch(path, options = {}) { } async function checkSession() { - const response = await apiFetch("/me"); - if (!response.ok) { + let me = null; + if (window.SiteAuth?.refreshSession) { + await window.SiteAuth.refreshSession(); + me = window.SiteAuth.getCurrentUser(); + } else { + const response = await apiFetch("/me"); + if (response.ok) { + me = await response.json(); + } + } + + if (!me) { currentAdmin = null; - setAdminStatus("Сессия не активна"); + setAdminProtectedVisible(false); + setAdminStatus("Сессия не активна. Нажмите \"Зайти как админ\"."); return null; } - const me = await response.json(); if (me.role !== "admin") { currentAdmin = me; + setAdminProtectedVisible(false); setAdminStatus(`Вход как ${me.username}, но роль не admin`); return me; } currentAdmin = me; + setAdminProtectedVisible(true); setAdminStatus(`Авторизован как admin: ${me.username}`); return me; } -async function loginAdmin() { - const username = document.getElementById("adminUser").value.trim(); - const password = document.getElementById("adminPass").value.trim(); - if (!username || !password) { - setAdminStatus("Введите логин и пароль"); - return; - } - - const body = new URLSearchParams(); - body.set("username", username); - body.set("password", password); - - const response = await apiFetch("/login", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: body.toString() - }); - - if (!response.ok) { - const payload = await response.json().catch(() => ({})); - setAdminStatus(`Ошибка входа: ${payload.detail || response.status}`); - return; - } - - await checkSession(); - await loadCards(); - await loadSuggestions(); -} - -async function logoutAdmin() { - await apiFetch("/logout", { method: "POST" }); - currentAdmin = null; - setAdminStatus("Вы вышли"); - document.getElementById("adminCardsList").innerHTML = ""; - document.getElementById("adminSuggestionsList").innerHTML = ""; -} - async function createCard() { if (!currentAdmin || currentAdmin.role !== "admin") { setAdminStatus("Нужна авторизация администратора"); @@ -299,7 +280,11 @@ async function loadSuggestions() { row.className = "admin-card-row"; const info = document.createElement("div"); - info.innerHTML = `${item.full_name} (${item.birth_year || "?"}) #${item.id}
state: ${item.state} | source: ${item.source || "-"}`; + const photoState = item.photo_url ? "photo: yes" : "photo: placeholder"; + const docState = item.document_filename + ? `doc: ${item.document_filename}` + : (item.document_text ? "doc: text" : "doc: -"); + info.innerHTML = `${item.full_name} (${item.birth_year || "?"}) #${item.id}
mode: ${item.suggestion_kind}${item.target_person_id ? ` | target: #${item.target_person_id}` : ""} | state: ${item.state} | source: ${item.source || "-"} | ${photoState} | ${docState}`; row.appendChild(info); const actions = document.createElement("div"); @@ -330,10 +315,8 @@ async function loadSuggestions() { } document.addEventListener("DOMContentLoaded", async () => { - document.getElementById("adminLoginBtn").addEventListener("click", loginAdmin); - document.getElementById("adminLogoutBtn").addEventListener("click", logoutAdmin); - document.getElementById("adminWhoamiBtn").addEventListener("click", async () => { - await checkSession(); + document.getElementById("openAdminLoginModalBtn")?.addEventListener("click", () => { + window.SiteAuth?.openModal?.(); }); document.getElementById("seedImportBtn").addEventListener("click", importSeedFile); @@ -341,7 +324,21 @@ document.addEventListener("DOMContentLoaded", async () => { document.getElementById("refreshCardsBtn").addEventListener("click", loadCards); document.getElementById("loadSuggestionsBtn").addEventListener("click", loadSuggestions); - await checkSession(); - await loadCards(); - await loadSuggestions(); + window.addEventListener("site-auth-changed", async () => { + const me = await checkSession(); + if (me?.role === "admin") { + await loadCards(); + await loadSuggestions(); + return; + } + + document.getElementById("adminCardsList").innerHTML = ""; + document.getElementById("adminSuggestionsList").innerHTML = ""; + }); + + const me = await checkSession(); + if (me?.role === "admin") { + await loadCards(); + await loadSuggestions(); + } }); diff --git a/front/admin.html b/front/admin.html index e36e56e..e79c1ca 100644 --- a/front/admin.html +++ b/front/admin.html @@ -17,35 +17,26 @@ -
+

Админ панель

- -
-

Вход администратора

-
- - -
+
+

Доступ к админке

+

Доступ только для администратора.

- - - +
-

Не авторизован

-
+ -
+ -
+
+ + +

© 2024 Архив Памяти

+ diff --git a/front/contacts.html b/front/contacts.html index 5211553..bc105d6 100644 --- a/front/contacts.html +++ b/front/contacts.html @@ -17,8 +17,6 @@ @@ -40,5 +38,7 @@

Контакты

© 2024 Архив Памяти

+ + diff --git a/front/header-auth.js b/front/header-auth.js new file mode 100644 index 0000000..77be38a --- /dev/null +++ b/front/header-auth.js @@ -0,0 +1,304 @@ +let siteCurrentUser = null; + +function resolveAuthApiBase() { + const params = new URLSearchParams(window.location.search); + const fromQuery = params.get("api"); + if (fromQuery) { + return fromQuery.replace(/\/$/, ""); + } + return ""; +} + +const SITE_AUTH_API_BASE = resolveAuthApiBase(); + +function authApiUrl(path) { + return SITE_AUTH_API_BASE ? `${SITE_AUTH_API_BASE}${path}` : path; +} + +async function authFetch(path, options = {}) { + return fetch(authApiUrl(path), { + credentials: "include", + ...options + }); +} + +function emitAuthChange() { + window.dispatchEvent(new CustomEvent("site-auth-changed", { + detail: { user: siteCurrentUser } + })); +} + +function setGlobalAuthStatus(text) { + const statusEl = document.getElementById("globalAuthStatus"); + if (statusEl) { + statusEl.textContent = text; + } +} + +function openGlobalAuthModal() { + const modal = document.getElementById("globalAuthModal"); + if (!modal) return; + modal.classList.add("open"); + modal.setAttribute("aria-hidden", "false"); +} + +function closeGlobalAuthModal() { + const modal = document.getElementById("globalAuthModal"); + if (!modal) return; + modal.classList.remove("open"); + modal.setAttribute("aria-hidden", "true"); +} + +function updateGlobalAuthControls() { + const loginBtn = document.getElementById("globalLoginOpenBtn"); + const logoutBtn = document.getElementById("globalLogoutBtn"); + if (!loginBtn || !logoutBtn) return; + + if (siteCurrentUser) { + loginBtn.hidden = true; + logoutBtn.hidden = false; + } else { + loginBtn.hidden = false; + logoutBtn.hidden = true; + } +} + +async function refreshSiteSession() { + const response = await authFetch("/me"); + if (!response.ok) { + siteCurrentUser = null; + setGlobalAuthStatus("Не авторизован"); + normalizeHeaderNav(); + updateGlobalAuthControls(); + emitAuthChange(); + return null; + } + + siteCurrentUser = await response.json(); + setGlobalAuthStatus(`Вход выполнен: ${siteCurrentUser.username} (${siteCurrentUser.role})`); + normalizeHeaderNav(); + updateGlobalAuthControls(); + emitAuthChange(); + return siteCurrentUser; +} + +async function registerSiteUser() { + const username = (document.getElementById("globalAuthUser")?.value || "").trim(); + const password = (document.getElementById("globalAuthPass")?.value || "").trim(); + if (!username || !password) { + setGlobalAuthStatus("Введите логин и пароль"); + return; + } + + const response = await authFetch("/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }) + }); + + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + setGlobalAuthStatus(`Ошибка регистрации: ${payload.detail || response.status}`); + return; + } + + setGlobalAuthStatus("Регистрация успешна. Теперь войдите."); +} + +async function loginSiteUser() { + const username = (document.getElementById("globalAuthUser")?.value || "").trim(); + const password = (document.getElementById("globalAuthPass")?.value || "").trim(); + if (!username || !password) { + setGlobalAuthStatus("Введите логин и пароль"); + return; + } + + const body = new URLSearchParams(); + body.set("username", username); + body.set("password", password); + + const response = await authFetch("/login", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString() + }); + + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + setGlobalAuthStatus(`Ошибка входа: ${payload.detail || response.status}`); + return; + } + + await refreshSiteSession(); + closeGlobalAuthModal(); +} + +async function logoutSiteUser() { + await authFetch("/logout", { method: "POST" }); + siteCurrentUser = null; + setGlobalAuthStatus("Вы вышли"); + normalizeHeaderNav(); + updateGlobalAuthControls(); + emitAuthChange(); +} + +function normalizeHeaderNav() { + const nav = document.querySelector(".header-nav"); + if (!nav) return; + + const links = [ + { href: "index.html", label: "Главная" }, + { href: "list.html", label: "База данных" }, + { href: "about.html", label: "О проекте" }, + { href: "contacts.html", label: "Контакты" }, + { href: "chat.html", label: "Чат AI" }, + { href: "suggestions.html", label: "Предложить запись" }, + { href: "admin.html", label: "Админ", adminOnly: true } + ]; + + const page = window.location.pathname.split("/").pop() || "index.html"; + nav.innerHTML = links + .filter((item) => !item.adminOnly || siteCurrentUser?.role === "admin") + .map(({ href, label }) => { + const active = href === page ? " class=\"nav-active\"" : ""; + return `${label}`; + }) + .join(""); +} + +function ensureHeaderSearchBar() { + const headerInner = document.querySelector(".header-inner"); + if (!headerInner || headerInner.querySelector(".search-bar")) return; + + const search = document.createElement("div"); + search.className = "search-bar"; + search.innerHTML = ` + + + `; + + const nav = headerInner.querySelector(".header-nav"); + if (nav) { + headerInner.insertBefore(search, nav); + } else { + headerInner.appendChild(search); + } +} + +function ensureHeaderAuthControls() { + const headerInner = document.querySelector(".header-inner"); + if (!headerInner || document.getElementById("globalLoginOpenBtn")) return; + + const controls = document.createElement("div"); + controls.className = "global-auth-controls"; + controls.innerHTML = ` + + + `; + headerInner.appendChild(controls); +} + +function ensureGlobalAuthModal() { + if (document.getElementById("globalAuthModal")) return; + + const modal = document.createElement("div"); + modal.id = "globalAuthModal"; + modal.className = "global-auth-modal"; + modal.setAttribute("aria-hidden", "true"); + modal.innerHTML = ` + + `; + + document.body.appendChild(modal); +} + +function setupGlobalAuthEvents() { + document.getElementById("globalLoginOpenBtn")?.addEventListener("click", openGlobalAuthModal); + document.getElementById("globalLogoutBtn")?.addEventListener("click", logoutSiteUser); + document.getElementById("globalAuthCloseBtn")?.addEventListener("click", closeGlobalAuthModal); + document.getElementById("globalAuthRegisterBtn")?.addEventListener("click", registerSiteUser); + document.getElementById("globalAuthLoginBtn")?.addEventListener("click", loginSiteUser); + + document.getElementById("globalAuthModal")?.addEventListener("click", (event) => { + if (event.target.id === "globalAuthModal") { + closeGlobalAuthModal(); + } + }); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + closeGlobalAuthModal(); + } + }); +} + +function setupGlobalSearch() { + const isListPage = + window.location.pathname.endsWith("/list.html") || + window.location.pathname.endsWith("list.html"); + if (isListPage) return; + + const input = document.getElementById("searchInput"); + const btn = document.getElementById("searchBtn"); + if (!input || !btn) return; + + const runSearch = () => { + const q = (input.value || "").trim(); + if (!q) return; + + const params = new URLSearchParams(window.location.search); + const api = params.get("api"); + const target = new URL("list.html", window.location.href); + target.searchParams.set("q", q); + if (api) { + target.searchParams.set("api", api); + } + window.location.href = target.toString(); + }; + + const existingFlag = "data-global-search-bound"; + if (!btn.hasAttribute(existingFlag)) { + btn.setAttribute(existingFlag, "1"); + btn.addEventListener("click", runSearch); + } + + if (!input.hasAttribute(existingFlag)) { + input.setAttribute(existingFlag, "1"); + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + runSearch(); + } + }); + } +} + +window.SiteAuth = { + getCurrentUser: () => siteCurrentUser, + refreshSession: refreshSiteSession, + openModal: openGlobalAuthModal, + logout: logoutSiteUser +}; + +document.addEventListener("DOMContentLoaded", async () => { + normalizeHeaderNav(); + ensureHeaderSearchBar(); + ensureHeaderAuthControls(); + ensureGlobalAuthModal(); + setupGlobalAuthEvents(); + setupGlobalSearch(); + await refreshSiteSession(); +}); diff --git a/front/index.html b/front/index.html index d655422..b9271d4 100644 --- a/front/index.html +++ b/front/index.html @@ -10,14 +10,6 @@ - -
- - - - -
-
-
+
🔍

Найти человека

Введите имя или фамилию в строку поиска, чтобы найти информацию о жертве репрессий.

-
+

Добавить сведения

Помогите дополнить архив — загрузите документы и воспоминания о ваших родственниках.

-
+

Помочь проекту

Станьте волонтёром или поддержите проект. Каждый вклад помогает сохранить память.

@@ -111,8 +99,8 @@

Ресурсы

@@ -122,7 +110,7 @@

Статистика

© 2024 Архив Памяти. Открытая энциклопедия жертв политических репрессий.

- + diff --git a/front/list-script.js b/front/list-script.js index 85e2c76..23348ea 100644 --- a/front/list-script.js +++ b/front/list-script.js @@ -103,6 +103,17 @@ function getFilterValues() { }; } +function initSearchFromUrl() { + const params = new URLSearchParams(window.location.search); + const q = (params.get("q") || "").trim(); + if (!q) return; + + const input = document.getElementById("searchInput"); + if (input) { + input.value = q; + } +} + function applyFilters() { const { query, region, year } = getFilterValues(); @@ -183,6 +194,25 @@ async function loadPeople() { allPeople = Array.isArray(data) ? data.map(mapPerson) : []; } +async function loadStats() { + const personsEl = document.getElementById("statsPersons"); + const docsEl = document.getElementById("statsDocuments"); + if (!personsEl || !docsEl) return; + + try { + const response = await fetch(`${API_BASE}/api/stats`); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const stats = await response.json(); + personsEl.textContent = String(stats.persons ?? 0); + docsEl.textContent = String(stats.documents ?? 0); + } catch (_err) { + personsEl.textContent = "-"; + docsEl.textContent = "-"; + } +} + function showError(message) { const container = document.getElementById("registryList"); container.innerHTML = `
Ошибка загрузки списка: ${message}
`; @@ -190,6 +220,8 @@ function showError(message) { document.addEventListener("DOMContentLoaded", async () => { setupSearch(); + loadStats(); + initSearchFromUrl(); try { await loadPeople(); fillRegionFilter(); diff --git a/front/list.html b/front/list.html index 64bc475..7ce724f 100644 --- a/front/list.html +++ b/front/list.html @@ -25,13 +25,9 @@ -
@@ -91,8 +87,8 @@

Ресурсы

@@ -102,6 +98,7 @@

Статистика

© 2024 Архив Памяти. Открытая энциклопедия жертв политических репрессий.

+ diff --git a/front/nginx.conf b/front/nginx.conf index 8be61f0..4e3310a 100644 --- a/front/nginx.conf +++ b/front/nginx.conf @@ -101,6 +101,14 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + location /uploads/ { + proxy_pass http://python_backend:8000/uploads/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/front/script.js b/front/script.js index 9d69980..fca6806 100644 --- a/front/script.js +++ b/front/script.js @@ -17,6 +17,7 @@ function resolveApiBase() { } const API_BASE = resolveApiBase(); +const PLACEHOLDER = "https://via.placeholder.com/250x350.png?text=Archive"; function formatDate(dateStr) { if (!dateStr) return "-"; @@ -33,7 +34,11 @@ function renderPerson(data) { document.getElementById("personYears").textContent = years ? `(${years})` : ""; const photoEl = document.getElementById("personPhoto"); - photoEl.src = data.photo_url || "https://via.placeholder.com/250x350.png?text=Archive"; + photoEl.src = data.photo_url || PLACEHOLDER; + photoEl.onerror = () => { + photoEl.onerror = null; + photoEl.src = PLACEHOLDER; + }; photoEl.alt = "Портрет: " + data.full_name; const tbody = document.querySelector("#metaTable tbody"); @@ -73,6 +78,8 @@ function renderPerson(data) { div.appendChild(img); grid.appendChild(div); }); + } else { + grid.textContent = "Документы пока не добавлены"; } const bioDiv = document.getElementById("biographyText"); @@ -86,21 +93,85 @@ function renderPerson(data) { p.textContent = text; bioDiv.appendChild(p); }); + } else { + bioDiv.textContent = "Биография пока не добавлена"; } } -function showLoadError(message) { - document.getElementById("personName").textContent = "Ошибка загрузки"; - document.getElementById("personYears").textContent = message; - document.getElementById("biographyText").textContent = "Проверьте доступность backend API."; +function showNoDataState() { + document.getElementById("personName").textContent = "Пока нет данных"; + document.getElementById("personYears").textContent = ""; + + const photoEl = document.getElementById("personPhoto"); + photoEl.src = PLACEHOLDER; + photoEl.alt = "Портрет"; + + const tbody = document.querySelector("#metaTable tbody"); + tbody.innerHTML = ""; + + const grid = document.getElementById("documentsGrid"); + grid.innerHTML = ""; + grid.textContent = "Документы пока не добавлены"; + + const bioDiv = document.getElementById("biographyText"); + bioDiv.innerHTML = ""; + bioDiv.textContent = "В базе пока нет данных."; +} + +async function loadStats() { + const personsEl = document.getElementById("statsPersons"); + const docsEl = document.getElementById("statsDocuments"); + + try { + const response = await fetch(`${API_BASE}/api/stats`); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const stats = await response.json(); + personsEl.textContent = String(stats.persons ?? 0); + docsEl.textContent = String(stats.documents ?? 0); + } catch (_err) { + personsEl.textContent = "-"; + docsEl.textContent = "-"; + } } -async function loadPerson() { +async function resolveInitialPersonId() { const params = new URLSearchParams(window.location.search); - const personId = Number(params.get("id")) || 1; + const fromQuery = Number(params.get("id")); + if (Number.isInteger(fromQuery) && fromQuery > 0) { + return fromQuery; + } + + try { + const response = await fetch(`${API_BASE}/api/persons?limit=1&offset=0`); + if (!response.ok) { + return null; + } + const people = await response.json(); + if (!Array.isArray(people) || people.length === 0) { + return null; + } + return Number(people[0].id) || null; + } catch (_err) { + return null; + } +} + +async function loadPerson(personId) { + if (!personId) { + showNoDataState(); + return; + } + const response = await fetch(`${API_BASE}/api/person/${personId}`); + if (response.status === 404) { + showNoDataState(); + return; + } if (!response.ok) { - throw new Error(`HTTP ${response.status}`); + showNoDataState(); + return; } const data = await response.json(); renderPerson(data); @@ -110,7 +181,8 @@ async function searchPerson() { const input = document.getElementById("searchInput"); const query = (input.value || "").trim(); if (!query) { - await loadPerson(); + const personId = await resolveInitialPersonId(); + await loadPerson(personId); return; } @@ -121,7 +193,7 @@ async function searchPerson() { const results = await response.json(); if (!results.length) { - alert("Ничего не найдено"); + showNoDataState(); return; } @@ -129,7 +201,35 @@ async function searchPerson() { const url = new URL(window.location.href); url.searchParams.set("id", String(person.id)); history.replaceState(null, "", url.toString()); - await loadPerson(); + await loadPerson(Number(person.id)); +} + +function bindCardAction(element, action) { + if (!element) return; + element.addEventListener("click", action); + element.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + action(); + } + }); +} + +function setupHeroCards() { + const searchInput = document.getElementById("searchInput"); + + bindCardAction(document.getElementById("heroFindCard"), () => { + searchInput.focus(); + searchInput.scrollIntoView({ behavior: "smooth", block: "center" }); + }); + + bindCardAction(document.getElementById("heroSuggestCard"), () => { + window.location.href = "suggestions.html"; + }); + + bindCardAction(document.getElementById("heroHelpCard"), () => { + window.location.href = "contacts.html"; + }); } document.addEventListener("DOMContentLoaded", () => { @@ -137,24 +237,16 @@ document.addEventListener("DOMContentLoaded", () => { const input = document.getElementById("searchInput"); btn.addEventListener("click", async () => { - try { - await searchPerson(); - } catch (err) { - showLoadError(`Поиск недоступен: ${err.message}`); - } + await searchPerson(); }); input.addEventListener("keydown", async (event) => { if (event.key === "Enter") { - try { - await searchPerson(); - } catch (err) { - showLoadError(`Поиск недоступен: ${err.message}`); - } + await searchPerson(); } }); - loadPerson().catch((err) => { - showLoadError(err.message); - }); + setupHeroCards(); + loadStats(); + resolveInitialPersonId().then((personId) => loadPerson(personId)); }); diff --git a/front/style.css b/front/style.css index 41320bd..fa96080 100644 --- a/front/style.css +++ b/front/style.css @@ -138,6 +138,102 @@ img { padding-bottom: 2px; } +.global-auth-controls { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.global-auth-controls button { + border: 1px solid #c8b99a; + background: #d7ccb2; + color: #3b2a18; + border-radius: 4px; + padding: 8px 12px; + font-weight: bold; + cursor: pointer; +} + +.global-auth-controls button:hover { + background: #e7ddc7; +} + +.global-auth-modal { + position: fixed; + inset: 0; + background: rgba(20, 12, 6, 0.5); + display: none; + align-items: center; + justify-content: center; + padding: 16px; + z-index: 400; +} + +.global-auth-modal.open { + display: flex; +} + +.global-auth-dialog { + width: min(420px, 96vw); + background: #fefcf8; + border: 1px solid #d8ccb3; + border-radius: 8px; + box-shadow: 0 14px 38px rgba(0, 0, 0, 0.3); + padding: 14px; +} + +.global-auth-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.global-auth-close { + border: 1px solid #d8ccb3; + background: #fff; + color: #5a1a1a; + border-radius: 4px; + width: 30px; + height: 30px; + cursor: pointer; +} + +.global-auth-fields { + display: grid; + gap: 8px; +} + +.global-auth-fields input { + border: 1px solid #c8b99a; + border-radius: 4px; + padding: 9px 10px; + font-size: 14px; +} + +.global-auth-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.global-auth-actions button { + border: 1px solid #5a1a1a; + background: #7a2020; + color: #fff; + border-radius: 4px; + padding: 8px 10px; + cursor: pointer; +} + +#globalAuthStatus { + margin-top: 10px; + font-size: 13px; + color: #5a4b36; +} + /* ===== HERO ===== */ .hero { background: linear-gradient(180deg, #e8e2d4 0%, #f4f1ea 100%); @@ -163,6 +259,10 @@ img { transition: transform 0.2s, box-shadow 0.2s; } +.hero-card[role="button"] { + cursor: pointer; +} + .hero-card:hover { transform: translateY(-3px); box-shadow: 0 6px 16px rgba(0,0,0,0.1); @@ -193,6 +293,15 @@ img { gap: 30px; } +.main-wrapper.page-wide { + max-width: 1320px; + grid-template-columns: 1fr; +} + +.main-wrapper.page-wide .admin-form-grid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + /* ===== CONTENT ===== */ .content { min-width: 0; @@ -517,64 +626,9 @@ img { max-width: 100%; width: 100%; } -} - -/* ===== LANGUAGE SWITCHER ===== */ -.lang-switcher { - position: fixed; - top: 80px; - right: 20px; - z-index: 999; - background: rgba(43, 43, 43, 0.95); - border-radius: 8px; - padding: 6px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); - display: flex; - gap: 4px; - backdrop-filter: blur(10px); -} - -.lang-btn { - background: transparent; - border: 2px solid transparent; - color: #e8e4dc; - font-size: 13px; - font-weight: 600; - padding: 6px 12px; - cursor: pointer; - border-radius: 5px; - transition: all 0.3s ease; - font-family: 'Courier New', monospace; - letter-spacing: 0.5px; -} - -.lang-btn:hover { - background: rgba(232, 228, 220, 0.1); - border-color: rgba(232, 228, 220, 0.3); - transform: translateY(-1px); -} - -.lang-btn.active { - background: linear-gradient(135deg, #7a2020, #5a1a1a); - border-color: #a03030; - box-shadow: 0 2px 8px rgba(122, 32, 32, 0.4); - color: #ffffff; -} - -.lang-btn:active { - transform: translateY(0); -} - -/* Responsive */ -@media (max-width: 768px) { - .lang-switcher { - top: 10px; - right: 10px; - padding: 4px; - } - - .lang-btn { - font-size: 11px; - padding: 5px 8px; + .global-auth-controls { + width: 100%; + justify-content: flex-end; } } + diff --git a/front/suggestions-script.js b/front/suggestions-script.js index 60acb76..7642715 100644 --- a/front/suggestions-script.js +++ b/front/suggestions-script.js @@ -1,4 +1,6 @@ let currentUser = null; +const MAX_IMAGE_BYTES = 2 * 1024 * 1024; +const MAX_DOCUMENT_BYTES = 3 * 1024 * 1024; function resolveApiBase() { const params = new URLSearchParams(window.location.search); @@ -39,110 +41,106 @@ function readText(id) { } async function checkSession() { - const response = await apiFetch("/me"); - if (!response.ok) { - currentUser = null; - setStatus("Не авторизован"); + if (window.SiteAuth?.refreshSession) { + await window.SiteAuth.refreshSession(); + currentUser = window.SiteAuth.getCurrentUser(); + } else { + const response = await apiFetch("/me"); + if (!response.ok) { + currentUser = null; + } else { + currentUser = await response.json(); + } + } + + if (!currentUser) { + setStatus("Не авторизован. Нажмите \"Зайти\" в шапке."); return; } - currentUser = await response.json(); setStatus(`Вход выполнен: ${currentUser.username} (${currentUser.role})`); } -async function registerUser() { - const username = readText("userLogin"); - const password = readText("userPassword"); - if (!username || !password) { - setStatus("Введите логин и пароль"); +async function sendSuggestion() { + if (!currentUser) { + setStatus("Сначала войдите в аккаунт"); return; } - const response = await apiFetch("/register", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username, password }) - }); + const suggestionKind = (readText("suggestionKind") || "create").toLowerCase(); + const targetPersonId = parseYear("targetPersonId"); + const documentText = readText("documentText"); - if (!response.ok) { - const body = await response.json().catch(() => ({})); - setStatus(`Ошибка регистрации: ${body.detail || response.status}`); + const fullName = readText("fullName"); + const birthYear = parseYear("birthYear"); + if (suggestionKind !== "document" && (!fullName || !birthYear)) { + setStatus("ФИО и год рождения обязательны"); return; } - setStatus("Регистрация успешна. Выполните вход."); -} - -async function loginUser() { - const username = readText("userLogin"); - const password = readText("userPassword"); - if (!username || !password) { - setStatus("Введите логин и пароль"); + if ((suggestionKind === "update" || suggestionKind === "document") && !targetPersonId) { + setStatus("Для update/document укажите ID существующей записи"); return; } - const body = new URLSearchParams(); - body.set("username", username); - body.set("password", password); - - const response = await apiFetch("/login", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: body.toString() - }); - - if (!response.ok) { - const payload = await response.json().catch(() => ({})); - setStatus(`Ошибка входа: ${payload.detail || response.status}`); + const photoInput = document.getElementById("photoFile"); + const photo = photoInput?.files?.[0] || null; + if (photo && photo.size >= MAX_IMAGE_BYTES) { + setStatus("Размер изображения должен быть меньше 2 МБ"); return; } - await checkSession(); - await loadMySuggestions(); -} + const documentInput = document.getElementById("documentFile"); + const documentFile = documentInput?.files?.[0] || null; + if (documentFile && documentFile.size >= MAX_DOCUMENT_BYTES) { + setStatus("Размер документа должен быть меньше 3 МБ"); + return; + } -async function logoutUser() { - await apiFetch("/logout", { method: "POST" }); - currentUser = null; - setStatus("Вы вышли"); - document.getElementById("mySuggestionsList").innerHTML = ""; -} + if (documentFile) { + const name = (documentFile.name || "").toLowerCase(); + if (!(name.endsWith(".txt") || name.endsWith(".md") || name.endsWith(".markdown"))) { + setStatus("Документ должен быть в формате .txt или .md"); + return; + } + } -async function sendSuggestion() { - if (!currentUser) { - setStatus("Сначала войдите в аккаунт"); + if (suggestionKind === "document" && !documentText && !documentFile) { + setStatus("Для document-режима добавьте текст или файл .txt/.md"); return; } - const fullName = readText("fullName"); - const birthYear = parseYear("birthYear"); - if (!fullName || !birthYear) { - setStatus("ФИО и год рождения обязательны"); - return; + const form = new FormData(); + form.set("suggestion_kind", suggestionKind); + form.set("full_name", fullName || `Документ к записи #${targetPersonId || "?"}`); + form.set("birth_year", String(birthYear || 1900)); + form.set("death_year", String(parseYear("deathYear") || "")); + form.set("nationality", readText("nationality") || ""); + form.set("region", readText("region") || ""); + form.set("district", readText("district") || ""); + form.set("occupation", readText("occupation") || ""); + form.set("charge", readText("charge") || ""); + form.set("sentence", readText("sentence") || ""); + form.set("arrest_date", readText("arrestDate") || ""); + form.set("sentence_date", readText("sentenceDate") || ""); + form.set("rehabilitation_date", readText("rehabilitationDate") || ""); + form.set("biography", readText("biography") || ""); + form.set("document_text", documentText || ""); + form.set("source", readText("source") || ""); + form.set("status", readText("status") || ""); + if (targetPersonId) { + form.set("target_person_id", String(targetPersonId)); + } + if (photo) { + form.set("photo", photo); + } + if (documentFile) { + form.set("document_file", documentFile); } - const payload = { - full_name: fullName, - birth_year: birthYear, - death_year: parseYear("deathYear"), - nationality: readText("nationality"), - region: readText("region"), - district: readText("district"), - occupation: readText("occupation"), - charge: readText("charge"), - sentence: readText("sentence"), - arrest_date: readText("arrestDate"), - sentence_date: readText("sentenceDate"), - rehabilitation_date: readText("rehabilitationDate"), - biography: readText("biography") || "", - source: readText("source"), - status: readText("status") - }; - - const response = await apiFetch("/suggestions", { + const response = await apiFetch("/suggestions/with-photo", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload) + body: form }); if (!response.ok) { @@ -152,10 +150,43 @@ async function sendSuggestion() { } const created = await response.json(); - setStatus(`Предложение отправлено: #${created.id}`); + setStatus(`Предложение отправлено: #${created.id} (${created.suggestion_kind})`); + if (photoInput) { + photoInput.value = ""; + } + if (documentInput) { + documentInput.value = ""; + } await loadMySuggestions(); } +async function findExistingPerson() { + const query = readText("existingSearch"); + const resultEl = document.getElementById("existingSearchResult"); + if (!query) { + resultEl.textContent = "Введите текст для поиска записи"; + return; + } + + const response = await apiFetch(`/api/persons/search?q=${encodeURIComponent(query)}&limit=5`); + if (!response.ok) { + resultEl.textContent = "Не удалось выполнить поиск"; + return; + } + + const items = await response.json(); + if (!items.length) { + resultEl.textContent = "Ничего не найдено"; + return; + } + + const first = items[0]; + document.getElementById("targetPersonId").value = String(first.id); + document.getElementById("fullName").value = first.full_name || ""; + document.getElementById("birthYear").value = String(first.birth_year || ""); + resultEl.textContent = `Найдено: ${first.full_name} (#${first.id}). ID подставлен.`; +} + async function loadMySuggestions() { if (!currentUser) { document.getElementById("mySuggestionsList").innerHTML = "Войдите для просмотра"; @@ -180,18 +211,30 @@ async function loadMySuggestions() { items.forEach((item) => { const row = document.createElement("div"); row.className = "admin-card-row"; - row.innerHTML = `${item.full_name} (${item.birth_year}) #${item.id}
state: ${item.state}${item.moderation_comment ? ` | ${item.moderation_comment}` : ""}
`; + const photoLabel = item.photo_url ? ` | фото: есть` : " | фото: placeholder"; + const docLabel = item.document_filename ? ` | doc: ${item.document_filename}` : (item.document_text ? " | doc: text" : ""); + row.innerHTML = `${item.full_name} (${item.birth_year}) #${item.id}
mode: ${item.suggestion_kind}${item.target_person_id ? ` | target: #${item.target_person_id}` : ""} | state: ${item.state}${item.moderation_comment ? ` | ${item.moderation_comment}` : ""}${photoLabel}${docLabel}
`; container.appendChild(row); }); } document.addEventListener("DOMContentLoaded", async () => { - document.getElementById("registerBtn").addEventListener("click", registerUser); - document.getElementById("loginBtn").addEventListener("click", loginUser); - document.getElementById("logoutBtn").addEventListener("click", logoutUser); + document.getElementById("findExistingBtn").addEventListener("click", findExistingPerson); document.getElementById("sendSuggestionBtn").addEventListener("click", sendSuggestion); document.getElementById("loadMySuggestionsBtn").addEventListener("click", loadMySuggestions); + window.addEventListener("site-auth-changed", async (event) => { + currentUser = event.detail?.user || null; + if (currentUser) { + setStatus(`Вход выполнен: ${currentUser.username} (${currentUser.role})`); + await loadMySuggestions(); + return; + } + + setStatus("Не авторизован. Нажмите \"Зайти\" в шапке."); + document.getElementById("mySuggestionsList").innerHTML = ""; + }); + await checkSession(); if (currentUser) { await loadMySuggestions(); diff --git a/front/suggestions.html b/front/suggestions.html index 376c1a2..232aba5 100644 --- a/front/suggestions.html +++ b/front/suggestions.html @@ -17,38 +17,31 @@
-
+

Экран предложений

Обычный пользователь может отправить предложение. После модерации администратором запись появится в базе.

- -
-

Авторизация

-
- - -
-
- - - -
-

Не авторизован

-
+

Не авторизован. Нажмите "Зайти" в шапке.

Новая предложка

+ + + + @@ -63,7 +56,14 @@

Новая предложка

+
+

Фото необязательно. Ограничение размера: меньше 2 МБ.

+

Документ к записи (необязательно)

+

Для режима "Документ" укажите ID существующей записи и добавьте текст или файл .txt/.md.

+ + +
@@ -85,6 +85,7 @@

Мои предложения

© 2024 Архив Памяти

+ From 9b7f1f7bf6e727d890f65ab233000a7206962f84 Mon Sep 17 00:00:00 2001 From: Dastan Medetbekov Date: Sat, 4 Apr 2026 06:50:21 +0600 Subject: [PATCH 06/41] Dastan --- AI_COMPONENTS.md | 268 --------- DEPLOYMENT_CHECKLIST.md | 126 ----- DEPLOY_SERVER.md | 97 ---- backend_python/main.py | 45 +- backend_python/rag_engine.py | 124 ++-- backend_python/routers/cards.py | 127 +++-- front/about-script.js | 25 +- front/about-style.css | 55 ++ front/about.html | 1 + front/admin-script.js | 155 ++++- front/admin.html | 31 + front/ai-chat.css | 59 ++ front/chat-script.js | 65 ++- front/chat.html | 1 + front/contacts.html | 1 + front/header-auth.js | 79 ++- front/i18n.js | 968 ++++++++++++++++++++++++++++++-- front/index.html | 1 + front/list-script.js | 33 +- front/list-style.css | 53 ++ front/list.html | 11 +- front/style.css | 208 +++++++ front/suggestions-script.js | 72 ++- front/suggestions.html | 1 + 24 files changed, 1900 insertions(+), 706 deletions(-) delete mode 100644 AI_COMPONENTS.md delete mode 100644 DEPLOYMENT_CHECKLIST.md delete mode 100644 DEPLOY_SERVER.md diff --git a/AI_COMPONENTS.md b/AI_COMPONENTS.md deleted file mode 100644 index c5c3c57..0000000 --- a/AI_COMPONENTS.md +++ /dev/null @@ -1,268 +0,0 @@ -# 🧠 AI Компоненты проекта "Архив Памяти" - -## 📁 Основные файлы - -### 1. `backend_python/rag_engine.py` ⭐ ГЛАВНЫЙ AI ФАЙЛ -**Что делает:** Ядро RAG (Retrieval-Augmented Generation) системы - -**Ключевые функции:** -- `add_documents_to_vector_db()` - добавление документов в векторную БД -- `search_documents()` - семантический поиск по документам -- `generate_answer()` - генерация ответа через Gemini -- `answer_with_rag()` - полный RAG pipeline - -**AI модели:** -```python -# Embeddings (векторизация текста) -GoogleGenerativeAIEmbeddings( - model="models/embedding-001", - google_api_key=GEMINI_API_KEY -) - -# LLM для генерации ответов -ChatGoogleGenerativeAI( - model="gemini-1.5-flash", - temperature=0.3, # Низкая = точнее, высокая = креативнее - google_api_key=GEMINI_API_KEY -) -``` - -**System Prompt (поведение AI):** -``` -Отвечай ТОЛЬКО по контексту. -Данные могут быть выдуманными, не используй знания из интернета. -Если в контексте нет ответа, прямо скажи, что информации недостаточно. -``` - ---- - -### 2. `backend_python/routers/rag.py` -**Что делает:** API endpoints для RAG функционала - -**Endpoints:** -- `POST /upload_document` - загрузка и векторизация документа -- `POST /chat` - чат с RAG системой -- `GET /chat/history` - история чата пользователя - -**Флоу загрузки документа:** -``` -1. Получение файла (.txt/.md) -2. → Отправка в C++ для обработки -3. → Получение чанков -4. → Векторизация через Gemini -5. → Сохранение в ChromaDB + PostgreSQL -``` - ---- - -### 3. `backend_cpp/main.cpp` -**Что делает:** Предобработка текста (не AI, но критично для качества RAG) - -**Функции:** -- `is_garbage()` - фильтрация мусорных текстов -- `clean_markdown()` - очистка markdown разметки -- `chunk_text()` - разбивка на куски с overlap - -**Параметры чанкинга:** -```cpp -chunk_size = 1000 // Размер куска в символах -overlap = 100 // Overlap между кусками -``` - ---- - -## 🔄 End-to-End поток данных - -### Загрузка документа: -``` -User (Frontend) - ↓ POST /upload_document + file -Python Backend (rag.py) - ↓ POST /process {"text": "..."} -C++ Backend (main.cpp) - ↓ {"chunks": [...], "is_garbage": false} -rag_engine.py - ↓ embeddings.embed_documents(chunks) -ChromaDB - ↓ vectors + metadata -PostgreSQL - ↓ metadata (document_id, person_id, etc.) -✓ Done -``` - -### Поиск/Чат: -``` -User query: "Где служил Иванов?" - ↓ POST /chat -rag_engine.search_documents(query) - ↓ embeddings.embed_query(query) -ChromaDB - ↓ top_k похожих chunks (vector search) -rag_engine.generate_answer(query, chunks) - ↓ Gemini LLM -Response: "Иванов служил в..." + sources: [person_id_1, ...] -``` - ---- - -## 📦 Зависимости (requirements.txt) - -```txt -# AI/ML Core -langchain==0.3.28 -langchain-google-genai==2.0.8 # ⭐ Gemini API -langchain-community==0.3.31 -chromadb==0.5.23 # Vector DB -``` - ---- - -## 🔑 Переменные окружения - -```bash -# Обязательно для AI: -GEMINI_API_KEY=your_key_here # Google Gemini API - -# ChromaDB: -CHROMA_HOST=vector_db # Hostname -CHROMA_PORT=8000 # Port -CHROMA_PATH=/app/chroma_db # Fallback local path -CHROMA_COLLECTION=documents # Collection name -``` - ---- - -## 🛠️ Как модифицировать - -### Изменить модель Gemini: -**Файл:** `backend_python/rag_engine.py:42` -```python -# Было: -model="gemini-1.5-flash" - -# Опции: -model="gemini-1.5-pro" # Более точная, дороже -model="gemini-2.0-flash" # Новейшая версия -``` - -### Настроить "креативность" ответов: -**Файл:** `backend_python/rag_engine.py:42` -```python -temperature=0.3 # Консервативные ответы (рекомендуется для архива) -temperature=0.7 # Более креативные ответы -``` - -### Изменить размер чанков: -**Файл:** `backend_cpp/main.cpp:111` -```cpp -chunk_text(cleaned, 1000, 100) // size=1000, overlap=100 - -// Меньше чанки = точнее поиск, но больше запросов -chunk_text(cleaned, 500, 50) - -// Больше чанки = быстрее, но менее точно -chunk_text(cleaned, 2000, 200) -``` - -### Изменить системный промпт: -**Файл:** `backend_python/rag_engine.py:116-120` -```python -system_prompt = ( - "Отвечай ТОЛЬКО по контексту. " - "Данные могут быть выдуманными, не используй знания из интернета. " - "Если в контексте нет ответа, прямо скажи, что информации недостаточно." -) -``` - -### Добавить больше контекста в поиск: -**Файл:** `backend_python/routers/rag.py:135` -```python -# Было: -rag_result = answer_with_rag(query, top_k=3) - -# Больше контекста (медленнее, но полнее): -rag_result = answer_with_rag(query, top_k=5) -``` - ---- - -## ⚠️ Fallback механизмы - -### При недоступности Gemini API: -**Файл:** `backend_python/rag_engine.py:128-132` - -Автоматически возвращает найденный контекст напрямую: -```python -"⚠️ AI-сервис временно недоступен. Найденная информация:\n\n" -"{первые 500 символов контекста}" -``` - -### При ошибке в чате: -**Файл:** `backend_python/routers/rag.py:146-152` - -Graceful degradation: -- ValueError → 503 "RAG service unavailable" -- RuntimeError → Fallback ответ -- Exception → "Произошла ошибка" - ---- - -## 📊 Метрики качества RAG - -### Что влияет на качество ответов: - -1. **Качество чанкинга** (C++ backend) - - Размер чанков: 1000 символов - - Overlap: 100 символов - - Очистка markdown - -2. **Качество embeddings** - - Модель: `models/embedding-001` - - Язык: русский (Gemini хорошо знает) - -3. **Количество контекста** - - top_k=3 по умолчанию - - Можно увеличить до 5-7 - -4. **Temperature LLM** - - 0.3 = точные ответы по фактам - - 0.7 = более развернутые, креативные - ---- - -## 🧪 Тестирование AI - -### Ручной тест загрузки: -```bash -curl -X POST http://localhost:8000/upload_document \ - -H "Authorization: Bearer YOUR_TOKEN" \ - -F "file=@test.txt" \ - -F "person_id=1" -``` - -### Ручной тест чата: -```bash -curl -X POST http://localhost:8000/chat \ - -H "Authorization: Bearer YOUR_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"query":"Где родился Иванов?"}' -``` - -### Проверка ChromaDB: -```bash -curl http://localhost:8001/api/v1/collections -``` - ---- - -## 🎯 Рекомендации для защиты проекта - -1. **Подготовьте демо-данные** - загрузите 5-10 тестовых документов -2. **Протестируйте разные запросы** - подготовьте список вопросов -3. **Имейте fallback** - система работает даже без Gemini API -4. **Объясните архитектуру** - RAG = Retrieval + Generation -5. **Покажите логи** - докажите что AI реально работает - ---- - -**Статус:** ✅ Все AI компоненты настроены и готовы к работе diff --git a/DEPLOYMENT_CHECKLIST.md b/DEPLOYMENT_CHECKLIST.md deleted file mode 100644 index e5fd036..0000000 --- a/DEPLOYMENT_CHECKLIST.md +++ /dev/null @@ -1,126 +0,0 @@ -# ✅ Deployment Checklist - -## Все проблемы из анализа уже исправлены! - -### 1. ✅ C++ Health Endpoint -- **Проблема**: Отсутствовал GET /health для healthcheck -- **Решение**: Добавлен в `backend_cpp/main.cpp:146-148` -- **Коммит**: f50208a - -```cpp -server.Get("/health", [](const httplib::Request&, httplib::Response& res) { - res.set_content(json{{"status", "ok"}}.dump(), "application/json"); -}); -``` - -### 2. ✅ OpenAI → Gemini Migration -- **Проблема**: RAG использовал OpenAI, но передавался GEMINI_API_KEY -- **Решение**: Полная миграция на Google Gemini -- **Коммит**: f50208a - -**Изменения:** -- `langchain_openai` → `langchain_google_genai` -- `OpenAIEmbeddings` → `GoogleGenerativeAIEmbeddings(model="models/embedding-001")` -- `ChatOpenAI` → `ChatGoogleGenerativeAI(model="gemini-1.5-flash")` -- `OPENAI_API_KEY` → `GEMINI_API_KEY` во всех проверках -- Добавлен пакет: `langchain-google-genai==2.0.8` - -### 3. ✅ Frontend Environment Variables -- **Проблема**: Хардкод URL в frontend_python/app.py -- **Решение**: Чтение из environment -- **Коммит**: f50208a - -```python -BACKEND_URL = os.getenv("BACKEND_URL", "http://python_backend:8000") -``` - -### 4. ✅ Database Credentials Sync -- **Проблема**: database.py использовал archive:archive, docker-compose - hackathon:hackathon -- **Решение**: Синхронизированы credentials -- **Коммит**: 147e78e - -```python -DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://hackathon:hackathon@db:5432/hackathon") -``` - -### 5. ✅ Test Configuration -- **Проблема**: Тесты ожидали OPENAI_API_KEY -- **Решение**: Обновлён conftest.py -- **Коммит**: 147e78e - ---- - -## 🚀 Запуск проекта - -### Предварительные требования -1. Docker и Docker Compose установлены -2. Получен GEMINI_API_KEY от Google - -### Шаги запуска - -1. **Создайте .env файл:** -```bash -cat > .env << 'ENVFILE' -GEMINI_API_KEY=your_gemini_api_key_here -SECRET_KEY=your_secret_key_or_leave_empty_for_autogen -ENVFILE -``` - -2. **Запустите сервисы:** -```bash -docker-compose up --build -``` - -3. **Откройте в браузере:** -- Frontend (Streamlit): http://localhost:8501 -- Backend API: http://localhost:8000 -- C++ Backend: http://localhost:8080 -- ChromaDB: http://localhost:8001 - ---- - -## 📦 Архитектура - -``` -┌─────────────┐ -│ Frontend │ :8501 -│ (Streamlit) │ -└──────┬──────┘ - │ depends_on - ↓ -┌─────────────┐ ┌─────────────┐ -│ Python │────→│ C++ │ :8080 -│ Backend │ :8000│ Backend │ -└──────┬──────┘ └─────────────┘ - │ - ├────→ PostgreSQL :5432 - └────→ ChromaDB :8001 -``` - ---- - -## ⚠️ Важные замечания - -1. **GEMINI_API_KEY обязателен** для работы RAG (chat, document search) -2. Без ключа система запустится, но функции AI будут отдавать 503 -3. SECRET_KEY автогенерируется при отсутствии, но токены не переживут рестарт -4. Healthcheck'и гарантируют правильный порядок запуска сервисов - ---- - -## 🔍 Проверка работоспособности - -После запуска проверьте healthcheck'и: -```bash -# C++ Backend -curl http://localhost:8080/health -# {"status":"ok"} - -# Python Backend -curl http://localhost:8000/health -# {"status":"ok","cpp_backend":"ok","persons":0,"documents":0,"chunks":0} -``` - ---- - -**Статус**: ✅ Все критические проблемы решены. Проект готов к развёртыванию. diff --git a/DEPLOY_SERVER.md b/DEPLOY_SERVER.md deleted file mode 100644 index cc9306e..0000000 --- a/DEPLOY_SERVER.md +++ /dev/null @@ -1,97 +0,0 @@ -# Ubuntu Server Deployment - -This guide provides a simple zip-based deployment flow with one start script. - -## One script - -- `scripts/start.sh` - builds and starts services from local files. - -## 1. Prepare server - -```bash -sudo apt-get update -sudo apt-get install -y docker.io docker-compose-plugin unzip -sudo usermod -aG docker $USER -``` - -Re-login, then check: - -```bash -docker --version -docker compose version -``` - -## 2. Upload and unpack zip - -```bash -mkdir -p ~/hackathon && cd ~/hackathon -unzip hackathon.zip -``` - -## 3. Configure environment - -```bash -cp .env.example .env -nano .env -``` - -Set at minimum: - -- `SECRET_KEY` -- `GEMINI_API_KEY` - -Recommended for persistent data outside project directory: - -- `DB_DATA_DIR=/srv/hackathon-data/postgres` -- `CHROMA_DATA_DIR=/srv/hackathon-data/chroma` -- `APP_DATA_DIR=/srv/hackathon-data/app` - -## 4. Start - -```bash -chmod +x scripts/*.sh -./scripts/start.sh -``` - -Check status: - -```bash -docker compose ps -curl http://localhost:8000/health -``` - -## 5. Update with new zip - -1. Replace project files with new zip contents. -2. Run again: - -```bash -./scripts/start.sh -``` - -## Useful operations - -View logs: - -```bash -docker compose logs -f python_backend -docker compose logs -f frontend -docker compose logs -f db -``` - -Restart services: - -```bash -docker compose restart -``` - -Stop stack without deleting data: - -```bash -docker compose down -``` - -## Important safety rules - -- Do not use `docker compose down -v` in production if you need to keep DB data. -- Keep `.env` only on server; do not commit secrets. diff --git a/backend_python/main.py b/backend_python/main.py index 3ec417b..237d70f 100644 --- a/backend_python/main.py +++ b/backend_python/main.py @@ -5,6 +5,7 @@ from fastapi import FastAPI, Depends, File, Form, HTTPException, UploadFile, status from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel from sqlalchemy import func from sqlalchemy.orm import Session import httpx @@ -12,7 +13,7 @@ from database import init_db, SessionLocal, PersonCard, Document, DocumentChunk, get_db, User from auth import require_admin from routers import auth_router, cards, rag, suggestions -from rag_engine import add_documents_to_vector_db +from rag_engine import add_documents_to_vector_db, get_runtime_config, update_runtime_config def _parse_cors_origins() -> list[str]: @@ -45,6 +46,18 @@ def _parse_cors_origins() -> list[str]: app.mount("/uploads", StaticFiles(directory=str(UPLOADS_DIR)), name="uploads") +class AIRuntimeConfigPatch(BaseModel): + gemini_api_key: str | None = None + openai_api_key: str | None = None + anthropic_api_key: str | None = None + rag_llm_provider: str | None = None + rag_embedding_provider: str | None = None + rag_gemini_model: str | None = None + rag_claude_model: str | None = None + rag_gemini_embedding_model: str | None = None + rag_openai_embedding_model: str | None = None + + @app.on_event("startup") def startup_event() -> None: init_db() @@ -60,6 +73,36 @@ def root() -> dict: return {"service": "backend_python", "status": "ok"} +@app.get("/admin/ai/runtime-config") +def admin_get_ai_runtime_config(current_user: User = Depends(require_admin)) -> dict: + return { + "config": get_runtime_config(mask_secrets=True), + "allowed_llm_providers": ["gemini", "claude"], + "allowed_embedding_providers": ["gemini", "openai"], + } + + +@app.post("/admin/ai/runtime-config") +def admin_update_ai_runtime_config( + payload: AIRuntimeConfigPatch, + current_user: User = Depends(require_admin), +) -> dict: + updates = payload.model_dump(exclude_unset=True) + + if "rag_llm_provider" in updates: + updates["rag_llm_provider"] = (updates["rag_llm_provider"] or "").strip().lower() + if updates["rag_llm_provider"] not in {"gemini", "claude"}: + raise HTTPException(status_code=400, detail="rag_llm_provider must be one of: gemini, claude") + + if "rag_embedding_provider" in updates: + updates["rag_embedding_provider"] = (updates["rag_embedding_provider"] or "").strip().lower() + if updates["rag_embedding_provider"] not in {"gemini", "openai"}: + raise HTTPException(status_code=400, detail="rag_embedding_provider must be one of: gemini, openai") + + effective = update_runtime_config(updates) + return {"message": "AI runtime config updated", "config": effective} + + @app.get("/health") async def health() -> dict: cpp_ok = False diff --git a/backend_python/rag_engine.py b/backend_python/rag_engine.py index 474e6a6..9c9bc2d 100644 --- a/backend_python/rag_engine.py +++ b/backend_python/rag_engine.py @@ -8,26 +8,77 @@ from langchain_google_genai import ChatGoogleGenerativeAI, GoogleGenerativeAIEmbeddings from langchain_openai import OpenAIEmbeddings -GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "") -OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "") -ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "") -RAG_LLM_PROVIDER = (os.getenv("RAG_LLM_PROVIDER", "gemini") or "gemini").strip().lower() -RAG_EMBEDDING_PROVIDER = (os.getenv("RAG_EMBEDDING_PROVIDER", "gemini") or "gemini").strip().lower() -RAG_GEMINI_MODEL = os.getenv("RAG_GEMINI_MODEL", "gemini-1.5-flash") -RAG_CLAUDE_MODEL = os.getenv("RAG_CLAUDE_MODEL", "claude-3-5-sonnet-20240620") -RAG_GEMINI_EMBEDDING_MODEL = os.getenv("RAG_GEMINI_EMBEDDING_MODEL", "models/embedding-001") -RAG_OPENAI_EMBEDDING_MODEL = os.getenv("RAG_OPENAI_EMBEDDING_MODEL", "text-embedding-3-large") -CHROMA_PATH = os.getenv("CHROMA_PATH", "/app/chroma_db") -CHROMA_HOST = os.getenv("CHROMA_HOST", "") -CHROMA_PORT = int(os.getenv("CHROMA_PORT", "8000")) -CHROMA_COLLECTION = os.getenv("CHROMA_COLLECTION", "documents") +RUNTIME_ENV_KEYS = { + "gemini_api_key": "GEMINI_API_KEY", + "openai_api_key": "OPENAI_API_KEY", + "anthropic_api_key": "ANTHROPIC_API_KEY", + "rag_llm_provider": "RAG_LLM_PROVIDER", + "rag_embedding_provider": "RAG_EMBEDDING_PROVIDER", + "rag_gemini_model": "RAG_GEMINI_MODEL", + "rag_claude_model": "RAG_CLAUDE_MODEL", + "rag_gemini_embedding_model": "RAG_GEMINI_EMBEDDING_MODEL", + "rag_openai_embedding_model": "RAG_OPENAI_EMBEDDING_MODEL", +} + + +def get_runtime_config(mask_secrets: bool = False) -> Dict[str, Any]: + config: Dict[str, Any] = { + "gemini_api_key": os.getenv("GEMINI_API_KEY", ""), + "openai_api_key": os.getenv("OPENAI_API_KEY", ""), + "anthropic_api_key": os.getenv("ANTHROPIC_API_KEY", ""), + "rag_llm_provider": (os.getenv("RAG_LLM_PROVIDER", "gemini") or "gemini").strip().lower(), + "rag_embedding_provider": (os.getenv("RAG_EMBEDDING_PROVIDER", "gemini") or "gemini").strip().lower(), + "rag_gemini_model": os.getenv("RAG_GEMINI_MODEL", "gemini-1.5-flash"), + "rag_claude_model": os.getenv("RAG_CLAUDE_MODEL", "claude-3-5-sonnet-20240620"), + "rag_gemini_embedding_model": os.getenv("RAG_GEMINI_EMBEDDING_MODEL", "models/embedding-001"), + "rag_openai_embedding_model": os.getenv("RAG_OPENAI_EMBEDDING_MODEL", "text-embedding-3-large"), + } + + if not mask_secrets: + return config + + def mask(value: str) -> str: + if not value: + return "" + if len(value) <= 6: + return "*" * len(value) + return f"{value[:3]}{'*' * (len(value) - 6)}{value[-3:]}" + + masked = dict(config) + masked["gemini_api_key"] = mask(config["gemini_api_key"]) + masked["openai_api_key"] = mask(config["openai_api_key"]) + masked["anthropic_api_key"] = mask(config["anthropic_api_key"]) + return masked + + +def _reset_runtime_clients() -> None: + global _embeddings, _llm + _embeddings = None + _llm = None + + +def update_runtime_config(updates: Dict[str, Any]) -> Dict[str, Any]: + for key, env_key in RUNTIME_ENV_KEYS.items(): + if key not in updates: + continue + value = updates[key] + if value is None: + continue + value_str = str(value).strip() + os.environ[env_key] = value_str + + _reset_runtime_clients() + return get_runtime_config(mask_secrets=True) def _build_chroma_client() -> chromadb.ClientAPI: + chroma_path = os.getenv("CHROMA_PATH", "/app/chroma_db") + chroma_host = os.getenv("CHROMA_HOST", "") + chroma_port = int(os.getenv("CHROMA_PORT", "8000")) try: - if CHROMA_HOST: - return chromadb.HttpClient(host=CHROMA_HOST, port=CHROMA_PORT) - return chromadb.PersistentClient(path=CHROMA_PATH) + if chroma_host: + return chromadb.HttpClient(host=chroma_host, port=chroma_port) + return chromadb.PersistentClient(path=chroma_path) except Exception as exc: raise RuntimeError(f"Failed to initialize Chroma client: {exc}") from exc @@ -44,36 +95,39 @@ def _get_client() -> chromadb.ClientAPI: def _get_collection() -> Any: + chroma_collection = os.getenv("CHROMA_COLLECTION", "documents") try: global _collection if _collection is None: - _collection = _get_client().get_or_create_collection(name=CHROMA_COLLECTION) + _collection = _get_client().get_or_create_collection(name=chroma_collection) return _collection except Exception as exc: - raise RuntimeError(f"Failed to access Chroma collection '{CHROMA_COLLECTION}': {exc}") from exc + raise RuntimeError(f"Failed to access Chroma collection '{chroma_collection}': {exc}") from exc _embeddings: Any | None = None def _build_embeddings() -> Any: - if RAG_EMBEDDING_PROVIDER == "openai": - if not OPENAI_API_KEY: + cfg = get_runtime_config(mask_secrets=False) + + if cfg["rag_embedding_provider"] == "openai": + if not cfg["openai_api_key"]: raise ValueError("OPENAI_API_KEY is not configured") return OpenAIEmbeddings( - model=RAG_OPENAI_EMBEDDING_MODEL, - openai_api_key=OPENAI_API_KEY, + model=cfg["rag_openai_embedding_model"], + openai_api_key=cfg["openai_api_key"], ) - if RAG_EMBEDDING_PROVIDER == "gemini": - if not GEMINI_API_KEY: + if cfg["rag_embedding_provider"] == "gemini": + if not cfg["gemini_api_key"]: raise ValueError("GEMINI_API_KEY is not configured") return GoogleGenerativeAIEmbeddings( - model=RAG_GEMINI_EMBEDDING_MODEL, - google_api_key=GEMINI_API_KEY, + model=cfg["rag_gemini_embedding_model"], + google_api_key=cfg["gemini_api_key"], ) - raise ValueError(f"Unsupported RAG_EMBEDDING_PROVIDER: {RAG_EMBEDDING_PROVIDER}") + raise ValueError(f"Unsupported RAG_EMBEDDING_PROVIDER: {cfg['rag_embedding_provider']}") def _get_embeddings() -> Any: @@ -84,8 +138,10 @@ def _get_embeddings() -> Any: def _build_llm() -> Any: - if RAG_LLM_PROVIDER == "claude": - if not ANTHROPIC_API_KEY: + cfg = get_runtime_config(mask_secrets=False) + + if cfg["rag_llm_provider"] == "claude": + if not cfg["anthropic_api_key"]: raise ValueError("ANTHROPIC_API_KEY is not configured") try: from langchain_anthropic import ChatAnthropic @@ -94,18 +150,18 @@ def _build_llm() -> Any: "Claude provider requires 'langchain-anthropic'. Install dependency first" ) from exc return ChatAnthropic( - model=RAG_CLAUDE_MODEL, - anthropic_api_key=ANTHROPIC_API_KEY, + model=cfg["rag_claude_model"], + anthropic_api_key=cfg["anthropic_api_key"], temperature=0.3, ) # Default provider: Gemini - if not GEMINI_API_KEY: + if not cfg["gemini_api_key"]: raise ValueError("GEMINI_API_KEY is not configured") return ChatGoogleGenerativeAI( - model=RAG_GEMINI_MODEL, + model=cfg["rag_gemini_model"], temperature=0.3, - google_api_key=GEMINI_API_KEY, + google_api_key=cfg["gemini_api_key"], ) diff --git a/backend_python/routers/cards.py b/backend_python/routers/cards.py index e2c5265..4f4e25f 100644 --- a/backend_python/routers/cards.py +++ b/backend_python/routers/cards.py @@ -1,6 +1,7 @@ import json from collections import defaultdict from datetime import date +from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File from sqlalchemy import or_ @@ -87,6 +88,54 @@ def _normalize_card_payload(card_data: PersonCardCreate) -> dict: return payload +def _import_seed_rows(items: List[SeedPersonCard], db: Session) -> dict: + created = 0 + skipped_duplicates = 0 + seen_keys = set() + + for item in items: + key = ((item.full_name or "").strip().lower(), item.birth_year or 1900) + if key in seen_keys: + skipped_duplicates += 1 + continue + + duplicate = db.query(PersonCard).filter( + PersonCard.name.ilike(item.full_name), + PersonCard.birth_year == (item.birth_year or 1900) + ).first() + if duplicate: + skipped_duplicates += 1 + continue + + db.add(PersonCard( + name=item.full_name, + birth_year=item.birth_year or 1900, + death_year=item.death_year, + nationality=item.nationality, + region=item.region or "Unknown", + district=item.district, + category=item.occupation, + charge=item.charge or "Unknown", + sentence=item.sentence, + arrest_date=item.arrest_date, + sentence_date=item.sentence_date, + rehabilitation_date=item.rehabilitation_date, + description=item.biography or "", + source=item.source, + status=item.status, + lat=None, + lon=None, + )) + seen_keys.add(key) + created += 1 + + return { + "created": created, + "skipped_duplicates": skipped_duplicates, + "total": len(items), + } + + def _to_public_person(card: PersonCard) -> dict: return { "id": card.id, @@ -349,51 +398,53 @@ def import_seed_cards( db: Session = Depends(get_db), current_user: User = Depends(require_admin) ): - created = 0 - skipped_duplicates = 0 - seen_keys = set() + result = _import_seed_rows(cards_data, db) + db.commit() + return result - for item in cards_data: - key = (item.full_name.strip().lower(), item.birth_year) - if key in seen_keys: - skipped_duplicates += 1 - continue - duplicate = db.query(PersonCard).filter( - PersonCard.name.ilike(item.full_name), - PersonCard.birth_year == item.birth_year - ).first() - if duplicate: - skipped_duplicates += 1 +@router.post("/cards/import/seed/examples") +def import_seed_examples( + db: Session = Depends(get_db), + current_user: User = Depends(require_admin) +): + root = Path(__file__).resolve().parents[2] + candidate_files = [ + root / "asset" / "seed.json", + root / "asset" / "test_data" / "seed.json", + ] + + processed_files = [] + aggregate = { + "created": 0, + "skipped_duplicates": 0, + "total": 0, + } + + for seed_path in candidate_files: + if not seed_path.exists(): continue - db.add(PersonCard( - name=item.full_name, - birth_year=item.birth_year or 1900, - death_year=item.death_year, - nationality=item.nationality, - region=item.region, - district=item.district, - category=item.occupation, - charge=item.charge, - sentence=item.sentence, - arrest_date=item.arrest_date, - sentence_date=item.sentence_date, - rehabilitation_date=item.rehabilitation_date, - description=item.biography, - source=item.source, - status=item.status, - lat=None, - lon=None, - )) - seen_keys.add(key) - created += 1 + with seed_path.open("r", encoding="utf-8") as f: + raw = json.load(f) + + if not isinstance(raw, list): + raise HTTPException(status_code=400, detail=f"Expected JSON array in {seed_path}") + + items = [SeedPersonCard(**item) for item in raw] + result = _import_seed_rows(items, db) + aggregate["created"] += result["created"] + aggregate["skipped_duplicates"] += result["skipped_duplicates"] + aggregate["total"] += result["total"] + processed_files.append(str(seed_path.relative_to(root))) + + if not processed_files: + raise HTTPException(status_code=404, detail="No bundled seed files found") db.commit() return { - "created": created, - "skipped_duplicates": skipped_duplicates, - "total": len(cards_data) + **aggregate, + "files": processed_files, } diff --git a/front/about-script.js b/front/about-script.js index f618990..2f2d91f 100644 --- a/front/about-script.js +++ b/front/about-script.js @@ -1,6 +1,10 @@ const partnersGrid = document.getElementById("partners-grid"); const statsGrid = document.getElementById("stats-grid"); +function tr(key, fallback) { + return window.AppI18n?.t?.(key) || fallback; +} + function resolveApiBase() { const params = new URLSearchParams(window.location.search); const fromQuery = params.get("api"); @@ -15,12 +19,12 @@ const API_BASE = resolveApiBase(); function renderPartners() { if (!partnersGrid) return; const partners = [ - "Государственный архив РФ", - "Мемориал", - "Сахаровский центр", - "РГАСПИ", - "Яд Вашем", - "Национальный архив РК" + tr("about_partner_1", "State Archive of the Russian Federation"), + tr("about_partner_2", "Memorial"), + tr("about_partner_3", "Sakharov Center"), + tr("about_partner_4", "RGASPI"), + tr("about_partner_5", "Yad Vashem"), + tr("about_partner_6", "National Archive of the Kyrgyz Republic") ]; partnersGrid.innerHTML = ""; @@ -53,8 +57,8 @@ async function renderStats() { } const cards = [ - { n: persons, l: "Записей в базе" }, - { n: documents, l: "Документов в базе" } + { n: persons, l: tr("about_stats_records", "Records in database") }, + { n: documents, l: tr("about_stats_documents", "Documents in database") } ]; cards.forEach((stat) => { @@ -69,4 +73,9 @@ async function renderStats() { document.addEventListener("DOMContentLoaded", async () => { renderPartners(); await renderStats(); + + window.addEventListener("site-language-changed", async () => { + renderPartners(); + await renderStats(); + }); }); \ No newline at end of file diff --git a/front/about-style.css b/front/about-style.css index adda799..7b9b21b 100644 --- a/front/about-style.css +++ b/front/about-style.css @@ -95,4 +95,59 @@ .stat-label { font-size: 0.85rem; color: #666; +} + +@media (max-width: 900px) { + .main-wrapper { + margin: 20px auto; + gap: 18px; + padding: 0 12px; + grid-template-columns: 1fr; + } + + .about-title { + font-size: 1.8rem; + line-height: 1.25; + } + + .about-section-title { + font-size: 1.3rem; + margin-top: 26px; + } + + .about-dropcap::first-letter { + font-size: 3rem; + margin-top: 3px; + } +} + +@media (max-width: 520px) { + .about-breadcrumbs { + font-size: 0.78rem; + margin-bottom: 10px; + } + + .about-back-link { + margin-bottom: 18px; + font-size: 0.92rem; + } + + .about-title { + font-size: 1.45rem; + margin-bottom: 14px; + } + + .about-text { + font-size: 0.96rem; + line-height: 1.65; + } + + .about-dropcap::first-letter { + font-size: 2.4rem; + } + + .about-grid-3 { + grid-template-columns: 1fr; + gap: 12px; + } } \ No newline at end of file diff --git a/front/about.html b/front/about.html index 6411425..4cbd331 100644 --- a/front/about.html +++ b/front/about.html @@ -65,6 +65,7 @@

Ресурсы

+ diff --git a/front/admin-script.js b/front/admin-script.js index 6ad7808..5d2cd2f 100644 --- a/front/admin-script.js +++ b/front/admin-script.js @@ -10,6 +10,18 @@ function resolveApiBase() { const ADMIN_API_BASE = resolveApiBase(); let currentAdmin = null; +function tr(key, fallback) { + return window.AppI18n?.t?.(key) || fallback; +} + +function tf(key, fallback, vars = {}) { + let text = tr(key, fallback); + Object.entries(vars).forEach(([name, value]) => { + text = text.replaceAll(`{${name}}`, String(value ?? "")); + }); + return text; +} + function apiUrl(path) { return ADMIN_API_BASE ? `${ADMIN_API_BASE}${path}` : path; } @@ -58,32 +70,32 @@ async function checkSession() { if (!me) { currentAdmin = null; setAdminProtectedVisible(false); - setAdminStatus("Сессия не активна. Нажмите \"Зайти как админ\"."); + setAdminStatus(tr("admin_session_inactive", "Session inactive. Click \"Login as admin\".")); return null; } if (me.role !== "admin") { currentAdmin = me; setAdminProtectedVisible(false); - setAdminStatus(`Вход как ${me.username}, но роль не admin`); + setAdminStatus(tf("admin_login_not_admin", "Signed in as {username}, but role is not admin", { username: me.username })); return me; } currentAdmin = me; setAdminProtectedVisible(true); - setAdminStatus(`Авторизован как admin: ${me.username}`); + setAdminStatus(tf("admin_logged_admin", "Signed in as admin: {username}", { username: me.username })); return me; } async function createCard() { if (!currentAdmin || currentAdmin.role !== "admin") { - setAdminStatus("Нужна авторизация администратора"); + setAdminStatus(tr("admin_need_auth", "Admin authorization is required")); return; } const name = document.getElementById("createName").value.trim(); if (!name) { - setAdminStatus("Введите ФИО для создания карточки"); + setAdminStatus(tr("admin_need_name", "Enter full name to create a card")); return; } @@ -141,7 +153,7 @@ async function importSeedFile() { const fileInput = document.getElementById("seedFile"); const file = fileInput.files[0]; if (!file) { - setAdminStatus("Выберите JSON файл"); + setAdminStatus(tr("admin_select_json", "Select a JSON file")); return; } @@ -161,13 +173,107 @@ async function importSeedFile() { } const body = await response.json(); - setAdminStatus(`Импорт завершен: создано ${body.created}, пропущено ${body.skipped_duplicates}`); + setAdminStatus(tf("admin_import_done", "Import complete: created {created}, skipped {skipped}", { + created: body.created, + skipped: body.skipped_duplicates, + })); await loadCards(); } catch (err) { setAdminStatus(`Ошибка импорта: ${err.message}`); } } +async function importBundledSeeds() { + if (!currentAdmin || currentAdmin.role !== "admin") { + setAdminStatus(tr("admin_need_auth", "Admin authorization is required")); + return; + } + + const response = await apiFetch("/cards/import/seed/examples", { + method: "POST" + }); + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + setAdminStatus(`Ошибка импорта встроенных seed: ${body.detail || response.status}`); + return; + } + + const body = await response.json(); + setAdminStatus(tf("admin_bundled_import_done", "Bundled import: created {created}, skipped {skipped}, files: {files}", { + created: body.created, + skipped: body.skipped_duplicates, + files: (body.files || []).join(", "), + })); + await loadCards(); +} + +async function loadAiRuntimeConfig() { + if (!currentAdmin || currentAdmin.role !== "admin") { + setAdminStatus(tr("admin_need_auth", "Admin authorization is required")); + return; + } + + const response = await apiFetch("/admin/ai/runtime-config"); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + setAdminStatus(`Ошибка чтения AI настроек: ${body.detail || response.status}`); + return; + } + + const payload = await response.json(); + const cfg = payload.config || {}; + + document.getElementById("llmProvider").value = cfg.rag_llm_provider || "gemini"; + document.getElementById("embeddingProvider").value = cfg.rag_embedding_provider || "gemini"; + document.getElementById("geminiModel").value = cfg.rag_gemini_model || ""; + document.getElementById("claudeModel").value = cfg.rag_claude_model || ""; + document.getElementById("geminiEmbeddingModel").value = cfg.rag_gemini_embedding_model || ""; + document.getElementById("openaiEmbeddingModel").value = cfg.rag_openai_embedding_model || ""; + + setAdminStatus(tr("admin_ai_loaded", "AI settings loaded")); +} + +async function saveAiRuntimeConfig() { + if (!currentAdmin || currentAdmin.role !== "admin") { + setAdminStatus(tr("admin_need_auth", "Admin authorization is required")); + return; + } + + const payload = { + rag_llm_provider: document.getElementById("llmProvider").value, + rag_embedding_provider: document.getElementById("embeddingProvider").value, + rag_gemini_model: document.getElementById("geminiModel").value.trim(), + rag_claude_model: document.getElementById("claudeModel").value.trim(), + rag_gemini_embedding_model: document.getElementById("geminiEmbeddingModel").value.trim(), + rag_openai_embedding_model: document.getElementById("openaiEmbeddingModel").value.trim(), + }; + + const geminiKey = document.getElementById("geminiApiKey").value.trim(); + const openaiKey = document.getElementById("openaiApiKey").value.trim(); + const anthropicKey = document.getElementById("anthropicApiKey").value.trim(); + if (geminiKey) payload.gemini_api_key = geminiKey; + if (openaiKey) payload.openai_api_key = openaiKey; + if (anthropicKey) payload.anthropic_api_key = anthropicKey; + + const response = await apiFetch("/admin/ai/runtime-config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + setAdminStatus(`Ошибка сохранения AI настроек: ${body.detail || response.status}`); + return; + } + + document.getElementById("geminiApiKey").value = ""; + document.getElementById("openaiApiKey").value = ""; + document.getElementById("anthropicApiKey").value = ""; + setAdminStatus(tr("admin_ai_saved", "AI settings saved")); +} + async function deleteCard(cardId) { const response = await apiFetch(`/cards/${cardId}`, { method: "DELETE" @@ -186,11 +292,11 @@ async function deleteCard(cardId) { async function loadCards() { const container = document.getElementById("adminCardsList"); if (!currentAdmin || currentAdmin.role !== "admin") { - container.innerHTML = "Войдите как admin для просмотра карточек"; + container.innerHTML = tr("admin_cards_login_needed", "Login as admin to view cards"); return; } - container.innerHTML = "Загрузка..."; + container.innerHTML = tr("common_loading", "Loading..."); const response = await apiFetch("/cards"); if (!response.ok) { @@ -201,7 +307,7 @@ async function loadCards() { const cards = await response.json(); if (!cards.length) { - container.innerHTML = "Нет карточек"; + container.innerHTML = tr("admin_no_cards", "No cards"); return; } @@ -213,7 +319,7 @@ async function loadCards() { const btn = document.createElement("button"); btn.type = "button"; - btn.textContent = "Удалить"; + btn.textContent = tr("common_delete", "Delete"); btn.addEventListener("click", () => deleteCard(card.id)); row.appendChild(btn); @@ -222,7 +328,7 @@ async function loadCards() { } async function moderationAction(id, action) { - const comment = window.prompt("Комментарий модератора (необязательно):", "") || ""; + const comment = window.prompt(tr("admin_prompt_comment", "Moderator comment (optional):"), "") || ""; const response = await apiFetch(`/admin/suggestions/${id}/${action}`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -236,7 +342,7 @@ async function moderationAction(id, action) { } const body = await response.json(); - setAdminStatus(body.message || "Модерация выполнена"); + setAdminStatus(body.message || tr("admin_moderation_done", "Moderation complete")); await loadSuggestions(); await loadCards(); } @@ -256,7 +362,7 @@ async function loadSuggestions() { const container = document.getElementById("adminSuggestionsList"); const state = document.getElementById("suggestionsStateFilter").value; if (!currentAdmin || currentAdmin.role !== "admin") { - container.innerHTML = "Войдите как admin для модерации"; + container.innerHTML = tr("admin_suggestions_login_needed", "Login as admin for moderation"); return; } @@ -270,7 +376,7 @@ async function loadSuggestions() { const items = await response.json(); if (!items.length) { - container.innerHTML = "Предложений нет"; + container.innerHTML = tr("admin_no_suggestions", "No suggestions"); return; } @@ -292,20 +398,20 @@ async function loadSuggestions() { if (item.state === "pending") { const approveBtn = document.createElement("button"); approveBtn.type = "button"; - approveBtn.textContent = "Одобрить"; + approveBtn.textContent = tr("common_approve", "Approve"); approveBtn.addEventListener("click", () => moderationAction(item.id, "approve")); actions.appendChild(approveBtn); const rejectBtn = document.createElement("button"); rejectBtn.type = "button"; - rejectBtn.textContent = "Отклонить"; + rejectBtn.textContent = tr("common_reject", "Reject"); rejectBtn.addEventListener("click", () => moderationAction(item.id, "reject")); actions.appendChild(rejectBtn); } const deleteBtn = document.createElement("button"); deleteBtn.type = "button"; - deleteBtn.textContent = "Удалить"; + deleteBtn.textContent = tr("common_delete", "Delete"); deleteBtn.addEventListener("click", () => deleteSuggestion(item.id)); actions.appendChild(deleteBtn); @@ -320,15 +426,19 @@ document.addEventListener("DOMContentLoaded", async () => { }); document.getElementById("seedImportBtn").addEventListener("click", importSeedFile); + document.getElementById("seedExamplesImportBtn").addEventListener("click", importBundledSeeds); document.getElementById("createCardBtn").addEventListener("click", createCard); document.getElementById("refreshCardsBtn").addEventListener("click", loadCards); document.getElementById("loadSuggestionsBtn").addEventListener("click", loadSuggestions); + document.getElementById("loadAiConfigBtn").addEventListener("click", loadAiRuntimeConfig); + document.getElementById("saveAiConfigBtn").addEventListener("click", saveAiRuntimeConfig); window.addEventListener("site-auth-changed", async () => { const me = await checkSession(); if (me?.role === "admin") { await loadCards(); await loadSuggestions(); + await loadAiRuntimeConfig(); return; } @@ -340,5 +450,14 @@ document.addEventListener("DOMContentLoaded", async () => { if (me?.role === "admin") { await loadCards(); await loadSuggestions(); + await loadAiRuntimeConfig(); } + + window.addEventListener("site-language-changed", async () => { + const me2 = await checkSession(); + if (me2?.role === "admin") { + await loadCards(); + await loadSuggestions(); + } + }); }); diff --git a/front/admin.html b/front/admin.html index e79c1ca..9f5c49b 100644 --- a/front/admin.html +++ b/front/admin.html @@ -41,6 +41,36 @@

Импорт seed (JSON массив)

+ +
+
+ + @@ -97,6 +127,7 @@

Модерация предложений

© 2024 Архив Памяти

+ diff --git a/front/ai-chat.css b/front/ai-chat.css index 40d8c76..1ecb8c6 100644 --- a/front/ai-chat.css +++ b/front/ai-chat.css @@ -249,11 +249,70 @@ min-height: 64vh; } + .chat-header { + padding: 12px 14px; + font-size: 1rem; + } + + .chat-history { + padding: 12px; + gap: 10px; + } + + .message { + max-width: 92%; + font-size: 14px; + padding: 9px 11px; + } + + .history-toolbar { + padding: 8px 10px; + gap: 8px; + } + + .history-toolbar button, + .history-toolbar select { + font-size: 13px; + padding: 6px 8px; + } + + .chat-helper, + .chat-archive { + margin-left: 10px; + margin-right: 10px; + } + .chat-input-area { flex-direction: column; + padding: 10px; + gap: 8px; } .chat-input-area button { width: 100%; + min-width: 0; + padding: 10px; + } +} + +@media (max-width: 520px) { + .message { + max-width: 96%; + font-size: 13px; + } + + .history-toolbar { + display: grid; + grid-template-columns: 1fr 1fr; + } + + .history-toolbar .chat-limit-label { + grid-column: 1 / -1; + margin-bottom: -4px; + } + + .chat-input-area textarea { + min-height: 42px; + font-size: 14px; } } diff --git a/front/chat-script.js b/front/chat-script.js index 727ddbd..010f368 100644 --- a/front/chat-script.js +++ b/front/chat-script.js @@ -1,6 +1,18 @@ let historyOffset = 0; let currentUser = null; +function tr(key, fallback) { + return window.AppI18n?.t?.(key) || fallback; +} + +function tf(key, fallback, vars = {}) { + let text = tr(key, fallback); + Object.entries(vars).forEach(([name, value]) => { + text = text.replaceAll(`{${name}}`, String(value ?? "")); + }); + return text; +} + function resolveApiBase() { const params = new URLSearchParams(window.location.search); const fromQuery = params.get("api"); @@ -71,8 +83,8 @@ function addSources(citations, sourceIds) { wrap.className = "message ai"; const idsText = Array.isArray(sourceIds) && sourceIds.length - ? `Источники person_id: ${sourceIds.join(", ")}` - : "Источники не найдены"; + ? `${tr("chat_sources_person_ids", "Source person_id:")} ${sourceIds.join(", ")}` + : tr("chat_sources_not_found", "No sources found"); let html = `
${idsText}
`; if (Array.isArray(citations) && citations.length) { @@ -100,7 +112,7 @@ function renderHistoryItems(items, append = false) { if (!Array.isArray(items) || !items.length) { if (!append) { - box.innerHTML = "История пуста"; + box.innerHTML = tr("chat_history_empty", "History is empty"); } return; } @@ -114,9 +126,9 @@ function renderHistoryItems(items, append = false) { : "-"; item.innerHTML = `
${ts}
-
Вы: ${entry.user_message || ""}
-
AI: ${entry.bot_response || ""}
-
sources: ${src}
+
${tr("chat_history_you", "You")}: ${entry.user_message || ""}
+
${tr("chat_history_ai", "AI")}: ${entry.bot_response || ""}
+
${tr("chat_history_sources", "Sources")}: ${src}
`; box.appendChild(item); }); @@ -143,17 +155,20 @@ async function checkSession() { } if (!currentUser) { - setStatus("Не авторизован. Нажмите \"Зайти\" в шапке."); + setStatus(tr("suggestions_desc_2", "Not authorized. Click \"Login\" in the header.")); return null; } - setStatus(`Вход выполнен: ${currentUser.username} (${currentUser.role})`); + setStatus(tf("auth_logged_in_as", "Logged in: {username} ({role})", { + username: currentUser.username, + role: currentUser.role, + })); return currentUser; } async function loadHistory(reset = true) { if (!currentUser) { - document.getElementById("chatHistory").innerHTML = "Войдите, чтобы увидеть историю"; + document.getElementById("chatHistory").innerHTML = tr("chat_history_login_needed", "Sign in to view history"); document.getElementById("chatHistoryMeta").textContent = ""; return; } @@ -168,7 +183,7 @@ async function loadHistory(reset = true) { if (!response.ok) { const payload = await response.json().catch(() => ({})); - document.getElementById("chatHistory").innerHTML = `Ошибка истории: ${payload.detail || response.status}`; + document.getElementById("chatHistory").innerHTML = `${tr("chat_history_error_prefix", "History error:")} ${payload.detail || response.status}`; return; } @@ -179,8 +194,11 @@ async function loadHistory(reset = true) { historyOffset += items.length; const backend = payload.storage_backend || "unknown"; const hasMore = Boolean(payload.has_more); - document.getElementById("chatHistoryMeta").textContent = - `history: ${historyOffset}/${payload.total || 0}, backend: ${backend}`; + document.getElementById("chatHistoryMeta").textContent = tf( + "chat_meta_template", + "history: {loaded}/{total}, backend: {backend}", + { loaded: historyOffset, total: payload.total || 0, backend } + ); const loadMoreBtn = document.getElementById("loadMoreHistoryBtn"); loadMoreBtn.disabled = !hasMore; @@ -192,7 +210,7 @@ async function sendMessage() { if (!query) return; if (!currentUser) { - setStatus("Сначала войдите в аккаунт"); + setStatus(tr("chat_login_first", "Please sign in first")); return; } @@ -212,7 +230,7 @@ async function sendMessage() { } const payload = await response.json(); - addMessage("assistant", payload.answer || "Пустой ответ"); + addMessage("assistant", payload.answer || tr("chat_empty_answer", "Empty response")); addSources(payload.citations || [], payload.sources || []); await loadHistory(true); } @@ -234,16 +252,31 @@ document.addEventListener("DOMContentLoaded", async () => { historyOffset = 0; if (currentUser) { - setStatus(`Вход выполнен: ${currentUser.username} (${currentUser.role})`); + setStatus(tf("auth_logged_in_as", "Logged in: {username} ({role})", { + username: currentUser.username, + role: currentUser.role, + })); await loadHistory(true); return; } - setStatus("Не авторизован. Нажмите \"Зайти\" в шапке."); + setStatus(tr("suggestions_desc_2", "Not authorized. Click \"Login\" in the header.")); document.getElementById("chatHistory").innerHTML = ""; document.getElementById("chatHistoryMeta").textContent = ""; }); + window.addEventListener("site-language-changed", async () => { + await loadHistory(true); + if (!currentUser) { + setStatus(tr("suggestions_desc_2", "Not authorized. Click \"Login\" in the header.")); + } else { + setStatus(tf("auth_logged_in_as", "Logged in: {username} ({role})", { + username: currentUser.username, + role: currentUser.role, + })); + } + }); + await checkSession(); if (currentUser) { await loadHistory(true); diff --git a/front/chat.html b/front/chat.html index ca80ec1..0a941f0 100644 --- a/front/chat.html +++ b/front/chat.html @@ -82,6 +82,7 @@

Статистика

© 2024 Архив Памяти

+ diff --git a/front/contacts.html b/front/contacts.html index bc105d6..63b24e6 100644 --- a/front/contacts.html +++ b/front/contacts.html @@ -39,6 +39,7 @@

Контакты

© 2024 Архив Памяти

+ diff --git a/front/header-auth.js b/front/header-auth.js index 77be38a..a908ae6 100644 --- a/front/header-auth.js +++ b/front/header-auth.js @@ -1,5 +1,17 @@ let siteCurrentUser = null; +function tr(key, fallback) { + return window.AppI18n?.t?.(key) || fallback; +} + +function tf(key, fallback, vars = {}) { + let text = tr(key, fallback); + Object.entries(vars).forEach(([name, value]) => { + text = text.replaceAll(`{${name}}`, String(value ?? "")); + }); + return text; +} + function resolveAuthApiBase() { const params = new URLSearchParams(window.location.search); const fromQuery = params.get("api"); @@ -67,7 +79,7 @@ async function refreshSiteSession() { const response = await authFetch("/me"); if (!response.ok) { siteCurrentUser = null; - setGlobalAuthStatus("Не авторизован"); + setGlobalAuthStatus(tr("auth_not_authorized", "Not authorized")); normalizeHeaderNav(); updateGlobalAuthControls(); emitAuthChange(); @@ -75,7 +87,10 @@ async function refreshSiteSession() { } siteCurrentUser = await response.json(); - setGlobalAuthStatus(`Вход выполнен: ${siteCurrentUser.username} (${siteCurrentUser.role})`); + setGlobalAuthStatus(tf("auth_logged_in_as", "Logged in: {username} ({role})", { + username: siteCurrentUser.username, + role: siteCurrentUser.role, + })); normalizeHeaderNav(); updateGlobalAuthControls(); emitAuthChange(); @@ -86,7 +101,7 @@ async function registerSiteUser() { const username = (document.getElementById("globalAuthUser")?.value || "").trim(); const password = (document.getElementById("globalAuthPass")?.value || "").trim(); if (!username || !password) { - setGlobalAuthStatus("Введите логин и пароль"); + setGlobalAuthStatus(tr("auth_need_credentials", "Enter username and password")); return; } @@ -98,18 +113,18 @@ async function registerSiteUser() { if (!response.ok) { const payload = await response.json().catch(() => ({})); - setGlobalAuthStatus(`Ошибка регистрации: ${payload.detail || response.status}`); + setGlobalAuthStatus(`Error: ${payload.detail || response.status}`); return; } - setGlobalAuthStatus("Регистрация успешна. Теперь войдите."); + setGlobalAuthStatus(tr("auth_register_success", "Registration successful. Please sign in.")); } async function loginSiteUser() { const username = (document.getElementById("globalAuthUser")?.value || "").trim(); const password = (document.getElementById("globalAuthPass")?.value || "").trim(); if (!username || !password) { - setGlobalAuthStatus("Введите логин и пароль"); + setGlobalAuthStatus(tr("auth_need_credentials", "Enter username and password")); return; } @@ -125,7 +140,7 @@ async function loginSiteUser() { if (!response.ok) { const payload = await response.json().catch(() => ({})); - setGlobalAuthStatus(`Ошибка входа: ${payload.detail || response.status}`); + setGlobalAuthStatus(`Error: ${payload.detail || response.status}`); return; } @@ -136,7 +151,7 @@ async function loginSiteUser() { async function logoutSiteUser() { await authFetch("/logout", { method: "POST" }); siteCurrentUser = null; - setGlobalAuthStatus("Вы вышли"); + setGlobalAuthStatus(tr("status_logged_out", "You have signed out")); normalizeHeaderNav(); updateGlobalAuthControls(); emitAuthChange(); @@ -147,13 +162,13 @@ function normalizeHeaderNav() { if (!nav) return; const links = [ - { href: "index.html", label: "Главная" }, - { href: "list.html", label: "База данных" }, - { href: "about.html", label: "О проекте" }, - { href: "contacts.html", label: "Контакты" }, - { href: "chat.html", label: "Чат AI" }, - { href: "suggestions.html", label: "Предложить запись" }, - { href: "admin.html", label: "Админ", adminOnly: true } + { href: "index.html", label: tr("nav_main", "Home") }, + { href: "list.html", label: tr("nav_db", "Database") }, + { href: "about.html", label: tr("nav_about", "About") }, + { href: "contacts.html", label: tr("nav_contacts", "Contacts") }, + { href: "chat.html", label: tr("nav_chat", "AI Chat") }, + { href: "suggestions.html", label: tr("nav_suggestions", "Submit Entry") }, + { href: "admin.html", label: tr("nav_admin", "Admin"), adminOnly: true } ]; const page = window.location.pathname.split("/").pop() || "index.html"; @@ -173,8 +188,8 @@ function ensureHeaderSearchBar() { const search = document.createElement("div"); search.className = "search-bar"; search.innerHTML = ` - - + + `; const nav = headerInner.querySelector(".header-nav"); @@ -192,8 +207,8 @@ function ensureHeaderAuthControls() { const controls = document.createElement("div"); controls.className = "global-auth-controls"; controls.innerHTML = ` - - + + `; headerInner.appendChild(controls); } @@ -208,18 +223,18 @@ function ensureGlobalAuthModal() { modal.innerHTML = ` `; @@ -301,4 +316,18 @@ document.addEventListener("DOMContentLoaded", async () => { setupGlobalAuthEvents(); setupGlobalSearch(); await refreshSiteSession(); + + window.addEventListener("site-language-changed", () => { + normalizeHeaderNav(); + ensureHeaderSearchBar(); + ensureHeaderAuthControls(); + if (!siteCurrentUser) { + setGlobalAuthStatus(tr("auth_not_authorized", "Not authorized")); + } else { + setGlobalAuthStatus(tf("auth_logged_in_as", "Logged in: {username} ({role})", { + username: siteCurrentUser.username, + role: siteCurrentUser.role, + })); + } + }); }); diff --git a/front/i18n.js b/front/i18n.js index 9f0d89a..ad4d675 100644 --- a/front/i18n.js +++ b/front/i18n.js @@ -1,5 +1,3 @@ -#rabota za frontedbeka - const translations = { ru: { nav_main: "Главная", @@ -10,7 +8,7 @@ const translations = { nav_about: "О проекте", nav_contacts: "Контакты", site_title: "АРХИВ ПАМЯТИ", - search_placeholder: "Поиск по имени, фамилии...", + search_placeholder: "Поиск по имени, фамилии, году...", search_btn: "Найти", hero_find: "Найти человека", hero_find_desc: "Введите имя или фамилию в строку поиска, чтобы найти информацию о жертве репрессий.", @@ -19,31 +17,357 @@ const translations = { hero_help: "Помочь проекту", hero_help_desc: "Станьте волонтёром или поддержите проект. Каждый вклад помогает сохранить память.", footer_text: "© 2024 Архив Памяти. Открытая энциклопедия жертв политических репрессий.", + footer_short: "© 2024 Архив Памяти", btn_register: "Регистрация", btn_login: "Войти", - btn_logout: "Выйти" + btn_logout: "Выйти", + title_about: "О проекте — Архив Памяти", + title_list: "Алфавитный список жертв — Архив Памяти", + title_chat: "ИИ-ассистент — Архив памяти", + title_suggestions: "Предложить запись — Архив Памяти", + title_admin: "Админ панель — Архив Памяти", + title_contacts: "Контакты — Архив Памяти", + title_index: "Архив памяти — База данных жертв политических репрессий", + about_breadcrumb: "Главная / О проекте", + about_back: "← Вернуться на главную", + about_title: "О проекте: Архив Памяти", + about_text_1: "Каждое имя — это история. История человека, чья судьба была перечёркнута машиной государственного террора. Десятилетия молчания превратили миллионы жизней в безымянные тени, стёртые из памяти и архивов.", + about_text_2: "Мы строим живой мост между поколениями — мост, по которому память о прошлом передаётся тем, кто живёт сегодня.", + partners_title: "Партнёры", + stats_title: "Статистика", + resources_title: "Ресурсы", + resources_archives: "Архивные фонды", + resources_publications: "Публикации", + resources_laws: "Законодательство", + resources_methods: "Методические материалы", + resources_map: "Карта", + list_page_title: "Алфавитный список жертв репрессий", + list_page_subtitle: "Реестр лиц, пострадавших от политических репрессий. Выберите букву или воспользуйтесь поиском.", + list_filters: "Фильтры", + filter_region: "Регион", + filter_year: "Год репрессии", + filter_apply: "Применить", + filter_all_regions: "Все регионы", + filter_all_years: "Все годы", + stats_records: "Записей в базе:", + stats_documents: "Документов:", + chat_header: "Диалог с ИИ-ассистентом", + chat_intro: "Здравствуйте. Я ИИ-ассистент проекта «Архив памяти». Напишите имя, фамилию или место, и я помогу найти информацию по архивным базам.", + chat_history_limit: "Лимит истории:", + chat_load_history: "Загрузить историю", + chat_load_more: "Еще", + chat_not_authorized: "Не авторизован", + chat_input_placeholder: "Введите ваш запрос здесь...", + chat_send: "Отправить", + suggestions_title: "Экран предложений", + suggestions_desc_1: "Обычный пользователь может отправить предложение. После модерации администратором запись появится в базе.", + suggestions_desc_2: "Не авторизован. Нажмите \"Зайти\" в шапке.", + suggestions_new: "Новая предложка", + suggestions_kind_create: "Новая запись", + suggestions_kind_update: "Изменение существующей записи", + suggestions_kind_document: "Документ к существующей записи", + suggestions_find_id: "Найти ID", + suggestions_photo_tip: "Фото необязательно. Ограничение размера: меньше 2 МБ.", + suggestions_document_title: "Документ к записи (необязательно)", + suggestions_document_tip: "Для режима \"Документ\" укажите ID существующей записи и добавьте текст или файл .txt/.md.", + suggestions_send: "Отправить предложение", + suggestions_my: "Мои предложения", + suggestions_refresh: "Обновить", + admin_title: "Админ панель", + admin_access_title: "Доступ к админке", + admin_access_desc: "Доступ только для администратора.", + admin_login_btn: "Зайти как админ", + admin_import_title: "Импорт seed (JSON массив)", + admin_import_btn: "Импортировать seed", + admin_import_examples_btn: "Импортировать встроенные seed", + admin_ai_settings_title: "Настройки AI токенов и провайдеров", + admin_ai_load_btn: "Обновить значения", + admin_ai_save_btn: "Сохранить AI настройки", + admin_create_title: "Добавить карточку вручную", + admin_create_btn: "Создать карточку", + admin_cards_title: "Список карточек и удаление", + admin_refresh_cards: "Обновить список", + admin_moderation_title: "Модерация предложений", + admin_filter_pending: "Только pending", + admin_filter_approved: "Только approved", + admin_filter_rejected: "Только rejected", + admin_filter_all: "Все", + admin_load_suggestions: "Загрузить предложения", + contacts_title: "Контакты", + contacts_phone: "Телефон:", + contacts_address: "Адрес:", + contacts_desc: "Для вопросов по данным и доступу к API пишите на email.", + docs_title: "Документы", + biography_title: "Биография", + profile_loading: "Загрузка...", + auth_title: "Вход в аккаунт", + auth_username_placeholder: "Логин", + auth_password_placeholder: "Пароль", + auth_not_authorized: "Не авторизован", + auth_logged_in_as: "Вход выполнен: {username} ({role})", + auth_need_credentials: "Введите логин и пароль", + auth_register_success: "Регистрация успешна. Теперь войдите.", + auth_login_btn: "Зайти", + status_logged_out: "Вы вышли", + list_error_prefix: "Ошибка загрузки списка:", + chat_sources_person_ids: "Источники person_id:", + chat_sources_not_found: "Источники не найдены", + chat_history_empty: "История пуста", + chat_history_you: "Вы", + chat_history_ai: "AI", + chat_history_sources: "Источники", + chat_history_login_needed: "Войдите, чтобы увидеть историю", + chat_login_first: "Сначала войдите в аккаунт", + chat_empty_answer: "Пустой ответ", + chat_history_error_prefix: "Ошибка истории:", + chat_meta_template: "история: {loaded}/{total}, backend: {backend}", + suggestions_required_name_year: "ФИО и год рождения обязательны", + suggestions_need_target_id: "Для update/document укажите ID существующей записи", + suggestions_image_limit: "Размер изображения должен быть меньше 2 МБ", + suggestions_doc_limit: "Размер документа должен быть меньше 3 МБ", + suggestions_doc_format: "Документ должен быть в формате .txt или .md", + suggestions_doc_required: "Для document-режима добавьте текст или файл .txt/.md", + suggestions_sent: "Предложение отправлено: #{id} ({kind})", + suggestions_search_enter: "Введите текст для поиска записи", + suggestions_search_failed: "Не удалось выполнить поиск", + suggestions_search_empty: "Ничего не найдено", + suggestions_search_found: "Найдено: {name} (#{id}). ID подставлен.", + suggestions_login_to_view: "Войдите для просмотра", + suggestions_no_items: "Пока нет предложений", + admin_session_inactive: "Сессия не активна. Нажмите \"Зайти как админ\".", + admin_login_not_admin: "Вход как {username}, но роль не admin", + admin_logged_admin: "Авторизован как admin: {username}", + admin_need_auth: "Нужна авторизация администратора", + admin_need_name: "Введите ФИО для создания карточки", + admin_select_json: "Выберите JSON файл", + admin_import_done: "Импорт завершен: создано {created}, пропущено {skipped}", + admin_bundled_import_done: "Встроенный импорт: создано {created}, пропущено {skipped}, файлов: {files}", + admin_ai_loaded: "AI настройки загружены", + admin_ai_saved: "AI настройки сохранены", + admin_cards_login_needed: "Войдите как admin для просмотра карточек", + common_loading: "Загрузка...", + admin_no_cards: "Нет карточек", + common_delete: "Удалить", + admin_prompt_comment: "Комментарий модератора (необязательно):", + admin_moderation_done: "Модерация выполнена", + admin_suggestions_login_needed: "Войдите как admin для модерации", + admin_no_suggestions: "Предложений нет", + common_approve: "Одобрить", + common_reject: "Отклонить", + about_partner_1: "Государственный архив РФ", + about_partner_2: "Мемориал", + about_partner_3: "Сахаровский центр", + about_partner_4: "РГАСПИ", + about_partner_5: "Яд Вашем", + about_partner_6: "Национальный архив РК", + about_stats_records: "Записей в базе", + about_stats_documents: "Документов в базе", + ph_target_person_id: "ID существующей записи (для update)", + ph_existing_search: "Быстрый поиск существующей записи", + ph_full_name_required: "ФИО *", + ph_birth_year_required: "Год рождения *", + ph_birth_year: "Год рождения", + ph_death_year: "Год смерти", + ph_nationality: "Национальность", + ph_region: "Регион", + ph_district: "Район", + ph_occupation: "Профессия / должность", + ph_occupation_category: "Профессия / категория", + ph_charge: "Обвинение", + ph_sentence: "Приговор", + ph_arrest_date: "Дата ареста", + ph_sentence_date: "Дата приговора", + ph_rehabilitation_date: "Дата реабилитации", + ph_source: "Источник", + ph_status_verified: "Статус (например, verified)", + ph_status_verified_short: "Статус (verified)", + ph_document_text: "Текст документа (или вставьте markdown)", + ph_biography: "Биография" }, ky: { nav_main: "Башкы бет", nav_db: "Маалымат базасы", - nav_chat: "AI Маек", + nav_chat: "AI маек", nav_suggestions: "Жазуу сунуш кылуу", nav_admin: "Админ", nav_about: "Долбоор жөнүндө", - nav_contacts: "Байланышуу", + nav_contacts: "Байланыш", site_title: "ЭС КУРЖУНУ", - search_placeholder: "Аты-жөнү боюнча издөө...", + search_placeholder: "Аты-жөнү, жыл боюнча издөө...", search_btn: "Издөө", hero_find: "Адамды табуу", - hero_find_desc: "Репрессиянын курмандыгы жөнүндө маалымат табуу үчүн аты-жөнүн киргизиңиз.", + hero_find_desc: "Репрессия курмандыгы жөнүндө маалымат табуу үчүн издөө тилкесине аты-жөнүн киргизиңиз.", hero_add: "Маалымат кошуу", - hero_add_desc: "Архивди толуктоого жардам бериңиз — туугандарыңыз жөнүндө документтерди жүктөп салыңыз.", + hero_add_desc: "Архивди толуктоого жардам бериңиз — туугандарыңыз тууралуу документтерди жана эскерүүлөрдү жүктөңүз.", hero_help: "Долбоорго жардам", - hero_help_desc: "Ыктыярчы болуңуз же долбоорду колдоңуз. Ар бир салым эсте калтырууга жардам берет.", - footer_text: "© 2024 Эс Куржуну. Саясий репрессиянын курмандыктарынын ачык энциклопедиясы.", - btn_register: "Каттоо", + hero_help_desc: "Ыктыярчы болуңуз же долбоорду колдоңуз. Ар бир салым эс-тутумду сактоого жардам берет.", + footer_text: "© 2024 Эс Куржуну. Саясий репрессия курмандыктарынын ачык энциклопедиясы.", + footer_short: "© 2024 Эс Куржуну", + btn_register: "Катталуу", btn_login: "Кирүү", - btn_logout: "Чыгуу" + btn_logout: "Чыгуу", + title_about: "Долбоор жөнүндө — Эс Куржуну", + title_list: "Курмандыктардын алфавиттик тизмеси — Эс Куржуну", + title_chat: "AI-ассистент — Эс Куржуну", + title_suggestions: "Жазуу сунуш кылуу — Эс Куржуну", + title_admin: "Админ панели — Эс Куржуну", + title_contacts: "Байланыш — Эс Куржуну", + title_index: "Эс Куржуну — Саясий репрессия курмандыктарынын базасы", + about_breadcrumb: "Башкы бет / Долбоор жөнүндө", + about_back: "← Башкы бетке кайтуу", + about_title: "Долбоор жөнүндө: Эс Куржуну", + about_text_1: "Ар бир ысым — бул тарых. Бул мамлекеттик террордун машинасы талкалаган адамдын тагдырынын тарыхы. Унчукпоо жылдары миллиондогон өмүрдү атсыз көлөкөгө айлантты.", + about_text_2: "Биз муундардын ортосунда жандуу көпүрө куруп жатабыз — өткөндүн эсин бүгүн жашагандарга өткөргөн көпүрө.", + partners_title: "Өнөктөштөр", + stats_title: "Статистика", + resources_title: "Ресурстар", + resources_archives: "Архив фонддору", + resources_publications: "Жарыялар", + resources_laws: "Мыйзамдар", + resources_methods: "Методикалык материалдар", + resources_map: "Карта", + list_page_title: "Репрессия курмандыктарынын алфавиттик тизмеси", + list_page_subtitle: "Саясий репрессиядан жапа чеккендердин реестри. Тамганы тандаңыз же издөө колдонуңуз.", + list_filters: "Чыпкалар", + filter_region: "Аймак", + filter_year: "Репрессия жылы", + filter_apply: "Колдонуу", + filter_all_regions: "Бардык аймактар", + filter_all_years: "Бардык жылдар", + stats_records: "Базадагы жазуулар:", + stats_documents: "Документтер:", + chat_header: "AI-ассистент менен диалог", + chat_intro: "Саламатсызбы. Мен «Эс Куржуну» долбоорунун AI-ассистентимин. Аты-жөндү же жерди жазыңыз, архив базаларынан маалымат табууга жардам берем.", + chat_history_limit: "Тарых чеги:", + chat_load_history: "Тарыхты жүктөө", + chat_load_more: "Дагы", + chat_not_authorized: "Авторизация жок", + chat_input_placeholder: "Сурамыңызды бул жерге жазыңыз...", + chat_send: "Жөнөтүү", + suggestions_title: "Сунуштар экраны", + suggestions_desc_1: "Кадимки колдонуучу сунуш жөнөтө алат. Админ модерациясынан кийин жазуу базага чыгат.", + suggestions_desc_2: "Авторизация жок. Жогору жактагы \"Кирүү\" баскычын басыңыз.", + suggestions_new: "Жаңы сунуш", + suggestions_kind_create: "Жаңы жазуу", + suggestions_kind_update: "Бар жазууну өзгөртүү", + suggestions_kind_document: "Бар жазууга документ", + suggestions_find_id: "ID табуу", + suggestions_photo_tip: "Сүрөт милдеттүү эмес. Өлчөм чеги: 2 МБдан аз.", + suggestions_document_title: "Жазууга документ (милдеттүү эмес)", + suggestions_document_tip: "\"Документ\" режими үчүн бар жазуунун ID'син көрсөтүп, текст же .txt/.md файл кошуңуз.", + suggestions_send: "Сунуш жөнөтүү", + suggestions_my: "Менин сунуштарым", + suggestions_refresh: "Жаңылоо", + admin_title: "Админ панели", + admin_access_title: "Админге кирүү", + admin_access_desc: "Кирүү администратор үчүн гана.", + admin_login_btn: "Админ катары кирүү", + admin_import_title: "seed импорт (JSON массив)", + admin_import_btn: "seed импорттоо", + admin_import_examples_btn: "Камтылган seed импорттоо", + admin_ai_settings_title: "AI токендери жана провайдерлеринин жөндөөлөрү", + admin_ai_load_btn: "Маанилерди жаңыртуу", + admin_ai_save_btn: "AI жөндөөлөрүн сактоо", + admin_create_title: "Карточканы кол менен кошуу", + admin_create_btn: "Карточка түзүү", + admin_cards_title: "Карточкалар тизмеси жана өчүрүү", + admin_refresh_cards: "Тизмени жаңылоо", + admin_moderation_title: "Сунуштарды модерациялоо", + admin_filter_pending: "pending гана", + admin_filter_approved: "approved гана", + admin_filter_rejected: "rejected гана", + admin_filter_all: "Баары", + admin_load_suggestions: "Сунуштарды жүктөө", + contacts_title: "Байланыш", + contacts_phone: "Телефон:", + contacts_address: "Дарек:", + contacts_desc: "Маалымат жана API жеткиликтүүлүгү боюнча суроолор үчүн email'ге жазыңыз.", + docs_title: "Документтер", + biography_title: "Өмүр баяны", + profile_loading: "Жүктөлүүдө...", + auth_title: "Аккаунтка кирүү", + auth_username_placeholder: "Логин", + auth_password_placeholder: "Сырсөз", + auth_not_authorized: "Авторизация жок", + auth_logged_in_as: "Кирди: {username} ({role})", + auth_need_credentials: "Логин жана сырсөз киргизиңиз", + auth_register_success: "Каттоо ийгиликтүү. Эми кириңиз.", + auth_login_btn: "Кирүү", + status_logged_out: "Сиз чыктыңыз", + list_error_prefix: "Тизмени жүктөө катасы:", + chat_sources_person_ids: "person_id булактары:", + chat_sources_not_found: "Булактар табылган жок", + chat_history_empty: "Тарых бош", + chat_history_you: "Сиз", + chat_history_ai: "AI", + chat_history_sources: "Булактар", + chat_history_login_needed: "Тарыхты көрүү үчүн кириңиз", + chat_login_first: "Адегенде аккаунтка кириңиз", + chat_empty_answer: "Бош жооп", + chat_history_error_prefix: "Тарых катасы:", + chat_meta_template: "тарых: {loaded}/{total}, backend: {backend}", + suggestions_required_name_year: "ФАА жана туулган жыл милдеттүү", + suggestions_need_target_id: "update/document үчүн бар жазуунун ID'син көрсөтүңүз", + suggestions_image_limit: "Сүрөт өлчөмү 2 МБдан аз болушу керек", + suggestions_doc_limit: "Документ өлчөмү 3 МБдан аз болушу керек", + suggestions_doc_format: "Документ .txt же .md форматында болушу керек", + suggestions_doc_required: "document режими үчүн текст же .txt/.md файл кошуңуз", + suggestions_sent: "Сунуш жөнөтүлдү: #{id} ({kind})", + suggestions_search_enter: "Издөө үчүн текст киргизиңиз", + suggestions_search_failed: "Издөө аткарылган жок", + suggestions_search_empty: "Эч нерсе табылган жок", + suggestions_search_found: "Табылды: {name} (#{id}). ID коюлду.", + suggestions_login_to_view: "Көрүү үчүн кириңиз", + suggestions_no_items: "Сунуштар азырынча жок", + admin_session_inactive: "Сессия активдүү эмес. \"Админ катары кирүү\" баскычын басыңыз.", + admin_login_not_admin: "Кирди: {username}, бирок role admin эмес", + admin_logged_admin: "admin катары кирди: {username}", + admin_need_auth: "Администратор авторизациясы керек", + admin_need_name: "Карточка түзүү үчүн ФАА киргизиңиз", + admin_select_json: "JSON файл тандаңыз", + admin_import_done: "Импорт бүттү: түзүлдү {created}, өткөрүлдү {skipped}", + admin_bundled_import_done: "Камтылган импорт: түзүлдү {created}, өткөрүлдү {skipped}, файлдар: {files}", + admin_ai_loaded: "AI жөндөөлөрү жүктөлдү", + admin_ai_saved: "AI жөндөөлөрү сакталды", + admin_cards_login_needed: "Карточкаларды көрүү үчүн admin болуп кириңиз", + common_loading: "Жүктөлүүдө...", + admin_no_cards: "Карточкалар жок", + common_delete: "Өчүрүү", + admin_prompt_comment: "Модератор комментарийи (милдеттүү эмес):", + admin_moderation_done: "Модерация аткарылды", + admin_suggestions_login_needed: "Модерация үчүн admin болуп кириңиз", + admin_no_suggestions: "Сунуштар жок", + common_approve: "Бекитүү", + common_reject: "Четке кагуу", + about_partner_1: "РФ мамлекеттик архиви", + about_partner_2: "Мемориал", + about_partner_3: "Сахаров борбору", + about_partner_4: "РГАСПИ", + about_partner_5: "Яд Вашем", + about_partner_6: "КР Улуттук архиви", + about_stats_records: "Базадагы жазуулар", + about_stats_documents: "Базадагы документтер", + ph_target_person_id: "Бар жазуунун ID'си (update үчүн)", + ph_existing_search: "Бар жазууну тез издөө", + ph_full_name_required: "ФАА *", + ph_birth_year_required: "Туулган жылы *", + ph_birth_year: "Туулган жылы", + ph_death_year: "Каза болгон жылы", + ph_nationality: "Улуту", + ph_region: "Аймак", + ph_district: "Район", + ph_occupation: "Кесиби / кызматы", + ph_occupation_category: "Кесиби / категория", + ph_charge: "Айып", + ph_sentence: "Өкүм", + ph_arrest_date: "Камакка алынган күн", + ph_sentence_date: "Өкүм чыккан күн", + ph_rehabilitation_date: "Акталган күн", + ph_source: "Булак", + ph_status_verified: "Статус (мисалы, verified)", + ph_status_verified_short: "Статус (verified)", + ph_document_text: "Документ тексти (же markdown киргизиңиз)", + ph_biography: "Өмүр баяны" }, en: { nav_main: "Home", @@ -54,69 +378,631 @@ const translations = { nav_about: "About", nav_contacts: "Contacts", site_title: "MEMORY ARCHIVE", - search_placeholder: "Search by name, surname...", + search_placeholder: "Search by name, surname, year...", search_btn: "Search", hero_find: "Find a Person", hero_find_desc: "Enter a name or surname in the search bar to find information about victims of repression.", hero_add: "Add Information", - hero_add_desc: "Help expand the archive — upload documents and memories about your relatives.", + hero_add_desc: "Help expand the archive by uploading documents and memories about your relatives.", hero_help: "Support the Project", hero_help_desc: "Become a volunteer or support the project. Every contribution helps preserve memory.", footer_text: "© 2024 Memory Archive. Open encyclopedia of political repression victims.", + footer_short: "© 2024 Memory Archive", btn_register: "Register", btn_login: "Login", - btn_logout: "Logout" + btn_logout: "Logout", + title_about: "About — Memory Archive", + title_list: "Alphabetical Victim List — Memory Archive", + title_chat: "AI Assistant — Memory Archive", + title_suggestions: "Submit Entry — Memory Archive", + title_admin: "Admin Panel — Memory Archive", + title_contacts: "Contacts — Memory Archive", + title_index: "Memory Archive — Database of Political Repression Victims", + about_breadcrumb: "Home / About", + about_back: "← Back to home", + about_title: "About the Project: Memory Archive", + about_text_1: "Every name is a story. A story of a person whose fate was broken by state terror. Decades of silence turned millions of lives into nameless shadows erased from memory and archives.", + about_text_2: "We are building a living bridge between generations, carrying memory of the past to those who live today.", + partners_title: "Partners", + stats_title: "Statistics", + resources_title: "Resources", + resources_archives: "Archive collections", + resources_publications: "Publications", + resources_laws: "Legislation", + resources_methods: "Methodological materials", + resources_map: "Map", + list_page_title: "Alphabetical list of repression victims", + list_page_subtitle: "Registry of people affected by political repression. Choose a letter or use search.", + list_filters: "Filters", + filter_region: "Region", + filter_year: "Year of repression", + filter_apply: "Apply", + filter_all_regions: "All regions", + filter_all_years: "All years", + stats_records: "Records in database:", + stats_documents: "Documents:", + chat_header: "Dialogue with AI Assistant", + chat_intro: "Hello. I am the AI assistant of the Memory Archive project. Enter a name, surname, or place and I will help find archive data.", + chat_history_limit: "History limit:", + chat_load_history: "Load history", + chat_load_more: "More", + chat_not_authorized: "Not authorized", + chat_input_placeholder: "Type your request here...", + chat_send: "Send", + suggestions_title: "Suggestion Screen", + suggestions_desc_1: "A regular user can send a suggestion. After admin moderation, the entry will appear in the database.", + suggestions_desc_2: "Not authorized. Click \"Login\" in the header.", + suggestions_new: "New suggestion", + suggestions_kind_create: "New entry", + suggestions_kind_update: "Update existing entry", + suggestions_kind_document: "Document for existing entry", + suggestions_find_id: "Find ID", + suggestions_photo_tip: "Photo is optional. Size limit: under 2 MB.", + suggestions_document_title: "Document for entry (optional)", + suggestions_document_tip: "For \"Document\" mode, specify an existing entry ID and add text or a .txt/.md file.", + suggestions_send: "Send suggestion", + suggestions_my: "My suggestions", + suggestions_refresh: "Refresh", + admin_title: "Admin Panel", + admin_access_title: "Admin access", + admin_access_desc: "Access is available to administrators only.", + admin_login_btn: "Login as admin", + admin_import_title: "Seed import (JSON array)", + admin_import_btn: "Import seed", + admin_import_examples_btn: "Import bundled seeds", + admin_ai_settings_title: "AI tokens and providers settings", + admin_ai_load_btn: "Reload values", + admin_ai_save_btn: "Save AI settings", + admin_create_title: "Add card manually", + admin_create_btn: "Create card", + admin_cards_title: "Card list and deletion", + admin_refresh_cards: "Refresh list", + admin_moderation_title: "Suggestion moderation", + admin_filter_pending: "Pending only", + admin_filter_approved: "Approved only", + admin_filter_rejected: "Rejected only", + admin_filter_all: "All", + admin_load_suggestions: "Load suggestions", + contacts_title: "Contacts", + contacts_phone: "Phone:", + contacts_address: "Address:", + contacts_desc: "For data and API access questions, please contact us by email.", + docs_title: "Documents", + biography_title: "Biography", + profile_loading: "Loading...", + auth_title: "Sign in", + auth_username_placeholder: "Username", + auth_password_placeholder: "Password", + auth_not_authorized: "Not authorized", + auth_logged_in_as: "Logged in: {username} ({role})", + auth_need_credentials: "Enter username and password", + auth_register_success: "Registration successful. Please sign in.", + auth_login_btn: "Sign in", + status_logged_out: "You have signed out", + list_error_prefix: "Failed to load list:", + chat_sources_person_ids: "Source person_id:", + chat_sources_not_found: "No sources found", + chat_history_empty: "History is empty", + chat_history_you: "You", + chat_history_ai: "AI", + chat_history_sources: "Sources", + chat_history_login_needed: "Sign in to view history", + chat_login_first: "Please sign in first", + chat_empty_answer: "Empty response", + chat_history_error_prefix: "History error:", + chat_meta_template: "history: {loaded}/{total}, backend: {backend}", + suggestions_required_name_year: "Full name and birth year are required", + suggestions_need_target_id: "For update/document, provide existing entry ID", + suggestions_image_limit: "Image size must be less than 2 MB", + suggestions_doc_limit: "Document size must be less than 3 MB", + suggestions_doc_format: "Document must be in .txt or .md format", + suggestions_doc_required: "For document mode, add text or a .txt/.md file", + suggestions_sent: "Suggestion sent: #{id} ({kind})", + suggestions_search_enter: "Enter text to search entries", + suggestions_search_failed: "Search failed", + suggestions_search_empty: "Nothing found", + suggestions_search_found: "Found: {name} (#{id}). ID was applied.", + suggestions_login_to_view: "Sign in to view", + suggestions_no_items: "No suggestions yet", + admin_session_inactive: "Session inactive. Click \"Login as admin\".", + admin_login_not_admin: "Signed in as {username}, but role is not admin", + admin_logged_admin: "Signed in as admin: {username}", + admin_need_auth: "Admin authorization is required", + admin_need_name: "Enter full name to create a card", + admin_select_json: "Select a JSON file", + admin_import_done: "Import complete: created {created}, skipped {skipped}", + admin_bundled_import_done: "Bundled import: created {created}, skipped {skipped}, files: {files}", + admin_ai_loaded: "AI settings loaded", + admin_ai_saved: "AI settings saved", + admin_cards_login_needed: "Login as admin to view cards", + common_loading: "Loading...", + admin_no_cards: "No cards", + common_delete: "Delete", + admin_prompt_comment: "Moderator comment (optional):", + admin_moderation_done: "Moderation complete", + admin_suggestions_login_needed: "Login as admin for moderation", + admin_no_suggestions: "No suggestions", + common_approve: "Approve", + common_reject: "Reject", + about_partner_1: "State Archive of the Russian Federation", + about_partner_2: "Memorial", + about_partner_3: "Sakharov Center", + about_partner_4: "RGASPI", + about_partner_5: "Yad Vashem", + about_partner_6: "National Archive of the Kyrgyz Republic", + about_stats_records: "Records in database", + about_stats_documents: "Documents in database", + ph_target_person_id: "Existing entry ID (for update)", + ph_existing_search: "Quick search for existing entry", + ph_full_name_required: "Full name *", + ph_birth_year_required: "Birth year *", + ph_birth_year: "Birth year", + ph_death_year: "Death year", + ph_nationality: "Nationality", + ph_region: "Region", + ph_district: "District", + ph_occupation: "Occupation / position", + ph_occupation_category: "Occupation / category", + ph_charge: "Charge", + ph_sentence: "Sentence", + ph_arrest_date: "Arrest date", + ph_sentence_date: "Sentence date", + ph_rehabilitation_date: "Rehabilitation date", + ph_source: "Source", + ph_status_verified: "Status (e.g., verified)", + ph_status_verified_short: "Status (verified)", + ph_document_text: "Document text (or paste markdown)", + ph_biography: "Biography" }, tr: { nav_main: "Ana Sayfa", nav_db: "Veritabanı", - nav_chat: "AI Sohbet", + nav_chat: "Yapay Zeka Sohbet", nav_suggestions: "Kayıt Öner", nav_admin: "Yönetici", nav_about: "Hakkında", nav_contacts: "İletişim", site_title: "HAFIZA ARŞİVİ", - search_placeholder: "Ad, soyadına göre ara...", + search_placeholder: "Ad, soyad, yıl ile ara...", search_btn: "Ara", - hero_find: "Bir Kişi Bul", - hero_find_desc: "Baskı kurbanları hakkında bilgi bulmak için arama çubuğuna bir ad veya soyadı girin.", + hero_find: "Kişi Bul", + hero_find_desc: "Baskı mağdurları hakkında bilgi bulmak için arama çubuğuna ad veya soyad girin.", hero_add: "Bilgi Ekle", - hero_add_desc: "Arşivi genişletmeye yardım edin — akrabalarınız hakkında belgeler ve anılar yükleyin.", + hero_add_desc: "Arşivi büyütmeye yardımcı olun; yakınlarınızla ilgili belgeleri ve anıları yükleyin.", hero_help: "Projeyi Destekle", hero_help_desc: "Gönüllü olun veya projeyi destekleyin. Her katkı hafızayı korumaya yardımcı olur.", - footer_text: "© 2024 Hafıza Arşivi. Siyasi baskı kurbanlarının açık ansiklopedisi.", + footer_text: "© 2024 Hafıza Arşivi. Siyasi baskı mağdurlarının açık ansiklopedisi.", + footer_short: "© 2024 Hafıza Arşivi", btn_register: "Kayıt Ol", btn_login: "Giriş Yap", - btn_logout: "Çıkış Yap" + btn_logout: "Çıkış Yap", + title_about: "Hakkında — Hafıza Arşivi", + title_list: "Alfabetik Mağdur Listesi — Hafıza Arşivi", + title_chat: "Yapay Zeka Asistanı — Hafıza Arşivi", + title_suggestions: "Kayıt Öner — Hafıza Arşivi", + title_admin: "Yönetici Paneli — Hafıza Arşivi", + title_contacts: "İletişim — Hafıza Arşivi", + title_index: "Hafıza Arşivi — Siyasi Baskı Mağdurları Veritabanı", + about_breadcrumb: "Ana Sayfa / Hakkında", + about_back: "← Ana sayfaya dön", + about_title: "Proje Hakkında: Hafıza Arşivi", + about_text_1: "Her isim bir hikayedir. Devlet terörü tarafından kaderi parçalanmış bir insanın hikayesi. Sessizliğin geçen on yılları milyonlarca hayatı hafızadan ve arşivlerden silinmiş isimsiz gölgelere dönüştürdü.", + about_text_2: "Geçmişin hafızasını bugün yaşayanlara taşıyan, kuşaklar arasında canlı bir köprü kuruyoruz.", + partners_title: "Ortaklar", + stats_title: "İstatistik", + resources_title: "Kaynaklar", + resources_archives: "Arşiv fonları", + resources_publications: "Yayınlar", + resources_laws: "Mevzuat", + resources_methods: "Yöntem materyalleri", + resources_map: "Harita", + list_page_title: "Baskı mağdurlarının alfabetik listesi", + list_page_subtitle: "Siyasi baskılardan etkilenen kişilerin kaydı. Bir harf seçin veya arama kullanın.", + list_filters: "Filtreler", + filter_region: "Bölge", + filter_year: "Baskı yılı", + filter_apply: "Uygula", + filter_all_regions: "Tüm bölgeler", + filter_all_years: "Tüm yıllar", + stats_records: "Veritabanındaki kayıtlar:", + stats_documents: "Belgeler:", + chat_header: "Yapay Zeka Asistanı ile Diyalog", + chat_intro: "Merhaba. Ben Hafıza Arşivi projesinin yapay zeka asistanıyım. Ad, soyad veya yer yazın; arşiv verilerinde bulmanıza yardımcı olayım.", + chat_history_limit: "Geçmiş limiti:", + chat_load_history: "Geçmişi yükle", + chat_load_more: "Daha fazla", + chat_not_authorized: "Yetkilendirilmedi", + chat_input_placeholder: "İsteğinizi buraya yazın...", + chat_send: "Gönder", + suggestions_title: "Öneri Ekranı", + suggestions_desc_1: "Normal kullanıcı öneri gönderebilir. Yönetici moderasyonundan sonra kayıt veritabanında görünür.", + suggestions_desc_2: "Yetkilendirilmedi. Üst menüdeki \"Giriş Yap\" düğmesine tıklayın.", + suggestions_new: "Yeni öneri", + suggestions_kind_create: "Yeni kayıt", + suggestions_kind_update: "Mevcut kaydı güncelle", + suggestions_kind_document: "Mevcut kayda belge", + suggestions_find_id: "Kimlik bul", + suggestions_photo_tip: "Fotoğraf isteğe bağlıdır. Boyut sınırı: 2 MB altı.", + suggestions_document_title: "Kayda belge (isteğe bağlı)", + suggestions_document_tip: "\"Belge\" modu için mevcut kayıt kimliğini girin ve metin veya .txt/.md dosyası ekleyin.", + suggestions_send: "Öneri gönder", + suggestions_my: "Önerilerim", + suggestions_refresh: "Yenile", + admin_title: "Yönetici Paneli", + admin_access_title: "Yönetici erişimi", + admin_access_desc: "Erişim yalnızca yöneticilere açıktır.", + admin_login_btn: "Yönetici olarak giriş", + admin_import_title: "Seed içe aktarımı (JSON dizi)", + admin_import_btn: "Seed içe aktar", + admin_import_examples_btn: "Yerleşik seed verilerini içe aktar", + admin_ai_settings_title: "AI token ve sağlayıcı ayarları", + admin_ai_load_btn: "Değerleri yenile", + admin_ai_save_btn: "AI ayarlarını kaydet", + admin_create_title: "Kartı elle ekle", + admin_create_btn: "Kart oluştur", + admin_cards_title: "Kart listesi ve silme", + admin_refresh_cards: "Listeyi yenile", + admin_moderation_title: "Öneri moderasyonu", + admin_filter_pending: "Sadece pending", + admin_filter_approved: "Sadece approved", + admin_filter_rejected: "Sadece rejected", + admin_filter_all: "Tümü", + admin_load_suggestions: "Önerileri yükle", + contacts_title: "İletişim", + contacts_phone: "Telefon:", + contacts_address: "Adres:", + contacts_desc: "Veri ve API erişimi soruları için lütfen e-posta ile yazın.", + docs_title: "Belgeler", + biography_title: "Biyografi", + profile_loading: "Yükleniyor...", + auth_title: "Hesaba giriş", + auth_username_placeholder: "Kullanıcı adı", + auth_password_placeholder: "Şifre", + auth_not_authorized: "Yetkilendirilmedi", + auth_logged_in_as: "Giriş yapıldı: {username} ({role})", + auth_need_credentials: "Kullanıcı adı ve şifre girin", + auth_register_success: "Kayıt başarılı. Şimdi giriş yapın.", + auth_login_btn: "Giriş", + status_logged_out: "Çıkış yaptınız", + list_error_prefix: "Liste yükleme hatası:", + chat_sources_person_ids: "Kaynak person_id:", + chat_sources_not_found: "Kaynak bulunamadı", + chat_history_empty: "Geçmiş boş", + chat_history_you: "Siz", + chat_history_ai: "AI", + chat_history_sources: "Kaynaklar", + chat_history_login_needed: "Geçmişi görmek için giriş yapın", + chat_login_first: "Önce giriş yapın", + chat_empty_answer: "Boş yanıt", + chat_history_error_prefix: "Geçmiş hatası:", + chat_meta_template: "geçmiş: {loaded}/{total}, backend: {backend}", + suggestions_required_name_year: "Ad soyad ve doğum yılı zorunludur", + suggestions_need_target_id: "update/document için mevcut kayıt ID girin", + suggestions_image_limit: "Görsel boyutu 2 MB'dan küçük olmalı", + suggestions_doc_limit: "Belge boyutu 3 MB'dan küçük olmalı", + suggestions_doc_format: "Belge .txt veya .md formatında olmalı", + suggestions_doc_required: "document modu için metin veya .txt/.md dosyası ekleyin", + suggestions_sent: "Öneri gönderildi: #{id} ({kind})", + suggestions_search_enter: "Kayıt aramak için metin girin", + suggestions_search_failed: "Arama başarısız oldu", + suggestions_search_empty: "Sonuç bulunamadı", + suggestions_search_found: "Bulundu: {name} (#{id}). ID yerleştirildi.", + suggestions_login_to_view: "Görüntülemek için giriş yapın", + suggestions_no_items: "Henüz öneri yok", + admin_session_inactive: "Oturum aktif değil. \"Yönetici olarak giriş\" düğmesine basın.", + admin_login_not_admin: "{username} olarak giriş yapıldı, ancak rol admin değil", + admin_logged_admin: "Admin olarak giriş yapıldı: {username}", + admin_need_auth: "Yönetici yetkilendirmesi gerekli", + admin_need_name: "Kart oluşturmak için ad soyad girin", + admin_select_json: "Bir JSON dosyası seçin", + admin_import_done: "İçe aktarma tamamlandı: oluşturulan {created}, atlanan {skipped}", + admin_bundled_import_done: "Yerleşik içe aktarma: oluşturulan {created}, atlanan {skipped}, dosyalar: {files}", + admin_ai_loaded: "AI ayarları yüklendi", + admin_ai_saved: "AI ayarları kaydedildi", + admin_cards_login_needed: "Kartları görmek için admin olarak giriş yapın", + common_loading: "Yükleniyor...", + admin_no_cards: "Kart yok", + common_delete: "Sil", + admin_prompt_comment: "Moderatör yorumu (isteğe bağlı):", + admin_moderation_done: "Moderasyon tamamlandı", + admin_suggestions_login_needed: "Moderasyon için admin olarak giriş yapın", + admin_no_suggestions: "Öneri yok", + common_approve: "Onayla", + common_reject: "Reddet", + about_partner_1: "Rusya Federasyonu Devlet Arşivi", + about_partner_2: "Memorial", + about_partner_3: "Sakharov Merkezi", + about_partner_4: "RGASPI", + about_partner_5: "Yad Vashem", + about_partner_6: "Kırgız Cumhuriyeti Ulusal Arşivi", + about_stats_records: "Veritabanındaki kayıtlar", + about_stats_documents: "Veritabanındaki belgeler", + ph_target_person_id: "Mevcut kayıt ID (update için)", + ph_existing_search: "Mevcut kayıtta hızlı arama", + ph_full_name_required: "Ad Soyad *", + ph_birth_year_required: "Doğum yılı *", + ph_birth_year: "Doğum yılı", + ph_death_year: "Ölüm yılı", + ph_nationality: "Uyruk", + ph_region: "Bölge", + ph_district: "İlçe", + ph_occupation: "Meslek / görev", + ph_occupation_category: "Meslek / kategori", + ph_charge: "Suçlama", + ph_sentence: "Hüküm", + ph_arrest_date: "Tutuklanma tarihi", + ph_sentence_date: "Hüküm tarihi", + ph_rehabilitation_date: "Rehabilitasyon tarihi", + ph_source: "Kaynak", + ph_status_verified: "Durum (örn. verified)", + ph_status_verified_short: "Durum (verified)", + ph_document_text: "Belge metni (veya markdown yapıştırın)", + ph_biography: "Biyografi" } }; +const PAGE_BINDINGS = { + shared: [ + { selector: ".logo-text", key: "site_title" }, + { selector: "#searchInput", key: "search_placeholder", attr: "placeholder" }, + { selector: "#searchBtn", key: "search_btn" }, + { selector: '.header-nav a[href="index.html"]', key: "nav_main" }, + { selector: '.header-nav a[href="list.html"]', key: "nav_db" }, + { selector: '.header-nav a[href="about.html"]', key: "nav_about" }, + { selector: '.header-nav a[href="contacts.html"]', key: "nav_contacts" }, + { selector: '.header-nav a[href="chat.html"]', key: "nav_chat" }, + { selector: '.header-nav a[href="suggestions.html"]', key: "nav_suggestions" }, + { selector: '.header-nav a[href="admin.html"]', key: "nav_admin" }, + { selector: ".site-footer p", key: "footer_text" }, + { selector: "#globalLoginOpenBtn", key: "btn_login" }, + { selector: "#globalLogoutBtn", key: "btn_logout" }, + { selector: "#globalAuthRegisterBtn", key: "btn_register" }, + { selector: "#globalAuthLoginBtn", key: "btn_login" }, + { selector: "#globalAuthTitle", key: "auth_title" }, + { selector: "#globalAuthUser", key: "auth_username_placeholder", attr: "placeholder" }, + { selector: "#globalAuthPass", key: "auth_password_placeholder", attr: "placeholder" }, + { selector: "#globalAuthStatus", key: "auth_not_authorized" } + ], + "index.html": [ + { selector: "#heroFindCard", key: "hero_find", attr: "aria-label" }, + { selector: "#heroSuggestCard", key: "hero_add", attr: "aria-label" }, + { selector: "#heroHelpCard", key: "hero_help", attr: "aria-label" }, + { selector: ".documents-section h2", key: "docs_title" }, + { selector: ".biography-section h2", key: "biography_title" }, + { selector: "#personName", key: "profile_loading" } + ], + "about.html": [ + { selector: ".about-back-link", key: "about_back" }, + { selector: ".about-title", key: "about_title" }, + { selector: ".about-dropcap", key: "about_text_1" }, + { selector: ".about-text p:nth-of-type(2)", key: "about_text_2" }, + { selector: ".about-section-title:nth-of-type(1)", key: "partners_title" }, + { selector: ".about-section-title:nth-of-type(2)", key: "stats_title" }, + { selector: ".sidebar-box h4", key: "resources_title" }, + { selector: ".sidebar-box li:nth-of-type(1) a", key: "resources_archives" }, + { selector: ".sidebar-box li:nth-of-type(2) a", key: "resources_publications" }, + { selector: ".sidebar-box li:nth-of-type(3) a", key: "resources_laws" }, + { selector: ".sidebar-box li:nth-of-type(4) a", key: "resources_methods" } + ], + "list.html": [ + { selector: ".page-title-inner h1", key: "list_page_title" }, + { selector: ".page-subtitle", key: "list_page_subtitle" }, + { selector: ".sidebar-box h4:nth-of-type(1)", key: "list_filters" }, + { selector: 'label[for="filterRegion"]', key: "filter_region" }, + { selector: 'label[for="filterYear"]', key: "filter_year" }, + { selector: ".filter-apply-btn", key: "filter_apply" }, + { selector: '#filterRegion option[value=""]', key: "filter_all_regions" }, + { selector: '#filterYear option[value=""]', key: "filter_all_years" }, + { selector: ".sidebar-box:nth-of-type(2) h4", key: "resources_title" }, + { selector: ".sidebar-box:nth-of-type(2) li:nth-of-type(1) a", key: "resources_archives" }, + { selector: ".sidebar-box:nth-of-type(2) li:nth-of-type(2) a", key: "resources_publications" }, + { selector: ".sidebar-box:nth-of-type(2) li:nth-of-type(3) a", key: "resources_laws" }, + { selector: ".sidebar-box:nth-of-type(2) li:nth-of-type(4) a", key: "resources_methods" }, + { selector: ".sidebar-box:nth-of-type(3) h4", key: "stats_title" }, + { selector: ".sidebar-box:nth-of-type(3) .sidebar-stat:nth-of-type(1)", key: "stats_records", mode: "prefix-strong" }, + { selector: ".sidebar-box:nth-of-type(3) .sidebar-stat:nth-of-type(2)", key: "stats_documents", mode: "prefix-strong" } + ], + "chat.html": [ + { selector: ".chat-header", key: "chat_header" }, + { selector: ".chat-history .message.ai", key: "chat_intro" }, + { selector: ".chat-limit-label", key: "chat_history_limit" }, + { selector: "#loadHistoryBtn", key: "chat_load_history" }, + { selector: "#loadMoreHistoryBtn", key: "chat_load_more" }, + { selector: "#chatStatus", key: "chat_not_authorized" }, + { selector: "#chatInput", key: "chat_input_placeholder", attr: "placeholder" }, + { selector: "#sendChatBtn", key: "chat_send" }, + { selector: ".sidebar-box:nth-of-type(1) h4", key: "resources_title" }, + { selector: ".sidebar-box:nth-of-type(1) li:nth-of-type(1) a", key: "resources_archives" }, + { selector: ".sidebar-box:nth-of-type(1) li:nth-of-type(2) a", key: "resources_publications" }, + { selector: ".sidebar-box:nth-of-type(1) li:nth-of-type(3) a", key: "resources_map" }, + { selector: ".sidebar-box:nth-of-type(1) li:nth-of-type(4) a", key: "resources_methods" }, + { selector: ".sidebar-box:nth-of-type(2) h4", key: "stats_title" }, + { selector: ".sidebar-box:nth-of-type(2) .sidebar-stat:nth-of-type(1)", key: "stats_records", mode: "prefix-strong" }, + { selector: ".sidebar-box:nth-of-type(2) .sidebar-stat:nth-of-type(2)", key: "stats_documents", mode: "prefix-strong" }, + { selector: ".site-footer p", key: "footer_short" } + ], + "suggestions.html": [ + { selector: ".biography-section h2", key: "suggestions_title" }, + { selector: ".biography-section p:nth-of-type(1)", key: "suggestions_desc_1" }, + { selector: "#suggestAuthStatus", key: "suggestions_desc_2" }, + { selector: ".admin-panel:nth-of-type(1) h3", key: "suggestions_new" }, + { selector: '#suggestionKind option[value="create"]', key: "suggestions_kind_create" }, + { selector: '#suggestionKind option[value="update"]', key: "suggestions_kind_update" }, + { selector: '#suggestionKind option[value="document"]', key: "suggestions_kind_document" }, + { selector: "#findExistingBtn", key: "suggestions_find_id" }, + { selector: ".admin-panel:nth-of-type(1) .chat-helper", key: "suggestions_photo_tip" }, + { selector: ".admin-panel:nth-of-type(1) h4", key: "suggestions_document_title" }, + { selector: ".admin-panel:nth-of-type(1) p:nth-of-type(2)", key: "suggestions_document_tip" }, + { selector: "#sendSuggestionBtn", key: "suggestions_send" }, + { selector: ".admin-panel:nth-of-type(2) h3", key: "suggestions_my" }, + { selector: "#loadMySuggestionsBtn", key: "suggestions_refresh" }, + { selector: ".site-footer p", key: "footer_short" } + ], + "admin.html": [ + { selector: ".biography-section h2", key: "admin_title" }, + { selector: "#adminAccessPanel h3", key: "admin_access_title" }, + { selector: "#adminStatus", key: "admin_access_desc" }, + { selector: "#openAdminLoginModalBtn", key: "admin_login_btn" }, + { selector: ".admin-panel.admin-protected:nth-of-type(2) h3", key: "admin_import_title" }, + { selector: "#seedImportBtn", key: "admin_import_btn" }, + { selector: "#seedExamplesImportBtn", key: "admin_import_examples_btn" }, + { selector: ".admin-panel.admin-protected:nth-of-type(3) h3", key: "admin_ai_settings_title" }, + { selector: "#loadAiConfigBtn", key: "admin_ai_load_btn" }, + { selector: "#saveAiConfigBtn", key: "admin_ai_save_btn" }, + { selector: ".admin-panel.admin-protected:nth-of-type(4) h3", key: "admin_create_title" }, + { selector: "#createCardBtn", key: "admin_create_btn" }, + { selector: ".admin-panel.admin-protected:nth-of-type(5) h3", key: "admin_cards_title" }, + { selector: "#refreshCardsBtn", key: "admin_refresh_cards" }, + { selector: ".admin-panel.admin-protected:nth-of-type(6) h3", key: "admin_moderation_title" }, + { selector: '#suggestionsStateFilter option[value="pending"]', key: "admin_filter_pending" }, + { selector: '#suggestionsStateFilter option[value="approved"]', key: "admin_filter_approved" }, + { selector: '#suggestionsStateFilter option[value="rejected"]', key: "admin_filter_rejected" }, + { selector: '#suggestionsStateFilter option[value=""]', key: "admin_filter_all" }, + { selector: "#loadSuggestionsBtn", key: "admin_load_suggestions" }, + { selector: ".site-footer p", key: "footer_short" } + ], + "contacts.html": [ + { selector: ".biography-section h2", key: "contacts_title" }, + { selector: ".biography-section p:nth-of-type(2) strong", key: "contacts_phone" }, + { selector: ".biography-section p:nth-of-type(3) strong", key: "contacts_address" }, + { selector: ".biography-section p:nth-of-type(4)", key: "contacts_desc" }, + { selector: ".site-footer p", key: "footer_short" } + ] +}; + +const SUPPORTED_LANGS = ["ru", "ky", "en", "tr"]; + +function normalizeLang(lang) { + return SUPPORTED_LANGS.includes(lang) ? lang : "ru"; +} + +function getCurrentLanguage() { + return normalizeLang(localStorage.getItem("archive_lang") || "ru"); +} + +function t(key) { + const lang = getCurrentLanguage(); + return translations[lang]?.[key] || translations.ru[key] || key; +} + function setLanguage(lang) { - localStorage.setItem('archive_lang', lang); - applyLanguage(lang); + const normalized = normalizeLang(lang); + localStorage.setItem("archive_lang", normalized); + applyLanguage(normalized); +} + +function applyElementText(element, value, mode) { + if (!element || !value) return; + if (mode === "prefix-strong") { + const strong = element.querySelector("strong"); + if (strong) { + element.textContent = `${value} `; + element.appendChild(strong); + return; + } + } + element.textContent = value; +} + +function applyBinding(binding, dict) { + const element = document.querySelector(binding.selector); + if (!element) return; + const value = dict[binding.key] || translations.ru[binding.key]; + if (!value) return; + + if (binding.attr) { + element.setAttribute(binding.attr, value); + return; + } + + applyElementText(element, value, binding.mode); +} + +function applyPageBindings(lang) { + const dict = translations[lang] || translations.ru; + const page = (window.location.pathname.split("/").pop() || "index.html").toLowerCase(); + const bindings = [...PAGE_BINDINGS.shared, ...(PAGE_BINDINGS[page] || [])]; + + bindings.forEach((binding) => applyBinding(binding, dict)); + + const titleKey = `title_${page.replace(".html", "")}`; + if (dict[titleKey]) { + document.title = dict[titleKey]; + } } function applyLanguage(lang) { - const t = translations[lang] || translations['ru']; - document.querySelectorAll('[data-i18n]').forEach(el => { - const key = el.getAttribute('data-i18n'); - if (t[key]) { - if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') { - el.placeholder = t[key]; - } else { - el.textContent = t[key]; - } + const normalized = normalizeLang(lang); + const dict = translations[normalized] || translations.ru; + + document.documentElement.lang = normalized; + + document.querySelectorAll("[data-i18n]").forEach((el) => { + const key = el.getAttribute("data-i18n"); + if (!dict[key]) return; + + if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") { + el.placeholder = dict[key]; + } else { + el.textContent = dict[key]; } }); - - // Подсвечиваем активную кнопку языка - document.querySelectorAll('.lang-btn').forEach(btn => { - const btnLang = btn.getAttribute('data-lang'); - btn.classList.toggle('active', btnLang === lang); + + document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => { + const key = el.getAttribute("data-i18n-placeholder"); + if (dict[key]) el.setAttribute("placeholder", dict[key]); + }); + + applyPageBindings(normalized); + + document.querySelectorAll(".lang-btn").forEach((btn) => { + const btnLang = btn.getAttribute("data-lang"); + btn.classList.toggle("active", btnLang === normalized); + }); + + window.dispatchEvent(new CustomEvent("site-language-changed", { detail: { lang: normalized } })); +} + +function ensureLanguageSwitcher() { + if (document.querySelector(".lang-switcher")) return; + + const host = document.body; + const switcher = document.createElement("div"); + switcher.className = "lang-switcher"; + + SUPPORTED_LANGS.forEach((lang) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "lang-btn"; + btn.setAttribute("data-lang", lang); + btn.textContent = lang.toUpperCase(); + btn.setAttribute("aria-label", `Switch language to ${lang.toUpperCase()}`); + btn.addEventListener("click", () => setLanguage(lang)); + switcher.appendChild(btn); }); + + host.appendChild(switcher); } -document.addEventListener('DOMContentLoaded', () => { - const savedLang = localStorage.getItem('archive_lang') || 'ru'; +window.AppI18n = { + setLanguage, + applyLanguage, + getCurrentLanguage, + t +}; + +window.setLanguage = setLanguage; + +document.addEventListener("DOMContentLoaded", () => { + ensureLanguageSwitcher(); + + const savedLang = getCurrentLanguage(); applyLanguage(savedLang); + + // Run once again after dynamic header render. + setTimeout(() => applyLanguage(savedLang), 0); + + window.addEventListener("site-auth-changed", () => { + applyLanguage(getCurrentLanguage()); + }); }); \ No newline at end of file diff --git a/front/index.html b/front/index.html index b9271d4..9ec28ac 100644 --- a/front/index.html +++ b/front/index.html @@ -110,6 +110,7 @@

Статистика

© 2024 Архив Памяти. Открытая энциклопедия жертв политических репрессий.

+ diff --git a/front/list-script.js b/front/list-script.js index 23348ea..83ff4c0 100644 --- a/front/list-script.js +++ b/front/list-script.js @@ -1,6 +1,10 @@ const CYRILLIC = "АБВГДЕЖЗИКЛМНОПРСТУФХЦЧШЩЭЮЯ".split(""); let allPeople = []; +function tr(key, fallback) { + return window.AppI18n?.t?.(key) || fallback; +} + function resolveApiBase() { const params = new URLSearchParams(window.location.search); const fromQuery = params.get("api"); @@ -120,7 +124,7 @@ function applyFilters() { const filtered = allPeople.filter((p) => { const text = `${p.name} ${p.region} ${p.district} ${p.occupation} ${p.charge}`.toLowerCase(); const byQuery = !query || text.includes(query); - const byRegion = !region || (p.region || "").toLowerCase().includes(region); + const byRegion = !region || (p.region || "").toLowerCase() === region; const byYear = !year || String(p.birth_year || "") === year || String(p.death_year || "") === year; return byQuery && byRegion && byYear; }); @@ -140,14 +144,21 @@ function fillRegionFilter() { } }); - const values = Array.from(existing).sort((a, b) => a.localeCompare(b, "ru")); - select.innerHTML = ""; + const values = Array.from(existing) + .map((v) => v.trim()) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b, "ru")); + const selected = (select.value || "").trim(); + select.innerHTML = ``; values.forEach((region) => { const option = document.createElement("option"); option.value = region; option.textContent = region; select.appendChild(option); }); + if (selected && values.includes(selected)) { + select.value = selected; + } } function fillYearFilter() { @@ -159,14 +170,18 @@ function fillYearFilter() { if (p.death_year) existing.add(String(p.death_year)); }); - const values = Array.from(existing).sort(); - select.innerHTML = ""; + const values = Array.from(existing).sort((a, b) => Number(a) - Number(b)); + const selected = (select.value || "").trim(); + select.innerHTML = ``; values.forEach((year) => { const option = document.createElement("option"); option.value = year; option.textContent = year; select.appendChild(option); }); + if (selected && values.includes(selected)) { + select.value = selected; + } } function setupSearch() { @@ -215,7 +230,7 @@ async function loadStats() { function showError(message) { const container = document.getElementById("registryList"); - container.innerHTML = `
Ошибка загрузки списка: ${message}
`; + container.innerHTML = `
${tr("list_error_prefix", "Failed to load list:")} ${message}
`; } document.addEventListener("DOMContentLoaded", async () => { @@ -230,4 +245,10 @@ document.addEventListener("DOMContentLoaded", async () => { } catch (err) { showError(err.message); } + + window.addEventListener("site-language-changed", () => { + fillRegionFilter(); + fillYearFilter(); + applyFilters(); + }); }); diff --git a/front/list-style.css b/front/list-style.css index b0928d7..123d5da 100644 --- a/front/list-style.css +++ b/front/list-style.css @@ -185,9 +185,62 @@ /* ===== RESPONSIVE ===== */ @media (max-width: 900px) { + .page-title-section { + padding: 20px 12px 16px; + } + + .page-title-inner h1 { + font-size: 22px; + margin-bottom: 4px; + padding-bottom: 8px; + } + + .page-subtitle { + font-size: 13px; + } + + .alphabet-bar { + gap: 5px; + margin-bottom: 18px; + padding-bottom: 12px; + } + .alphabet-bar a { width: 30px; height: 30px; font-size: 13px; } + + .letter-heading { + font-size: 19px; + margin-bottom: 8px; + } + + .name-list a { + font-size: 14px; + padding: 10px 10px; + } + + .name-list .name-id { + display: block; + margin-left: 0; + margin-top: 2px; + } +} + +@media (max-width: 520px) { + .alphabet-bar a { + width: 28px; + height: 28px; + font-size: 12px; + } + + .letter-heading { + font-size: 17px; + } + + .name-list a { + padding: 9px 8px; + font-size: 13px; + } } diff --git a/front/list.html b/front/list.html index 7ce724f..7374fe9 100644 --- a/front/list.html +++ b/front/list.html @@ -58,20 +58,10 @@

Фильтры

@@ -98,6 +88,7 @@

Статистика

© 2024 Архив Памяти. Открытая энциклопедия жертв политических репрессий.

+ diff --git a/front/style.css b/front/style.css index fa96080..0f9e38b 100644 --- a/front/style.css +++ b/front/style.css @@ -159,6 +159,43 @@ img { background: #e7ddc7; } +.lang-switcher { + position: fixed; + right: 14px; + bottom: 14px; + z-index: 350; + display: flex; + gap: 6px; + padding: 6px; + border-radius: 999px; + border: 1px solid #d3c7b0; + background: rgba(250, 245, 236, 0.95); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18); + backdrop-filter: blur(3px); +} + +.lang-btn { + border: 1px solid #bda982; + background: #f8f3e8; + color: #4b3620; + border-radius: 999px; + padding: 5px 9px; + font-size: 11px; + font-weight: bold; + letter-spacing: 0.5px; + cursor: pointer; +} + +.lang-btn:hover { + background: #efe4cf; +} + +.lang-btn.active { + background: #7a2020; + border-color: #7a2020; + color: #fff; +} + .global-auth-modal { position: fixed; inset: 0; @@ -630,5 +667,176 @@ img { width: 100%; justify-content: flex-end; } + .lang-switcher { + right: 10px; + bottom: 10px; + gap: 4px; + padding: 5px; + } + .lang-btn { + padding: 4px 7px; + font-size: 10px; + } +} + +@media (max-width: 700px) { + .site-header { + position: static; + } + + .header-inner { + padding: 10px 12px; + gap: 10px; + } + + .logo-text { + font-size: 14px; + letter-spacing: 1px; + } + + .search-bar input, + .search-bar button { + font-size: 13px; + padding-top: 7px; + padding-bottom: 7px; + } + + .header-nav { + width: 100%; + order: 4; + overflow-x: auto; + white-space: nowrap; + gap: 8px; + padding-bottom: 4px; + -webkit-overflow-scrolling: touch; + } + + .header-nav a { + display: inline-block; + padding: 6px 9px; + background: #383838; + border-radius: 999px; + font-size: 12px; + } + + .header-nav a.nav-active { + border-bottom: none; + background: #7a2020; + } + + .global-auth-controls { + width: 100%; + order: 5; + justify-content: flex-end; + } + + .global-auth-controls button { + padding: 7px 10px; + font-size: 12px; + } + + .main-wrapper { + margin: 18px auto; + padding: 0 12px; + gap: 16px; + } + + .hero { + padding: 20px 12px; + } + + .hero-card { + padding: 18px 14px; + } + + .person-name { + font-size: 22px; + } + + .person-years { + font-size: 14px; + margin-bottom: 14px; + } + + .documents-section h2, + .biography-section h2 { + font-size: 20px; + } + + .profile-meta td { + font-size: 13px; + padding: 6px 8px; + } + + .profile-meta td:first-child { + width: 42%; + white-space: normal; + } + + .documents-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .sidebar-box { + padding: 14px 12px; + } + + .admin-form-grid { + grid-template-columns: 1fr; + gap: 8px; + } + + .admin-card-row { + flex-direction: column; + align-items: stretch; + } + + .admin-card-row button { + width: 100%; + } + + .lang-switcher { + right: 8px; + bottom: 8px; + max-width: calc(100vw - 16px); + } +} + +@media (max-width: 520px) { + .header-inner { + padding: 8px 10px; + } + + .logo-icon { + font-size: 18px; + } + + .logo-text { + font-size: 12px; + } + + .hero-card h3 { + font-size: 16px; + } + + .hero-card p, + .biography-text { + font-size: 14px; + } + + .documents-grid { + grid-template-columns: 1fr 1fr; + gap: 10px; + } + + .lang-btn { + padding: 4px 6px; + font-size: 9px; + } + + .site-footer { + margin-top: 20px; + padding: 14px 10px; + } } diff --git a/front/suggestions-script.js b/front/suggestions-script.js index 7642715..be3b994 100644 --- a/front/suggestions-script.js +++ b/front/suggestions-script.js @@ -2,6 +2,18 @@ let currentUser = null; const MAX_IMAGE_BYTES = 2 * 1024 * 1024; const MAX_DOCUMENT_BYTES = 3 * 1024 * 1024; +function tr(key, fallback) { + return window.AppI18n?.t?.(key) || fallback; +} + +function tf(key, fallback, vars = {}) { + let text = tr(key, fallback); + Object.entries(vars).forEach(([name, value]) => { + text = text.replaceAll(`{${name}}`, String(value ?? "")); + }); + return text; +} + function resolveApiBase() { const params = new URLSearchParams(window.location.search); const fromQuery = params.get("api"); @@ -54,16 +66,19 @@ async function checkSession() { } if (!currentUser) { - setStatus("Не авторизован. Нажмите \"Зайти\" в шапке."); + setStatus(tr("suggestions_desc_2", "Not authorized. Click \"Login\" in the header.")); return; } - setStatus(`Вход выполнен: ${currentUser.username} (${currentUser.role})`); + setStatus(tf("auth_logged_in_as", "Logged in: {username} ({role})", { + username: currentUser.username, + role: currentUser.role, + })); } async function sendSuggestion() { if (!currentUser) { - setStatus("Сначала войдите в аккаунт"); + setStatus(tr("chat_login_first", "Please sign in first")); return; } @@ -74,39 +89,39 @@ async function sendSuggestion() { const fullName = readText("fullName"); const birthYear = parseYear("birthYear"); if (suggestionKind !== "document" && (!fullName || !birthYear)) { - setStatus("ФИО и год рождения обязательны"); + setStatus(tr("suggestions_required_name_year", "Full name and birth year are required")); return; } if ((suggestionKind === "update" || suggestionKind === "document") && !targetPersonId) { - setStatus("Для update/document укажите ID существующей записи"); + setStatus(tr("suggestions_need_target_id", "For update/document, provide existing entry ID")); return; } const photoInput = document.getElementById("photoFile"); const photo = photoInput?.files?.[0] || null; if (photo && photo.size >= MAX_IMAGE_BYTES) { - setStatus("Размер изображения должен быть меньше 2 МБ"); + setStatus(tr("suggestions_image_limit", "Image size must be less than 2 MB")); return; } const documentInput = document.getElementById("documentFile"); const documentFile = documentInput?.files?.[0] || null; if (documentFile && documentFile.size >= MAX_DOCUMENT_BYTES) { - setStatus("Размер документа должен быть меньше 3 МБ"); + setStatus(tr("suggestions_doc_limit", "Document size must be less than 3 MB")); return; } if (documentFile) { const name = (documentFile.name || "").toLowerCase(); if (!(name.endsWith(".txt") || name.endsWith(".md") || name.endsWith(".markdown"))) { - setStatus("Документ должен быть в формате .txt или .md"); + setStatus(tr("suggestions_doc_format", "Document must be in .txt or .md format")); return; } } if (suggestionKind === "document" && !documentText && !documentFile) { - setStatus("Для document-режима добавьте текст или файл .txt/.md"); + setStatus(tr("suggestions_doc_required", "For document mode, add text or a .txt/.md file")); return; } @@ -150,7 +165,10 @@ async function sendSuggestion() { } const created = await response.json(); - setStatus(`Предложение отправлено: #${created.id} (${created.suggestion_kind})`); + setStatus(tf("suggestions_sent", "Suggestion sent: #{id} ({kind})", { + id: created.id, + kind: created.suggestion_kind, + })); if (photoInput) { photoInput.value = ""; } @@ -164,19 +182,19 @@ async function findExistingPerson() { const query = readText("existingSearch"); const resultEl = document.getElementById("existingSearchResult"); if (!query) { - resultEl.textContent = "Введите текст для поиска записи"; + resultEl.textContent = tr("suggestions_search_enter", "Enter text to search entries"); return; } const response = await apiFetch(`/api/persons/search?q=${encodeURIComponent(query)}&limit=5`); if (!response.ok) { - resultEl.textContent = "Не удалось выполнить поиск"; + resultEl.textContent = tr("suggestions_search_failed", "Search failed"); return; } const items = await response.json(); if (!items.length) { - resultEl.textContent = "Ничего не найдено"; + resultEl.textContent = tr("suggestions_search_empty", "Nothing found"); return; } @@ -184,12 +202,15 @@ async function findExistingPerson() { document.getElementById("targetPersonId").value = String(first.id); document.getElementById("fullName").value = first.full_name || ""; document.getElementById("birthYear").value = String(first.birth_year || ""); - resultEl.textContent = `Найдено: ${first.full_name} (#${first.id}). ID подставлен.`; + resultEl.textContent = tf("suggestions_search_found", "Found: {name} (#{id}). ID was applied.", { + name: first.full_name, + id: first.id, + }); } async function loadMySuggestions() { if (!currentUser) { - document.getElementById("mySuggestionsList").innerHTML = "Войдите для просмотра"; + document.getElementById("mySuggestionsList").innerHTML = tr("suggestions_login_to_view", "Sign in to view"); return; } @@ -203,7 +224,7 @@ async function loadMySuggestions() { const items = await response.json(); const container = document.getElementById("mySuggestionsList"); if (!items.length) { - container.innerHTML = "Пока нет предложений"; + container.innerHTML = tr("suggestions_no_items", "No suggestions yet"); return; } @@ -226,15 +247,30 @@ document.addEventListener("DOMContentLoaded", async () => { window.addEventListener("site-auth-changed", async (event) => { currentUser = event.detail?.user || null; if (currentUser) { - setStatus(`Вход выполнен: ${currentUser.username} (${currentUser.role})`); + setStatus(tf("auth_logged_in_as", "Logged in: {username} ({role})", { + username: currentUser.username, + role: currentUser.role, + })); await loadMySuggestions(); return; } - setStatus("Не авторизован. Нажмите \"Зайти\" в шапке."); + setStatus(tr("suggestions_desc_2", "Not authorized. Click \"Login\" in the header.")); document.getElementById("mySuggestionsList").innerHTML = ""; }); + window.addEventListener("site-language-changed", async () => { + if (!currentUser) { + setStatus(tr("suggestions_desc_2", "Not authorized. Click \"Login\" in the header.")); + return; + } + setStatus(tf("auth_logged_in_as", "Logged in: {username} ({role})", { + username: currentUser.username, + role: currentUser.role, + })); + await loadMySuggestions(); + }); + await checkSession(); if (currentUser) { await loadMySuggestions(); diff --git a/front/suggestions.html b/front/suggestions.html index 232aba5..190977b 100644 --- a/front/suggestions.html +++ b/front/suggestions.html @@ -85,6 +85,7 @@

Мои предложения

© 2024 Архив Памяти

+ From a833f50208c88be126be79ad3b56de7bad147f31 Mon Sep 17 00:00:00 2001 From: Dastan Medetbekov Date: Sat, 4 Apr 2026 06:51:19 +0600 Subject: [PATCH 07/41] Dastan 2 --- front/i18n.js | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/front/i18n.js b/front/i18n.js index ad4d675..2dadf1f 100644 --- a/front/i18n.js +++ b/front/i18n.js @@ -829,6 +829,24 @@ const PAGE_BINDINGS = { { selector: '#suggestionKind option[value="create"]', key: "suggestions_kind_create" }, { selector: '#suggestionKind option[value="update"]', key: "suggestions_kind_update" }, { selector: '#suggestionKind option[value="document"]', key: "suggestions_kind_document" }, + { selector: "#targetPersonId", key: "ph_target_person_id", attr: "placeholder" }, + { selector: "#existingSearch", key: "ph_existing_search", attr: "placeholder" }, + { selector: "#fullName", key: "ph_full_name_required", attr: "placeholder" }, + { selector: "#birthYear", key: "ph_birth_year_required", attr: "placeholder" }, + { selector: "#deathYear", key: "ph_death_year", attr: "placeholder" }, + { selector: "#nationality", key: "ph_nationality", attr: "placeholder" }, + { selector: "#region", key: "ph_region", attr: "placeholder" }, + { selector: "#district", key: "ph_district", attr: "placeholder" }, + { selector: "#occupation", key: "ph_occupation", attr: "placeholder" }, + { selector: "#charge", key: "ph_charge", attr: "placeholder" }, + { selector: "#sentence", key: "ph_sentence", attr: "placeholder" }, + { selector: "#arrestDate", key: "ph_arrest_date", attr: "placeholder" }, + { selector: "#sentenceDate", key: "ph_sentence_date", attr: "placeholder" }, + { selector: "#rehabilitationDate", key: "ph_rehabilitation_date", attr: "placeholder" }, + { selector: "#source", key: "ph_source", attr: "placeholder" }, + { selector: "#status", key: "ph_status_verified", attr: "placeholder" }, + { selector: "#documentText", key: "ph_document_text", attr: "placeholder" }, + { selector: "#biography", key: "ph_biography", attr: "placeholder" }, { selector: "#findExistingBtn", key: "suggestions_find_id" }, { selector: ".admin-panel:nth-of-type(1) .chat-helper", key: "suggestions_photo_tip" }, { selector: ".admin-panel:nth-of-type(1) h4", key: "suggestions_document_title" }, @@ -850,6 +868,21 @@ const PAGE_BINDINGS = { { selector: "#loadAiConfigBtn", key: "admin_ai_load_btn" }, { selector: "#saveAiConfigBtn", key: "admin_ai_save_btn" }, { selector: ".admin-panel.admin-protected:nth-of-type(4) h3", key: "admin_create_title" }, + { selector: "#createName", key: "ph_full_name_required", attr: "placeholder" }, + { selector: "#createRegion", key: "ph_region", attr: "placeholder" }, + { selector: "#createDistrict", key: "ph_district", attr: "placeholder" }, + { selector: "#createBirthYear", key: "ph_birth_year", attr: "placeholder" }, + { selector: "#createDeathYear", key: "ph_death_year", attr: "placeholder" }, + { selector: "#createNationality", key: "ph_nationality", attr: "placeholder" }, + { selector: "#createOccupation", key: "ph_occupation_category", attr: "placeholder" }, + { selector: "#createCharge", key: "ph_charge", attr: "placeholder" }, + { selector: "#createSentence", key: "ph_sentence", attr: "placeholder" }, + { selector: "#createArrestDate", key: "ph_arrest_date", attr: "placeholder" }, + { selector: "#createSentenceDate", key: "ph_sentence_date", attr: "placeholder" }, + { selector: "#createRehabilitationDate", key: "ph_rehabilitation_date", attr: "placeholder" }, + { selector: "#createSource", key: "ph_source", attr: "placeholder" }, + { selector: "#createStatus", key: "ph_status_verified_short", attr: "placeholder" }, + { selector: "#createBiography", key: "ph_biography", attr: "placeholder" }, { selector: "#createCardBtn", key: "admin_create_btn" }, { selector: ".admin-panel.admin-protected:nth-of-type(5) h3", key: "admin_cards_title" }, { selector: "#refreshCardsBtn", key: "admin_refresh_cards" }, From 44cf42830b672e71b92297f70acfe024df72b9a9 Mon Sep 17 00:00:00 2001 From: Dastan Medetbekov Date: Sat, 4 Apr 2026 07:08:09 +0600 Subject: [PATCH 08/41] Testing langs --- INTEGRATION_REVIEW.md | 7 -- backend_python/rag_engine.py | 30 ++++++- backend_python/routers/rag.py | 26 ++++++ front/about.html | 24 +++--- front/admin.html | 74 ++++++++-------- front/chat.html | 16 ++-- front/contacts.html | 12 +-- front/i18n.js | 28 ++++++- front/index.html | 18 ++-- front/list.html | 28 +++---- front/script.js | 33 +++++--- front/suggestions.html | 64 +++++++------- test_integration.sh | 153 ---------------------------------- test_system.sh | 78 ----------------- 14 files changed, 218 insertions(+), 373 deletions(-) delete mode 100644 INTEGRATION_REVIEW.md delete mode 100755 test_integration.sh delete mode 100755 test_system.sh diff --git a/INTEGRATION_REVIEW.md b/INTEGRATION_REVIEW.md deleted file mode 100644 index 7842e4a..0000000 --- a/INTEGRATION_REVIEW.md +++ /dev/null @@ -1,7 +0,0 @@ - -### 🎯 Рекомендации для production: - -- [ ] Добавить retry logic (httpx-retry или tenacity) -- [ ] Circuit breaker для C++ backend (если часто падает) -- [ ] Metrics/logging для мониторинга C++ вызовов -- [ ] Health check в background task (не блокировать startup) diff --git a/backend_python/rag_engine.py b/backend_python/rag_engine.py index 9c9bc2d..4adde21 100644 --- a/backend_python/rag_engine.py +++ b/backend_python/rag_engine.py @@ -314,7 +314,24 @@ def search_documents_ranked( } -def generate_answer(query: str, context_docs: List[str], language: str = "") -> str: +def _format_recent_history(chat_history: List[Dict[str, str]]) -> str: + lines: List[str] = [] + for idx, item in enumerate(chat_history, start=1): + user_text = (item.get("user_message") or "").strip() + bot_text = (item.get("bot_response") or "").strip() + if not user_text and not bot_text: + continue + lines.append(f"{idx}. Пользователь: {user_text}") + lines.append(f" Ассистент: {bot_text}") + return "\n".join(lines) + + +def generate_answer( + query: str, + context_docs: List[str], + language: str = "", + chat_history: List[Dict[str, str]] | None = None, +) -> str: """Generate a strict context-only answer for a user query.""" llm = _get_llm() @@ -330,7 +347,13 @@ def generate_answer(query: str, context_docs: List[str], language: str = "") -> "'Недостаточно данных в контексте'. " "Пиши кратко и по существу." ) - user_prompt = f"Контекст:\n{context}\n\nВопрос: {query}" + history_block = "" + if chat_history: + rendered_history = _format_recent_history(chat_history) + if rendered_history: + history_block = f"История диалога (последние сообщения):\n{rendered_history}\n\n" + + user_prompt = f"{history_block}Контекст:\n{context}\n\nВопрос: {query}" try: response = llm.invoke( @@ -351,6 +374,7 @@ def answer_with_rag( candidate_k: int = 16, min_score: float = 0.2, person_id: int | None = None, + chat_history: List[Dict[str, str]] | None = None, ) -> Dict[str, Any]: """Full RAG pipeline: retrieve ranked context, answer, and return sources + citations.""" search_result = search_documents_ranked( @@ -371,7 +395,7 @@ def answer_with_rag( "citations": [], } - answer = generate_answer(query=query, context_docs=docs) + answer = generate_answer(query=query, context_docs=docs, chat_history=chat_history) sources: List[int] = [] citations: List[dict] = [] diff --git a/backend_python/routers/rag.py b/backend_python/routers/rag.py index caeb496..7006447 100644 --- a/backend_python/routers/rag.py +++ b/backend_python/routers/rag.py @@ -55,6 +55,30 @@ def _save_chat_history(db: Session, user_id: int, query: str, answer: str, perso raise RuntimeError(f"Unsupported CHAT_HISTORY_BACKEND: {CHAT_HISTORY_BACKEND}") +def _load_recent_history_sql(db: Session, user_id: int, limit: int = 6) -> List[dict]: + entries = db.query(ChatHistory)\ + .filter(ChatHistory.user_id == user_id)\ + .order_by(ChatHistory.timestamp.desc())\ + .limit(max(1, limit))\ + .all() + + # Reverse to keep chronological order for better conversational continuity. + entries = list(reversed(entries)) + return [ + { + "user_message": e.user_message or "", + "bot_response": e.bot_response or "", + } + for e in entries + ] + + +def _load_recent_chat_history(db: Session, user_id: int, limit: int = 6) -> List[dict]: + if CHAT_HISTORY_BACKEND == "sql": + return _load_recent_history_sql(db, user_id, limit=limit) + return [] + + @router.post("/upload_document") async def upload_document( file: UploadFile = File(...), @@ -168,6 +192,7 @@ def chat( ): query = chat_request.query top_k = max(1, min(int(chat_request.top_k or 4), 8)) + recent_history = _load_recent_chat_history(db, current_user.id, limit=6) try: rag_result = answer_with_rag( @@ -176,6 +201,7 @@ def chat( candidate_k=max(10, top_k * 4), min_score=0.2, person_id=chat_request.person_id, + chat_history=recent_history, ) except ValueError as e: raise HTTPException( diff --git a/front/about.html b/front/about.html index 4cbd331..5584a74 100644 --- a/front/about.html +++ b/front/about.html @@ -31,34 +31,34 @@
-
Главная / О проекте
- ← Вернуться на главную +
Главная / О проекте
+ ← Вернуться на главную -

О проекте: Архив Памяти

+

О проекте: Архив Памяти

-

+

Каждое имя — это история. История человека, чья судьба была перечёркнута машиной государственного террора. Десятилетия молчания превратили миллионы жизней в безымянные тени, стёртые из памяти и архивов.

-

Мы строим живой мост между поколениями — мост, по которому память о прошлом передаётся тем, кто живёт сегодня.

+

Мы строим живой мост между поколениями — мост, по которому память о прошлом передаётся тем, кто живёт сегодня.

-

Партнёры

+

Партнёры

-

Статистика

+

Статистика