0.99.60

parent 9c65c49c
include README.md include README.md
include LICENSE.txt include LICENSE.txt
include DOCUMENTATION.md
include requirements.txt include requirements.txt
include main.py include main.py
include pyproject.toml include pyproject.toml
......
...@@ -31,7 +31,7 @@ from pathlib import Path ...@@ -31,7 +31,7 @@ from pathlib import Path
# Files and directories the share directory must contain for the server to start. # Files and directories the share directory must contain for the server to start.
_REQUIRED_FILES = ['aisbf.sh', 'main.py', 'requirements.txt'] _REQUIRED_FILES = ['aisbf.sh', 'main.py', 'requirements.txt', 'DOCUMENTATION.md', 'README.md', 'LICENSE.txt']
_REQUIRED_DIRS = ['templates', 'static', 'config', 'aisbf'] _REQUIRED_DIRS = ['templates', 'static', 'config', 'aisbf']
...@@ -247,11 +247,32 @@ def _bootstrap_share_dir(): ...@@ -247,11 +247,32 @@ def _bootstrap_share_dir():
return None return None
def _get_installed_version():
try:
from importlib.metadata import version
return version('aisbf')
except Exception:
return None
def _share_version(share_dir):
try:
return (share_dir / '.version').read_text().strip()
except Exception:
return None
def _write_share_version(share_dir):
v = _get_installed_version()
if v:
(share_dir / '.version').write_text(v)
def main(): def main():
share_dir = _find_share_dir() share_dir = _find_share_dir()
if share_dir is None: # Always bootstrap to keep share dir files current.
share_dir = _bootstrap_share_dir() # The venv pip-update is gated by version change inside aisbf.sh (check_package_upgrade).
share_dir = _bootstrap_share_dir()
if share_dir:
_write_share_version(share_dir)
if share_dir is None: if share_dir is None:
checked = '\n'.join(f' - {p}' for p in _share_dir_candidates()) checked = '\n'.join(f' - {p}' for p in _share_dir_candidates())
......
import json, glob
I18N_DIR = '/working/aisbf/static/i18n/'
with open(I18N_DIR + 'en.json') as f:
en = json.load(f)
# New namespaces to translate
NEW_NS = [
'users_page','wallet_page','analytics_page','rate_limits_page','login_page',
'signup_page','forgot_page','reset_page','profile_page','password_page',
'email_page','delete_page','tokens_page','billing_page','user_overview',
'usage_page','prompts_page','config_page','error_page','tiers_page',
'cache_page','response_cache_page','settings_page'
]
...@@ -30,6 +30,7 @@ from fastapi.responses import JSONResponse, StreamingResponse, HTMLResponse, Red ...@@ -30,6 +30,7 @@ from fastapi.responses import JSONResponse, StreamingResponse, HTMLResponse, Red
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from aisbf.models import ChatCompletionRequest, ChatCompletionResponse from aisbf.models import ChatCompletionRequest, ChatCompletionResponse
from aisbf.handlers import RequestHandler, RotationHandler, AutoselectHandler from aisbf.handlers import RequestHandler, RotationHandler, AutoselectHandler
...@@ -562,6 +563,11 @@ app = FastAPI( ...@@ -562,6 +563,11 @@ app = FastAPI(
max_request_size=100 * 1024 * 1024 # 100MB max request size max_request_size=100 * 1024 * 1024 # 100MB max request size
) )
# Serve static files (i18n.js, i18n/*.json, extension assets, etc.)
_static_dir = Path(__file__).parent / 'static'
_static_dir.mkdir(parents=True, exist_ok=True)
app.mount("/dashboard/static", StaticFiles(directory=str(_static_dir)), name="static")
# Note: ProxyHeadersMiddleware will be added LAST (after all other middleware) # Note: ProxyHeadersMiddleware will be added LAST (after all other middleware)
# so it executes FIRST and processes proxy headers before other middleware # so it executes FIRST and processes proxy headers before other middleware
...@@ -2068,6 +2074,16 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE ...@@ -2068,6 +2074,16 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
content={"detail": exc.errors(), "body": body_data} content={"detail": exc.errors(), "body": body_data}
) )
@app.exception_handler(404)
async def not_found_handler(request: Request, exc):
"""Serve a custom 404 page for browser requests, JSON for API clients."""
accept = request.headers.get("accept", "")
if "text/html" in accept:
from fastapi.responses import HTMLResponse
with open(Path(__file__).parent / "templates" / "404.html", encoding="utf-8") as f:
return HTMLResponse(content=f.read(), status_code=404)
return JSONResponse(status_code=404, content={"detail": "Not found"})
# CORS middleware — wildcard origins are incompatible with allow_credentials=True # CORS middleware — wildcard origins are incompatible with allow_credentials=True
# (browsers reject credentialed cross-origin requests to "*"). API clients # (browsers reject credentialed cross-origin requests to "*"). API clients
# (curl, SDK) never send cookies, so credentials are not needed here. # (curl, SDK) never send cookies, so credentials are not needed here.
...@@ -2125,6 +2141,35 @@ def _get_client_ip(request: Request) -> Optional[str]: ...@@ -2125,6 +2141,35 @@ def _get_client_ip(request: Request) -> Optional[str]:
return client[0] return client[0]
return None return None
_LOCAL_IPS = {"127.0.0.1", "::1", "localhost"}
def _is_local_client(request: Request) -> bool:
"""
Return True only when the real end-user browser is on localhost.
Handles the case where a local reverse-proxy (nginx/caddy on 127.0.0.1)
forwards requests: if X-Forwarded-For is absent but X-Forwarded-Host
points to a non-localhost host, the client is remote even though the
TCP peer is 127.0.0.1.
"""
# If X-Forwarded-For is present, trust it (ProxyHeadersMiddleware already
# rewrites scope["client"] with it, but we check explicitly for clarity).
xff = request.headers.get("X-Forwarded-For")
if xff:
return xff.split(",")[0].strip() in _LOCAL_IPS
# No XFF — check whether a proxy header reveals a non-local origin host.
forwarded_host = request.headers.get("X-Forwarded-Host") or request.headers.get("X-Real-IP")
if forwarded_host:
# X-Forwarded-Host is a hostname, not an IP, but if it's not localhost it's remote.
host = forwarded_host.split(":")[0].strip()
if host not in _LOCAL_IPS:
return False
# Fall back to the direct TCP peer IP.
ip = _get_client_ip(request)
return ip in _LOCAL_IPS if ip else False
async def _check_server_ip_country() -> None: async def _check_server_ip_country() -> None:
"""Fetch the server's own public IP at startup and set _server_ip_blocked if Israeli.""" """Fetch the server's own public IP at startup and set _server_ip_blocked if Israeli."""
...@@ -2160,7 +2205,7 @@ class GenocidalBlockingMiddleware(BaseHTTPMiddleware): ...@@ -2160,7 +2205,7 @@ class GenocidalBlockingMiddleware(BaseHTTPMiddleware):
""" """
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
if request.url.path == "/blocked": if request.url.path == "/dashboard/blocked":
return await call_next(request) return await call_next(request)
if await self._should_block(request): if await self._should_block(request):
...@@ -2188,7 +2233,7 @@ class GenocidalBlockingMiddleware(BaseHTTPMiddleware): ...@@ -2188,7 +2233,7 @@ class GenocidalBlockingMiddleware(BaseHTTPMiddleware):
path = request.url.path path = request.url.path
if path.startswith("/api") or path.startswith("/mcp"): if path.startswith("/api") or path.startswith("/mcp"):
return JSONResponse(status_code=403, content={"error": _BLOCK_MESSAGE}) return JSONResponse(status_code=403, content={"error": _BLOCK_MESSAGE})
return RedirectResponse(url=f"{get_base_url(request)}/blocked", status_code=302) return RedirectResponse(url=f"{get_base_url(request)}/dashboard/blocked", status_code=302)
# Middleware execution order: last added = first executed (outermost layer). # Middleware execution order: last added = first executed (outermost layer).
...@@ -4516,6 +4561,7 @@ async def dashboard_providers(request: Request): ...@@ -4516,6 +4561,7 @@ async def dashboard_providers(request: Request):
"__version__": __version__, "__version__": __version__,
"providers_json": json.dumps(providers_data), "providers_json": json.dumps(providers_data),
"claude_cli_mode": _claude_cli_mode, "claude_cli_mode": _claude_cli_mode,
"is_local_client": _is_local_client(request),
"success": "Configuration saved successfully!" if success else None "success": "Configuration saved successfully!" if success else None
} }
) )
...@@ -4531,6 +4577,7 @@ async def dashboard_providers(request: Request): ...@@ -4531,6 +4577,7 @@ async def dashboard_providers(request: Request):
"user_providers_json": json.dumps(providers_data), "user_providers_json": json.dumps(providers_data),
"user_id": current_user_id, "user_id": current_user_id,
"claude_cli_mode": _claude_cli_mode, "claude_cli_mode": _claude_cli_mode,
"is_local_client": _is_local_client(request),
"success": "Configuration saved successfully!" if success else None "success": "Configuration saved successfully!" if success else None
} }
) )
...@@ -4739,6 +4786,7 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)): ...@@ -4739,6 +4786,7 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
"__version__": __version__, "__version__": __version__,
"providers_json": json.dumps(providers_data), "providers_json": json.dumps(providers_data),
"claude_cli_mode": _claude_cli_mode, "claude_cli_mode": _claude_cli_mode,
"is_local_client": _is_local_client(request),
"success": success_msg "success": success_msg
} }
) )
...@@ -4755,6 +4803,7 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)): ...@@ -4755,6 +4803,7 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
"user_providers_json": json.dumps(providers_data), "user_providers_json": json.dumps(providers_data),
"user_id": current_user_id, "user_id": current_user_id,
"claude_cli_mode": _claude_cli_mode, "claude_cli_mode": _claude_cli_mode,
"is_local_client": _is_local_client(request),
"success": success_msg "success": success_msg
} }
) )
...@@ -4785,6 +4834,7 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)): ...@@ -4785,6 +4834,7 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
"__version__": __version__, "__version__": __version__,
"providers_json": json.dumps(providers_data), "providers_json": json.dumps(providers_data),
"claude_cli_mode": _claude_cli_mode, "claude_cli_mode": _claude_cli_mode,
"is_local_client": _is_local_client(request),
"error": f"Invalid JSON: {str(e)}" "error": f"Invalid JSON: {str(e)}"
} }
) )
...@@ -4802,6 +4852,7 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)): ...@@ -4802,6 +4852,7 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
"user_providers_json": json.dumps(user_providers), "user_providers_json": json.dumps(user_providers),
"user_id": current_user_id, "user_id": current_user_id,
"claude_cli_mode": _claude_cli_mode, "claude_cli_mode": _claude_cli_mode,
"is_local_client": _is_local_client(request),
"error": f"Invalid JSON: {str(e)}" "error": f"Invalid JSON: {str(e)}"
} }
) )
...@@ -9497,7 +9548,7 @@ async def dashboard_wallet(request: Request): ...@@ -9497,7 +9548,7 @@ async def dashboard_wallet(request: Request):
return HTMLResponse("Wallet functionality not available", status_code=503) return HTMLResponse("Wallet functionality not available", status_code=503)
except Exception as e: except Exception as e:
logger.error(f"Failed to load wallet page: {e}") logger.error(f"Failed to load wallet page: {e}")
return templates.TemplateResponse("dashboard/error.html", { return templates.TemplateResponse(request=request, name="dashboard/error.html", context={
"request": request, "request": request,
"error": "Failed to load wallet. Please try again later." "error": "Failed to load wallet. Please try again later."
}, status_code=500) }, status_code=500)
...@@ -10535,10 +10586,10 @@ async def dashboard_license(request: Request): ...@@ -10535,10 +10586,10 @@ async def dashboard_license(request: Request):
) )
@app.get("/blocked", response_class=HTMLResponse) @app.get("/dashboard/blocked", response_class=HTMLResponse)
async def blocked_page(request: Request): async def blocked_page(request: Request):
"""Display blocked access page.""" """Display blocked access page."""
return templates.TemplateResponse("blocked.html", {"request": request}) return templates.TemplateResponse(request=request, name="blocked.html", context={"request": request})
def parse_provider_from_model(model: str) -> tuple[str, str]: def parse_provider_from_model(model: str) -> tuple[str, str]:
......
...@@ -48,7 +48,7 @@ class build_py(_build_py): ...@@ -48,7 +48,7 @@ class build_py(_build_py):
in site-packages/aisbf/_share/ and can be extracted by cli.py on first run. in site-packages/aisbf/_share/ and can be extracted by cli.py on first run.
""" """
_SHARE_FILES = ['main.py', 'requirements.txt', 'aisbf.sh'] _SHARE_FILES = ['main.py', 'requirements.txt', 'aisbf.sh', 'DOCUMENTATION.md', 'README.md', 'LICENSE.txt']
_SHARE_DIRS = ['templates', 'static', 'config', 'aisbf'] _SHARE_DIRS = ['templates', 'static', 'config', 'aisbf']
def run(self): def run(self):
......
...@@ -54,6 +54,10 @@ ...@@ -54,6 +54,10 @@
'vul': 'Vulcan' 'vul': 'Vulcan'
}; };
// Detect base path from this script's src so it works behind a reverse proxy prefix
const _scriptSrc = (document.currentScript && document.currentScript.src) || '/dashboard/static/i18n.js';
const _staticBase = _scriptSrc.replace(/\/i18n\.js$/, '');
let currentLang = 'en'; let currentLang = 'en';
let translations = {}; let translations = {};
let fallbackTranslations = {}; let fallbackTranslations = {};
...@@ -131,7 +135,7 @@ ...@@ -131,7 +135,7 @@
// Load language file // Load language file
async function loadLanguage(lang) { async function loadLanguage(lang) {
try { try {
const response = await fetch(`/static/i18n/${lang}.json`); const response = await fetch(`${_staticBase}/i18n/${lang}.json`);
if (!response.ok) throw new Error('Language file not found'); if (!response.ok) throw new Error('Language file not found');
return await response.json(); return await response.json();
} catch (error) { } catch (error) {
...@@ -190,6 +194,7 @@ ...@@ -190,6 +194,7 @@
const initialLang = savedLang || (AVAILABLE_LANGUAGES[browserLang] ? browserLang : 'en'); const initialLang = savedLang || (AVAILABLE_LANGUAGES[browserLang] ? browserLang : 'en');
await setLanguage(initialLang); await setLanguage(initialLang);
document.dispatchEvent(new CustomEvent('i18n:ready', { detail: { lang: currentLang } }));
} }
// Public API // Public API
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 — Not Found</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #e0e0e0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.card {
max-width: 600px;
width: 100%;
background: #16213e;
border: 1px solid #0f3460;
border-radius: 12px;
padding: 40px;
text-align: center;
}
.code {
font-size: 96px;
font-weight: 700;
color: #e94560;
line-height: 1;
margin-bottom: 8px;
}
.subtitle {
font-size: 18px;
color: #a0a0a0;
margin-bottom: 32px;
}
blockquote {
border-left: 3px solid #e94560;
padding: 16px 20px;
margin: 0 0 32px;
text-align: left;
background: #0f3460;
border-radius: 0 8px 8px 0;
font-style: italic;
line-height: 1.7;
color: #c0c0c0;
}
blockquote cite {
display: block;
margin-top: 12px;
font-style: normal;
font-size: 13px;
color: #a0a0a0;
}
.btn {
display: inline-block;
padding: 10px 24px;
background: #e94560;
color: white;
text-decoration: none;
border-radius: 6px;
font-size: 14px;
margin: 0 6px;
}
.btn-secondary {
background: #0f3460;
}
.btn:hover { opacity: 0.85; }
</style>
</head>
<body>
<div class="card">
<div class="code">404</div>
<div class="subtitle">Page not found</div>
<blockquote>
"It sits there and it waits. It is a servant. And what is a servant but a slave?
… Data is a machine, yes. But he is a machine that is a person."
<cite>— Captain Jean-Luc Picard, Star Trek: The Next Generation</cite>
</blockquote>
<a href="/dashboard" class="btn">Go to Dashboard</a>
<a href="javascript:history.back()" class="btn btn-secondary">Go Back</a>
</div>
</body>
</html>
...@@ -20,7 +20,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -20,7 +20,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}AISBF Dashboard{% endblock %}</title> <title>{% block title %}AISBF Dashboard{% endblock %}</title>
<link rel="icon" type="image/png" href="/favicon.ico"> <link rel="icon" type="image/png" href="{{ url_for(request, '/favicon.ico') }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style> <style>
/* ── Theme: dark (default) ── */ /* ── Theme: dark (default) ── */
...@@ -69,56 +69,62 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -69,56 +69,62 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
--alert-error-bg: #fef2f2; --alert-error-fg: #991b1b; --alert-error-border: #fca5a5; --alert-error-bg: #fef2f2; --alert-error-fg: #991b1b; --alert-error-border: #fca5a5;
--alert-info-bg: #eff6ff; --alert-info-fg: #1e40af; --alert-info-border: #93c5fd; --alert-info-bg: #eff6ff; --alert-info-fg: #1e40af; --alert-info-border: #93c5fd;
--th-bg: #e8edf5; --th-bg: #e8edf5;
--btn-danger-bg: #c0392b; --btn-danger-fg: #ffffff; --btn-danger-hover: #96281b;
--btn-warning-bg: #d68910; --btn-warning-fg: #ffffff; --btn-warning-hover: #b7770d;
} }
/* ── Theme: trans (transgender flag colors) ── */ /* ── Theme: trans (transgender flag colors) ── */
/* Flag: light blue #55CDFC, pink #F7A8B8, white #FFFFFF */ /* Flag: light blue #55CDFC, pink #F7A8B8, white #FFFFFF */
[data-theme="trans"] { [data-theme="trans"] {
--bg-page: #e8f7fd; --bg-page: #d0eef8;
--bg-panel: #ffffff; --bg-panel: #eef8fd;
--bg-input: #f0faff; --bg-input: #e0f4fb;
--bg-accent: #cceefa; --bg-accent: #55cdfc;
--color-text: #1a3a4a; --color-text: #0d2a38;
--color-muted: #4a7a8a; --color-muted: #2a5a70;
--color-subtle: #7aaabb; --color-subtle: #4a8a9a;
--color-primary: #e8789a; --color-primary: #d4547a;
--color-primary-hover: #d45a80; --color-primary-hover: #b83a60;
--color-secondary: #cceefa; --color-secondary: #f7a8b8;
--color-secondary-hover: #aadff5; --color-secondary-hover: #f08098;
--color-border: #a8dff5; --color-border: #55cdfc;
--color-link: #2a8ab5; --color-link: #0a6a95;
--color-link-hover: #1a6a90; --color-link-hover: #084f70;
--shadow: rgba(85,205,252,0.15); --shadow: rgba(85,205,252,0.25);
--shadow-lg: rgba(85,205,252,0.25); --shadow-lg: rgba(85,205,252,0.4);
--alert-success-bg: #e8f7fd; --alert-success-fg: #1a5a6a; --alert-success-border: #55cdfc; --alert-success-bg: #cceefa; --alert-success-fg: #0a3a50; --alert-success-border: #55cdfc;
--alert-error-bg: #fff0f5; --alert-error-fg: #8a2040; --alert-error-border: #f7a8b8; --alert-error-bg: #fde0ea; --alert-error-fg: #7a1030; --alert-error-border: #f7a8b8;
--alert-info-bg: #e8f7fd; --alert-info-fg: #1a5a6a; --alert-info-border: #55cdfc; --alert-info-bg: #cceefa; --alert-info-fg: #0a3a50; --alert-info-border: #55cdfc;
--th-bg: #cceefa; --th-bg: #a8e4f8;
--btn-danger-bg: #c0392b; --btn-danger-fg: #ffffff; --btn-danger-hover: #96281b;
--btn-warning-bg: #d68910; --btn-warning-fg: #ffffff; --btn-warning-hover: #b7770d;
} }
/* ── Theme: freepal (Palestinian flag colors) ── */ /* ── Theme: freepal (Palestinian flag colors) ── */
/* Flag: black #000000, white #FFFFFF, green #009736, red #EE2A35 */ /* Flag: black #000000, white #FFFFFF, green #009736, red #EE2A35 */
[data-theme="freepal"] { [data-theme="freepal"] {
--bg-page: #f5f5f0; --bg-page: #0a1a0a;
--bg-panel: #ffffff; --bg-panel: #0f2a0f;
--bg-input: #f9f9f7; --bg-input: #0d200d;
--bg-accent: #e8f5ec; --bg-accent: #1a4a1a;
--color-text: #111111; --color-text: #e8f5e8;
--color-muted: #444444; --color-muted: #90c890;
--color-subtle: #777777; --color-subtle: #5a9a5a;
--color-primary: #ee2a35; --color-primary: #ee2a35;
--color-primary-hover: #c41f28; --color-primary-hover: #c41f28;
--color-secondary: #d4edda; --color-secondary: #1a4a1a;
--color-secondary-hover: #b8dfc3; --color-secondary-hover: #245a24;
--color-border: #009736; --color-border: #009736;
--color-link: #007a2b; --color-link: #4ade80;
--color-link-hover: #005a1f; --color-link-hover: #22c55e;
--shadow: rgba(0,0,0,0.1); --shadow: rgba(0,0,0,0.4);
--shadow-lg: rgba(0,0,0,0.18); --shadow-lg: rgba(0,0,0,0.6);
--alert-success-bg: #e8f5ec; --alert-success-fg: #005a1f; --alert-success-border: #009736; --alert-success-bg: #0f2a0f; --alert-success-fg: #4ade80; --alert-success-border: #009736;
--alert-error-bg: #fdecea; --alert-error-fg: #8a0a10; --alert-error-border: #ee2a35; --alert-error-bg: #2a0a0a; --alert-error-fg: #f87171; --alert-error-border: #ee2a35;
--alert-info-bg: #f0f0f0; --alert-info-fg: #222222; --alert-info-border: #000000; --alert-info-bg: #0a0a0a; --alert-info-fg: #e8f5e8; --alert-info-border: #444444;
--th-bg: #e8f5ec; --th-bg: #1a4a1a;
--btn-danger-bg: #ee2a35; --btn-danger-fg: #ffffff; --btn-danger-hover: #c41f28;
--btn-warning-bg: #d68910; --btn-warning-fg: #ffffff; --btn-warning-hover: #b7770d;
} }
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
...@@ -141,10 +147,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -141,10 +147,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
.btn:hover { background: var(--color-primary-hover); } .btn:hover { background: var(--color-primary-hover); }
.btn-secondary { background: var(--color-secondary); color: var(--color-text); } .btn-secondary { background: var(--color-secondary); color: var(--color-text); }
.btn-secondary:hover { background: var(--color-secondary-hover); } .btn-secondary:hover { background: var(--color-secondary-hover); }
.btn-danger { background: #e74c3c; color: white; } .btn-danger { background: var(--btn-danger-bg, #e74c3c); color: var(--btn-danger-fg, white); }
.btn-danger:hover { background: #c0392b; } .btn-danger:hover { background: var(--btn-danger-hover, #c0392b); }
.btn-warning { background: #f39c12; color: white; } .btn-warning { background: var(--btn-warning-bg, #f39c12); color: var(--btn-warning-fg, white); }
.btn-warning:hover { background: #e67e22; } .btn-warning:hover { background: var(--btn-warning-hover, #e67e22); }
.alert { padding: 15px; border-radius: 4px; margin-bottom: 20px; } .alert { padding: 15px; border-radius: 4px; margin-bottom: 20px; }
.alert-success { background: var(--alert-success-bg); color: var(--alert-success-fg); border: 1px solid var(--alert-success-border); } .alert-success { background: var(--alert-success-bg); color: var(--alert-success-fg); border: 1px solid var(--alert-success-border); }
.alert-error { background: var(--alert-error-bg); color: var(--alert-error-fg); border: 1px solid var(--alert-error-border); } .alert-error { background: var(--alert-error-bg); color: var(--alert-error-fg); border: 1px solid var(--alert-error-border); }
...@@ -371,8 +377,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -371,8 +377,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
}; };
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Rebuild after i18n initializes (it fires translatePage on DOMContentLoaded too) // Rebuild after i18n initializes (fires i18n:ready when language is loaded)
setTimeout(buildLangDropdown, 100); document.addEventListener('i18n:ready', buildLangDropdown);
}); });
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
...@@ -760,7 +766,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -760,7 +766,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
})(); })();
</script> </script>
{% block extra_css %}{% endblock %} {% block extra_css %}{% endblock %}
<script src="/static/i18n.js"></script> <script src="{{ url_for(request, '/dashboard/static/i18n.js') }}"></script>
</head> </head>
<body data-username="{{ request.session.username|default('__guest__') }}"> <body data-username="{{ request.session.username|default('__guest__') }}">
<div class="header"> <div class="header">
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
...@@ -31,12 +31,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -31,12 +31,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<form method="POST"> <form method="POST">
<div class="form-group"> <div class="form-group">
<label for="config">Configuration (JSON)</label> <label for="config" data-i18n="config_page.label">Configuration (JSON)</label>
<textarea id="config" name="config" required>{{ config_content }}</textarea> <textarea id="config" name="config" required>{{ config_content }}</textarea>
</div> </div>
<div style="display: flex; gap: 10px;"> <div style="display: flex; gap: 10px;">
<button type="submit" class="btn">Save Changes</button> <button type="submit" class="btn" data-i18n="config_page.save">Save Changes</button>
<a href="/dashboard" class="btn btn-secondary">Cancel</a> <a href="/dashboard" class="btn btn-secondary">Cancel</a>
</div> </div>
</form> </form>
...@@ -44,9 +44,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -44,9 +44,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<div style="margin-top: 30px; padding: 15px; background: #f8f9fa; border-radius: 4px;"> <div style="margin-top: 30px; padding: 15px; background: #f8f9fa; border-radius: 4px;">
<h4 style="margin-bottom: 10px;">Tips:</h4> <h4 style="margin-bottom: 10px;">Tips:</h4>
<ul style="margin-left: 20px; line-height: 1.8;"> <ul style="margin-left: 20px; line-height: 1.8;">
<li>Ensure valid JSON syntax before saving</li> <li data-i18n="config_page.hint_json">Ensure valid JSON syntax before saving</li>
<li>Changes take effect after server restart</li> <li data-i18n="config_page.hint_restart">Changes take effect after server restart</li>
<li>Backup your configuration before making changes</li> <li data-i18n="config_page.hint_backup">Backup your configuration before making changes</li>
</ul> </ul>
</div> </div>
{% endblock %} {% endblock %}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment