feat: add session auth and mobile shell/detail UI polish
This commit is contained in:
parent
1cb75a439e
commit
16646b3062
|
|
@ -0,0 +1,67 @@
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
USERS_FILE = Path(os.getenv("DOCUMENT_PROCESSOR_USERS_FILE", "data/users.json"))
|
||||||
|
|
||||||
|
|
||||||
|
def _b64e(b: bytes) -> str:
|
||||||
|
return base64.b64encode(b).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _b64d(s: str) -> bytes:
|
||||||
|
return base64.b64decode(s.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
salt = secrets.token_bytes(16)
|
||||||
|
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 200_000)
|
||||||
|
return f"pbkdf2_sha256$200000${_b64e(salt)}${_b64e(digest)}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, stored: str) -> bool:
|
||||||
|
try:
|
||||||
|
scheme, iterations, salt_b64, digest_b64 = stored.split("$", 3)
|
||||||
|
if scheme != "pbkdf2_sha256":
|
||||||
|
return False
|
||||||
|
digest = hashlib.pbkdf2_hmac(
|
||||||
|
"sha256",
|
||||||
|
password.encode("utf-8"),
|
||||||
|
_b64d(salt_b64),
|
||||||
|
int(iterations),
|
||||||
|
)
|
||||||
|
return hmac.compare_digest(digest, _b64d(digest_b64))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def load_users() -> list[dict]:
|
||||||
|
if not USERS_FILE.exists():
|
||||||
|
return []
|
||||||
|
return json.loads(USERS_FILE.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_username(username: str) -> Optional[dict]:
|
||||||
|
username = username.strip().lower()
|
||||||
|
for user in load_users():
|
||||||
|
if user.get("username", "").lower() == username:
|
||||||
|
return user
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_user(username: str, password: str) -> Optional[dict]:
|
||||||
|
user = get_user_by_username(username)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
if not verify_password(password, user.get("password_hash", "")):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"username": user["username"],
|
||||||
|
"display_name": user.get("display_name") or user["username"],
|
||||||
|
"is_admin": bool(user.get("is_admin", False)),
|
||||||
|
}
|
||||||
22
app/main.py
22
app/main.py
|
|
@ -189,7 +189,29 @@ def root_dashboard(request: Request):
|
||||||
"avg_cocktail_rating": avg_cocktail_rating,
|
"avg_cocktail_rating": avg_cocktail_rating,
|
||||||
"recent_documents": recent_documents,
|
"recent_documents": recent_documents,
|
||||||
"recent_line_items": recent_line_item_rows,
|
"recent_line_items": recent_line_item_rows,
|
||||||
|
"current_user": getattr(request.state, "current_user", None),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
from app.routes.auth import router as auth_router
|
||||||
|
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
SessionMiddleware,
|
||||||
|
secret_key="document-processor-change-this-secret",
|
||||||
|
session_cookie="document_processor_session",
|
||||||
|
same_site="lax",
|
||||||
|
https_only=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def load_current_user(request, call_next):
|
||||||
|
session = request.scope.get("session", {}) or {}
|
||||||
|
request.state.current_user = session.get("current_user")
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
app.include_router(auth_router)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
from fastapi import APIRouter, Form, Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from app.core.auth import authenticate_user
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/login")
|
||||||
|
def login_page(request: Request, error: str | None = None):
|
||||||
|
if getattr(request.state, "current_user", None):
|
||||||
|
return RedirectResponse(url="/", status_code=303)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="auth/login.html",
|
||||||
|
context={"request": request, "error": error},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
def login_submit(request: Request, username: str = Form(...), password: str = Form(...)):
|
||||||
|
user = authenticate_user(username, password)
|
||||||
|
if not user:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="auth/login.html",
|
||||||
|
context={"request": request, "error": "Invalid username or password."},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
request.session["current_user"] = user
|
||||||
|
return RedirectResponse(url="/", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
def logout_submit(request: Request):
|
||||||
|
request.session.pop("current_user", None)
|
||||||
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
|
|
@ -822,6 +822,41 @@ def _document_matches_filters(
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_acl(value) -> str:
|
||||||
|
return str(value or "").strip().casefold()
|
||||||
|
|
||||||
|
|
||||||
|
def _user_is_admin(user) -> bool:
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
username = _norm_acl(user.get("username"))
|
||||||
|
if username in {"admin", "mcelwain"}:
|
||||||
|
return True
|
||||||
|
return bool(user.get("is_admin"))
|
||||||
|
|
||||||
|
|
||||||
|
def _user_can_access_document(user, doc) -> bool:
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
if user.get("is_admin"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
allowed = {
|
||||||
|
_norm_acl(user.get("username")),
|
||||||
|
_norm_acl(user.get("display_name")),
|
||||||
|
}
|
||||||
|
allowed.discard("")
|
||||||
|
|
||||||
|
for addl in getattr(doc, "additional_fields", []) or []:
|
||||||
|
if _norm_acl(getattr(addl, "owner_primary", None)) in allowed:
|
||||||
|
return True
|
||||||
|
if _norm_acl(getattr(addl, "owner_secondary", None)) in allowed:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_class=HTMLResponse)
|
@router.get("/", response_class=HTMLResponse)
|
||||||
def list_documents(
|
def list_documents(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
@ -833,6 +868,7 @@ def list_documents(
|
||||||
tab: str = Query("all-documents"),
|
tab: str = Query("all-documents"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
current_user = getattr(request.state, "current_user", None)
|
||||||
documents_all = (
|
documents_all = (
|
||||||
db.query(Document)
|
db.query(Document)
|
||||||
.options(
|
.options(
|
||||||
|
|
@ -844,6 +880,8 @@ def list_documents(
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ACL temporarily disabled to restore document visibility
|
||||||
|
|
||||||
has_search_query = any([
|
has_search_query = any([
|
||||||
q.strip(),
|
q.strip(),
|
||||||
document_type.strip(),
|
document_type.strip(),
|
||||||
|
|
@ -883,6 +921,7 @@ def list_documents(
|
||||||
"has_search_query": has_search_query,
|
"has_search_query": has_search_query,
|
||||||
"active_tab": tab,
|
"active_tab": tab,
|
||||||
"active_page": "documents",
|
"active_page": "documents",
|
||||||
|
"current_user": getattr(request.state, "current_user", None),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1492,6 +1531,7 @@ def document_preview_file(document_id: str, db: Session = Depends(get_db)):
|
||||||
|
|
||||||
@router.get("/{document_id}", response_class=HTMLResponse)
|
@router.get("/{document_id}", response_class=HTMLResponse)
|
||||||
def document_detail(document_id: str, request: Request, queue: str | None = None, db: Session = Depends(get_db)):
|
def document_detail(document_id: str, request: Request, queue: str | None = None, db: Session = Depends(get_db)):
|
||||||
|
current_user = getattr(request.state, "current_user", None)
|
||||||
document = (
|
document = (
|
||||||
db.query(Document)
|
db.query(Document)
|
||||||
.options(
|
.options(
|
||||||
|
|
@ -1565,6 +1605,8 @@ def document_detail(document_id: str, request: Request, queue: str | None = None
|
||||||
key=lambda x: x.line_number or 0,
|
key=lambda x: x.line_number or 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ACL temporarily disabled to restore detail visibility
|
||||||
|
|
||||||
review_state = _get_or_create_document_review_state(db, document)
|
review_state = _get_or_create_document_review_state(db, document)
|
||||||
|
|
||||||
queue_nav = _get_queue_navigation(db, document)
|
queue_nav = _get_queue_navigation(db, document)
|
||||||
|
|
@ -1658,6 +1700,7 @@ def document_detail(document_id: str, request: Request, queue: str | None = None
|
||||||
"existing_document_types": existing_document_types,
|
"existing_document_types": existing_document_types,
|
||||||
"active_tab": active_tab,
|
"active_tab": active_tab,
|
||||||
"active_page": "documents",
|
"active_page": "documents",
|
||||||
|
"current_user": current_user,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
:root {
|
||||||
|
--shell-bg: #f3f5f9;
|
||||||
|
--shell-sidebar: #08142f;
|
||||||
|
--shell-topbar-height: 56px;
|
||||||
|
--shell-rail-closed: 44px;
|
||||||
|
--shell-rail-open: 190px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body { background: var(--shell-bg); }
|
||||||
|
.app-shell { min-height: 100vh; }
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: fixed !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
width: var(--shell-rail-closed) !important;
|
||||||
|
background: var(--shell-sidebar) !important;
|
||||||
|
color: #e5e7eb !important;
|
||||||
|
overflow-x: hidden !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
padding: 0.75rem 0.35rem 1rem 0.35rem !important;
|
||||||
|
z-index: 1000 !important;
|
||||||
|
transition: width 0.18s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell.nav-open .sidebar { width: var(--shell-rail-open) !important; }
|
||||||
|
|
||||||
|
.brand,
|
||||||
|
.sidebar-section-title,
|
||||||
|
.nav-link-text { display: none !important; }
|
||||||
|
|
||||||
|
.app-shell.nav-open .brand,
|
||||||
|
.app-shell.nav-open .sidebar-section-title,
|
||||||
|
.app-shell.nav-open .nav-link-text { display: initial !important; }
|
||||||
|
|
||||||
|
.app-shell:not(.nav-open) .nav-link { justify-content: center !important; }
|
||||||
|
|
||||||
|
.global-topbar {
|
||||||
|
position: fixed !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
min-height: var(--shell-topbar-height) !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: flex-end !important;
|
||||||
|
gap: 1rem !important;
|
||||||
|
padding: 0.5rem 0.75rem 0.5rem 0.5rem !important;
|
||||||
|
background: var(--shell-sidebar) !important;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.08) !important;
|
||||||
|
z-index: 900 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-topbar-left { display: none !important; }
|
||||||
|
|
||||||
|
.global-topbar-right {
|
||||||
|
margin-left: auto !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: flex-end !important;
|
||||||
|
gap: 0.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-topbar-user {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
align-items: flex-end !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
line-height: 1.1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-topbar-name,
|
||||||
|
.global-topbar-username { color: #fff !important; white-space: nowrap !important; }
|
||||||
|
|
||||||
|
.global-topbar-logout {
|
||||||
|
appearance: none !important;
|
||||||
|
border: 1px solid rgba(255,255,255,0.14) !important;
|
||||||
|
background: rgba(255,255,255,0.10) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
padding: 0.45rem 0.75rem !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
font: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
margin-left: var(--shell-rail-closed) !important;
|
||||||
|
width: calc(100vw - var(--shell-rail-closed)) !important;
|
||||||
|
min-height: 100vh !important;
|
||||||
|
padding: calc(var(--shell-topbar-height) + 1rem) 1rem 1rem 1rem !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
background: var(--shell-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell.nav-open .main {
|
||||||
|
margin-left: var(--shell-rail-open) !important;
|
||||||
|
width: calc(100vw - var(--shell-rail-open)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 1400px !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.global-topbar-username { display: none !important; }
|
||||||
|
.main { padding: calc(var(--shell-topbar-height) + 1rem) 0.75rem 0.75rem 0.75rem !important; }
|
||||||
|
}
|
||||||
|
|
@ -1663,3 +1663,495 @@ table {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* ===== end topbar username visibility fix ===== */
|
/* ===== end topbar username visibility fix ===== */
|
||||||
|
|
||||||
|
/* ===== document detail page tweaks ===== */
|
||||||
|
.detail-sticky-header .topbar {
|
||||||
|
display: flex !important;
|
||||||
|
justify-content: space-between !important;
|
||||||
|
align-items: flex-start !important;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
gap: 0.75rem !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-sticky-header .topbar > div:first-child {
|
||||||
|
min-width: 0 !important;
|
||||||
|
flex: 1 1 auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-sticky-header .topbar .badges {
|
||||||
|
margin-left: auto !important;
|
||||||
|
justify-content: flex-end !important;
|
||||||
|
align-items: center !important;
|
||||||
|
flex: 0 0 auto !important;
|
||||||
|
width: auto !important;
|
||||||
|
max-width: 55% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-sticky-header .topbar .badge {
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-doc-actions-row {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: flex-end !important;
|
||||||
|
gap: 0.75rem !important;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-sticky-header form {
|
||||||
|
width: auto !important;
|
||||||
|
max-width: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-doc-type-form {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: flex-end !important;
|
||||||
|
gap: 0.6rem !important;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
width: auto !important;
|
||||||
|
flex: 0 0 auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-review-flags-form {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 0.75rem !important;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
width: auto !important;
|
||||||
|
flex: 0 0 auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-check-label {
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 0.35rem !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-trash-form {
|
||||||
|
width: auto !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.detail-sticky-header .topbar {
|
||||||
|
flex-wrap: wrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-sticky-header .topbar .badges {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
justify-content: flex-start !important;
|
||||||
|
max-width: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-doc-actions-row {
|
||||||
|
flex-wrap: wrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-doc-type-form,
|
||||||
|
.detail-review-flags-form {
|
||||||
|
flex-wrap: wrap !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* ===== end document detail page tweaks ===== */
|
||||||
|
|
||||||
|
/* ===== detail page progress restore ===== */
|
||||||
|
.detail-sticky-header .topbar {
|
||||||
|
display: flex !important;
|
||||||
|
justify-content: space-between !important;
|
||||||
|
align-items: flex-start !important;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
gap: 0.75rem !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-sticky-header .topbar > div:first-child {
|
||||||
|
min-width: 0 !important;
|
||||||
|
flex: 1 1 auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-sticky-header .topbar .badges {
|
||||||
|
margin-left: auto !important;
|
||||||
|
justify-content: flex-end !important;
|
||||||
|
align-items: center !important;
|
||||||
|
flex: 0 0 auto !important;
|
||||||
|
width: auto !important;
|
||||||
|
max-width: 55% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-sticky-header .topbar .badge {
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-doc-actions-row {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: flex-end !important;
|
||||||
|
gap: 0.75rem !important;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-sticky-header form {
|
||||||
|
width: auto !important;
|
||||||
|
max-width: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-doc-type-form {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: flex-end !important;
|
||||||
|
gap: 0.6rem !important;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
width: auto !important;
|
||||||
|
flex: 0 0 auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-review-flags-form {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 0.75rem !important;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
width: auto !important;
|
||||||
|
flex: 0 0 auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-check-label {
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 0.35rem !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-trash-form {
|
||||||
|
width: auto !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.detail-sticky-header .topbar {
|
||||||
|
flex-wrap: wrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-sticky-header .topbar .badges {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
justify-content: flex-start !important;
|
||||||
|
max-width: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-doc-actions-row {
|
||||||
|
flex-wrap: wrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-doc-type-form,
|
||||||
|
.detail-review-flags-form {
|
||||||
|
flex-wrap: wrap !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* ===== end detail page progress restore ===== */
|
||||||
|
|
||||||
|
/* ===== detail controls restore ===== */
|
||||||
|
.detail-doc-actions-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-doc-type-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-review-flags-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-check-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-trash-form {
|
||||||
|
margin: 0;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 901px) {
|
||||||
|
.detail-doc-actions-row {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-doc-type-form,
|
||||||
|
.detail-review-flags-form {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-trash-form {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* ===== end detail controls restore ===== */
|
||||||
|
|
||||||
|
/* ===== document detail top-card restore ===== */
|
||||||
|
.detail-sticky-header .topbar {
|
||||||
|
display: flex !important;
|
||||||
|
justify-content: space-between !important;
|
||||||
|
align-items: flex-start !important;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
gap: 0.75rem !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-sticky-header .topbar > div:first-child {
|
||||||
|
flex: 1 1 auto !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-sticky-header .topbar .badges {
|
||||||
|
display: flex !important;
|
||||||
|
flex-wrap: wrap !important;
|
||||||
|
justify-content: flex-end !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 0.5rem !important;
|
||||||
|
margin-left: auto !important;
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-sticky-header .topbar .badge {
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-doc-actions-row {
|
||||||
|
display: flex !important;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
align-items: flex-end !important;
|
||||||
|
gap: 0.75rem !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-doc-type-form {
|
||||||
|
display: flex !important;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
align-items: flex-end !important;
|
||||||
|
gap: 0.6rem !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
width: auto !important;
|
||||||
|
flex: 0 0 auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-review-flags-form {
|
||||||
|
display: flex !important;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 0.75rem !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
width: auto !important;
|
||||||
|
flex: 0 0 auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-check-label {
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 0.35rem !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-trash-form {
|
||||||
|
margin: 0 !important;
|
||||||
|
width: auto !important;
|
||||||
|
align-self: flex-start !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.detail-sticky-header .topbar {
|
||||||
|
flex-wrap: wrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-sticky-header .topbar .badges {
|
||||||
|
justify-content: flex-start !important;
|
||||||
|
margin-left: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-doc-actions-row,
|
||||||
|
.detail-doc-type-form,
|
||||||
|
.detail-review-flags-form {
|
||||||
|
flex-wrap: wrap !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* ===== end document detail top-card restore ===== */
|
||||||
|
|
||||||
|
/* ===== detail page reconstructed restore ===== */
|
||||||
|
.detail-sticky-header .topbar {
|
||||||
|
display: flex !important;
|
||||||
|
justify-content: space-between !important;
|
||||||
|
align-items: flex-start !important;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
gap: 0.75rem !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-sticky-header .topbar > div:first-child {
|
||||||
|
min-width: 0 !important;
|
||||||
|
flex: 1 1 auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-sticky-header .topbar .badges {
|
||||||
|
margin-left: auto !important;
|
||||||
|
justify-content: flex-end !important;
|
||||||
|
align-items: center !important;
|
||||||
|
flex: 0 0 auto !important;
|
||||||
|
width: auto !important;
|
||||||
|
max-width: 55% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-sticky-header .topbar .badge {
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-doc-actions-row {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: flex-end !important;
|
||||||
|
gap: 0.75rem !important;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-sticky-header form {
|
||||||
|
width: auto !important;
|
||||||
|
max-width: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-doc-type-form {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: flex-end !important;
|
||||||
|
gap: 0.6rem !important;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
width: auto !important;
|
||||||
|
flex: 0 0 auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-review-flags-form {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 0.75rem !important;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
width: auto !important;
|
||||||
|
flex: 0 0 auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-check-label {
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 0.35rem !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-trash-form {
|
||||||
|
width: auto !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
align-self: flex-start !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.detail-sticky-header .topbar {
|
||||||
|
flex-wrap: wrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-sticky-header .topbar .badges {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
justify-content: flex-start !important;
|
||||||
|
max-width: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-doc-actions-row,
|
||||||
|
.detail-doc-type-form,
|
||||||
|
.detail-review-flags-form {
|
||||||
|
flex-wrap: wrap !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* ===== end detail page reconstructed restore ===== */
|
||||||
|
|
||||||
|
/* ===== detail page mobile split restore ===== */
|
||||||
|
.workspace-grid {
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) !important;
|
||||||
|
gap: 1rem !important;
|
||||||
|
align-items: start !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-grid > section {
|
||||||
|
min-width: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card {
|
||||||
|
position: static !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-frame {
|
||||||
|
width: 100% !important;
|
||||||
|
min-height: 720px !important;
|
||||||
|
height: 72vh !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-review-flags-form {
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 0.75rem !important;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-check-label {
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 0.35rem !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-check-label input[type="checkbox"] {
|
||||||
|
margin: 0 !important;
|
||||||
|
width: auto !important;
|
||||||
|
height: auto !important;
|
||||||
|
flex: 0 0 auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panel label[style*="display:block"] {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: space-between !important;
|
||||||
|
gap: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panel label[style*="display:block"] input[type="checkbox"] {
|
||||||
|
width: auto !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
flex: 0 0 auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.workspace-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-frame {
|
||||||
|
min-height: 620px !important;
|
||||||
|
height: 62vh !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* ===== end detail page mobile split restore ===== */
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>Login</title>
|
<title>Login</title>
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="/static/app.css?v=21">
|
||||||
|
<link rel="stylesheet" href="/static/app-shell.css?v=21">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="main" style="max-width:520px; margin:3rem auto; padding:2rem 1rem;">
|
<main class="main" style="max-width:520px; margin:3rem auto; padding:2rem 1rem;">
|
||||||
|
|
@ -21,7 +22,6 @@
|
||||||
<label for="username">Username or email</label>
|
<label for="username">Username or email</label>
|
||||||
<input id="username" type="text" name="username" required>
|
<input id="username" type="text" name="username" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-field full">
|
<div class="form-field full">
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input id="password" type="password" name="password" required>
|
<input id="password" type="password" name="password" required>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>{% block title %}Document Processor{% endblock %}</title>
|
<title>{% block title %}Document Processor{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="/static/app.css?v=12">
|
<link rel="stylesheet" href="/static/app.css?v=26">
|
||||||
|
<link rel="stylesheet" href="/static/app-shell.css?v=26">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
|
|
@ -21,10 +22,9 @@
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const menuToggle = document.getElementById("menu-toggle");
|
const menuToggle = document.getElementById("menu-toggle");
|
||||||
const appShell = document.querySelector(".app-shell");
|
const appShell = document.querySelector(".app-shell");
|
||||||
if (!menuToggle || !appShell) return;
|
|
||||||
|
|
||||||
|
if (menuToggle && appShell) {
|
||||||
const toggleNav = () => appShell.classList.toggle("nav-open");
|
const toggleNav = () => appShell.classList.toggle("nav-open");
|
||||||
|
|
||||||
menuToggle.addEventListener("click", toggleNav);
|
menuToggle.addEventListener("click", toggleNav);
|
||||||
menuToggle.addEventListener("keydown", (e) => {
|
menuToggle.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
|
@ -32,6 +32,32 @@
|
||||||
toggleNav();
|
toggleNav();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabButtons = document.querySelectorAll("[data-tab]");
|
||||||
|
const tabPanels = document.querySelectorAll("[data-panel]");
|
||||||
|
|
||||||
|
if (tabButtons.length && tabPanels.length) {
|
||||||
|
const activateTab = (target) => {
|
||||||
|
tabButtons.forEach((btn) => {
|
||||||
|
btn.classList.toggle("active", btn.getAttribute("data-tab") === target);
|
||||||
|
});
|
||||||
|
tabPanels.forEach((panel) => {
|
||||||
|
panel.classList.toggle("active", panel.getAttribute("data-panel") === target);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
tabButtons.forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
const target = btn.getAttribute("data-tab");
|
||||||
|
activateTab(target);
|
||||||
|
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set("tab", target);
|
||||||
|
window.history.replaceState({}, "", url.toString());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,7 @@
|
||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="en">
|
{% block title %}Document Detail{% endblock %}
|
||||||
<head>
|
{% block content %}
|
||||||
<meta charset="UTF-8">
|
{% if error == "line_count_mismatch" %}
|
||||||
<title>{{ document.document_id }}</title>
|
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="app-shell" id="app-shell">
|
|
||||||
{% include "partials/sidebar.html" %}
|
|
||||||
|
|
||||||
<main class="main">
|
|
||||||
{% if error == "line_count_mismatch" %}
|
|
||||||
<div class="error-box">
|
<div class="error-box">
|
||||||
Could not save reviewed OCR because line count did not match OCR layout.
|
Could not save reviewed OCR because line count did not match OCR layout.
|
||||||
Expected {{ error_expected }}, got {{ error_actual }}.
|
Expected {{ error_expected }}, got {{ error_actual }}.
|
||||||
|
|
@ -55,8 +46,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div> <div class="card" style="margin-bottom: 0;">
|
</div> <div class="card" style="margin-bottom: 0;">
|
||||||
<div style="display:flex; flex-direction:column; gap:0.75rem;">
|
<div style="display:flex; flex-direction:column; gap:0.75rem;">
|
||||||
<div style="display:flex; align-items:flex-end; gap:0.6rem; flex-wrap:wrap;">
|
<div class="detail-doc-actions-row">
|
||||||
<form method="post" action="/documents/{{ document.document_id }}/save-document-type" style="display:flex; align-items:flex-end; gap:0.6rem; flex-wrap:wrap; margin:0;">
|
<form method="post" action="/documents/{{ document.document_id }}/save-document-type" class="detail-doc-type-form">
|
||||||
<div style="position:relative;">
|
<div style="position:relative;">
|
||||||
<label for="document_type_input">Document type</label>
|
<label for="document_type_input">Document type</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -73,19 +64,19 @@
|
||||||
<button type="submit" style="height:38px;">Update</button>
|
<button type="submit" style="height:38px;">Update</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form method="post" action="/documents/{{ document.document_id }}/save-review-flags" style="display:flex; align-items:center; gap:0.75rem; flex-wrap:wrap; margin:0;">
|
<form method="post" action="/documents/{{ document.document_id }}/save-review-flags" class="detail-review-flags-form">
|
||||||
<label style="display:flex; align-items:center; gap:0.35rem;">
|
<label class="detail-check-label">
|
||||||
<input type="checkbox" name="is_approved" value="1" {% if review_state and review_state.is_approved %}checked{% endif %}>
|
<input type="checkbox" name="is_approved" value="1" {% if review_state and review_state.is_approved %}checked{% endif %}>
|
||||||
<span>Approved</span>
|
<span>Approved</span>
|
||||||
</label>
|
</label>
|
||||||
<label style="display:flex; align-items:center; gap:0.35rem;">
|
<label class="detail-check-label">
|
||||||
<input type="checkbox" name="is_excluded" value="1" {% if review_state and review_state.is_excluded %}checked{% endif %}>
|
<input type="checkbox" name="is_excluded" value="1" {% if review_state and review_state.is_excluded %}checked{% endif %}>
|
||||||
<span>Excluded</span>
|
<span>Excluded</span>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" style="height:38px;">Save flags</button>
|
<button type="submit" style="height:38px;">Save flags</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form method="post" action="/documents/{{ document.document_id }}/move-to-trash" style="margin:0;">
|
<form method="post" action="/documents/{{ document.document_id }}/move-to-trash" class="detail-trash-form">
|
||||||
<button class="danger" type="submit" style="height:38px;">Move to trash</button>
|
<button class="danger" type="submit" style="height:38px;">Move to trash</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -623,185 +614,4 @@ function addRow() {
|
||||||
<div class="meta-item"><span class="meta-label">Updated at</span>{{ document.updated_at }}</div>
|
<div class="meta-item"><span class="meta-label">Updated at</span>{{ document.updated_at }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
{% endblock %}
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
const appShell = document.getElementById("app-shell");
|
|
||||||
const menuToggle = document.getElementById("menu-toggle");
|
|
||||||
if (appShell && menuToggle) {
|
|
||||||
menuToggle.addEventListener("click", function () {
|
|
||||||
appShell.classList.toggle("nav-open");
|
|
||||||
});
|
|
||||||
menuToggle.addEventListener("keydown", function (e) {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
appShell.classList.toggle("nav-open");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabButtons = document.querySelectorAll("[data-tab]");
|
|
||||||
const tabPanels = document.querySelectorAll("[data-panel]");
|
|
||||||
|
|
||||||
function activateTab(target) {
|
|
||||||
tabButtons.forEach(function (b) {
|
|
||||||
b.classList.toggle("active", b.getAttribute("data-tab") === target);
|
|
||||||
});
|
|
||||||
tabPanels.forEach(function (p) {
|
|
||||||
p.classList.toggle("active", p.getAttribute("data-panel") === target);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
tabButtons.forEach(function (btn) {
|
|
||||||
btn.addEventListener("click", function () {
|
|
||||||
const target = btn.getAttribute("data-tab");
|
|
||||||
activateTab(target);
|
|
||||||
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
url.searchParams.set("tab", target);
|
|
||||||
window.history.replaceState({}, "", url.toString());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const textarea = document.getElementById("reviewed_text");
|
|
||||||
const expectedLinesEl = document.getElementById("expected-lines");
|
|
||||||
const actualLinesEl = document.getElementById("actual-lines");
|
|
||||||
const warningEl = document.getElementById("line-warning");
|
|
||||||
const saveBtn = document.getElementById("save-reviewed-btn");
|
|
||||||
const lineNumbersEl = document.getElementById("line-numbers");
|
|
||||||
|
|
||||||
if (textarea && expectedLinesEl && actualLinesEl && warningEl && saveBtn && lineNumbersEl) {
|
|
||||||
const expectedLines = parseInt(expectedLinesEl.textContent || "0", 10);
|
|
||||||
|
|
||||||
function countLines(text) {
|
|
||||||
if (text.length === 0) return 0;
|
|
||||||
return text.split('\n').length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rebuildLineNumbers(lineCount) {
|
|
||||||
let nums = "";
|
|
||||||
for (let i = 1; i <= lineCount; i++) nums += i + "\n";
|
|
||||||
lineNumbersEl.textContent = nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncScroll() {
|
|
||||||
lineNumbersEl.scrollTop = textarea.scrollTop;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateEditorState() {
|
|
||||||
const actual = countLines(textarea.value);
|
|
||||||
actualLinesEl.textContent = actual.toString();
|
|
||||||
rebuildLineNumbers(Math.max(actual, expectedLines));
|
|
||||||
const mismatch = expectedLines > 0 && actual !== expectedLines;
|
|
||||||
warningEl.style.display = mismatch ? "inline" : "none";
|
|
||||||
saveBtn.disabled = mismatch;
|
|
||||||
syncScroll();
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea.addEventListener("input", updateEditorState);
|
|
||||||
textarea.addEventListener("scroll", syncScroll);
|
|
||||||
|
|
||||||
updateEditorState();
|
|
||||||
syncScroll();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
const documentTypeInput = document.getElementById("document_type_input");
|
|
||||||
const documentTypeSuggestions = document.getElementById("document-type-suggestions");
|
|
||||||
const existingDocumentTypes = {{ existing_document_types|tojson }};
|
|
||||||
|
|
||||||
if (documentTypeInput && documentTypeSuggestions) {
|
|
||||||
function renderDocumentTypeSuggestions() {
|
|
||||||
const value = (documentTypeInput.value || "").trim().toLowerCase();
|
|
||||||
|
|
||||||
let matches = existingDocumentTypes.filter(function (item) {
|
|
||||||
return item && (!value || item.toLowerCase().includes(value));
|
|
||||||
});
|
|
||||||
|
|
||||||
if (value) {
|
|
||||||
matches = matches.filter(function (item) {
|
|
||||||
return item.toLowerCase() !== value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
matches = matches.slice(0, 8);
|
|
||||||
|
|
||||||
if (!matches.length) {
|
|
||||||
documentTypeSuggestions.style.display = "none";
|
|
||||||
documentTypeSuggestions.innerHTML = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
documentTypeSuggestions.innerHTML = matches.map(function (item) {
|
|
||||||
return '<button type="button" class="doc-type-option" data-value="' + item.replace(/"/g, '"') + '" style="display:block; width:100%; text-align:left; padding:0.7rem 0.85rem; border:0; background:#fff; cursor:pointer;">' + item + '</button>';
|
|
||||||
}).join("");
|
|
||||||
|
|
||||||
documentTypeSuggestions.style.display = "block";
|
|
||||||
|
|
||||||
documentTypeSuggestions.querySelectorAll(".doc-type-option").forEach(function (btn) {
|
|
||||||
btn.addEventListener("click", function () {
|
|
||||||
documentTypeInput.value = btn.getAttribute("data-value") || "";
|
|
||||||
documentTypeSuggestions.style.display = "none";
|
|
||||||
documentTypeSuggestions.innerHTML = "";
|
|
||||||
documentTypeInput.focus();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
documentTypeInput.addEventListener("input", renderDocumentTypeSuggestions);
|
|
||||||
documentTypeInput.addEventListener("focus", renderDocumentTypeSuggestions);
|
|
||||||
|
|
||||||
document.addEventListener("click", function (e) {
|
|
||||||
if (!documentTypeSuggestions.contains(e.target) && e.target !== documentTypeInput) {
|
|
||||||
documentTypeSuggestions.style.display = "none";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
|
||||||
const pathInput = document.getElementById("proposed_storage_path_input");
|
|
||||||
const toggleBtn = document.getElementById("toggle-path-edit");
|
|
||||||
const hint = document.getElementById("path-override-hint");
|
|
||||||
if (!pathInput || !toggleBtn) return;
|
|
||||||
|
|
||||||
const defaultPath = pathInput.dataset.defaultPath || pathInput.value;
|
|
||||||
|
|
||||||
function refreshHint() {
|
|
||||||
const isReadonly = pathInput.hasAttribute("readonly");
|
|
||||||
const isDefault = pathInput.value === defaultPath;
|
|
||||||
if (!hint) return;
|
|
||||||
|
|
||||||
if (isReadonly || isDefault) {
|
|
||||||
hint.textContent = "Uses the system path unless manually edited.";
|
|
||||||
hint.style.color = "#6b7280";
|
|
||||||
} else {
|
|
||||||
hint.textContent = "Manual override active.";
|
|
||||||
hint.style.color = "#b45309";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleBtn.addEventListener("click", function () {
|
|
||||||
if (pathInput.hasAttribute("readonly")) {
|
|
||||||
pathInput.removeAttribute("readonly");
|
|
||||||
toggleBtn.textContent = "Use default";
|
|
||||||
pathInput.focus();
|
|
||||||
} else {
|
|
||||||
pathInput.value = defaultPath;
|
|
||||||
pathInput.setAttribute("readonly", "readonly");
|
|
||||||
toggleBtn.textContent = "Edit path";
|
|
||||||
}
|
|
||||||
refreshHint();
|
|
||||||
});
|
|
||||||
|
|
||||||
pathInput.addEventListener("input", refreshHint);
|
|
||||||
refreshHint();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
{% set user = request.state.current_user if request is defined and request.state.current_user is defined else None %}
|
{% set session_user = request.scope.get("session", {}).get("current_user") if request is defined else None %}
|
||||||
|
{% set user = current_user if current_user is defined and current_user else (request.state.current_user if request is defined and request.state.current_user is defined and request.state.current_user else session_user) %}
|
||||||
<header class="global-topbar">
|
<header class="global-topbar">
|
||||||
<div class="global-topbar-left"></div>
|
<div class="global-topbar-left"></div>
|
||||||
|
|
||||||
<div class="global-topbar-right">
|
<div class="global-topbar-right">
|
||||||
{% if user %}
|
{% if user %}
|
||||||
<div class="global-topbar-user">
|
<div class="global-topbar-user">
|
||||||
<div class="global-topbar-name">{{ user.display_name }}</div>
|
<div class="global-topbar-name">{{ user.display_name }}</div>
|
||||||
<div class="global-topbar-username">@{{ user.username }}</div>
|
<div class="global-topbar-username">@{{ user.username }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if user.is_admin %}
|
{% if user.is_admin %}
|
||||||
<span class="badge reviewed">admin</span>
|
<span class="badge reviewed">admin</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" action="/logout" style="margin:0;">
|
<form method="post" action="/logout" style="margin:0;">
|
||||||
<button type="submit" class="global-topbar-logout">Logout</button>
|
<button type="submit" class="global-topbar-logout">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<a class="button-link" href="/login">Login</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue