feat: add session auth and mobile shell/detail UI polish

This commit is contained in:
Sean McElwain 2026-04-22 22:31:09 -05:00
parent 1cb75a439e
commit 16646b3062
10 changed files with 826 additions and 217 deletions

67
app/core/auth.py Normal file
View File

@ -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)),
}

View File

@ -189,7 +189,29 @@ def root_dashboard(request: Request):
"avg_cocktail_rating": avg_cocktail_rating,
"recent_documents": recent_documents,
"recent_line_items": recent_line_item_rows,
"current_user": getattr(request.state, "current_user", None),
},
)
finally:
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)

39
app/routes/auth.py Normal file
View File

@ -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)

View File

@ -822,6 +822,41 @@ def _document_matches_filters(
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)
def list_documents(
request: Request,
@ -833,6 +868,7 @@ def list_documents(
tab: str = Query("all-documents"),
db: Session = Depends(get_db),
):
current_user = getattr(request.state, "current_user", None)
documents_all = (
db.query(Document)
.options(
@ -844,6 +880,8 @@ def list_documents(
.all()
)
# ACL temporarily disabled to restore document visibility
has_search_query = any([
q.strip(),
document_type.strip(),
@ -883,6 +921,7 @@ def list_documents(
"has_search_query": has_search_query,
"active_tab": tab,
"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)
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 = (
db.query(Document)
.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,
)
# ACL temporarily disabled to restore detail visibility
review_state = _get_or_create_document_review_state(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,
"active_tab": active_tab,
"active_page": "documents",
"current_user": current_user,
},
)

110
app/static/app-shell.css Normal file
View File

@ -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; }
}

View File

@ -1663,3 +1663,495 @@ table {
}
}
/* ===== 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 ===== */

View File

@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8">
<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>
<body>
<main class="main" style="max-width:520px; margin:3rem auto; padding:2rem 1rem;">
@ -21,7 +22,6 @@
<label for="username">Username or email</label>
<input id="username" type="text" name="username" required>
</div>
<div class="form-field full">
<label for="password">Password</label>
<input id="password" type="password" name="password" required>

View File

@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8">
<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>
<body>
<div class="app-shell">
@ -21,17 +22,42 @@
document.addEventListener("DOMContentLoaded", () => {
const menuToggle = document.getElementById("menu-toggle");
const appShell = document.querySelector(".app-shell");
if (!menuToggle || !appShell) return;
const toggleNav = () => appShell.classList.toggle("nav-open");
if (menuToggle && appShell) {
const toggleNav = () => appShell.classList.toggle("nav-open");
menuToggle.addEventListener("click", toggleNav);
menuToggle.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleNav();
}
});
}
menuToggle.addEventListener("click", toggleNav);
menuToggle.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
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>
</body>

View File

@ -1,16 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<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" %}
{% extends "base.html" %}
{% block title %}Document Detail{% endblock %}
{% block content %}
{% if error == "line_count_mismatch" %}
<div class="error-box">
Could not save reviewed OCR because line count did not match OCR layout.
Expected {{ error_expected }}, got {{ error_actual }}.
@ -55,8 +46,8 @@
</div>
</div> <div class="card" style="margin-bottom: 0;">
<div style="display:flex; flex-direction:column; gap:0.75rem;">
<div style="display:flex; align-items:flex-end; gap:0.6rem; flex-wrap:wrap;">
<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;">
<div class="detail-doc-actions-row">
<form method="post" action="/documents/{{ document.document_id }}/save-document-type" class="detail-doc-type-form">
<div style="position:relative;">
<label for="document_type_input">Document type</label>
<input
@ -73,19 +64,19 @@
<button type="submit" style="height:38px;">Update</button>
</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;">
<label style="display:flex; align-items:center; gap:0.35rem;">
<form method="post" action="/documents/{{ document.document_id }}/save-review-flags" class="detail-review-flags-form">
<label class="detail-check-label">
<input type="checkbox" name="is_approved" value="1" {% if review_state and review_state.is_approved %}checked{% endif %}>
<span>Approved</span>
</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 %}>
<span>Excluded</span>
</label>
<button type="submit" style="height:38px;">Save flags</button>
</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>
</form>
</div>
@ -623,185 +614,4 @@ function addRow() {
<div class="meta-item"><span class="meta-label">Updated at</span>{{ document.updated_at }}</div>
</div>
</div>
</main>
</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, '&quot;') + '" 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>
{% endblock %}

View File

@ -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">
<div class="global-topbar-left"></div>
<div class="global-topbar-right">
{% if user %}
<div class="global-topbar-user">
<div class="global-topbar-name">{{ user.display_name }}</div>
<div class="global-topbar-username">@{{ user.username }}</div>
</div>
{% if user.is_admin %}
<span class="badge reviewed">admin</span>
{% endif %}
<form method="post" action="/logout" style="margin:0;">
<button type="submit" class="global-topbar-logout">Logout</button>
</form>
{% else %}
<a class="button-link" href="/login">Login</a>
{% endif %}
</div>
</header>