feat(core): implement secure MVP core with dictionary search, auth, and CRUD
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
# Server Configuration
|
||||||
|
HOST=127.0.0.1
|
||||||
|
PORT=8002
|
||||||
|
DEBUG=False
|
||||||
|
|
||||||
|
# Security
|
||||||
|
SECRET_KEY=change_this_to_a_random_secret_string
|
||||||
|
SESSION_COOKIE_NAME=notes_session
|
||||||
|
SESSION_EXPIRE_DAYS=30
|
||||||
|
RATE_LIMIT_LOGIN_MAX=5
|
||||||
|
RATE_LIMIT_LOGIN_WINDOW_SEC=60
|
||||||
|
|
||||||
|
# Initial Admin Credentials (auto-provisioned on first run if no users exist)
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=change_this_admin_password
|
||||||
|
|
||||||
|
# External APIs
|
||||||
|
MYMEMORY_EMAIL=admin@qpid.pro
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_PATH=/home/kopri/notes.qpid.pro/data/notes.db
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Notes Application Package."""
|
||||||
|
__version__ = "1.0.0"
|
||||||
+116
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
""")
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(string) {
|
||||||
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
initTheme();
|
||||||
|
checkAuth();
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
annotated-doc==0.0.5
|
||||||
|
annotated-types==0.8.0
|
||||||
|
anyio==4.15.1
|
||||||
|
certifi==2026.7.22
|
||||||
|
click==8.5.0
|
||||||
|
fastapi==0.141.1
|
||||||
|
h11==0.16.0
|
||||||
|
httpcore==1.0.9
|
||||||
|
httpx==0.28.1
|
||||||
|
idna==3.20
|
||||||
|
pydantic==2.13.5
|
||||||
|
pydantic_core==2.46.5
|
||||||
|
python-multipart==0.0.32
|
||||||
|
starlette==1.6.0
|
||||||
|
typing-inspection==0.4.4
|
||||||
|
typing_extensions==4.16.0
|
||||||
|
uvicorn==0.53.0
|
||||||
Executable
+14
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." >/dev/null 2>&1 && pwd )"
|
||||||
|
cd "$DIR"
|
||||||
|
|
||||||
|
# Source virtualenv
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# Default port 8002, host 127.0.0.1
|
||||||
|
HOST="${HOST:-127.0.0.1}"
|
||||||
|
PORT="${PORT:-8002}"
|
||||||
|
|
||||||
|
exec uvicorn app.main:app --host "$HOST" --port "$PORT" --workers 2
|
||||||
Executable
+42
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add project root to sys.path
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(BASE_DIR))
|
||||||
|
|
||||||
|
from app.database import init_db, get_db
|
||||||
|
from app.auth import create_user, hash_password
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Manage admin users for Notes App")
|
||||||
|
parser.add_argument("--username", "-u", required=True, help="Username")
|
||||||
|
parser.add_argument("--password", "-p", required=True, help="Password")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
username = args.username.strip()
|
||||||
|
password = args.password
|
||||||
|
|
||||||
|
pwd_hash, salt = hash_password(password)
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
existing = conn.execute("SELECT id FROM users WHERE username = ?", (username,)).fetchone()
|
||||||
|
if existing:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET password_hash = ?, salt = ? WHERE id = ?",
|
||||||
|
(pwd_hash, salt, existing["id"])
|
||||||
|
)
|
||||||
|
print(f"Password updated successfully for existing user '{username}'.")
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO users (username, password_hash, salt) VALUES (?, ?, ?)",
|
||||||
|
(username, pwd_hash, salt)
|
||||||
|
)
|
||||||
|
print(f"User '{username}' created successfully.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
# Use temporary database for tests
|
||||||
|
temp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||||
|
os.environ["DB_PATH"] = temp_db.name
|
||||||
|
os.environ["ADMIN_USERNAME"] = "testadmin"
|
||||||
|
os.environ["ADMIN_PASSWORD"] = "testpass12345"
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app import config
|
||||||
|
from app.database import init_db, get_db
|
||||||
|
from app.auth import hash_password, verify_password, create_user, authenticate_user, ensure_admin_user
|
||||||
|
|
||||||
|
config.ADMIN_USERNAME = "testadmin"
|
||||||
|
config.ADMIN_PASSWORD = "testpass12345"
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
def setup_module():
|
||||||
|
init_db()
|
||||||
|
ensure_admin_user()
|
||||||
|
|
||||||
|
def teardown_module():
|
||||||
|
try:
|
||||||
|
Path(temp_db.name).unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_password_hashing():
|
||||||
|
pwd = "superSecretPassword123"
|
||||||
|
h, salt = hash_password(pwd)
|
||||||
|
assert verify_password(pwd, h, salt) is True
|
||||||
|
assert verify_password("wrongPassword", h, salt) is False
|
||||||
|
|
||||||
|
def test_user_creation_and_auth():
|
||||||
|
uid = create_user("demo_user", "demo_pass_999")
|
||||||
|
assert uid > 0
|
||||||
|
auth = authenticate_user("demo_user", "demo_pass_999")
|
||||||
|
assert auth is not None
|
||||||
|
assert auth["username"] == "demo_user"
|
||||||
|
|
||||||
|
wrong = authenticate_user("demo_user", "wrong_pass")
|
||||||
|
assert wrong is None
|
||||||
|
|
||||||
|
def test_unauthenticated_access_blocked():
|
||||||
|
# Attempting to access protected API without session
|
||||||
|
resp = client.get("/api/me")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
resp = client.get("/api/cards")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
resp = client.post("/api/cards", json={"term": "apple", "translation": "яблоко"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_login_and_card_crud_workflow():
|
||||||
|
# 1. Login with testadmin
|
||||||
|
resp = client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert "user" in data
|
||||||
|
|
||||||
|
# 2. Check /api/me
|
||||||
|
resp = client.get("/api/me")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["user"]["username"] == "testadmin"
|
||||||
|
|
||||||
|
# 3. Create a card
|
||||||
|
card_payload = {
|
||||||
|
"term": "reluctant",
|
||||||
|
"translation": "неохотный, нежелающий",
|
||||||
|
"context_sentence": "He was reluctant to leave the room.",
|
||||||
|
"phonetic": "/rɪˈlʌktənt/",
|
||||||
|
"part_of_speech": "Adjective",
|
||||||
|
"definition_en": "Not wanting to take some action",
|
||||||
|
"tags": "general, daily",
|
||||||
|
"notes": "Remember: reluctant to do sth"
|
||||||
|
}
|
||||||
|
resp = client.post("/api/cards", json=card_payload)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
created = resp.json()
|
||||||
|
card_id = created["id"]
|
||||||
|
assert created["term"] == "reluctant"
|
||||||
|
|
||||||
|
# 4. Get cards list
|
||||||
|
resp = client.get("/api/cards")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
res_data = resp.json()
|
||||||
|
assert res_data["total"] >= 1
|
||||||
|
assert any(c["id"] == card_id for c in res_data["cards"])
|
||||||
|
|
||||||
|
# 5. Filter cards by search
|
||||||
|
resp = client.get("/api/cards?search=reluctant")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["total"] >= 1
|
||||||
|
|
||||||
|
# 6. Filter cards by tag
|
||||||
|
resp = client.get("/api/cards?tag=general")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["total"] >= 1
|
||||||
|
|
||||||
|
# 7. Check tags list
|
||||||
|
resp = client.get("/api/tags")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
tags = resp.json()
|
||||||
|
tag_names = [t["tag"] for t in tags]
|
||||||
|
assert "general" in tag_names
|
||||||
|
assert "daily" in tag_names
|
||||||
|
|
||||||
|
# 8. Update card
|
||||||
|
update_payload = {
|
||||||
|
"translation": "неохотный, сопротивляющийся",
|
||||||
|
"notes": "Updated note"
|
||||||
|
}
|
||||||
|
resp = client.put(f"/api/cards/{card_id}", json=update_payload)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
updated = resp.json()
|
||||||
|
assert updated["translation"] == "неохотный, сопротивляющийся"
|
||||||
|
assert updated["notes"] == "Updated note"
|
||||||
|
|
||||||
|
# 9. Check stats
|
||||||
|
resp = client.get("/api/stats")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
stats = resp.json()
|
||||||
|
assert stats["total_cards"] >= 1
|
||||||
|
assert stats["today_added"] >= 1
|
||||||
|
|
||||||
|
# 10. Delete card
|
||||||
|
resp = client.delete(f"/api/cards/{card_id}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "ok"
|
||||||
|
|
||||||
|
# Verify deleted
|
||||||
|
resp = client.get("/api/cards")
|
||||||
|
assert not any(c["id"] == card_id for c in resp.json()["cards"])
|
||||||
|
|
||||||
|
# 11. Logout
|
||||||
|
resp = client.post("/api/logout")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Verify session ended
|
||||||
|
resp = client.get("/api/me")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_login_rate_limiting():
|
||||||
|
# Attempt multiple failed logins from the same client
|
||||||
|
for _ in range(5):
|
||||||
|
resp = client.post("/api/login", json={"username": "testadmin", "password": "wrong_password_999"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
# 6th attempt should be rate limited (429)
|
||||||
|
resp = client.post("/api/login", json={"username": "testadmin", "password": "wrong_password_999"})
|
||||||
|
assert resp.status_code == 429
|
||||||
|
|
||||||
|
def test_lookup_endpoint_authenticated():
|
||||||
|
from app.auth import _login_failures
|
||||||
|
_login_failures.clear()
|
||||||
|
|
||||||
|
# Login first
|
||||||
|
resp = client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
resp = client.get("/api/lookup?q=reluctant")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["query"] == "reluctant"
|
||||||
|
assert "translation" in data
|
||||||
|
assert "phonetic" in data
|
||||||
|
assert len(data["translation"]) > 0
|
||||||
|
|
||||||
Reference in New Issue
Block a user