📚 Мой словарь
+ (0 записей) +Ваш словарь пока пуст
+Введите первое слово или фразу в поле поиска выше и сохраните её!
+diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..295a2ed --- /dev/null +++ b/.env.example @@ -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 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..f21d8dc --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,2 @@ +"""Notes Application Package.""" +__version__ = "1.0.0" diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..a254925 --- /dev/null +++ b/app/auth.py @@ -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) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..8c053b6 --- /dev/null +++ b/app/config.py @@ -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) diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..7cb550d --- /dev/null +++ b/app/database.py @@ -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 + ); + """) diff --git a/app/dictionary.py b/app/dictionary.py new file mode 100644 index 0000000..324cb4b --- /dev/null +++ b/app/dictionary.py @@ -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 diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..74639b5 --- /dev/null +++ b/app/main.py @@ -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 + } diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..fb647f9 --- /dev/null +++ b/app/static/css/style.css @@ -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%; + } +} diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..953a637 --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,220 @@ + + +
+ + +