feat(core): implement secure MVP core with dictionary search, auth, and CRUD

This commit is contained in:
Antigravity committed 2026-09-22 13:27:11 +00:00
1 parent 4dbc53890a
commit 813d567b0e
14 files changed
+2960

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
"""Notes Application Package."""
__version__ = "1.0.0"
+116
View File
@@ -0,0 +1,116 @@
import hashlib
import os
import secrets
import time
from datetime import datetime, timezone, timedelta
from typing import Optional
from app import config
from app.database import get_db
# In-memory IP-based rate limiting for login attempts
_login_failures: dict[str, list[float]] = {}
def hash_password(password: str, salt: Optional[str] = None) -> tuple[str, str]:
if not salt:
salt = secrets.token_hex(16)
salt_bytes = bytes.fromhex(salt)
key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt_bytes, 100_000)
return key.hex(), salt
def verify_password(password: str, stored_hash: str, salt: str) -> bool:
new_hash, _ = hash_password(password, salt)
return secrets.compare_digest(new_hash, stored_hash)
def check_login_rate_limit(ip: str) -> bool:
now = time.time()
cutoff = now - config.RATE_LIMIT_LOGIN_WINDOW_SEC
attempts = _login_failures.get(ip, [])
# Filter attempts within the time window
recent_attempts = [t for t in attempts if t > cutoff]
_login_failures[ip] = recent_attempts
return len(recent_attempts) < config.RATE_LIMIT_LOGIN_MAX
def record_login_failure(ip: str):
now = time.time()
if ip not in _login_failures:
_login_failures[ip] = []
_login_failures[ip].append(now)
def record_login_success(ip: str):
if ip in _login_failures:
_login_failures.pop(ip, None)
def create_user(username: str, password: str) -> int:
pwd_hash, salt = hash_password(password)
with get_db() as conn:
cursor = conn.execute(
"INSERT INTO users (username, password_hash, salt) VALUES (?, ?, ?)",
(username.strip(), pwd_hash, salt)
)
return cursor.lastrowid
def authenticate_user(username: str, password: str) -> Optional[dict]:
with get_db() as conn:
row = conn.execute(
"SELECT id, username, password_hash, salt FROM users WHERE username = ?",
(username.strip(),)
).fetchone()
if not row:
return None
if verify_password(password, row["password_hash"], row["salt"]):
return {"id": row["id"], "username": row["username"]}
return None
def create_session(user_id: int, user_agent: str = "", ip: str = "") -> str:
token = secrets.token_urlsafe(32)
expires_at = (datetime.now(timezone.utc) + timedelta(days=config.SESSION_EXPIRE_DAYS)).isoformat()
with get_db() as conn:
conn.execute(
"INSERT INTO sessions (token, user_id, expires_at, user_agent, ip) VALUES (?, ?, ?, ?, ?)",
(token, user_id, expires_at, user_agent, ip)
)
return token
def get_user_from_session(token: str) -> Optional[dict]:
if not token:
return None
with get_db() as conn:
row = conn.execute(
"""
SELECT u.id, u.username, s.expires_at
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.token = ?
""",
(token,)
).fetchone()
if not row:
return None
expires_at_str = row["expires_at"]
try:
expires_at = datetime.fromisoformat(expires_at_str)
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
except Exception:
return None
if expires_at < datetime.now(timezone.utc):
# Session expired
delete_session(token)
return None
return {"id": row["id"], "username": row["username"]}
def delete_session(token: str):
if not token:
return
with get_db() as conn:
conn.execute("DELETE FROM sessions WHERE token = ?", (token,))
def ensure_admin_user():
"""Ensures at least one user exists; creates default admin from config if table empty."""
with get_db() as conn:
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if count == 0 and config.ADMIN_PASSWORD:
create_user(config.ADMIN_USERNAME, config.ADMIN_PASSWORD)
+40
View File
@@ -0,0 +1,40 @@
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
# Helper to read simple KEY=VALUE from .env without extra heavy dependencies
def load_env_file(filepath: Path):
if not filepath.is_file():
return
with open(filepath, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, val = line.split("=", 1)
key = key.strip()
val = val.strip().strip("\"'")
if key not in os.environ:
os.environ[key] = val
load_env_file(BASE_DIR / ".env")
HOST = os.getenv("HOST", "127.0.0.1")
PORT = int(os.getenv("PORT", "8002"))
DEBUG = os.getenv("DEBUG", "False").lower() in ("1", "true", "yes")
SECRET_KEY = os.getenv("SECRET_KEY", "default-dev-secret-key-please-change-in-production")
SESSION_COOKIE_NAME = os.getenv("SESSION_COOKIE_NAME", "notes_session")
SESSION_EXPIRE_DAYS = int(os.getenv("SESSION_EXPIRE_DAYS", "30"))
RATE_LIMIT_LOGIN_MAX = int(os.getenv("RATE_LIMIT_LOGIN_MAX", "5"))
RATE_LIMIT_LOGIN_WINDOW_SEC = int(os.getenv("RATE_LIMIT_LOGIN_WINDOW_SEC", "60"))
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "")
MYMEMORY_EMAIL = os.getenv("MYMEMORY_EMAIL", "admin@qpid.pro")
DB_PATH = Path(os.getenv("DB_PATH", str(BASE_DIR / "data" / "notes.db")))
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
+77
View File
@@ -0,0 +1,77 @@
import sqlite3
import contextlib
from typing import Generator
from app import config
def get_db_connection() -> sqlite3.Connection:
conn = sqlite3.connect(
config.DB_PATH,
timeout=15.0
)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL;")
conn.execute("PRAGMA synchronous=NORMAL;")
conn.execute("PRAGMA foreign_keys=ON;")
return conn
@contextlib.contextmanager
def get_db() -> Generator[sqlite3.Connection, None, None]:
conn = get_db_connection()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def init_db():
config.DB_PATH.parent.mkdir(parents=True, exist_ok=True)
with get_db() as conn:
conn.executescript("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
user_agent TEXT,
ip TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS vocabulary_cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
term TEXT NOT NULL,
translation TEXT NOT NULL,
context_sentence TEXT,
phonetic TEXT,
audio_url TEXT,
part_of_speech TEXT,
definition_en TEXT,
tags TEXT,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_vocab_user ON vocabulary_cards(user_id);
CREATE INDEX IF NOT EXISTS idx_vocab_term ON vocabulary_cards(term);
CREATE INDEX IF NOT EXISTS idx_vocab_created ON vocabulary_cards(created_at DESC);
CREATE TABLE IF NOT EXISTS dictionary_cache (
query TEXT PRIMARY KEY COLLATE NOCASE,
response_json TEXT NOT NULL,
cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
+283
View File
@@ -0,0 +1,283 @@
import json
import re
import urllib.parse
from typing import Optional, Dict, Any, List
import httpx
from app import config
from app.database import get_db
USER_AGENT = "NotesApp/1.0 (https://notes.qpid.pro; admin@qpid.pro)"
async def fetch_wiktionary_wikitext(word: str) -> Dict[str, Any]:
url = "https://en.wiktionary.org/w/api.php"
params = {
"action": "parse",
"page": word,
"prop": "wikitext",
"format": "json"
}
try:
async with httpx.AsyncClient(timeout=4.0) as client:
resp = await client.get(url, params=params, headers={"User-Agent": USER_AGENT})
if resp.status_code == 200:
data = resp.json()
wikitext = data.get("parse", {}).get("wikitext", {}).get("*", "")
# Extract IPA
ipa_raw = re.findall(r"\{\{IPA\|en\|([^}]+)\}\}", wikitext)
ipa_cleaned = []
for entry in ipa_raw:
parts = entry.split("|")
for p in parts:
if p.startswith("/"):
ipa_cleaned.append(p)
# Extract Audio file names
audio_matches = re.findall(r"\{\{audio\|en\|([^|}]+)", wikitext)
# Extract Russian translations
ru_raw = re.findall(r"\{\{t[+-]?\|ru\|([^|}]+)", wikitext)
ru_cleaned = []
for r in ru_raw:
# Clean wiki markup like [[word]] or stress marks
clean = r.replace("[[", "").replace("]]", "").strip()
if clean and clean not in ru_cleaned:
ru_cleaned.append(clean)
return {
"ipa": ipa_cleaned,
"audio": audio_matches,
"ru_translations": ru_cleaned
}
except Exception:
pass
return {"ipa": [], "audio": [], "ru_translations": []}
async def fetch_wiktionary_definitions(word: str) -> Dict[str, Any]:
url = f"https://en.wiktionary.org/api/rest_v1/page/definition/{urllib.parse.quote(word)}"
try:
async with httpx.AsyncClient(timeout=4.0) as client:
resp = await client.get(url, headers={"User-Agent": USER_AGENT})
if resp.status_code == 200:
data = resp.json()
en_entries = data.get("en", [])
parts_of_speech = []
definitions = []
examples = []
for entry in en_entries:
pos = entry.get("partOfSpeech")
if pos and pos not in parts_of_speech:
parts_of_speech.append(pos)
for d in entry.get("definitions", []):
raw_def = d.get("definition", "")
# strip html tags
clean_def = re.sub(r"<[^>]+>", "", raw_def).strip()
if clean_def and clean_def not in definitions:
definitions.append(clean_def)
for ex in d.get("examples", []):
clean_ex = re.sub(r"<[^>]+>", "", ex).strip()
if clean_ex and clean_ex not in examples:
examples.append(clean_ex)
return {
"parts_of_speech": parts_of_speech,
"definitions": definitions[:4],
"examples": examples[:3]
}
except Exception:
pass
return {"parts_of_speech": [], "definitions": [], "examples": []}
async def fetch_datamuse(word: str) -> Dict[str, Any]:
url = "https://api.datamuse.com/words"
params = {"sp": word, "md": "drp", "ipa": "1"}
try:
async with httpx.AsyncClient(timeout=3.0) as client:
resp = await client.get(url, params=params)
if resp.status_code == 200:
items = resp.json()
if items and isinstance(items, list):
first = items[0]
tags = first.get("tags", [])
ipa = None
pos = None
for t in tags:
if t.startswith("ipa_pron:"):
ipa = "/" + t.split(":", 1)[1] + "/"
elif t in ("n", "v", "adj", "adv"):
pos_map = {"n": "Noun", "v": "Verb", "adj": "Adjective", "adv": "Adverb"}
pos = pos_map.get(t)
defs = []
for d in first.get("defs", []):
parts = d.split("\t", 1)
defs.append(parts[1].strip() if len(parts) > 1 else parts[0].strip())
return {
"ipa": ipa,
"part_of_speech": pos,
"definitions": defs[:3]
}
except Exception:
pass
return {"ipa": None, "part_of_speech": None, "definitions": []}
async def fetch_mymemory(query: str) -> Dict[str, Any]:
url = "https://api.mymemory.translated.net/get"
params = {
"q": query,
"langpair": "en|ru",
"de": config.MYMEMORY_EMAIL
}
try:
async with httpx.AsyncClient(timeout=4.0) as client:
resp = await client.get(url, params=params)
if resp.status_code == 200:
data = resp.json()
main_trans = data.get("responseData", {}).get("translatedText", "").strip()
matches = data.get("matches", [])
alt_list = []
if main_trans:
alt_list.append(main_trans)
for m in matches:
t = m.get("translation", "").strip()
if t and t not in alt_list and t.lower() != query.lower():
alt_list.append(t)
return {
"translation": main_trans,
"matches": alt_list[:6]
}
except Exception:
pass
return {"translation": "", "matches": []}
async def fetch_free_dictionary_api(word: str) -> Dict[str, Any]:
"""Optional tier: api.dictionaryapi.dev with fast timeout."""
url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{urllib.parse.quote(word)}"
try:
async with httpx.AsyncClient(timeout=1.5) as client:
resp = await client.get(url)
if resp.status_code == 200:
data = resp.json()[0]
phonetic = data.get("phonetic")
audio_url = None
for p in data.get("phonetics", []):
if p.get("audio"):
audio_url = p.get("audio")
break
return {
"phonetic": phonetic,
"audio_url": audio_url
}
except Exception:
pass
return {"phonetic": None, "audio_url": None}
async def lookup_term(query: str) -> Dict[str, Any]:
query_clean = query.strip()
if not query_clean:
return {}
# Check cache first
with get_db() as conn:
row = conn.execute(
"SELECT response_json FROM dictionary_cache WHERE query = ?",
(query_clean,)
).fetchone()
if row:
try:
return json.loads(row["response_json"])
except Exception:
pass
words = query_clean.split()
is_sentence = len(words) > 4
result: Dict[str, Any] = {
"query": query_clean,
"is_sentence": is_sentence,
"translation": "",
"translations_list": [],
"phonetic": "",
"audio_file": "",
"audio_url": "",
"part_of_speech": "",
"definitions": [],
"examples": []
}
# Parallel queries via asyncio
import asyncio
if is_sentence:
# For long sentences, primary focus is translation
mm_data = await fetch_mymemory(query_clean)
result["translation"] = mm_data.get("translation", "")
result["translations_list"] = mm_data.get("matches", [])
else:
# For words and short phrases
tasks = [
fetch_wiktionary_wikitext(query_clean),
fetch_wiktionary_definitions(query_clean),
fetch_datamuse(query_clean),
fetch_mymemory(query_clean),
fetch_free_dictionary_api(query_clean)
]
w_wiki, w_defs, d_muse, mm_data, free_dict = await asyncio.gather(*tasks)
# 1. Phonetic IPA
if free_dict.get("phonetic"):
result["phonetic"] = free_dict["phonetic"]
elif w_wiki.get("ipa"):
result["phonetic"] = w_wiki["ipa"][0]
elif d_muse.get("ipa"):
result["phonetic"] = d_muse["ipa"]
# 2. Audio URL / File
if free_dict.get("audio_url"):
result["audio_url"] = free_dict["audio_url"]
elif w_wiki.get("audio"):
result["audio_file"] = w_wiki["audio"][0]
result["audio_url"] = f"/api/audio?file={urllib.parse.quote(result['audio_file'])}"
# 3. Part of Speech
if w_defs.get("parts_of_speech"):
result["part_of_speech"] = ", ".join(w_defs["parts_of_speech"][:2])
elif d_muse.get("part_of_speech"):
result["part_of_speech"] = d_muse["part_of_speech"]
# 4. Definitions & Examples
if w_defs.get("definitions"):
result["definitions"] = w_defs["definitions"]
elif d_muse.get("definitions"):
result["definitions"] = d_muse["definitions"]
if w_defs.get("examples"):
result["examples"] = w_defs["examples"]
# 5. Translations
candidates = []
# Wiktionary curated Russian translations
for t in w_wiki.get("ru_translations", []):
if t not in candidates:
candidates.append(t)
# MyMemory translation
for t in mm_data.get("matches", []):
if t and t not in candidates and t.lower() != query_clean.lower():
candidates.append(t)
if candidates:
result["translation"] = ", ".join(candidates[:3])
result["translations_list"] = candidates
else:
result["translation"] = mm_data.get("translation", "")
result["translations_list"] = [result["translation"]] if result["translation"] else []
# Store into SQLite cache
with get_db() as conn:
conn.execute(
"INSERT OR REPLACE INTO dictionary_cache (query, response_json) VALUES (?, ?)",
(query_clean, json.dumps(result, ensure_ascii=False))
)
return result
+352
View File
@@ -0,0 +1,352 @@
import os
import urllib.parse
from contextlib import asynccontextmanager
from typing import Optional, List
from fastapi import FastAPI, Depends, HTTPException, Request, Response, status, Query
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
import httpx
from app import config
from app.database import init_db, get_db
from app.auth import (
ensure_admin_user,
authenticate_user,
create_session,
get_user_from_session,
delete_session,
check_login_rate_limit,
record_login_failure,
record_login_success
)
from app.dictionary import lookup_term, USER_AGENT
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
ensure_admin_user()
yield
app = FastAPI(
title="Notes App",
description="Personal Vocabulary & Language Notebook (notes.qpid.pro)",
version="1.0.0",
lifespan=lifespan
)
# Mount static files
app.mount("/static", StaticFiles(directory=str(config.BASE_DIR / "app" / "static")), name="static")
# Security Headers Middleware
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
response = await call_next(request)
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "same-origin"
return response
# Dependency: get authenticated user or None
def get_optional_user(request: Request) -> Optional[dict]:
token = request.cookies.get(config.SESSION_COOKIE_NAME)
if not token:
# Check Authorization header bearer token fallback
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
if token:
return get_user_from_session(token)
return None
# Dependency: require authenticated user
def require_user(request: Request) -> dict:
user = get_optional_user(request)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required"
)
return user
# Models
class LoginRequest(BaseModel):
username: str = Field(..., min_length=1)
password: str = Field(..., min_length=1)
class CardCreateRequest(BaseModel):
term: str = Field(..., min_length=1)
translation: str = Field(..., min_length=1)
context_sentence: Optional[str] = ""
phonetic: Optional[str] = ""
audio_url: Optional[str] = ""
part_of_speech: Optional[str] = ""
definition_en: Optional[str] = ""
tags: Optional[str] = "general"
notes: Optional[str] = ""
class CardUpdateRequest(BaseModel):
term: Optional[str] = None
translation: Optional[str] = None
context_sentence: Optional[str] = None
phonetic: Optional[str] = None
audio_url: Optional[str] = None
part_of_speech: Optional[str] = None
definition_en: Optional[str] = None
tags: Optional[str] = None
notes: Optional[str] = None
# Routes
@app.api_route("/", methods=["GET", "HEAD"], response_class=HTMLResponse)
async def serve_index(request: Request):
index_path = config.BASE_DIR / "app" / "static" / "index.html"
if not index_path.is_file():
raise HTTPException(status_code=404, detail="Index file not found")
with open(index_path, "r", encoding="utf-8") as f:
content = f.read()
return HTMLResponse(content)
@app.post("/api/login")
async def api_login(req: LoginRequest, request: Request, response: Response):
client_ip = request.client.host if request.client else "unknown"
if not check_login_rate_limit(client_ip):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many failed login attempts. Please wait 1 minute."
)
user = authenticate_user(req.username, req.password)
if not user:
record_login_failure(client_ip)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password"
)
record_login_success(client_ip)
user_agent = request.headers.get("User-Agent", "")[:255]
session_token = create_session(user["id"], user_agent, client_ip)
# Set secure HttpOnly cookie
response.set_cookie(
key=config.SESSION_COOKIE_NAME,
value=session_token,
max_age=config.SESSION_EXPIRE_DAYS * 86400,
httponly=True,
samesite="lax",
secure=False # Nginx handles SSL termination
)
return {"status": "ok", "user": user}
@app.post("/api/logout")
async def api_logout(request: Request, response: Response):
token = request.cookies.get(config.SESSION_COOKIE_NAME)
if token:
delete_session(token)
response.delete_cookie(config.SESSION_COOKIE_NAME)
return {"status": "ok", "message": "Logged out successfully"}
@app.get("/api/me")
async def api_me(user: dict = Depends(require_user)):
return {"user": user}
@app.get("/api/lookup")
async def api_lookup(q: str = Query(..., min_length=1), user: dict = Depends(require_user)):
result = await lookup_term(q)
return result
@app.get("/api/audio")
async def api_audio_proxy(file: str = Query(..., min_length=1)):
"""Proxies Wikimedia audio file with required User-Agent."""
cleaned_file = urllib.parse.unquote(file).strip()
# Basic safety check
if ".." in cleaned_file or "/" in cleaned_file or "\\" in cleaned_file:
raise HTTPException(status_code=400, detail="Invalid filename")
target_url = f"https://commons.wikimedia.org/wiki/Special:FilePath/{urllib.parse.quote(cleaned_file)}"
async def stream_audio():
headers = {"User-Agent": USER_AGENT}
async with httpx.AsyncClient(follow_redirects=True, timeout=10.0) as client:
async with client.stream("GET", target_url, headers=headers) as resp:
if resp.status_code != 200:
yield b""
return
async for chunk in resp.aiter_bytes():
yield chunk
# Determine mime-type based on extension
ext = cleaned_file.lower().split(".")[-1]
mime_map = {
"ogg": "audio/ogg",
"oga": "audio/ogg",
"mp3": "audio/mpeg",
"wav": "audio/wav",
"flac": "audio/flac"
}
media_type = mime_map.get(ext, "application/octet-stream")
return StreamingResponse(
stream_audio(),
media_type=media_type,
headers={"Cache-Control": "public, max-age=86400"}
)
@app.get("/api/cards")
async def api_get_cards(
search: Optional[str] = None,
tag: Optional[str] = None,
limit: int = 100,
offset: int = 0,
user: dict = Depends(require_user)
):
with get_db() as conn:
query = """
SELECT id, term, translation, context_sentence, phonetic, audio_url,
part_of_speech, definition_en, tags, notes, created_at, updated_at
FROM vocabulary_cards
WHERE user_id = ?
"""
params = [user["id"]]
if search:
query += " AND (term LIKE ? OR translation LIKE ? OR context_sentence LIKE ? OR notes LIKE ?)"
s = f"%{search.strip()}%"
params.extend([s, s, s, s])
if tag:
query += " AND (',' || tags || ',') LIKE ?"
params.append(f"%,{tag.strip()}%,")
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
params.extend([min(limit, 500), max(offset, 0)])
rows = conn.execute(query, params).fetchall()
# Count total
count_query = "SELECT COUNT(*) FROM vocabulary_cards WHERE user_id = ?"
count_params = [user["id"]]
if search:
count_query += " AND (term LIKE ? OR translation LIKE ? OR context_sentence LIKE ? OR notes LIKE ?)"
s = f"%{search.strip()}%"
count_params.extend([s, s, s, s])
if tag:
count_query += " AND (',' || tags || ',') LIKE ?"
count_params.append(f"%,{tag.strip()}%,")
total = conn.execute(count_query, count_params).fetchone()[0]
cards = [dict(r) for r in rows]
return {"total": total, "cards": cards}
@app.post("/api/cards")
async def api_create_card(card: CardCreateRequest, user: dict = Depends(require_user)):
with get_db() as conn:
cursor = conn.execute(
"""
INSERT INTO vocabulary_cards (
user_id, term, translation, context_sentence, phonetic, audio_url,
part_of_speech, definition_en, tags, notes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
user["id"],
card.term.strip(),
card.translation.strip(),
card.context_sentence.strip() if card.context_sentence else "",
card.phonetic.strip() if card.phonetic else "",
card.audio_url.strip() if card.audio_url else "",
card.part_of_speech.strip() if card.part_of_speech else "",
card.definition_en.strip() if card.definition_en else "",
card.tags.strip() if card.tags else "general",
card.notes.strip() if card.notes else ""
)
)
new_id = cursor.lastrowid
row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (new_id,)).fetchone()
return dict(row)
@app.put("/api/cards/{card_id}")
async def api_update_card(card_id: int, update: CardUpdateRequest, user: dict = Depends(require_user)):
with get_db() as conn:
row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ? AND user_id = ?", (card_id, user["id"])).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Card not found")
current = dict(row)
term = update.term.strip() if update.term is not None else current["term"]
translation = update.translation.strip() if update.translation is not None else current["translation"]
context_sentence = update.context_sentence.strip() if update.context_sentence is not None else current["context_sentence"]
phonetic = update.phonetic.strip() if update.phonetic is not None else current["phonetic"]
audio_url = update.audio_url.strip() if update.audio_url is not None else current["audio_url"]
part_of_speech = update.part_of_speech.strip() if update.part_of_speech is not None else current["part_of_speech"]
definition_en = update.definition_en.strip() if update.definition_en is not None else current["definition_en"]
tags = update.tags.strip() if update.tags is not None else current["tags"]
notes = update.notes.strip() if update.notes is not None else current["notes"]
conn.execute(
"""
UPDATE vocabulary_cards
SET term = ?, translation = ?, context_sentence = ?, phonetic = ?,
audio_url = ?, part_of_speech = ?, definition_en = ?, tags = ?,
notes = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?
""",
(term, translation, context_sentence, phonetic, audio_url, part_of_speech, definition_en, tags, notes, card_id, user["id"])
)
updated_row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (card_id,)).fetchone()
return dict(updated_row)
@app.delete("/api/cards/{card_id}")
async def api_delete_card(card_id: int, user: dict = Depends(require_user)):
with get_db() as conn:
cursor = conn.execute(
"DELETE FROM vocabulary_cards WHERE id = ? AND user_id = ?",
(card_id, user["id"])
)
if cursor.rowcount == 0:
raise HTTPException(status_code=404, detail="Card not found")
return {"status": "ok", "deleted_id": card_id}
@app.get("/api/tags")
async def api_get_tags(user: dict = Depends(require_user)):
with get_db() as conn:
rows = conn.execute(
"SELECT tags FROM vocabulary_cards WHERE user_id = ?",
(user["id"],)
).fetchall()
tag_counts: dict[str, int] = {}
for r in rows:
raw = r["tags"]
if raw:
for t in raw.split(","):
tag_clean = t.strip()
if tag_clean:
tag_counts[tag_clean] = tag_counts.get(tag_clean, 0) + 1
sorted_tags = sorted(
[{"tag": k, "count": v} for k, v in tag_counts.items()],
key=lambda x: x["count"],
reverse=True
)
return sorted_tags
@app.get("/api/stats")
async def api_stats(user: dict = Depends(require_user)):
with get_db() as conn:
total = conn.execute(
"SELECT COUNT(*) FROM vocabulary_cards WHERE user_id = ?",
(user["id"],)
).fetchone()[0]
today_added = conn.execute(
"""
SELECT COUNT(*) FROM vocabulary_cards
WHERE user_id = ? AND date(created_at) = date('now')
""",
(user["id"],)
).fetchone()[0]
return {
"total_cards": total,
"today_added": today_added
}
+951
View File
@@ -0,0 +1,951 @@
/* Modern CSS Design System for Notes App */
:root {
--bg-main: #0f172a;
--bg-surface: #1e293b;
--bg-surface-hover: #334155;
--bg-card: #1e293b;
--bg-input: #0f172a;
--border-color: #334155;
--border-focus: #38bdf8;
--text-main: #f8fafc;
--text-muted: #94a3b8;
--text-dim: #64748b;
--primary: #38bdf8;
--primary-hover: #0ea5e9;
--primary-dim: rgba(56, 189, 248, 0.15);
--success: #10b981;
--success-hover: #059669;
--success-dim: rgba(16, 185, 129, 0.15);
--danger: #ef4444;
--danger-hover: #dc2626;
--danger-dim: rgba(239, 68, 68, 0.15);
--tag-bg: #293548;
--tag-text: #7dd3fc;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-full: 9999px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
--font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
--transition: all 0.18s ease-in-out;
}
[data-theme="light"] {
--bg-main: #f1f5f9;
--bg-surface: #ffffff;
--bg-surface-hover: #f8fafc;
--bg-card: #ffffff;
--bg-input: #f8fafc;
--border-color: #cbd5e1;
--border-focus: #0284c7;
--text-main: #0f172a;
--text-muted: #475569;
--text-dim: #94a3b8;
--primary: #0284c7;
--primary-hover: #0369a1;
--primary-dim: rgba(2, 132, 199, 0.1);
--success: #059669;
--success-hover: #047857;
--success-dim: rgba(5, 150, 105, 0.1);
--tag-bg: #e2e8f0;
--tag-text: #0369a1;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-main);
color: var(--text-main);
font-family: var(--font-family);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
}
.hidden {
display: none !important;
}
/* Auth Screen */
.auth-screen {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: radial-gradient(circle at top, rgba(56, 189, 248, 0.12), transparent 70%);
}
.auth-card {
background-color: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 36px 32px;
width: 100%;
max-width: 420px;
box-shadow: var(--shadow-lg);
}
.auth-header {
text-align: center;
margin-bottom: 28px;
}
.auth-header .app-logo {
font-size: 48px;
margin-bottom: 8px;
}
.auth-header h2 {
font-size: 24px;
font-weight: 700;
color: var(--text-main);
}
.auth-header p {
color: var(--text-muted);
font-size: 14px;
margin-top: 4px;
}
.auth-footer {
margin-top: 24px;
text-align: center;
font-size: 12px;
color: var(--text-dim);
}
.form-group {
margin-bottom: 18px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 6px;
}
.form-group input {
width: 100%;
padding: 12px 14px;
background-color: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-main);
font-size: 15px;
outline: none;
transition: var(--transition);
}
.form-group input:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--primary-dim);
}
.error-banner {
background-color: var(--danger-dim);
border: 1px solid var(--danger);
color: #fca5a5;
padding: 10px 14px;
border-radius: var(--radius-sm);
font-size: 13px;
margin-bottom: 16px;
}
/* App Navbar */
.app-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.navbar {
background-color: var(--bg-surface);
border-bottom: 1px solid var(--border-color);
padding: 12px 24px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
}
.nav-brand {
display: flex;
align-items: center;
gap: 8px;
}
.brand-icon {
font-size: 24px;
}
.brand-title {
font-size: 20px;
font-weight: 800;
color: var(--text-main);
letter-spacing: -0.5px;
}
.brand-sub {
font-size: 12px;
background-color: var(--primary-dim);
color: var(--primary);
padding: 2px 8px;
border-radius: var(--radius-full);
font-weight: 600;
margin-left: 4px;
}
.nav-stats {
display: flex;
gap: 12px;
}
.stat-badge {
font-size: 13px;
background-color: var(--bg-main);
border: 1px solid var(--border-color);
padding: 4px 12px;
border-radius: var(--radius-full);
color: var(--text-muted);
}
.stat-badge strong {
color: var(--text-main);
}
.stat-badge.stat-today strong {
color: var(--success);
}
.nav-actions {
display: flex;
align-items: center;
gap: 12px;
}
.user-profile {
display: flex;
align-items: center;
gap: 8px;
}
.user-badge {
font-size: 13px;
color: var(--text-muted);
font-weight: 600;
background-color: var(--bg-surface-hover);
padding: 4px 10px;
border-radius: var(--radius-sm);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 18px;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 600;
cursor: pointer;
border: none;
transition: var(--transition);
}
.btn-primary {
background-color: var(--primary);
color: #0f172a;
}
.btn-primary:hover {
background-color: var(--primary-hover);
}
.btn-success {
background-color: var(--success);
color: #ffffff;
}
.btn-success:hover {
background-color: var(--success-hover);
}
.btn-outline {
background-color: transparent;
border: 1px solid var(--border-color);
color: var(--text-muted);
}
.btn-outline:hover {
border-color: var(--text-main);
color: var(--text-main);
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.btn-lg {
padding: 14px 24px;
font-size: 16px;
}
.btn-block {
width: 100%;
}
.btn-subtext {
font-size: 11px;
opacity: 0.8;
margin-left: 4px;
}
.icon-btn {
background: transparent;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 6px 10px;
cursor: pointer;
color: var(--text-muted);
font-size: 16px;
transition: var(--transition);
}
.icon-btn:hover {
color: var(--text-main);
border-color: var(--text-main);
}
/* Main Content */
.main-content {
max-width: 900px;
width: 100%;
margin: 0 auto;
padding: 32px 20px;
display: flex;
flex-direction: column;
gap: 36px;
}
/* Search Box */
.search-box-wrapper {
display: flex;
flex-direction: column;
gap: 8px;
}
.search-input-box {
display: flex;
align-items: center;
background-color: var(--bg-surface);
border: 2px solid var(--border-color);
border-radius: var(--radius-md);
padding: 6px 10px 6px 16px;
transition: var(--transition);
box-shadow: var(--shadow-sm);
}
.search-input-box:focus-within {
border-color: var(--border-focus);
box-shadow: 0 0 0 4px var(--primary-dim);
}
.search-icon {
font-size: 18px;
color: var(--text-muted);
margin-right: 12px;
}
.search-input-box input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-size: 17px;
color: var(--text-main);
padding: 8px 0;
}
.clear-btn {
background: transparent;
border: none;
color: var(--text-dim);
font-size: 16px;
cursor: pointer;
padding: 4px 8px;
border-radius: 50%;
}
.clear-btn:hover {
color: var(--text-main);
}
.translate-action-btn {
margin-left: 8px;
}
.hotkey-hints {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 4px 8px;
font-size: 12px;
color: var(--text-dim);
}
.hotkey-hints kbd {
background-color: var(--bg-surface-hover);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 1px 5px;
font-family: monospace;
font-size: 11px;
color: var(--text-muted);
}
/* Spinner */
.lookup-spinner {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 24px;
color: var(--primary);
font-size: 14px;
font-weight: 500;
}
.spinner {
width: 24px;
height: 24px;
border: 3px solid var(--border-color);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Live Result Card */
.result-card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 24px;
box-shadow: var(--shadow-md);
animation: fadeIn 0.25s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.result-header {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-color);
}
.term-title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.res-term {
font-size: 28px;
font-weight: 800;
letter-spacing: -0.5px;
color: var(--text-main);
}
.badge-pill {
padding: 3px 10px;
border-radius: var(--radius-full);
font-size: 13px;
font-weight: 600;
}
.res-phonetic {
background-color: var(--primary-dim);
color: var(--primary);
font-family: monospace;
}
.res-pos {
background-color: var(--bg-surface-hover);
color: var(--text-muted);
}
.audio-btn {
background-color: var(--primary-dim);
border: 1px solid var(--primary);
color: var(--primary);
border-radius: var(--radius-full);
padding: 4px 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
transition: var(--transition);
}
.audio-btn:hover {
background-color: var(--primary);
color: #0f172a;
}
.definition-box {
background-color: var(--bg-main);
border-left: 3px solid var(--primary);
padding: 12px 16px;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
margin-bottom: 20px;
}
.def-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
color: var(--primary);
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.def-text {
font-size: 14px;
color: var(--text-main);
}
.card-edit-grid {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
}
.input-styled {
width: 100%;
padding: 10px 14px;
background-color: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-main);
font-size: 14px;
outline: none;
transition: var(--transition);
}
.input-styled:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--primary-dim);
}
.field-group label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 5px;
}
.field-group .req {
color: var(--danger);
}
.field-row {
display: flex;
gap: 16px;
}
.flex-1 { flex: 1; }
.flex-2 { flex: 2; }
.suggestions-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.suggestion-chip {
font-size: 12px;
background-color: var(--bg-surface-hover);
color: var(--text-muted);
padding: 2px 8px;
border-radius: var(--radius-full);
cursor: pointer;
transition: var(--transition);
border: 1px solid transparent;
}
.suggestion-chip:hover {
border-color: var(--primary);
color: var(--primary);
}
.quick-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.tag-chip {
font-size: 11px;
background-color: var(--tag-bg);
color: var(--tag-text);
padding: 2px 8px;
border-radius: var(--radius-full);
cursor: pointer;
user-select: none;
transition: var(--transition);
}
.tag-chip:hover, .tag-chip.active {
background-color: var(--primary);
color: #0f172a;
font-weight: 600;
}
.result-actions {
display: flex;
justify-content: flex-end;
}
/* Vocabulary Section */
.vocabulary-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.section-title-wrap {
display: flex;
align-items: baseline;
gap: 8px;
}
.section-title-wrap h3 {
font-size: 20px;
font-weight: 700;
}
.cards-count-label {
font-size: 13px;
color: var(--text-dim);
}
.search-mini-box input {
background-color: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 8px 12px;
color: var(--text-main);
font-size: 13px;
outline: none;
min-width: 260px;
transition: var(--transition);
}
.search-mini-box input:focus {
border-color: var(--border-focus);
}
/* Tags Filter Bar */
.tags-filter-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.filter-chip {
background-color: var(--bg-surface);
border: 1px solid var(--border-color);
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
padding: 4px 12px;
border-radius: var(--radius-full);
cursor: pointer;
transition: var(--transition);
}
.filter-chip:hover {
border-color: var(--primary);
color: var(--primary);
}
.filter-chip.active {
background-color: var(--primary);
border-color: var(--primary);
color: #0f172a;
}
/* Cards List */
.cards-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.vocab-card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 16px 20px;
box-shadow: var(--shadow-sm);
transition: var(--transition);
display: flex;
flex-direction: column;
gap: 10px;
}
.vocab-card:hover {
border-color: var(--border-focus);
box-shadow: var(--shadow-md);
}
.card-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.card-term-group {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.card-term {
font-size: 18px;
font-weight: 700;
color: var(--text-main);
}
.card-phonetic {
font-size: 13px;
color: var(--primary);
font-family: monospace;
}
.card-pos {
font-size: 11px;
background-color: var(--bg-surface-hover);
color: var(--text-dim);
padding: 2px 6px;
border-radius: 4px;
}
.card-actions {
display: flex;
gap: 6px;
}
.card-translation {
font-size: 16px;
font-weight: 600;
color: var(--text-main);
}
.card-context {
font-size: 14px;
color: var(--text-muted);
font-style: italic;
background-color: var(--bg-main);
padding: 8px 12px;
border-radius: var(--radius-sm);
border-left: 2px solid var(--border-color);
}
.card-context mark {
background-color: var(--primary-dim);
color: var(--primary);
font-weight: 600;
padding: 0 4px;
border-radius: 2px;
}
.card-bottom {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 8px;
font-size: 12px;
color: var(--text-dim);
}
.card-tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.card-tag {
background-color: var(--tag-bg);
color: var(--tag-text);
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: 11px;
}
.card-notes {
font-size: 12px;
color: #fbbf24;
display: flex;
align-items: center;
gap: 4px;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 48px 24px;
background-color: var(--bg-surface);
border: 1px dashed var(--border-color);
border-radius: var(--radius-md);
color: var(--text-muted);
}
.empty-icon {
font-size: 40px;
margin-bottom: 12px;
}
.empty-state h4 {
font-size: 16px;
color: var(--text-main);
margin-bottom: 6px;
}
.empty-state p {
font-size: 13px;
color: var(--text-dim);
}
/* Modal */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.modal-window {
background-color: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
width: 100%;
max-width: 520px;
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.modal-header h3 {
font-size: 18px;
font-weight: 700;
}
.modal-body {
padding: 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 20px;
border-top: 1px solid var(--border-color);
}
/* Responsive */
@media (max-width: 640px) {
.navbar {
padding: 10px 14px;
}
.brand-sub {
display: none;
}
.main-content {
padding: 16px 12px;
gap: 24px;
}
.field-row {
flex-direction: column;
}
.res-term {
font-size: 22px;
}
.hotkey-hints {
display: none;
}
.search-mini-box input {
min-width: 100%;
}
}
+220
View File
@@ -0,0 +1,220 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Notes — Персональный тренажёр английского языка</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📖</text></svg>">
</head>
<body>
<!-- Login Modal / Screen -->
<div id="auth-screen" class="auth-screen hidden">
<div class="auth-card">
<div class="auth-header">
<div class="app-logo">📖</div>
<h2>Вход в Notes</h2>
<p>Персональный словарь и тренажёр языка</p>
</div>
<form id="login-form">
<div class="form-group">
<label for="username">Имя пользователя</label>
<input type="text" id="username" autocomplete="username" required placeholder="admin" autofocus>
</div>
<div class="form-group">
<label for="password">Пароль</label>
<input type="password" id="password" autocomplete="current-password" required placeholder="••••••••">
</div>
<div id="auth-error" class="error-banner hidden"></div>
<button type="submit" id="login-btn" class="btn btn-primary btn-block">Войти в систему</button>
</form>
<div class="auth-footer">
<span>Защищённый контур: notes.qpid.pro</span>
</div>
</div>
</div>
<!-- Main Application UI -->
<div id="app-container" class="app-container hidden">
<!-- Top Navigation -->
<header class="navbar">
<div class="nav-brand">
<span class="brand-icon">📖</span>
<span class="brand-title">Notes</span>
<span class="brand-sub">English Lexicon</span>
</div>
<div class="nav-stats" id="nav-stats">
<span class="stat-badge" title="Всего слов в словаре">Всего: <strong id="stat-total">0</strong></span>
<span class="stat-badge stat-today" title="Добавлено сегодня">Сегодня: <strong id="stat-today">+0</strong></span>
</div>
<div class="nav-actions">
<button id="theme-toggle" class="icon-btn" title="Переключить тему">🌓</button>
<div class="user-profile">
<span id="user-display-name" class="user-badge">user</span>
<button id="logout-btn" class="btn btn-sm btn-outline" title="Выйти из аккаунта">Выход</button>
</div>
</div>
</header>
<main class="main-content">
<!-- Search & Translation Section -->
<section class="search-section">
<div class="search-box-wrapper">
<div class="search-input-box">
<span class="search-icon">🔍</span>
<input type="text" id="term-input" placeholder="Введите слово, фразовый глагол или предложение..." autocomplete="off" autofocus>
<button id="clear-search-btn" class="clear-btn hidden" title="Очистить (Esc)">✕</button>
<button id="translate-btn" class="btn btn-primary translate-action-btn">Перевести</button>
</div>
<div class="hotkey-hints">
<span class="hint-item"><kbd>Enter</kbd> перевод</span>
<span class="hint-item"><kbd>Ctrl</kbd>+<kbd>Enter</kbd> сохранить</span>
<span class="hint-item"><kbd>Esc</kbd> сброс</span>
<span class="hint-item"><kbd>Space</kbd> / <kbd>🔊</kbd> озвучить</span>
</div>
</div>
<!-- Loader -->
<div id="lookup-spinner" class="lookup-spinner hidden">
<div class="spinner"></div>
<span>Ищем толкование, произношение и перевод...</span>
</div>
<!-- Live Result Card -->
<div id="result-card" class="result-card hidden">
<div class="result-header">
<div class="term-title-row">
<h2 id="res-term" class="res-term">reluctant</h2>
<span id="res-phonetic" class="res-phonetic badge-pill">/rɪˈlʌktənt/</span>
<span id="res-pos" class="res-pos badge-pill">Adjective</span>
<button id="play-audio-btn" class="audio-btn" title="Прослушать произношение">
🔊 <span class="audio-text">Слушать</span>
</button>
</div>
</div>
<div id="res-definition-box" class="definition-box">
<div class="def-label">Толкование (English):</div>
<p id="res-definition" class="def-text"></p>
</div>
<div class="card-edit-grid">
<div class="field-group">
<label for="edit-translation">Перевод на русский <span class="req">*</span></label>
<input type="text" id="edit-translation" class="input-styled" placeholder="Основной перевод...">
<div id="translation-suggestions" class="suggestions-chips"></div>
</div>
<div class="field-group">
<label for="edit-context">Контекстное предложение (где встретилось?)</label>
<input type="text" id="edit-context" class="input-styled" placeholder="Например: The switch drops the frame if the VLAN ID is invalid.">
</div>
<div class="field-row">
<div class="field-group flex-2">
<label for="edit-tags">Теги (через запятую)</label>
<input type="text" id="edit-tags" class="input-styled" placeholder="general, phrasal-verb, networking">
<div class="quick-tags" id="quick-tags-bar">
<span class="tag-chip" data-tag="general">#general</span>
<span class="tag-chip" data-tag="phrasal-verb">#phrasal-verb</span>
<span class="tag-chip" data-tag="idiom">#idiom</span>
<span class="tag-chip" data-tag="daily">#daily</span>
<span class="tag-chip" data-tag="tech">#tech</span>
<span class="tag-chip" data-tag="work">#work</span>
</div>
</div>
<div class="field-group flex-1">
<label for="edit-notes">Заметка / Мнемоника</label>
<input type="text" id="edit-notes" class="input-styled" placeholder="Ассоциация...">
</div>
</div>
</div>
<div class="result-actions">
<button id="save-card-btn" class="btn btn-success btn-lg">
💾 Сохранить в словарь <span class="btn-subtext">(Ctrl + Enter)</span>
</button>
</div>
</div>
</section>
<!-- Vocabulary Notebook & History -->
<section class="vocabulary-section">
<div class="section-header">
<div class="section-title-wrap">
<h3>📚 Мой словарь</h3>
<span id="cards-count-label" class="cards-count-label">(0 записей)</span>
</div>
<div class="list-controls">
<div class="search-mini-box">
<input type="text" id="filter-input" placeholder="Фильтр по слову, переводу, контексту...">
</div>
</div>
</div>
<!-- Active Tags Filter -->
<div id="tags-filter-bar" class="tags-filter-bar">
<button class="filter-chip active" data-tag="">Все теги</button>
</div>
<!-- Cards Grid/List -->
<div id="cards-list" class="cards-list">
<div class="empty-state" id="empty-state">
<div class="empty-icon">🌱</div>
<h4>Ваш словарь пока пуст</h4>
<p>Введите первое слово или фразу в поле поиска выше и сохраните её!</p>
</div>
</div>
</section>
</main>
</div>
<!-- Edit Card Modal -->
<div id="edit-modal" class="modal-backdrop hidden">
<div class="modal-window">
<div class="modal-header">
<h3>Редактирование карточки</h3>
<button id="close-modal-btn" class="icon-btn">✕</button>
</div>
<div class="modal-body">
<input type="hidden" id="modal-card-id">
<div class="form-group">
<label>Слово / Фраза</label>
<input type="text" id="modal-term" class="input-styled">
</div>
<div class="form-group">
<label>Перевод на русский</label>
<input type="text" id="modal-translation" class="input-styled">
</div>
<div class="form-group">
<label>Контекстное предложение</label>
<input type="text" id="modal-context" class="input-styled">
</div>
<div class="form-group">
<label>Теги</label>
<input type="text" id="modal-tags" class="input-styled">
</div>
<div class="form-group">
<label>Заметки / Мнемоника</label>
<input type="text" id="modal-notes" class="input-styled">
</div>
</div>
<div class="modal-footer">
<button id="modal-cancel-btn" class="btn btn-outline">Отмена</button>
<button id="modal-save-btn" class="btn btn-primary">Сохранить изменения</button>
</div>
</div>
</div>
<!-- Audio Element for native streaming -->
<audio id="global-audio-player" preload="none"></audio>
<script src="/static/js/app.js"></script>
</body>
</html>
+650
View File
@@ -0,0 +1,650 @@
// Notes Application Frontend Logic
document.addEventListener('DOMContentLoaded', () => {
// State
let currentUser = null;
let currentLookupData = null;
let allCards = [];
let selectedTag = '';
let searchQuery = '';
// Elements - Auth
const authScreen = document.getElementById('auth-screen');
const appContainer = document.getElementById('app-container');
const loginForm = document.getElementById('login-form');
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
const authError = document.getElementById('auth-error');
const loginBtn = document.getElementById('login-btn');
const logoutBtn = document.getElementById('logout-btn');
const userDisplayName = document.getElementById('user-display-name');
// Elements - Navbar & Stats
const themeToggle = document.getElementById('theme-toggle');
const statTotal = document.getElementById('stat-total');
const statToday = document.getElementById('stat-today');
// Elements - Search & Result
const termInput = document.getElementById('term-input');
const clearSearchBtn = document.getElementById('clear-search-btn');
const translateBtn = document.getElementById('translate-btn');
const lookupSpinner = document.getElementById('lookup-spinner');
const resultCard = document.getElementById('result-card');
const resTerm = document.getElementById('res-term');
const resPhonetic = document.getElementById('res-phonetic');
const resPos = document.getElementById('res-pos');
const playAudioBtn = document.getElementById('play-audio-btn');
const resDefinitionBox = document.getElementById('res-definition-box');
const resDefinition = document.getElementById('res-definition');
const editTranslation = document.getElementById('edit-translation');
const translationSuggestions = document.getElementById('translation-suggestions');
const editContext = document.getElementById('edit-context');
const editTags = document.getElementById('edit-tags');
const quickTagsBar = document.getElementById('quick-tags-bar');
const editNotes = document.getElementById('edit-notes');
const saveCardBtn = document.getElementById('save-card-btn');
// Elements - Vocabulary List
const filterInput = document.getElementById('filter-input');
const tagsFilterBar = document.getElementById('tags-filter-bar');
const cardsList = document.getElementById('cards-list');
const emptyState = document.getElementById('empty-state');
const cardsCountLabel = document.getElementById('cards-count-label');
// Elements - Modal
const editModal = document.getElementById('edit-modal');
const closeModalBtn = document.getElementById('close-modal-btn');
const modalCancelBtn = document.getElementById('modal-cancel-btn');
const modalSaveBtn = document.getElementById('modal-save-btn');
const modalCardId = document.getElementById('modal-card-id');
const modalTerm = document.getElementById('modal-term');
const modalTranslation = document.getElementById('modal-translation');
const modalContext = document.getElementById('modal-context');
const modalTags = document.getElementById('modal-tags');
const modalNotes = document.getElementById('modal-notes');
// Audio
const globalAudioPlayer = document.getElementById('global-audio-player');
// Theme Management
function initTheme() {
const savedTheme = localStorage.getItem('notes_theme') || 'dark';
document.documentElement.setAttribute('data-theme', savedTheme);
themeToggle.textContent = savedTheme === 'dark' ? '☀️' : '🌙';
}
themeToggle.addEventListener('click', () => {
const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark';
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('notes_theme', newTheme);
themeToggle.textContent = newTheme === 'dark' ? '☀️' : '🌙';
});
// Pronunciation Helper (Audio file or Web Speech API fallback)
function playAudio(text, audioUrl) {
if (audioUrl) {
globalAudioPlayer.src = audioUrl;
globalAudioPlayer.play().catch(() => {
fallbackSpeechSynthesis(text);
});
} else {
fallbackSpeechSynthesis(text);
}
}
function fallbackSpeechSynthesis(text) {
if ('speechSynthesis' in window) {
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = 'en-US';
utterance.rate = 0.9;
window.speechSynthesis.speak(utterance);
}
}
// Auth Handlers
async function checkAuth() {
try {
const resp = await fetch('/api/me');
if (resp.ok) {
const data = await resp.json();
currentUser = data.user;
showApp();
} else {
showAuth();
}
} catch (e) {
showAuth();
}
}
function showAuth() {
authScreen.classList.remove('hidden');
appContainer.classList.add('hidden');
usernameInput.focus();
}
function showApp() {
authScreen.classList.add('hidden');
appContainer.classList.remove('hidden');
userDisplayName.textContent = currentUser ? currentUser.username : 'user';
termInput.focus();
loadStats();
loadCards();
loadTags();
}
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
authError.classList.add('hidden');
loginBtn.disabled = true;
loginBtn.textContent = 'Вход...';
try {
const resp = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: usernameInput.value.trim(),
password: passwordInput.value
})
});
const data = await resp.json();
if (resp.ok) {
currentUser = data.user;
passwordInput.value = '';
showApp();
} else {
authError.textContent = data.detail || 'Ошибка авторизации';
authError.classList.remove('hidden');
}
} catch (err) {
authError.textContent = 'Ошибка сети. Проверьте подключение к серверу.';
authError.classList.remove('hidden');
} finally {
loginBtn.disabled = false;
loginBtn.textContent = 'Войти в систему';
}
});
logoutBtn.addEventListener('click', async () => {
try {
await fetch('/api/logout', { method: 'POST' });
} catch (e) {}
currentUser = null;
showAuth();
});
// Stats
async function loadStats() {
try {
const resp = await fetch('/api/stats');
if (resp.ok) {
const data = await resp.json();
statTotal.textContent = data.total_cards;
statToday.textContent = `+${data.today_added}`;
}
} catch (e) {}
}
// Search & Translate
async function performLookup() {
const query = termInput.value.trim();
if (!query) return;
lookupSpinner.classList.remove('hidden');
resultCard.classList.add('hidden');
translateBtn.disabled = true;
try {
const resp = await fetch(`/api/lookup?q=${encodeURIComponent(query)}`);
if (resp.status === 401) {
showAuth();
return;
}
if (!resp.ok) throw new Error('Ошибка словаря');
const data = await resp.json();
currentLookupData = data;
renderResultCard(data);
} catch (err) {
alert('Не удалось получить перевод. Попробуйте снова или введите перевод вручную.');
} finally {
lookupSpinner.classList.add('hidden');
translateBtn.disabled = false;
}
}
function renderResultCard(data) {
resTerm.textContent = data.query;
// Phonetic
if (data.phonetic) {
resPhonetic.textContent = data.phonetic;
resPhonetic.classList.remove('hidden');
} else {
resPhonetic.classList.add('hidden');
}
// Part of Speech
if (data.part_of_speech) {
resPos.textContent = data.part_of_speech;
resPos.classList.remove('hidden');
} else {
resPos.classList.add('hidden');
}
// Definitions
if (data.definitions && data.definitions.length > 0) {
resDefinition.textContent = data.definitions[0];
resDefinitionBox.classList.remove('hidden');
} else {
resDefinitionBox.classList.add('hidden');
}
// Translation field
editTranslation.value = data.translation || '';
// Translation suggestions chips
translationSuggestions.innerHTML = '';
if (data.translations_list && data.translations_list.length > 1) {
data.translations_list.forEach(t => {
const chip = document.createElement('span');
chip.className = 'suggestion-chip';
chip.textContent = t;
chip.addEventListener('click', () => {
editTranslation.value = t;
});
translationSuggestions.appendChild(chip);
});
}
// Context: if user entered a sentence, put it in context field
if (data.is_sentence) {
editContext.value = data.query;
} else {
editContext.value = '';
}
// Auto-assign default tags
let defaultTags = ['general'];
if (data.is_sentence) {
defaultTags = ['phrase'];
} else if (data.query.includes(' ') && data.part_of_speech && data.part_of_speech.toLowerCase().includes('verb')) {
defaultTags = ['phrasal-verb'];
}
editTags.value = defaultTags.join(', ');
editNotes.value = '';
resultCard.classList.remove('hidden');
editTranslation.focus();
}
// Audio Play for Result Card
playAudioBtn.addEventListener('click', () => {
if (!currentLookupData) return;
playAudio(currentLookupData.query, currentLookupData.audio_url);
});
// Quick Tags Selector
quickTagsBar.addEventListener('click', (e) => {
if (e.target.classList.contains('tag-chip')) {
const tag = e.target.dataset.tag;
const currentTags = editTags.value.split(',').map(t => t.trim()).filter(Boolean);
if (!currentTags.includes(tag)) {
currentTags.push(tag);
editTags.value = currentTags.join(', ');
}
}
});
// Save Card
async function saveCurrentCard() {
if (!currentLookupData) return;
const term = currentLookupData.query;
const translation = editTranslation.value.trim();
if (!translation) {
alert('Пожалуйста, укажите перевод слова.');
editTranslation.focus();
return;
}
saveCardBtn.disabled = true;
saveCardBtn.textContent = 'Сохранение...';
try {
const payload = {
term: term,
translation: translation,
context_sentence: editContext.value.trim(),
phonetic: currentLookupData.phonetic || '',
audio_url: currentLookupData.audio_url || '',
part_of_speech: currentLookupData.part_of_speech || '',
definition_en: currentLookupData.definitions && currentLookupData.definitions.length ? currentLookupData.definitions[0] : '',
tags: editTags.value.trim() || 'general',
notes: editNotes.value.trim()
};
const resp = await fetch('/api/cards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (resp.ok) {
// Success feedback
saveCardBtn.textContent = '✅ Сохранено!';
setTimeout(() => {
resultCard.classList.add('hidden');
termInput.value = '';
clearSearchBtn.classList.add('hidden');
termInput.focus();
saveCardBtn.textContent = '💾 Сохранить в словарь (Ctrl + Enter)';
saveCardBtn.disabled = false;
}, 600);
loadCards();
loadStats();
loadTags();
} else {
throw new Error('Ошибка сохранения');
}
} catch (err) {
alert('Не удалось сохранить карточку. Попробуйте еще раз.');
saveCardBtn.disabled = false;
saveCardBtn.textContent = '💾 Сохранить в словарь (Ctrl + Enter)';
}
}
saveCardBtn.addEventListener('click', saveCurrentCard);
// Term Input controls
termInput.addEventListener('input', () => {
if (termInput.value.trim()) {
clearSearchBtn.classList.remove('hidden');
} else {
clearSearchBtn.classList.add('hidden');
}
});
clearSearchBtn.addEventListener('click', () => {
termInput.value = '';
clearSearchBtn.classList.add('hidden');
resultCard.classList.add('hidden');
termInput.focus();
});
translateBtn.addEventListener('click', performLookup);
// Global Hotkeys
document.addEventListener('keydown', (e) => {
// Ctrl+Enter or Cmd+Enter to Save Card
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
if (!resultCard.classList.contains('hidden')) {
saveCurrentCard();
}
return;
}
// Enter in search input
if (e.key === 'Enter' && document.activeElement === termInput) {
e.preventDefault();
performLookup();
return;
}
// Esc to clear search or close modal
if (e.key === 'Escape') {
if (!editModal.classList.contains('hidden')) {
editModal.classList.add('hidden');
} else if (document.activeElement === termInput) {
termInput.value = '';
clearSearchBtn.classList.add('hidden');
resultCard.classList.add('hidden');
}
return;
}
});
// Vocabulary Cards & Tags
async function loadCards() {
try {
let url = `/api/cards?limit=150`;
if (searchQuery) url += `&search=${encodeURIComponent(searchQuery)}`;
if (selectedTag) url += `&tag=${encodeURIComponent(selectedTag)}`;
const resp = await fetch(url);
if (resp.status === 401) {
showAuth();
return;
}
if (resp.ok) {
const data = await resp.json();
allCards = data.cards;
cardsCountLabel.textContent = `(${data.total} записей)`;
renderCards(allCards);
}
} catch (e) {}
}
function renderCards(cards) {
cardsList.innerHTML = '';
if (!cards || cards.length === 0) {
emptyState.classList.remove('hidden');
cardsList.appendChild(emptyState);
return;
}
emptyState.classList.add('hidden');
cards.forEach(card => {
const cardEl = document.createElement('div');
cardEl.className = 'vocab-card';
// Format date
const cardDate = new Date(card.created_at);
const dateStr = cardDate.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
// Format tags
const tagsList = (card.tags || '').split(',').map(t => t.trim()).filter(Boolean);
const tagsHtml = tagsList.map(t => `<span class="card-tag">#${escapeHtml(t)}</span>`).join('');
// Highlight context
let contextHtml = '';
if (card.context_sentence) {
let formattedContext = escapeHtml(card.context_sentence);
// Highlight term occurrence in context
const regex = new RegExp(`(${escapeRegExp(card.term)})`, 'gi');
formattedContext = formattedContext.replace(regex, '<mark>$1</mark>');
contextHtml = `<div class="card-context">“${formattedContext}”</div>`;
}
// Notes
let notesHtml = '';
if (card.notes) {
notesHtml = `<span class="card-notes" title="Заметка">💡 ${escapeHtml(card.notes)}</span>`;
}
cardEl.innerHTML = `
<div class="card-top">
<div class="card-term-group">
<span class="card-term">${escapeHtml(card.term)}</span>
${card.phonetic ? `<span class="card-phonetic">${escapeHtml(card.phonetic)}</span>` : ''}
${card.part_of_speech ? `<span class="card-pos">${escapeHtml(card.part_of_speech)}</span>` : ''}
<button class="icon-btn play-card-audio-btn" title="Озвучить" data-term="${escapeHtml(card.term)}" data-audio="${escapeHtml(card.audio_url || '')}">🔊</button>
</div>
<div class="card-actions">
<button class="icon-btn edit-card-btn" title="Редактировать" data-id="${card.id}">✏️</button>
<button class="icon-btn delete-card-btn" title="Удалить" data-id="${card.id}">🗑️</button>
</div>
</div>
<div class="card-translation">${escapeHtml(card.translation)}</div>
${contextHtml}
<div class="card-bottom">
<div class="card-tags">${tagsHtml}</div>
<div style="display: flex; align-items: center; gap: 10px;">
${notesHtml}
<span class="card-date">${dateStr}</span>
</div>
</div>
`;
// Card Listeners
cardEl.querySelector('.play-card-audio-btn').addEventListener('click', (e) => {
const btn = e.currentTarget;
playAudio(btn.dataset.term, btn.dataset.audio);
});
cardEl.querySelector('.edit-card-btn').addEventListener('click', () => {
openEditModal(card);
});
cardEl.querySelector('.delete-card-btn').addEventListener('click', async () => {
if (confirm(`Удалить слово "${card.term}" из словаря?`)) {
await deleteCard(card.id);
}
});
cardsList.appendChild(cardEl);
});
}
async function deleteCard(cardId) {
try {
const resp = await fetch(`/api/cards/${cardId}`, { method: 'DELETE' });
if (resp.ok) {
loadCards();
loadStats();
loadTags();
}
} catch (e) {
alert('Ошибка удаления карточки');
}
}
// Modal Edit
function openEditModal(card) {
modalCardId.value = card.id;
modalTerm.value = card.term;
modalTranslation.value = card.translation;
modalContext.value = card.context_sentence || '';
modalTags.value = card.tags || '';
modalNotes.value = card.notes || '';
editModal.classList.remove('hidden');
modalTranslation.focus();
}
closeModalBtn.addEventListener('click', () => editModal.classList.add('hidden'));
modalCancelBtn.addEventListener('click', () => editModal.classList.add('hidden'));
modalSaveBtn.addEventListener('click', async () => {
const id = modalCardId.value;
try {
const payload = {
term: modalTerm.value.trim(),
translation: modalTranslation.value.trim(),
context_sentence: modalContext.value.trim(),
tags: modalTags.value.trim(),
notes: modalNotes.value.trim()
};
const resp = await fetch(`/api/cards/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (resp.ok) {
editModal.classList.add('hidden');
loadCards();
loadTags();
} else {
alert('Не удалось обновить карточку.');
}
} catch (e) {
alert('Ошибка при сохранении.');
}
});
// Tag Filter Bar
async function loadTags() {
try {
const resp = await fetch('/api/tags');
if (resp.ok) {
const tags = await resp.json();
renderTagsFilter(tags);
}
} catch (e) {}
}
function renderTagsFilter(tags) {
tagsFilterBar.innerHTML = '';
// "All tags" button
const allBtn = document.createElement('button');
allBtn.className = `filter-chip ${selectedTag === '' ? 'active' : ''}`;
allBtn.textContent = 'Все теги';
allBtn.addEventListener('click', () => {
selectedTag = '';
document.querySelectorAll('.filter-chip').forEach(b => b.classList.remove('active'));
allBtn.classList.add('active');
loadCards();
});
tagsFilterBar.appendChild(allBtn);
tags.forEach(({ tag, count }) => {
const btn = document.createElement('button');
btn.className = `filter-chip ${selectedTag === tag ? 'active' : ''}`;
btn.textContent = `#${tag} (${count})`;
btn.addEventListener('click', () => {
if (selectedTag === tag) {
selectedTag = '';
btn.classList.remove('active');
allBtn.classList.add('active');
} else {
selectedTag = tag;
document.querySelectorAll('.filter-chip').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
}
loadCards();
});
tagsFilterBar.appendChild(btn);
});
}
// Mini search filter
let debounceTimer = null;
filterInput.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
searchQuery = filterInput.value.trim();
loadCards();
}, 250);
});
// Helper functions
function escapeHtml(str) {
if (!str) return '';
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Initialize
initTheme();
checkAuth();
});