From 1b6d5543a64eb83e34db5f07574d1f3166db3d43 Mon Sep 17 00:00:00 2001 From: Georges Haddad Date: Sat, 11 Apr 2026 02:31:11 +0300 Subject: [PATCH] UI beautification: SVG icons, polished buttons, glass navbar, refined login, animated orbs, modern cards --- routers/auth.py | 13 +- routers/review.py | 348 ++++++++++--- routers/search.py | 14 +- services/auth_service.py | 4 +- services/search_service.py | 62 ++- static/css/main.css | 980 +++++++++++++++++++++++++++++------ static/css/review.css | 160 +++++- templates/base.html | 119 ++++- templates/documents.html | 31 +- templates/index.html | 50 +- templates/login.html | 14 +- templates/person_detail.html | 67 ++- templates/review.html | 207 +++++--- templates/search.html | 136 ++--- templates/users.html | 33 +- 15 files changed, 1734 insertions(+), 504 deletions(-) diff --git a/routers/auth.py b/routers/auth.py index 39b45c0..6563d92 100644 --- a/routers/auth.py +++ b/routers/auth.py @@ -70,11 +70,14 @@ async def users_list(request: Request, _=Depends(get_current_user)): return templates.TemplateResponse(request=request, name="users.html", context={"users": users}) @router.post("/users/create") -async def add_user(username: str = Form(...), password: str = Form(...), _=Depends(get_current_user)): - try: - create_user(username, password) - except Exception: - pass # Probably duplicate +async def add_user(request: Request, username: str = Form(...), password: str = Form(...), _=Depends(get_current_user)): + if len(password) < 8: + users = get_all_users() + return templates.TemplateResponse(request=request, name="users.html", context={"users": users, "error": "Password must be at least 8 characters."}) + if get_user_by_username(username): + users = get_all_users() + return templates.TemplateResponse(request=request, name="users.html", context={"users": users, "error": f"User '{username}' already exists."}) + create_user(username, password) return RedirectResponse(url="/auth/users", status_code=status.HTTP_303_SEE_OTHER) @router.post("/users/delete/{user_id}") diff --git a/routers/review.py b/routers/review.py index 6cdd8d4..c8c8ca4 100644 --- a/routers/review.py +++ b/routers/review.py @@ -1,18 +1,20 @@ import json import asyncio -from fastapi import APIRouter, Request +from fastapi import APIRouter, HTTPException, Request, status from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.templating import Jinja2Templates from config import UPLOAD_DIR from database.connection import get_db from services.extractor import extract_document -from services.search_service import normalize_arabic +from services.search_service import normalize_arabic, _normalize_scope router = APIRouter() templates = Jinja2Templates(directory="templates") +REVIEWABLE_DOCUMENT_STATUSES = {"extracted", "confirmed", "error"} + def _get_document(doc_id: int) -> dict | None: with get_db() as conn: @@ -43,6 +45,98 @@ def _get_document(doc_id: int) -> dict | None: return doc +def _parse_optional_bool(value): + if value is None: + return None + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + + normalized = str(value).strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off", ""}: + return False + return None + + +def _get_merge_candidates( + first_name: str, + father_name: str = "", + family_name: str = "", + search_scope: str = "", + registry_number: str = "", +) -> list[dict]: + if not first_name: + return [] + + first_norm = normalize_arabic(first_name.strip()) + family_norm = normalize_arabic(family_name.strip()) if family_name else "" + normalized_scope = _normalize_scope(search_scope) + normalized_registry = (registry_number or "").strip() + + with get_db() as conn: + if family_norm: + rows = conn.execute( + """SELECT p.*, + COUNT(DISTINCT pr.id) AS property_count, + COUNT(DISTINCT d.id) AS document_count, + GROUP_CONCAT(DISTINCT NULLIF(TRIM(d.search_scope), '')) AS search_scopes + FROM persons p + LEFT JOIN documents d ON d.person_id = p.id + LEFT JOIN properties pr ON pr.person_id = p.id + WHERE p.first_name_norm = ? AND p.family_name_norm = ? + GROUP BY p.id""", + (first_norm, family_norm), + ).fetchall() + else: + rows = conn.execute( + """SELECT p.*, + COUNT(DISTINCT pr.id) AS property_count, + COUNT(DISTINCT d.id) AS document_count, + GROUP_CONCAT(DISTINCT NULLIF(TRIM(d.search_scope), '')) AS search_scopes + FROM persons p + LEFT JOIN documents d ON d.person_id = p.id + LEFT JOIN properties pr ON pr.person_id = p.id + WHERE p.first_name_norm = ? + GROUP BY p.id""", + (first_norm,), + ).fetchall() + + matches = [] + normalized_father = normalize_arabic(father_name.strip()) if father_name else "" + for row in rows: + person = dict(row) + if normalized_father and person.get("father_name"): + if normalize_arabic(person["father_name"]) != normalized_father: + continue + scope_values = [scope.strip() for scope in (person.get("search_scopes") or "").split(",") if scope.strip()] + same_scope = bool(normalized_scope and normalized_scope in scope_values) + registry_match = bool( + normalized_registry + and person.get("registry_number") + and person["registry_number"].strip() == normalized_registry + ) + person["search_scope_list"] = scope_values + person["same_scope"] = same_scope + person["registry_match"] = registry_match + person["merge_allowed"] = same_scope or registry_match + matches.append(person) + + matches.sort( + key=lambda person: ( + 0 if person.get("merge_allowed") else 1, + 0 if person.get("same_scope") else 1, + 0 if person.get("registry_match") else 1, + -(person.get("document_count") or 0), + -(person.get("property_count") or 0), + person.get("id") or 0, + ) + ) + return matches + + @router.get("/review/next") async def review_next(): with get_db() as conn: @@ -100,49 +194,34 @@ async def check_duplicate( first_name: str = "", father_name: str = "", family_name: str = "", + search_scope: str = "", + registry_number: str = "", ): """Check if a person with similar name already exists. Called via AJAX from review page.""" if not first_name: return JSONResponse({"matches": []}) - first_norm = normalize_arabic(first_name.strip()) - family_norm = normalize_arabic(family_name.strip()) if family_name else "" - - with get_db() as conn: - if family_norm: - rows = conn.execute( - """SELECT p.*, COUNT(DISTINCT pr.id) AS property_count - FROM persons p - LEFT JOIN properties pr ON pr.person_id = p.id - WHERE p.first_name_norm = ? AND p.family_name_norm = ? - GROUP BY p.id""", - (first_norm, family_norm), - ).fetchall() - else: - rows = conn.execute( - """SELECT p.*, COUNT(DISTINCT pr.id) AS property_count - FROM persons p - LEFT JOIN properties pr ON pr.person_id = p.id - WHERE p.first_name_norm = ? - GROUP BY p.id""", - (first_norm,), - ).fetchall() - - # Further filter by father_name if provided - matches = [] - for r in rows: - d = dict(r) - if father_name and d.get("father_name"): - if normalize_arabic(father_name.strip()) != normalize_arabic(d["father_name"]): - continue - matches.append({ - "id": d["id"], - "first_name": d["first_name"], - "father_name": d.get("father_name"), - "family_name": d.get("family_name"), - "family_origin": d.get("family_origin"), - "property_count": d["property_count"], - }) + matches = [] + for candidate in _get_merge_candidates( + first_name, + father_name, + family_name, + search_scope, + registry_number, + ): + matches.append({ + "id": candidate["id"], + "first_name": candidate["first_name"], + "father_name": candidate.get("father_name"), + "family_name": candidate.get("family_name"), + "family_origin": candidate.get("family_origin"), + "property_count": candidate.get("property_count", 0), + "document_count": candidate.get("document_count", 0), + "search_scopes": candidate.get("search_scope_list", []), + "same_scope": candidate.get("same_scope", False), + "registry_match": candidate.get("registry_match", False), + "merge_allowed": candidate.get("merge_allowed", False), + }) return JSONResponse({"matches": matches}) @@ -155,34 +234,104 @@ async def confirm_document(doc_id: int, request: Request): properties_data = body.get("properties", []) merge_person_id = body.get("merge_person_id") # If user chose to merge - first_name = (person_data.get("first_name") or "").strip() - registry_number = (person_data.get("registry_number") or "").strip() or None - - # We should update document fields as well since user might have edited them - request_number = (body.get("request_number") or "").strip() or None - request_date = (body.get("request_date") or "").strip() or None - page_info = (body.get("page_info") or "").strip() or None - search_scope = (body.get("search_scope") or "").strip() or None - request_purpose = (body.get("request_purpose") or "").strip() or None - data_valid_until = (body.get("data_valid_until") or "").strip() or None - registry_office = (body.get("registry_office") or "").strip() or None - owns_properties = body.get("owns_properties") - if owns_properties is not None: - owns_properties = bool(owns_properties) - declared_property_count = body.get("declared_property_count") - if declared_property_count is not None: - try: - declared_property_count = int(declared_property_count) - except ValueError: - declared_property_count = None + if not isinstance(person_data, dict): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid person payload") + if not isinstance(properties_data, list): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid properties payload") + first_name = (person_data.get("first_name") or "").strip() + if not first_name: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="First name is required") + + registry_number = (person_data.get("registry_number") or "").strip() or None with get_db() as conn: + current_doc = conn.execute( + "SELECT * FROM documents WHERE id=?", + (doc_id,), + ).fetchone() + if not current_doc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found") + + current_doc = dict(current_doc) + if current_doc["status"] == "pending": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Document is still being processed", + ) + if current_doc["status"] not in REVIEWABLE_DOCUMENT_STATUSES: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Document status '{current_doc['status']}' cannot be confirmed", + ) + + def _body_or_existing(field_name: str): + if field_name in body: + value = body.get(field_name) + if isinstance(value, str): + value = value.strip() + return value or None + return current_doc.get(field_name) + + request_number = _body_or_existing("request_number") + request_date = _body_or_existing("request_date") + page_info = _body_or_existing("page_info") + search_scope = _body_or_existing("search_scope") + request_purpose = _body_or_existing("request_purpose") + data_valid_until = _body_or_existing("data_valid_until") + registry_office = _body_or_existing("registry_office") + normalized_search_scope = _normalize_scope(search_scope) or "" + + if "owns_properties" in body: + owns_properties = _parse_optional_bool(body.get("owns_properties")) + else: + owns_properties = current_doc.get("owns_properties") + + if "declared_property_count" in body: + declared_property_count = body.get("declared_property_count") + if declared_property_count is not None and str(declared_property_count).strip() != "": + try: + declared_property_count = int(declared_property_count) + except (TypeError, ValueError): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid declared property count", + ) + else: + declared_property_count = None + else: + declared_property_count = current_doc.get("declared_property_count") + person_id = None # Option 1: User explicitly chose to merge with an existing person if merge_person_id: - person_id = int(merge_person_id) + try: + person_id = int(merge_person_id) + except (TypeError, ValueError): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid merge target") + + existing_person = conn.execute( + "SELECT * FROM persons WHERE id=?", + (person_id,), + ).fetchone() + if not existing_person: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Merge target not found") + + allowed_merge_ids = {candidate["id"] for candidate in _get_merge_candidates( + first_name, + person_data.get("father_name") or "", + person_data.get("family_name") or "", + normalized_search_scope, + registry_number or "", + ) if candidate.get("merge_allowed")} + + if person_id not in allowed_merge_ids: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Merge target is not a verified match for this search scope", + ) + # Update person info with latest data conn.execute( """UPDATE persons SET @@ -241,31 +390,66 @@ async def confirm_document(doc_id: int, request: Request): # Option 3: Create new person if not person_id: - cursor = conn.execute( - """INSERT INTO persons - (first_name, father_name, mother_name, family_name, family_origin, - nationality, birth_date, registry_number, registry_place, - first_name_norm, family_name_norm) - VALUES (?,?,?,?,?,?,?,?,?,?,?)""", - ( - first_name, - person_data.get("father_name"), - person_data.get("mother_name"), - person_data.get("family_name"), - person_data.get("family_origin"), - person_data.get("nationality"), - person_data.get("birth_date"), - registry_number, - person_data.get("registry_place"), - normalize_arabic(first_name), - normalize_arabic(person_data.get("family_name") or ""), - ), - ) - person_id = cursor.lastrowid + existing_person = None + if current_doc.get("person_id"): + existing_person = conn.execute( + "SELECT id FROM persons WHERE id=?", + (current_doc["person_id"],), + ).fetchone() + + if existing_person: + person_id = existing_person["id"] + conn.execute( + """UPDATE persons SET + first_name=?, father_name=?, mother_name=?, + family_name=?, family_origin=?, nationality=?, + birth_date=?, registry_number=?, registry_place=?, + first_name_norm=?, family_name_norm=?, + updated_at=CURRENT_TIMESTAMP + WHERE id=?""", + ( + first_name, + person_data.get("father_name"), + person_data.get("mother_name"), + person_data.get("family_name"), + person_data.get("family_origin"), + person_data.get("nationality"), + person_data.get("birth_date"), + registry_number, + person_data.get("registry_place"), + normalize_arabic(first_name), + normalize_arabic(person_data.get("family_name") or ""), + person_id, + ), + ) + else: + cursor = conn.execute( + """INSERT INTO persons + (first_name, father_name, mother_name, family_name, family_origin, + nationality, birth_date, registry_number, registry_place, + first_name_norm, family_name_norm) + VALUES (?,?,?,?,?,?,?,?,?,?,?)""", + ( + first_name, + person_data.get("father_name"), + person_data.get("mother_name"), + person_data.get("family_name"), + person_data.get("family_origin"), + person_data.get("nationality"), + person_data.get("birth_date"), + registry_number, + person_data.get("registry_place"), + normalize_arabic(first_name), + normalize_arabic(person_data.get("family_name") or ""), + ), + ) + person_id = cursor.lastrowid # Replace properties for this document conn.execute("DELETE FROM properties WHERE document_id=?", (doc_id,)) for i, prop in enumerate(properties_data): + if not isinstance(prop, dict): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid property payload") conn.execute( """INSERT INTO properties (document_id, person_id, row_order, party_name, property_number, diff --git a/routers/search.py b/routers/search.py index cb04570..9d93eaa 100644 --- a/routers/search.py +++ b/routers/search.py @@ -32,7 +32,10 @@ async def search( # If exactly one person found, preload their full details if len(persons) == 1 and not properties: - selected_person = get_person_with_properties(persons[0]["id"]) + selected_person = get_person_with_properties( + persons[0]["id"], + persons[0].get("search_scope"), + ) return templates.TemplateResponse( request, @@ -50,8 +53,8 @@ async def search( @router.get("/persons/{person_id}") -async def person_detail(request: Request, person_id: int): - data = get_person_with_properties(person_id) +async def person_detail(request: Request, person_id: int, search_scope: str = ""): + data = get_person_with_properties(person_id, search_scope.strip() or None) if not data: return templates.TemplateResponse( request, @@ -71,16 +74,13 @@ async def person_export_csv(person_id: int, qaza: str = ""): import io import csv - data = get_person_with_properties(person_id) + data = get_person_with_properties(person_id, qaza.strip() or None) if not data: return Response("Person not found", status_code=404) person = data["person"] properties = data["properties"] - if qaza: - properties = [p for p in properties if (p.get("search_scope") or p.get("qaza") or "").strip() == qaza.strip()] - output = io.StringIO() # Write BOM for Excel to open Arabic UTF-8 correctly output.write('\ufeff') diff --git a/services/auth_service.py b/services/auth_service.py index 6b339d5..9a3bb2a 100644 --- a/services/auth_service.py +++ b/services/auth_service.py @@ -1,5 +1,5 @@ -import sqlite3 import hashlib +import hmac import secrets def hash_password(password: str) -> str: @@ -11,7 +11,7 @@ def verify_password(stored_password: str, provided_password: str) -> bool: try: salt, stored_hash = stored_password.split('$') hashed = hashlib.pbkdf2_hmac('sha256', provided_password.encode('utf-8'), salt.encode('utf-8'), 100000).hex() - return hashed == stored_hash + return hmac.compare_digest(hashed, stored_hash) except Exception: return False diff --git a/services/search_service.py b/services/search_service.py index 5c4d158..d489919 100644 --- a/services/search_service.py +++ b/services/search_service.py @@ -12,37 +12,53 @@ def normalize_arabic(text: str) -> str: return text +def _normalize_scope(text: str | None) -> str | None: + if text is None: + return None + normalized = text.strip() + return normalized or None + + def search_persons(query: str) -> list[dict]: - """Search persons by name (first, family, or father name).""" + """Search persons by name, keeping separate result rows per search scope.""" norm = normalize_arabic(query.strip()) pattern = f"%{norm}%" raw_pattern = f"%{query.strip()}%" + scope_expr = "COALESCE(NULLIF(TRIM(d.search_scope), ''), '')" with get_db() as conn: rows = conn.execute( - """ + f""" SELECT p.*, + {scope_expr} AS search_scope, COUNT(DISTINCT pr.id) AS property_count, - COUNT(DISTINCT d.id) AS document_count, - GROUP_CONCAT(DISTINCT d.search_scope) AS search_scopes + COUNT(DISTINCT d.id) AS document_count FROM persons p - LEFT JOIN properties pr ON pr.person_id = p.id LEFT JOIN documents d ON d.person_id = p.id + LEFT JOIN properties pr ON pr.document_id = d.id WHERE p.first_name_norm LIKE ? OR p.family_name_norm LIKE ? OR p.father_name LIKE ? OR p.first_name LIKE ? OR p.family_name LIKE ? - GROUP BY p.id + GROUP BY p.id, {scope_expr} ORDER BY CASE WHEN p.first_name_norm = ? THEN 0 WHEN p.family_name_norm = ? THEN 0 ELSE 1 END, - p.first_name + p.first_name, + p.family_name, + search_scope """, (pattern, pattern, raw_pattern, raw_pattern, raw_pattern, norm, norm), ).fetchall() - return [dict(r) for r in rows] + + results = [] + for row in rows: + person = dict(row) + person["search_scope"] = _normalize_scope(person.get("search_scope")) + results.append(person) + return results def search_properties( @@ -94,8 +110,9 @@ def search_properties( return results -def get_person_with_properties(person_id: int) -> dict | None: - """Fetch a person and all their properties.""" +def get_person_with_properties(person_id: int, search_scope: str | None = None) -> dict | None: + """Fetch a person and all their properties, optionally filtered by search scope.""" + normalized_scope = _normalize_scope(search_scope) with get_db() as conn: person = conn.execute( "SELECT * FROM persons WHERE id = ?", (person_id,) @@ -103,21 +120,25 @@ def get_person_with_properties(person_id: int) -> dict | None: if not person: return None - props = conn.execute( - """ + props_query = """ SELECT pr.*, d.image_path, d.id AS document_id, d.search_scope FROM properties pr LEFT JOIN documents d ON d.id = pr.document_id WHERE pr.person_id = ? - ORDER BY pr.real_estate_district, pr.row_order - """, - (person_id,), - ).fetchall() + """ + props_params = [person_id] + if normalized_scope: + props_query += " AND COALESCE(NULLIF(TRIM(d.search_scope), ''), '') = ?" + props_params.append(normalized_scope) + props_query += " ORDER BY pr.real_estate_district, pr.row_order" + props = conn.execute(props_query, props_params).fetchall() - docs = conn.execute( - "SELECT id, image_path, request_number, request_date, status, page_info, search_scope FROM documents WHERE person_id = ?", - (person_id,), - ).fetchall() + docs_query = "SELECT id, image_path, request_number, request_date, status, page_info, search_scope FROM documents WHERE person_id = ?" + docs_params = [person_id] + if normalized_scope: + docs_query += " AND COALESCE(NULLIF(TRIM(search_scope), ''), '') = ?" + docs_params.append(normalized_scope) + docs = conn.execute(docs_query, docs_params).fetchall() properties_list = [] doc_ids_with_props = set() @@ -149,4 +170,5 @@ def get_person_with_properties(person_id: int) -> dict | None: "person": dict(person), "properties": properties_list, "documents": docs_list, + "current_search_scope": normalized_scope, } diff --git a/static/css/main.css b/static/css/main.css index 23ae30a..7a0550d 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -4,45 +4,119 @@ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } :root { - --primary: #1a56db; - --primary-dark: #1145b8; - --success: #057a55; - --warning: #c27803; - --danger: #c81e1e; - --bg: #f9fafb; - --surface: #ffffff; - --border: #e5e7eb; - --text: #111827; - --text-muted: #6b7280; - --radius: 8px; - --shadow: 0 1px 3px rgba(0,0,0,.1), 0 1px 2px rgba(0,0,0,.06); - --shadow-md: 0 4px 6px rgba(0,0,0,.07), 0 2px 4px rgba(0,0,0,.05); + --primary: #0d9488; + --primary-dark: #0f766e; + --primary-light: #5eead4; + --primary-soft: #ccfbf1; + --accent: #ea580c; + --accent-soft: #fff7ed; + --success: #059669; + --warning: #d97706; + --danger: #dc2626; + --bg: #f8f6f1; + --bg-strong: #ece7db; + --surface: rgba(255,255,255,.88); + --surface-solid: #ffffff; + --surface-tint: #faf8f4; + --border: rgba(23, 37, 44, .08); + --text: #0f1c22; + --text-muted: #64748b; + --radius: 18px; + --radius-sm: 12px; + --shadow: 0 4px 24px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04); + --shadow-md: 0 12px 40px rgba(0,0,0,.1), 0 2px 6px rgba(0,0,0,.04); + --shadow-lg: 0 24px 60px rgba(0,0,0,.12); + --transition: .22s cubic-bezier(.4,0,.2,1); } -html { font-size: 15px; } +html { font-size: 15px; scroll-behavior: smooth; } + +::selection { + background: rgba(13,148,136,.18); + color: var(--text); +} body { - font-family: 'Segoe UI', 'Tahoma', 'Arial', sans-serif; - background: var(--bg); + font-family: 'Noto Kufi Arabic', 'Segoe UI', Tahoma, sans-serif; + background: + radial-gradient(ellipse 80% 60% at 70% 0%, rgba(13,148,136,.1), transparent), + radial-gradient(ellipse 60% 50% at 0% 40%, rgba(234,88,12,.07), transparent), + linear-gradient(180deg, #faf8f4 0%, var(--bg) 50%, #f5f2ec 100%); color: var(--text); - line-height: 1.6; + line-height: 1.65; direction: rtl; + min-height: 100vh; + min-height: 100dvh; + position: relative; + overscroll-behavior-y: contain; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } a { color: var(--primary); text-decoration: none; } a:hover { text-decoration: underline; } +button, a, input, select, textarea { + -webkit-tap-highlight-color: transparent; +} + +input, select, textarea, button { + font: inherit; +} + +.page-backdrop { + position: fixed; + inset: 0; + pointer-events: none; + overflow: hidden; + z-index: 0; +} + +.backdrop-orb { + position: absolute; + border-radius: 999px; + filter: blur(80px); + opacity: .35; + animation: orb-drift 20s ease-in-out infinite alternate; +} + +@keyframes orb-drift { + 0% { transform: translate(0, 0) scale(1); } + 50% { transform: translate(20px, -15px) scale(1.05); } + 100% { transform: translate(-10px, 10px) scale(.98); } +} + +.orb-1 { + width: 500px; + height: 500px; + top: -120px; + right: -100px; + background: radial-gradient(circle, rgba(13,148,136,.2), rgba(94,234,212,.08)); +} + +.orb-2 { + width: 400px; + height: 400px; + bottom: 5%; + left: -100px; + background: radial-gradient(circle, rgba(234,88,12,.14), rgba(251,146,60,.06)); + animation-delay: -10s; +} + /* ============================ Navbar ============================ */ .navbar { - background: var(--primary); + background: linear-gradient(135deg, rgba(15,28,34,.92), rgba(23,37,44,.88)); + -webkit-backdrop-filter: blur(24px) saturate(1.4); + backdrop-filter: blur(24px) saturate(1.4); color: white; - padding: .75rem 1.5rem; + padding: .85rem 1.5rem; position: sticky; top: 0; z-index: 100; - box-shadow: var(--shadow-md); + box-shadow: 0 1px 0 rgba(255,255,255,.06), 0 8px 32px rgba(0,0,0,.18); + border-bottom: 1px solid rgba(255,255,255,.06); } .nav-container { max-width: 1300px; @@ -56,11 +130,59 @@ a:hover { text-decoration: underline; } font-size: 1.1rem; font-weight: 700; white-space: nowrap; + display: inline-flex; + align-items: center; + gap: .75rem; } .nav-brand:hover { text-decoration: none; opacity: .9; } -.nav-links { display: flex; gap: 1rem; } -.nav-link { color: rgba(255,255,255,.88); font-size: .9rem; padding: .25rem .5rem; border-radius: 4px; } -.nav-link:hover { color: white; background: rgba(255,255,255,.15); text-decoration: none; } +.nav-brand-mark { + width: 2.4rem; + height: 2.4rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 12px; + background: linear-gradient(135deg, var(--primary), var(--primary-dark)); + border: 1px solid rgba(255,255,255,.18); + font-size: 1rem; + box-shadow: 0 4px 12px rgba(13,148,136,.35); + transition: transform var(--transition), box-shadow var(--transition); +} +.nav-brand:hover .nav-brand-mark { + transform: scale(1.08) rotate(-3deg); + box-shadow: 0 6px 20px rgba(13,148,136,.45); +} +.nav-brand-text { letter-spacing: -.02em; font-size: 1.05rem; } +.nav-links { display: flex; gap: .65rem; margin-inline-start: auto; } +.nav-link { + color: rgba(255,255,255,.85); + font-size: .88rem; + padding: .5rem .85rem; + border-radius: 10px; + transition: all var(--transition); + display: inline-flex; + align-items: center; + gap: .4rem; + position: relative; +} +.nav-link svg { + opacity: .7; + flex-shrink: 0; + transition: opacity var(--transition); +} +.nav-link:hover { + color: white; + background: rgba(255,255,255,.1); + text-decoration: none; +} +.nav-link:hover svg { opacity: 1; } +.nav-link-logout { + color: rgba(255,180,180,.85); +} +.nav-link-logout:hover { + background: rgba(220,38,38,.15); + color: #fca5a5; +} /* ============================ Layout @@ -68,20 +190,57 @@ a:hover { text-decoration: underline; } .main-content { max-width: 1300px; margin: 0 auto; - padding: 1.5rem; + padding: 1.75rem 1.5rem 2.5rem; + position: relative; + z-index: 1; +} + +.mobile-dock { + display: none; } .page-header { display: flex; align-items: flex-start; justify-content: space-between; - text-wrap: wrap; + flex-wrap: wrap; gap: 1rem; margin-bottom: 1.5rem; + padding: 1.5rem 1.6rem; + border: 1px solid rgba(23, 37, 44, .06); + border-radius: 20px; + background: + linear-gradient(135deg, rgba(255,255,255,.94), rgba(255,255,255,.78)); + box-shadow: var(--shadow); + -webkit-backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); } -.page-header h1 { font-size: 1.5rem; color: var(--text); } -.subtitle { color: var(--text-muted); margin-top: .25rem; font-size: .95rem; } +.page-header h1 { font-size: 1.6rem; color: var(--text); line-height: 1.35; font-weight: 800; } +.page-header-hero { padding-block: 1.75rem; } +.page-kicker { + display: inline-flex; + align-items: center; + gap: .4rem; + font-size: .75rem; + font-weight: 700; + color: var(--accent); + background: var(--accent-soft); + border: 1px solid rgba(234,88,12,.1); + border-radius: 8px; + padding: .3rem .65rem; + margin-bottom: .75rem; + letter-spacing: .02em; + text-transform: uppercase; +} +.subtitle { color: var(--text-muted); margin-top: .45rem; font-size: .98rem; max-width: 58rem; } .back-link { display: block; font-size: .875rem; color: var(--text-muted); margin-bottom: .35rem; } +.header-actions { + display: flex; + flex-wrap: wrap; + gap: .75rem; + align-items: center; +} /* ============================ Buttons @@ -89,27 +248,43 @@ a:hover { text-decoration: underline; } .btn { display: inline-flex; align-items: center; - gap: .4rem; - padding: .5rem 1rem; - border-radius: var(--radius); + gap: .45rem; + justify-content: center; + padding: .6rem 1.2rem; + border-radius: 12px; border: 1px solid transparent; - font-size: .9rem; - font-weight: 500; + font-size: .88rem; + font-weight: 600; cursor: pointer; - transition: background .15s, opacity .15s; + transition: all var(--transition); white-space: nowrap; text-decoration: none; + position: relative; + overflow: hidden; } -.btn:hover { text-decoration: none; opacity: .9; } -.btn:disabled { opacity: .5; cursor: not-allowed; } +.btn::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(255,255,255,.12), transparent); + pointer-events: none; + border-radius: inherit; +} +.btn:hover { text-decoration: none; transform: translateY(-1px); box-shadow: 0 4px 16px rgba(0,0,0,.1); } +.btn:active { transform: translateY(0) scale(.98); } +.btn:disabled { opacity: .45; cursor: not-allowed; transform: none !important; box-shadow: none !important; } -.btn-primary { background: var(--primary); color: white; } -.btn-secondary { background: white; color: var(--text); border-color: var(--border); } -.btn-success { background: var(--success); color: white; } -.btn-warning { background: var(--warning); color: white; } -.btn-danger { background: var(--danger); color: white; } -.btn-large { padding: .7rem 1.5rem; font-size: 1rem; } -.btn-sm { padding: .3rem .65rem; font-size: .8rem; } +.btn-primary { background: linear-gradient(135deg, var(--primary), var(--primary-dark)); color: white; box-shadow: 0 2px 12px rgba(13,148,136,.25); } +.btn-primary:hover { box-shadow: 0 6px 24px rgba(13,148,136,.35); } +.btn-secondary { background: var(--surface); color: var(--text); border-color: rgba(23,37,44,.1); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); } +.btn-secondary:hover { background: white; border-color: var(--primary); } +.btn-success { background: linear-gradient(135deg, var(--success), #047857); color: white; box-shadow: 0 2px 12px rgba(5,150,105,.2); } +.btn-warning { background: linear-gradient(135deg, var(--warning), #b45309); color: white; box-shadow: 0 2px 12px rgba(217,119,6,.2); } +.btn-danger { background: linear-gradient(135deg, var(--danger), #b91c1c); color: white; box-shadow: 0 2px 12px rgba(220,38,38,.2); } +.btn-large { padding: .75rem 1.6rem; font-size: .95rem; border-radius: 14px; } +.btn-sm { padding: .4rem .75rem; font-size: .8rem; border-radius: 10px; } +.btn-sm::after { display: none; } +.btn-block { width: 100%; } /* ============================ Stats Bar @@ -121,10 +296,10 @@ a:hover { text-decoration: underline; } margin-bottom: 1rem; } .stat { - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: .6rem 1rem; + background: linear-gradient(180deg, rgba(255,255,255,.98), rgba(255,255,255,.88)); + border: 1px solid rgba(23,37,44,.06); + border-radius: 14px; + padding: .85rem 1.1rem; display: flex; flex-direction: column; align-items: center; @@ -133,9 +308,10 @@ a:hover { text-decoration: underline; } cursor: default; text-decoration: none; color: var(--text); + transition: all var(--transition); } -a.stat:hover { border-color: var(--primary); text-decoration: none; } -a.stat.active { border-color: var(--primary); background: #eff6ff; } +a.stat:hover { border-color: var(--primary); text-decoration: none; transform: translateY(-2px); box-shadow: var(--shadow-md); } +a.stat.active { border-color: rgba(15,118,110,.24); background: var(--primary-soft); } .stat-num { font-size: 1.4rem; font-weight: 700; line-height: 1; } .stat-label { font-size: .75rem; color: var(--text-muted); margin-top: .15rem; } .stat.confirmed .stat-num { color: var(--success); } @@ -146,19 +322,25 @@ a.stat.active { border-color: var(--primary); background: #eff6ff; } Alerts / Banners ============================ */ .alert-banner { - background: #eff6ff; - border: 1px solid #bfdbfe; - border-radius: var(--radius); - padding: .75rem 1rem; + background: linear-gradient(135deg, var(--primary-soft), rgba(255,255,255,.8)); + border: 1px solid rgba(13,148,136,.12); + border-radius: 14px; + padding: .9rem 1.1rem; margin-bottom: 1rem; - font-weight: 500; + font-weight: 700; + font-size: .92rem; +} +.merge-note { + font-size: .82rem; + color: #92400e; + margin-bottom: .6rem; } .alert-banner a { color: var(--primary); } .error-banner { - background: #fef2f2; + background: linear-gradient(135deg, rgba(254,242,242,.95), rgba(255,255,255,.82)); border: 1px solid #fecaca; - border-radius: var(--radius); - padding: .75rem 1rem; + border-radius: calc(var(--radius) - 4px); + padding: .85rem 1rem; margin-bottom: 1rem; color: var(--danger); display: flex; @@ -166,10 +348,10 @@ a.stat.active { border-color: var(--primary); background: #eff6ff; } gap: .75rem; } .success-banner { - background: #f0fdf4; + background: linear-gradient(135deg, rgba(240,253,244,.95), rgba(255,255,255,.82)); border: 1px solid #bbf7d0; - border-radius: var(--radius); - padding: .75rem 1rem; + border-radius: calc(var(--radius) - 4px); + padding: .85rem 1rem; margin-bottom: 1rem; color: var(--success); } @@ -178,29 +360,49 @@ a.stat.active { border-color: var(--primary); background: #eff6ff; } Upload Card ============================ */ .upload-card { - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); + background: linear-gradient(180deg, rgba(255,255,255,.96), rgba(255,255,255,.86)); + border: 1px solid rgba(23,37,44,.06); + border-radius: 20px; padding: 1.5rem; box-shadow: var(--shadow); margin-bottom: 1.5rem; + backdrop-filter: blur(8px); +} +.card-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} +.card-heading h2 { + font-size: 1.15rem; + margin-bottom: .2rem; +} +.card-heading p { + color: var(--text-muted); + font-size: .9rem; } .drop-zone { display: block; - border: 2px dashed var(--border); - border-radius: var(--radius); - padding: 2.5rem 1rem; + border: 2px dashed rgba(13,148,136,.2); + border-radius: 20px; + padding: 3rem 1rem; text-align: center; cursor: pointer; - transition: border-color .2s, background .2s; + transition: all var(--transition); -webkit-tap-highlight-color: transparent; + background: linear-gradient(180deg, rgba(250,248,244,.8), rgba(255,255,255,.9)); + position: relative; } .drop-zone:hover, .drop-zone.drag-over { border-color: var(--primary); - background: #eff6ff; + background: linear-gradient(180deg, var(--primary-soft), rgba(255,255,255,.95)); + transform: translateY(-2px); + box-shadow: 0 8px 32px rgba(13,148,136,.1); } -.drop-icon { font-size: 2.5rem; margin-bottom: .5rem; } -.drop-text { font-size: 1rem; font-weight: 500; margin-bottom: .3rem; } +.drop-icon { font-size: 3rem; margin-bottom: .75rem; filter: grayscale(.2); } +.drop-text { font-size: 1.05rem; font-weight: 700; margin-bottom: .3rem; color: var(--text); } .drop-hint { font-size: .85rem; color: var(--text-muted); } .file-input { position: absolute; @@ -215,9 +417,9 @@ a.stat.active { border-color: var(--primary); background: #eff6ff; } .file-list { margin-top: 1rem; display: flex; flex-direction: column; gap: .35rem; } .file-item { - background: #f9fafb; - border: 1px solid var(--border); - border-radius: 4px; + background: var(--surface-tint); + border: 1px solid rgba(23,37,44,.08); + border-radius: var(--radius-sm); padding: .4rem .75rem; font-size: .875rem; display: flex; @@ -236,13 +438,14 @@ a.stat.active { border-color: var(--primary); background: #eff6ff; } .provider-card { display: flex; flex-direction: column; - padding: .5rem 1rem; - border: 2px solid var(--border); - border-radius: var(--radius); + padding: .75rem 1rem; + border: 2px solid rgba(23,37,44,.08); + border-radius: calc(var(--radius) - 2px); transition: border-color .15s, background .15s; min-width: 140px; + background: rgba(255,255,255,.78); } -.provider-option input:checked + .provider-card { border-color: var(--primary); background: #eff6ff; } +.provider-option input:checked + .provider-card { border-color: var(--primary); background: var(--primary-soft); } .provider-option:hover .provider-card { border-color: var(--primary); } .provider-name { font-size: .9rem; font-weight: 600; } .provider-model { font-size: .75rem; color: var(--text-muted); } @@ -283,28 +486,48 @@ a.stat.active { border-color: var(--primary); background: #eff6ff; } margin-top: .5rem; } .quick-link-card { - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); + background: linear-gradient(180deg, rgba(255,255,255,.97), rgba(255,255,255,.88)); + border: 1px solid rgba(23,37,44,.06); + border-radius: 20px; padding: 1.25rem; display: flex; - align-items: center; - gap: .75rem; + align-items: flex-start; + gap: .85rem; font-weight: 500; color: var(--text); box-shadow: var(--shadow); - transition: box-shadow .15s, border-color .15s; + transition: all var(--transition); +} +.quick-link-card:hover { border-color: var(--primary); box-shadow: var(--shadow-md); text-decoration: none; transform: translateY(-3px); } +.quick-link-card strong { display: block; margin-bottom: .2rem; } +.quick-link-card small { display: block; color: var(--text-muted); font-size: .82rem; line-height: 1.7; } +.ql-icon { + font-size: 1.4rem; + width: 2.8rem; + height: 2.8rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 12px; + background: linear-gradient(135deg, var(--primary-soft), rgba(13,148,136,.08)); + flex-shrink: 0; } -.quick-link-card:hover { border-color: var(--primary); box-shadow: var(--shadow-md); text-decoration: none; } -.ql-icon { font-size: 1.4rem; } +.merge-person-safe { + border-color: rgba(15,118,110,.18); + background: rgba(223,245,241,.55); +} +.merge-person-unsafe { + border-color: rgba(194,65,12,.18); + background: rgba(255,247,237,.8); +} /* ============================ Forms ============================ */ .form-section { - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); + background: linear-gradient(180deg, rgba(255,255,255,.96), rgba(255,255,255,.88)); + border: 1px solid rgba(23,37,44,.06); + border-radius: 20px; padding: 1.25rem; margin-bottom: 1rem; box-shadow: var(--shadow); @@ -330,19 +553,21 @@ a.stat.active { border-color: var(--primary); background: #eff6ff; } .form-group { display: flex; flex-direction: column; gap: .25rem; } .form-group label { font-size: .8rem; font-weight: 500; color: var(--text-muted); } .form-group input, .form-group select { - border: 1px solid var(--border); - border-radius: 5px; - padding: .45rem .6rem; + border: 1px solid rgba(23,37,44,.1); + border-radius: 12px; + padding: .7rem .85rem; font-size: .9rem; font-family: inherit; direction: rtl; width: 100%; - background: white; + background: rgba(255,255,255,.95); + transition: all var(--transition); } .form-group input:focus, .form-group select:focus { outline: none; border-color: var(--primary); - box-shadow: 0 0 0 3px rgba(26,86,219,.12); + box-shadow: 0 0 0 3px rgba(13,148,136,.1), 0 2px 8px rgba(13,148,136,.08); + background: white; } .form-actions { display: flex; @@ -350,44 +575,53 @@ a.stat.active { border-color: var(--primary); background: #eff6ff; } gap: 1rem; margin-top: 1rem; padding: 1rem; - background: var(--surface); - border-radius: var(--radius); - border: 1px solid var(--border); + background: linear-gradient(180deg, rgba(255,255,255,.95), rgba(255,255,255,.82)); + border-radius: calc(var(--radius) + 2px); + border: 1px solid rgba(23,37,44,.08); } .keyboard-hint { font-size: .8rem; color: var(--text-muted); } /* ============================ Tables ============================ */ -.table-wrapper { overflow-x: auto; border-radius: var(--radius); border: 1px solid var(--border); } +.table-wrapper { + overflow-x: auto; + border-radius: calc(var(--radius) + 2px); + border: 1px solid rgba(23,37,44,.08); + box-shadow: var(--shadow); +} +.table-wrapper-spaced { margin-top: 1rem; } .results-table, .props-table { width: 100%; border-collapse: collapse; font-size: .875rem; - background: var(--surface); + background: rgba(255,255,255,.92); } .results-table th, .props-table th { - background: #f3f4f6; - padding: .6rem .75rem; + background: linear-gradient(180deg, #f8f5ef, #f2ede4); + padding: .7rem .8rem; text-align: right; - font-weight: 600; - font-size: .8rem; + font-weight: 700; + font-size: .78rem; white-space: nowrap; - border-bottom: 1px solid var(--border); + border-bottom: 1px solid rgba(23,37,44,.08); + color: var(--text-muted); + letter-spacing: .01em; + text-transform: uppercase; } .results-table td, .props-table td { - padding: .5rem .75rem; - border-bottom: 1px solid #f3f4f6; + padding: .65rem .8rem; + border-bottom: 1px solid rgba(23,37,44,.04); vertical-align: middle; } .results-table tr:last-child td, .props-table tr:last-child td { border-bottom: none; } -.results-table tr:hover td, .props-table tr:hover td { background: #fafafa; } +.results-table tr:hover td, .props-table tr:hover td { background: rgba(13,148,136,.02); } .prop-num { font-family: monospace; font-weight: 600; } .row-num { color: var(--text-muted); font-size: .8rem; } .props-table input { border: 1px solid transparent; - border-radius: 4px; + border-radius: 10px; padding: .3rem .4rem; font-size: .85rem; font-family: inherit; @@ -417,11 +651,14 @@ a.stat.active { border-color: var(--primary); background: #eff6ff; } Status Badges ============================ */ .status-badge { - display: inline-block; - padding: .2rem .55rem; - border-radius: 99px; - font-size: .75rem; - font-weight: 600; + display: inline-flex; + align-items: center; + gap: .3rem; + padding: .25rem .6rem; + border-radius: 8px; + font-size: .73rem; + font-weight: 700; + letter-spacing: .01em; } .status-confirmed { background: #d1fae5; color: #065f46; } .status-extracted { background: #fef3c7; color: #92400e; } @@ -438,27 +675,42 @@ a.stat.active { border-color: var(--primary); background: #eff6ff; } margin-top: .75rem; } .person-card { - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 1rem; + background: linear-gradient(180deg, rgba(255,255,255,.98), rgba(255,255,255,.88)); + border: 1px solid rgba(23,37,44,.06); + border-radius: 18px; + padding: 1.1rem; box-shadow: var(--shadow); display: block; color: var(--text); - transition: border-color .15s, box-shadow .15s; + transition: all var(--transition); + position: relative; + overflow: hidden; } -.person-card:hover { border-color: var(--primary); box-shadow: var(--shadow-md); text-decoration: none; } +.person-card::before { + content: ''; + position: absolute; + top: 0; + right: 0; + width: 4px; + height: 100%; + background: linear-gradient(180deg, var(--primary), var(--primary-light)); + opacity: 0; + transition: opacity var(--transition); +} +.person-card:hover { border-color: var(--primary); box-shadow: var(--shadow-md); text-decoration: none; transform: translateY(-3px); } +.person-card:hover::before { opacity: 1; } .person-name { font-size: 1rem; font-weight: 600; margin-bottom: .3rem; } .person-meta { font-size: .85rem; color: var(--text-muted); } .person-reg { font-size: .8rem; color: var(--text-muted); margin-top: .2rem; font-family: monospace; } +.person-meta-alert { color: var(--danger); font-weight: 700; } /* ============================ Person Detail ============================ */ .person-info-card { - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); + background: linear-gradient(180deg, rgba(255,255,255,.96), rgba(255,255,255,.84)); + border: 1px solid rgba(23,37,44,.08); + border-radius: calc(var(--radius) + 2px); padding: 1.25rem; margin-bottom: 1.25rem; box-shadow: var(--shadow); @@ -495,38 +747,49 @@ a.stat.active { border-color: var(--primary); background: #eff6ff; } Search ============================ */ .search-card { - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); + background: linear-gradient(180deg, rgba(255,255,255,.95), rgba(255,255,255,.82)); + border: 1px solid rgba(23,37,44,.08); + border-radius: calc(var(--radius) + 2px); padding: 1.25rem; box-shadow: var(--shadow); margin-bottom: 1.5rem; } -.search-tabs { display: flex; gap: .5rem; margin-bottom: 1rem; } +.search-tabs { + display: inline-flex; + gap: .35rem; + margin-bottom: 1rem; + padding: .3rem; + background: rgba(245,239,231,.85); + border: 1px solid rgba(23,37,44,.06); + border-radius: 14px; +} .tab-btn { - padding: .4rem .9rem; - border: 1px solid var(--border); - border-radius: var(--radius); - background: white; + padding: .55rem 1rem; + border: 1px solid transparent; + border-radius: 11px; + background: transparent; cursor: pointer; font-size: .875rem; font-family: inherit; - color: var(--text); - transition: background .15s, border-color .15s; + color: var(--text-muted); + font-weight: 600; + transition: all var(--transition); } -.tab-btn:hover { border-color: var(--primary); } -.tab-btn.active { background: var(--primary); color: white; border-color: var(--primary); } +.tab-btn:hover { color: var(--text); background: rgba(255,255,255,.6); } +.tab-btn.active { background: linear-gradient(135deg, var(--primary), var(--primary-dark)); color: white; border-color: var(--primary); box-shadow: 0 2px 8px rgba(13,148,136,.2); } .search-row { display: flex; gap: .75rem; } .search-input { flex: 1; - border: 1px solid var(--border); - border-radius: var(--radius); - padding: .55rem .85rem; + border: 1px solid rgba(23,37,44,.1); + border-radius: 14px; + padding: .85rem 1.1rem; font-size: 1rem; font-family: inherit; direction: rtl; + background: rgba(255,255,255,.95); + transition: all var(--transition); } -.search-input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(26,86,219,.12); } +.search-input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(13,148,136,.1), 0 4px 16px rgba(13,148,136,.06); background: white; } .search-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); @@ -534,8 +797,22 @@ a.stat.active { border-color: var(--primary); background: #eff6ff; } margin-bottom: .75rem; } .results-section { margin-bottom: 2rem; } -.results-section h2 { font-size: 1.1rem; font-weight: 600; margin-bottom: .75rem; } +.results-section h2 { font-size: 1.1rem; font-weight: 700; margin-bottom: .85rem; } .no-results { text-align: center; padding: 2rem; color: var(--text-muted); } +.person-actions { + margin-top: 1rem; + display: flex; + gap: .75rem; + flex-wrap: wrap; + align-items: center; +} +.centered-banner { justify-content: center; } +.banner-spaced { margin-top: 1rem; } +.ownership-alert td { + background: #fff3f3; + color: var(--danger); + font-weight: 700; +} /* ============================ Review page header @@ -564,25 +841,29 @@ a.stat.active { border-color: var(--primary); background: #eff6ff; } position: absolute; left: 0; top: 100%; - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); + background: var(--surface-solid); + border: 1px solid rgba(23,37,44,.08); + border-radius: 14px; box-shadow: var(--shadow-md); z-index: 100; min-width: 200px; - margin-top: 4px; + margin-top: 6px; + overflow: hidden; } .export-menu.show { display: block; } .export-option { - display: block; - padding: .6rem 1rem; + display: flex; + align-items: center; + gap: .5rem; + padding: .65rem 1rem; text-decoration: none; color: var(--text); - font-size: .9rem; - border-bottom: 1px solid #f3f4f6; + font-size: .88rem; + border-bottom: 1px solid rgba(23,37,44,.04); + transition: background var(--transition); } .export-option:last-child { border-bottom: none; } -.export-option:hover { background: #f9fafb; text-decoration: none; } +.export-option:hover { background: var(--surface-tint); text-decoration: none; } /* ============================ Hamburger Toggle @@ -610,22 +891,180 @@ a.stat.active { border-color: var(--primary); background: #eff6ff; } .nav-toggle.active span:nth-child(2) { opacity: 0; } .nav-toggle.active span:nth-child(3) { transform: translateY(-6.5px) rotate(-45deg); } +.mobile-dock-link.active { + color: var(--primary-dark); +} + +.mobile-dock-link.active .mobile-dock-icon { + background: var(--primary-soft); + color: var(--primary-dark); +} + +@media print { + @page { + size: auto; + margin: 12mm; + } + + body { + background: #fff !important; + color: #000; + } + + a { + color: inherit !important; + text-decoration: none !important; + } + + .page-backdrop, + .navbar, + .mobile-dock, + .nav-toggle, + .no-print, + .export-menu, + .export-dropdown, + .search-tabs, + .search-card { + display: none !important; + } + + .main-content { + max-width: none; + padding: 0; + margin: 0; + } + + .page-header, + .person-info-card, + .upload-card, + .form-section, + .admin-section, + .table-wrapper, + .quick-link-card { + background: #fff !important; + border: 1px solid #cfcfcf !important; + box-shadow: none !important; + } + + .page-header, + .person-info-card, + .table-wrapper, + .results-section, + .docs-section { + break-inside: avoid; + page-break-inside: avoid; + } + + .page-header { + padding: 0 0 10px; + margin-bottom: 12px; + border: none !important; + border-bottom: 2px solid #000 !important; + border-radius: 0; + } + + .page-kicker, + .subtitle, + .person-reg, + .info-label { + color: #444 !important; + } + + .table-wrapper { + overflow: visible; + border-radius: 0; + } + + .results-table, + .props-table, + .responsive-table { + width: 100%; + border-collapse: collapse !important; + border-spacing: 0; + background: #fff !important; + font-size: 12px; + } + + .responsive-table thead, + .results-table thead, + .props-table thead { + display: table-header-group !important; + } + + .responsive-table tbody, + .responsive-table tr, + .responsive-table td { + display: table-row-group; + width: auto; + } + + .responsive-table tr { + display: table-row !important; + margin: 0 !important; + background: transparent !important; + border: none !important; + box-shadow: none !important; + } + + .responsive-table td, + .results-table td, + .props-table td, + .results-table th, + .props-table th { + display: table-cell !important; + padding: 6px 8px !important; + border: 1px solid #cfcfcf !important; + background: #fff !important; + color: #000 !important; + } + + .results-table th, + .props-table th { + background: #f2f2f2 !important; + } + + .responsive-table td::before { + display: none !important; + content: none !important; + } + + .ownership-alert td, + .person-meta-alert { + color: #000 !important; + background: #fff !important; + } +} + /* ============================ Login Page ============================ */ .login-card { - max-width: 400px; - margin: 3rem auto; - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 2rem; - box-shadow: var(--shadow-md); + max-width: 420px; + margin: 4rem auto; + background: linear-gradient(180deg, rgba(255,255,255,.98), rgba(255,255,255,.9)); + border: 1px solid rgba(23,37,44,.06); + border-radius: 24px; + padding: 2.5rem 2rem; + box-shadow: var(--shadow-lg); + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); + position: relative; + overflow: hidden; +} +.login-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: linear-gradient(90deg, var(--primary), var(--primary-light), var(--accent)); } .login-card h2 { - font-size: 1.35rem; - margin-bottom: 1.25rem; + font-size: 1.4rem; + margin-bottom: 1.5rem; text-align: center; + font-weight: 800; } .login-card .form-group { margin-bottom: 1rem; @@ -639,26 +1078,52 @@ a.stat.active { border-color: var(--primary); background: #eff6ff; } } .login-card .form-group input { width: 100%; - border: 1px solid var(--border); - border-radius: 5px; - padding: .55rem .75rem; + border: 1px solid rgba(23,37,44,.1); + border-radius: 12px; + padding: .8rem .9rem; font-size: .95rem; font-family: inherit; direction: rtl; + background: var(--surface-tint); + transition: all var(--transition); } .login-card .form-group input:focus { outline: none; border-color: var(--primary); - box-shadow: 0 0 0 3px rgba(26,86,219,.12); + box-shadow: 0 0 0 3px rgba(13,148,136,.1), 0 2px 8px rgba(13,148,136,.08); + background: white; +} +.login-submit { margin-top: .75rem; } +.login-logo { + text-align: center; + margin-bottom: 1rem; +} +.login-logo-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 3.5rem; + height: 3.5rem; + border-radius: 16px; + background: linear-gradient(135deg, var(--primary), var(--primary-dark)); + color: white; + box-shadow: 0 8px 24px rgba(13,148,136,.3); +} +.login-subtitle { + text-align: center; + color: var(--text-muted); + font-size: .88rem; + margin-bottom: 1.5rem; } .login-error { - background: #fef2f2; + background: linear-gradient(135deg, #fef2f2, #fff5f5); border: 1px solid #fecaca; - border-radius: var(--radius); - padding: .6rem .85rem; + border-radius: 12px; + padding: .7rem .9rem; margin-bottom: 1rem; color: var(--danger); - font-size: .9rem; + font-size: .88rem; + font-weight: 600; } /* ============================ @@ -673,17 +1138,24 @@ a.stat.active { border-color: var(--primary); background: #eff6ff; } margin-bottom: 1.25rem; } .admin-section { - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); + background: linear-gradient(180deg, rgba(255,255,255,.98), rgba(255,255,255,.88)); + border: 1px solid rgba(23,37,44,.06); + border-radius: 20px; padding: 1.25rem; margin-bottom: 1.25rem; box-shadow: var(--shadow); } .admin-section h3 { font-size: 1rem; - font-weight: 600; + font-weight: 700; margin-bottom: .5rem; + display: flex; + align-items: center; + gap: .5rem; +} +.section-icon { + vertical-align: -2px; + flex-shrink: 0; } .admin-section p { color: var(--text-muted); @@ -710,6 +1182,13 @@ a.stat.active { border-color: var(--primary); background: #eff6ff; } white-space: nowrap; } +.inline-form { display: inline; } + +.responsive-table td[data-label]::before { + content: attr(data-label); + display: none; +} + /* ============================ Responsive — Tablet & Mobile ============================ */ @@ -725,27 +1204,78 @@ a.stat.active { border-color: var(--primary); background: #eff6ff; } flex-direction: column; width: 100%; gap: 0; - padding-top: .5rem; + padding-top: .75rem; } .nav-links.open { display: flex; } .nav-link { - padding: .6rem .5rem; + padding: .75rem .9rem; border-top: 1px solid rgba(255,255,255,.12); font-size: 1rem; + border-radius: 12px; } .nav-brand { font-size: 1rem; } /* Layout */ - .main-content { padding: 1rem .75rem; } - .page-header { flex-direction: column; gap: .5rem; } + .main-content { + padding: 1rem .75rem calc(5.75rem + env(safe-area-inset-bottom)); + } + .page-header { flex-direction: column; gap: .75rem; padding: 1.1rem; } .page-header h1 { font-size: 1.25rem; } + .header-actions { width: 100%; } + .header-actions .btn { flex: 1; } + + .mobile-dock { + position: fixed; + right: .75rem; + left: .75rem; + bottom: max(.6rem, env(safe-area-inset-bottom)); + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: .45rem; + padding: .55rem; + border: 1px solid rgba(23,37,44,.08); + border-radius: 24px; + background: rgba(255,255,255,.92); + -webkit-backdrop-filter: blur(18px); + backdrop-filter: blur(18px); + box-shadow: 0 18px 40px rgba(23,37,44,.16); + z-index: 120; + } + .mobile-dock-link { + min-height: 58px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: .2rem; + border-radius: 16px; + color: var(--text-muted); + font-size: .72rem; + font-weight: 700; + text-decoration: none; + } + .mobile-dock-link:hover { + text-decoration: none; + } + .mobile-dock-icon { + width: 2rem; + height: 2rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + background: rgba(23,37,44,.06); + color: var(--text); + font-size: .95rem; + } /* Stats */ .stats-bar { gap: .5rem; } .stat { min-width: 75px; padding: .5rem .65rem; } .stat-num { font-size: 1.15rem; } + .stats-bar .stat { flex: 1 1 calc(50% - .5rem); } /* Quick links — single column */ .quick-links { grid-template-columns: 1fr; } @@ -755,11 +1285,23 @@ a.stat.active { border-color: var(--primary); background: #eff6ff; } .drop-zone { padding: 1.5rem .75rem; } .provider-options { flex-direction: column; } .provider-card { min-width: auto; } + .person-actions { flex-direction: column; align-items: stretch; } + .doc-actions { gap: .5rem; } /* Search */ .search-row { flex-direction: column; } .search-input { width: 100%; } .search-grid { grid-template-columns: 1fr 1fr; } + .search-tabs { display: flex; width: 100%; } + .tab-btn { flex: 1; } + + .form-group input, + .form-group select, + .search-input, + .login-card .form-group input, + .props-table input { + font-size: 16px; + } /* Forms */ .form-grid { grid-template-columns: 1fr; } @@ -773,6 +1315,12 @@ a.stat.active { border-color: var(--primary); background: #eff6ff; } border-left: none; border-right: none; } + .table-wrapper-editable { + margin-inline: 0; + border-radius: calc(var(--radius) - 2px); + border-left: 1px solid rgba(23,37,44,.08); + border-right: 1px solid rgba(23,37,44,.08); + } .results-table th, .results-table td, .props-table th, .props-table td { padding: .45rem .5rem; @@ -792,6 +1340,13 @@ a.stat.active { border-color: var(--primary); background: #eff6ff; } .btn { padding: .55rem 1rem; font-size: .9rem; } .btn-large { padding: .65rem 1.25rem; } .btn-sm { padding: .35rem .6rem; } + .export-dropdown { width: 100%; } + .export-dropdown .btn { width: 100%; } + .export-menu { + left: 0; + right: 0; + min-width: 0; + } /* Login */ .login-card { margin: 1.5rem .75rem; padding: 1.5rem; } @@ -811,4 +1366,87 @@ a.stat.active { border-color: var(--primary); background: #eff6ff; } .doc-thumbnails { gap: .5rem; } .doc-thumb { width: 100px; } .doc-thumb img { height: 70px; } + .stats-bar .stat { flex-basis: 100%; } + + .responsive-table { + border-collapse: separate; + border-spacing: 0; + background: transparent; + } + .responsive-table thead { + display: none; + } + .responsive-table tbody, + .responsive-table tr, + .responsive-table td { + display: block; + width: 100%; + } + .responsive-table tbody { + padding: .35rem .75rem .85rem; + } + .responsive-table tr { + margin-bottom: .75rem; + border: 1px solid rgba(23,37,44,.08); + border-radius: 18px; + background: rgba(255,255,255,.96); + box-shadow: 0 10px 24px rgba(23,37,44,.08); + overflow: hidden; + } + .responsive-table td { + display: grid; + grid-template-columns: minmax(92px, 110px) 1fr; + gap: .65rem; + align-items: center; + padding: .7rem .9rem; + text-align: right; + border-bottom: 1px solid rgba(23,37,44,.06); + } + .responsive-table td:last-child { + border-bottom: none; + } + .responsive-table td[data-label]::before { + display: block; + color: var(--text-muted); + font-size: .76rem; + font-weight: 700; + } + .responsive-table .doc-actions, + .responsive-table td[data-label="الإجراءات"] { + display: flex; + flex-wrap: wrap; + justify-content: stretch; + gap: .5rem; + } + .responsive-table td[data-label="الإجراءات"]::before { + width: 100%; + } + .responsive-table .doc-actions .btn, + .responsive-table td[data-label="الإجراءات"] .btn, + .responsive-table td[data-label="الإجراءات"] .inline-form, + .responsive-table td[data-label="الإجراءات"] .inline-form .btn { + flex: 1 1 100%; + width: 100%; + } + .responsive-table .doc-thumb-mini { + width: 72px; + height: 54px; + } + .ownership-alert td { + background: transparent; + } + .ownership-alert { + border-color: #fecaca; + background: #fff8f8; + } + .mobile-dock { + right: .5rem; + left: .5rem; + gap: .35rem; + padding: .45rem; + } + .mobile-dock-link { + min-height: 54px; + font-size: .68rem; + } } diff --git a/static/css/review.css b/static/css/review.css index 0705cdb..aff368c 100644 --- a/static/css/review.css +++ b/static/css/review.css @@ -1,25 +1,27 @@ /* Review page — two-column sticky layout */ .review-layout { display: grid; - grid-template-columns: 1fr 1.4fr; - gap: 1.25rem; + grid-template-columns: minmax(320px, .92fr) minmax(0, 1.4fr); + gap: 1.4rem; align-items: start; } /* Image panel */ .image-panel { position: sticky; - top: 70px; - background: #1f2937; - border-radius: 8px; + top: 88px; + background: linear-gradient(180deg, #1a2e38, #0f1c22); + border-radius: 20px; overflow: hidden; - box-shadow: 0 4px 12px rgba(0,0,0,.2); + box-shadow: 0 16px 48px rgba(15, 28, 34, .28); + border: 1px solid rgba(255,255,255,.06); } .image-toolbar { display: flex; - gap: .4rem; - padding: .5rem .75rem; - background: #111827; + gap: .35rem; + padding: .65rem .75rem; + background: rgba(10, 18, 22, .94); + border-bottom: 1px solid rgba(255,255,255,.06); } .image-container { overflow: auto; @@ -27,15 +29,24 @@ display: flex; align-items: flex-start; justify-content: center; - padding: .5rem; + padding: .9rem; + background: + linear-gradient(45deg, rgba(255,255,255,.03) 25%, transparent 25%), + linear-gradient(-45deg, rgba(255,255,255,.03) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, rgba(255,255,255,.03) 75%), + linear-gradient(-45deg, transparent 75%, rgba(255,255,255,.03) 75%); + background-size: 24px 24px; + background-position: 0 0, 0 12px, 12px -12px, -12px 0; } .doc-image { max-width: 100%; height: auto; display: block; transform-origin: top center; - transition: transform .2s; - border-radius: 4px; + transition: transform .2s cubic-bezier(.4,0,.2,1); + border-radius: 12px; + box-shadow: 0 12px 36px rgba(0,0,0,.32); + background: white; } /* Form panel */ @@ -43,6 +54,27 @@ min-width: 0; } +.table-wrapper-editable { + overflow: visible; +} + +.mobile-edit-table td[data-label]::before { + content: attr(data-label); + display: none; +} + +.review-title { + align-items: flex-start; +} + +.review-title .page-kicker { + margin-bottom: .45rem; +} + +.review-title h2 { + line-height: 1.35; +} + /* ============================ Review — Responsive ============================ */ @@ -57,6 +89,107 @@ .image-container { max-height: 50vh; } + .review-nav { + width: 100%; + } + .review-nav .btn { + flex: 1 1 calc(50% - .5rem); + } +} + +@media (max-width: 640px) { + .review-layout { + gap: 1rem; + } + .image-container { + max-height: 36vh; + padding: .7rem; + } + .image-toolbar { + padding: .65rem; + } + .review-nav .btn { + flex-basis: 100%; + } + .form-section { + padding: 1rem; + } + .table-wrapper-editable { + border: none; + box-shadow: none; + background: transparent; + margin-inline: 0; + } + .mobile-edit-table { + border-collapse: separate; + border-spacing: 0; + background: transparent; + } + .mobile-edit-table thead { + display: none; + } + .mobile-edit-table tbody, + .mobile-edit-table tr, + .mobile-edit-table td { + display: block; + width: 100%; + } + .mobile-edit-table tr { + margin-bottom: .85rem; + padding: .85rem; + border: 1px solid rgba(23,37,44,.08); + border-radius: 18px; + background: rgba(255,255,255,.96); + box-shadow: 0 10px 24px rgba(23,37,44,.08); + } + .mobile-edit-table td { + padding: 0; + border-bottom: none; + margin-bottom: .65rem; + } + .mobile-edit-table td:last-child { + margin-bottom: 0; + } + .mobile-edit-table td[data-label]::before { + display: block; + font-size: .76rem; + font-weight: 700; + color: var(--text-muted); + margin-bottom: .25rem; + } + .mobile-edit-table input { + min-width: 0; + padding: .7rem .8rem; + background: rgba(248,244,236,.72); + border: 1px solid rgba(23,37,44,.08); + } + .mobile-edit-table td[data-label="حذف"] { + margin-top: .2rem; + } + .mobile-edit-table td[data-label="حذف"] .btn-del { + width: 100%; + min-height: 42px; + border: 1px solid #fecaca; + background: #fff5f5; + border-radius: 12px; + } + .form-actions { + position: sticky; + bottom: calc(5.4rem + env(safe-area-inset-bottom)); + z-index: 20; + margin-top: 1rem; + padding: .85rem; + border-radius: 18px 18px 0 0; + box-shadow: 0 -12px 24px rgba(23,37,44,.12); + background: rgba(255,255,255,.96); + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); + padding-bottom: calc(.85rem + env(safe-area-inset-bottom)); + } + .keyboard-hint { + text-align: center; + width: 100%; + } } @media (max-width: 480px) { @@ -66,4 +199,7 @@ .image-toolbar { justify-content: center; } + .image-panel { + border-radius: 18px; + } } diff --git a/templates/base.html b/templates/base.html index 29c73ea..9ad5f1b 100644 --- a/templates/base.html +++ b/templates/base.html @@ -2,26 +2,62 @@ - + + + {% block title %}سجل العقارات اللبناني{% endblock %} + + + {% block head %}{% endblock %} + {% block navbar %} @@ -31,6 +67,33 @@ {% block content %}{% endblock %} + + {% block scripts %}{% endblock %} diff --git a/templates/documents.html b/templates/documents.html index 3de78da..a35c483 100644 --- a/templates/documents.html +++ b/templates/documents.html @@ -3,8 +3,15 @@ {% block content %} {% if uploaded %} @@ -33,12 +40,12 @@ {% if stats.pending_review %}
- ▶ ابدأ مراجعة {{ stats.pending_review }} وثيقة + ابدأ مراجعة {{ stats.pending_review }} وثيقة →
{% endif %}
- +
@@ -53,21 +60,21 @@ {% for d in documents %} - - + - - - - - + + +
#
{{ d.id }} - + {{ d.id }} + + {% if d.person_id %} {{ d.first_name or '' }} {{ d.family_name or '' }} {% else %}—{% endif %} {{ d.request_number or '—' }}{{ d.status }}{{ d.created_at[:10] if d.created_at else '' }} + {{ d.request_number or '—' }}{{ d.status }}{{ d.created_at[:10] if d.created_at else '' }} {% if d.status == 'confirmed' %}عرض{% else %}مراجعة{% endif %} diff --git a/templates/index.html b/templates/index.html index 59342fd..1f50634 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2,9 +2,16 @@ {% block title %}رفع وثائق — سجل العقارات{% endblock %} {% block content %} - {% if stats.pending_review %} {% endif %} {% endif %}
+
+
+

رفع ملفات جديدة

+

اختر دفعة صور أو PDF، ثم حدّد محرك الاستخراج قبل بدء المعالجة.

+
+
@@ -51,7 +66,8 @@ @@ -60,12 +76,22 @@ {% endblock %} @@ -96,7 +122,7 @@ fileInput.addEventListener('change', () => showFiles(fileInput.files)); function showFiles(files) { if (!files.length) return; fileList.innerHTML = Array.from(files).map(f => - `
${f.name.endsWith('.pdf')?'📕':'📄'} ${f.name} ${(f.size/1024).toFixed(0)} KB
` + `
${f.name} ${(f.size/1024).toFixed(0)} KB
` ).join(''); fileList.classList.remove('hidden'); document.getElementById('providerSection').classList.remove('hidden'); @@ -112,7 +138,7 @@ function clearFiles() { } document.getElementById('uploadForm').addEventListener('submit', function() { - document.querySelector('.btn-primary').textContent = '⏳ جارٍ الرفع...'; + document.querySelector('.btn-primary').textContent = 'جارٍ الرفع...'; document.querySelector('.btn-primary').disabled = true; }); diff --git a/templates/login.html b/templates/login.html index 8368d69..5a61c87 100644 --- a/templates/login.html +++ b/templates/login.html @@ -6,7 +6,17 @@ {% block content %} -
diff --git a/templates/person_detail.html b/templates/person_detail.html index a7fe900..233c918 100644 --- a/templates/person_detail.html +++ b/templates/person_detail.html @@ -4,18 +4,28 @@ {% block content %}