add
This commit is contained in:
BIN
app_core/ui/__pycache__/auth_ui.cpython-312.pyc
Normal file
BIN
app_core/ui/__pycache__/auth_ui.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app_core/ui/__pycache__/layout.cpython-312.pyc
Normal file
BIN
app_core/ui/__pycache__/layout.cpython-312.pyc
Normal file
Binary file not shown.
37
app_core/ui/auth_ui.py
Normal file
37
app_core/ui/auth_ui.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import re
|
||||
import streamlit as st
|
||||
from app_core.services.auth_service import AuthService
|
||||
|
||||
|
||||
def _is_valid_email(value: str) -> bool:
|
||||
if not value:
|
||||
return False
|
||||
pattern = r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$"
|
||||
return re.match(pattern, value.strip()) is not None
|
||||
|
||||
|
||||
def render_auth_card(auth_service: AuthService) -> None:
|
||||
left, center, right = st.columns([1, 1.2, 1])
|
||||
with center:
|
||||
|
||||
st.markdown('<div class="auth-title">Welcome !!</div>', unsafe_allow_html=True)
|
||||
st.markdown('<div class="muted" style="margin-bottom:16px;">Sign in to continue</div>', unsafe_allow_html=True)
|
||||
|
||||
with st.form("login_form", clear_on_submit=False):
|
||||
email = st.text_input("Email", placeholder="you@example.com", key="login_email")
|
||||
password = st.text_input("Password", type="password", placeholder="••••••••", key="login_password")
|
||||
submitted = st.form_submit_button("Sign in →", use_container_width=True)
|
||||
if submitted:
|
||||
if not _is_valid_email(email):
|
||||
st.error("Enter a valid email address.")
|
||||
elif not password:
|
||||
st.error("Password is required.")
|
||||
else:
|
||||
ok, user, msg = auth_service.login(email, password)
|
||||
if ok and user:
|
||||
st.session_state.auth_user = user
|
||||
st.success(msg)
|
||||
st.rerun()
|
||||
else:
|
||||
st.error(msg)
|
||||
st.markdown('</div>', unsafe_allow_html=True)
|
||||
314
app_core/ui/layout.py
Normal file
314
app_core/ui/layout.py
Normal file
@@ -0,0 +1,314 @@
|
||||
import streamlit as st
|
||||
from app_core.config.settings import STORES, STORE_CODE_TO_TENANT_ID
|
||||
|
||||
def apply_global_style(background_url: str | None = None) -> None:
|
||||
css = """
|
||||
<style>
|
||||
:root { --brand: ; --brandDark: #0F4FD6; --text: #0F172A; --muted: #64748b; --border: rgba(15, 23, 42, 0.08); --inputBorder: rgba(22,98,243,0.35); }
|
||||
.stApp { background: transparent !important; position: relative !important; min-height: 100vh; font-size: 17px; }
|
||||
[data-testid="stAppViewContainer"] { background: transparent !important; }
|
||||
[data-testid="stAppViewContainer"] > header, [data-testid="stHeader"], .stApp header { background: transparent !important; box-shadow: none !important; }
|
||||
.stAppToolbar { background: transparent !important; box-shadow: none !important; border-bottom: none !important; }
|
||||
|
||||
|
||||
/* Header sections */
|
||||
.tfw-header { background: #FFFFFF; border-bottom: 1px solid var(--border); }
|
||||
.tfw-header-white { background: #FFFFFF; padding: 16px 0; }
|
||||
.tfw-header-grey { background: #F8FAFC; padding: 12px 0; border-top: 1px solid rgba(15, 23, 42, 0.00); }
|
||||
.tfw-header-content { max-width: 1200px; margin: 0 auto; padding: 0 20px; }
|
||||
|
||||
/* Topbar (same height, narrower sides) */
|
||||
.tfw-topbar { position: fixed; top: 0; left: 0; right: 0; height: 48px; background: rgba(248,250,252,0.6); backdrop-filter: saturate(180%) border-bottom: none; z-index: 999; }
|
||||
.tfw-topbar .tfw-inner { height: 48px; display: flex; align-items: center; gap: 8px; padding: 0 8px; max-width: 1100px; margin: 0 auto; }
|
||||
.tfw-logo { width: 18px; height: 18px; border-radius: 6px; background: linear-gradient(135deg, var(--brand), var(--brandDark)); display: inline-block; }
|
||||
.tfw-title { font-weight: 800; color: var(--text); letter-spacing: -0.02em; font-size: 1.0rem; }
|
||||
|
||||
/* Sidebar enhancements */
|
||||
[data-testid="stSidebar"] .sidebar-content { display: flex; flex-direction: column; height: 100%; font-size: 1rem; }
|
||||
[data-testid="stSidebar"] .sidebar-section { padding: 8px 6px; }
|
||||
[data-testid="stSidebar"] .sidebar-spacer { flex: 1 1 auto; }
|
||||
[data-testid="stSidebar"] .sidebar-logout { padding: 10px 6px; border-top: 1px solid var(--border); }
|
||||
[data-testid="stSidebar"] .sidebar-logout button { width: 100%; border-radius: 10px; }
|
||||
|
||||
/* Sidebar logo */
|
||||
.sidebar-logo { display: flex; align-items: center; gap: 12px; padding: 16px 20px; border-bottom: 1px solid var(--border); margin-bottom: 8px; }
|
||||
.sidebar-logo-icon { width: 32px; height: 32px; border-radius: 8px; overflow: hidden; }
|
||||
.sidebar-logo-icon img { width: 100%; height: 100%; object-fit: contain; }
|
||||
.sidebar-logo-text { font-weight: 800; color: var(--text); font-size: 1.1rem; letter-spacing: -0.02em; }
|
||||
|
||||
/* Auth card (extra-slim, centered) */
|
||||
.auth-card { position: relative; padding: 24px; background: rgba(255,255,255,0.85); backdrop-filter: blur(8px); border: 1px solid rgba(22,98,240,0.22); border-radius: 16px; box-shadow: 0 18px 40px rgba(2,6,23,0.10); transition: box-shadow .2s ease, transform .2s ease; max-width: 520px; width: 100%; margin: 0 auto; }
|
||||
.auth-card.auth-slim { max-width: 420px; }
|
||||
.auth-card.auth-xs { max-width: 360px; }
|
||||
.auth-card::before { content: ""; position: absolute; top: 0; left: 0; right: 0; height: 6px; background: linear-gradient(90deg, #22C55E, #16A34A, #0ea5e9); border-top-left-radius: 16px; border-top-right-radius: 16px; }
|
||||
.auth-card:hover { box-shadow: 0 26px 56px rgba(2,6,23,0.16); transform: translateY(-2px); }
|
||||
|
||||
/* Success ribbon shown on login */
|
||||
.login-success { background: linear-gradient(90deg, #22C55E, #16A34A); color: #fff; border-radius: 12px; padding: 10px 14px; box-shadow: 0 10px 24px rgba(34,197,94,0.35); display: flex; align-items: center; gap: 8px; font-weight: 700; }
|
||||
.login-success .emoji { filter: drop-shadow(0 4px 8px rgba(0,0,0,0.2)); }
|
||||
|
||||
.auth-title { margin: 6px 0 8px 0; font-size: 1.8rem; font-weight: 800; color: var(--text); letter-spacing: -0.02em; text-align: center; }
|
||||
.muted { color: var(--muted); font-size: 1.0rem; text-align: center; }
|
||||
|
||||
/* Inputs: light blue border (global) */
|
||||
div[data-testid="stTextInput"] input,
|
||||
div[data-testid="stPassword"] input,
|
||||
textarea {
|
||||
border-radius: 10px !important;
|
||||
border: 1px solid var(--inputBorder) !important;
|
||||
box-shadow: inset 0 1px 2px rgba(2,6,23,0.04) !important;
|
||||
background: #FFFFFF !important;
|
||||
}
|
||||
/* Prevent outer wrapper hover/focus rings (avoid double boxes) */
|
||||
div[data-baseweb="input"]:hover, div[data-baseweb="input"]:focus-within,
|
||||
div[data-baseweb="textarea"]:hover, div[data-baseweb="textarea"]:focus-within,
|
||||
div[data-testid="stTextInput"] > div:hover, div[data-testid="stTextInput"] > div:focus-within,
|
||||
div[data-testid="stTextInput"] > div > div:hover, div[data-testid="stTextInput"] > div > div:focus-within,
|
||||
div[data-testid="stPassword"] > div:hover, div[data-testid="stPassword"] > div:focus-within,
|
||||
div[data-testid="stPassword"] > div > div:hover, div[data-testid="stPassword"] > div > div:focus-within {
|
||||
outline: none !important; box-shadow: none !important; border-color: transparent !important;
|
||||
}
|
||||
/* Subtle inner hover/focus on the actual field only */
|
||||
div[data-testid="stTextInput"] input:hover,
|
||||
div[data-testid="stPassword"] input:hover,
|
||||
textarea:hover { border-color: var(--inputBorder) !important; box-shadow: inset 0 0 0 1px rgba(22,98,243,0.25) !important; }
|
||||
div[data-testid="stTextInput"] input:focus,
|
||||
div[data-testid="stPassword"] input:focus,
|
||||
textarea:focus { outline: none !important; border-color: var(--inputBorder) !important; box-shadow: inset 0 0 0 1px rgba(22,98,243,0.45) !important; }
|
||||
|
||||
/* Constrain Streamlit form width regardless of dynamic class names */
|
||||
form[class*="stForm"], div[class*="stForm"] { max-width: 760px !important; margin-left: auto !important; margin-right: auto !important; padding-left: 8px !important; padding-right: 8px !important; }
|
||||
|
||||
/* Password field styling - expand to match email box and position eye icon */
|
||||
div[data-testid="stPassword"] { width: 100% !important; }
|
||||
div[data-testid="stPassword"] input { width: 100% !important; padding-right: 60px !important; }
|
||||
div[data-testid="stPassword"] button { position: absolute !important; right: 8px !important; top: 50% !important; transform: translateY(-50%) !important; background: none !important; border: none !important; padding: 4px !important; margin: 0 !important; }
|
||||
div[data-testid="stPassword"] button:hover { background: rgba(0,0,0,0.05) !important; border-radius: 4px !important; }
|
||||
|
||||
/* Buttons: global size + hover/transition */
|
||||
.stButton > button, [data-testid="stDownloadButton"] button { height: 40px; font-size: 0.95rem; border-radius: 10px; transition: transform .15s ease, box-shadow .15s ease; }
|
||||
.stButton > button:hover, [data-testid="stDownloadButton"] button:hover { transform: translateY(-1px); box-shadow: 0 10px 18px rgba(22,98,243,0.25); }
|
||||
|
||||
.auth-card .stCheckbox { font-size: 1.0rem; }
|
||||
|
||||
/* Auth buttons inherit global size but keep gradient */
|
||||
.auth-card .stButton > button { background: linear-gradient(135deg, #22C55E, #16A34A); color: #fff; font-weight: 800; letter-spacing: .2px; border: none; box-shadow: 0 10px 18px rgba(34,197,94,0.28); }
|
||||
.auth-card .stButton > button:hover { filter: brightness(0.98); }
|
||||
|
||||
/* Match info alert with content card look */
|
||||
div[role="alert"] { background: #F6FAFF !important; border: 1px solid rgba(22,98,243,0.18) !important; color: var(--text) !important; }
|
||||
|
||||
/* DataFrame font sizes */
|
||||
div[data-testid="stDataFrame"] table { font-size: 0.98rem; }
|
||||
div[data-testid="stDataFrame"] th { font-size: 1.0rem; }
|
||||
</style>
|
||||
"""
|
||||
st.markdown(css, unsafe_allow_html=True)
|
||||
|
||||
# Optional login/background image with 50% transparency
|
||||
if background_url:
|
||||
# Support @prefix and local files by embedding as base64 when needed
|
||||
try:
|
||||
import os, base64
|
||||
url = background_url.lstrip('@').strip()
|
||||
if url.startswith('http://') or url.startswith('https://'):
|
||||
data_url = url
|
||||
else:
|
||||
# Treat as local file path
|
||||
# Map shorthand names to assets/ if needed
|
||||
if url in {"bg.jpg", "workolik.png"}:
|
||||
url = os.path.join("assets", url)
|
||||
if os.path.exists(url):
|
||||
ext = os.path.splitext(url)[1].lower()
|
||||
mime = 'image/jpeg' if ext in ['.jpg', '.jpeg'] else 'image/png' if ext == '.png' else 'image/webp' if ext == '.webp' else 'image/*'
|
||||
with open(url, 'rb') as f:
|
||||
b64 = base64.b64encode(f.read()).decode()
|
||||
data_url = f'data:{mime};base64,{b64}'
|
||||
else:
|
||||
data_url = url # fallback; let browser try
|
||||
st.markdown(
|
||||
f"""
|
||||
<style>
|
||||
.stApp::before {{
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background-image: url('{data_url}');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
opacity: 0.5; /* 50% transparent */
|
||||
filter: saturate(110%);
|
||||
}}
|
||||
</style>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def render_header(brand_name: str = "Workolik") -> None:
|
||||
st.markdown(
|
||||
f"""
|
||||
<div class="tfw-header">
|
||||
|
||||
</div>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
|
||||
def render_topbar(brand_name: str = "") -> None:
|
||||
st.markdown(
|
||||
f"""
|
||||
<div class=\"tfw-topbar\">\n <div class=\"tfw-inner\">\n <span class=\"tfw-logo\"></span>\n <span class=\"tfw-title\">{brand_name}</span>\n </div>\n</div>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
|
||||
def render_sidebar_logo(brand_name: str = "Workolik") -> None:
|
||||
import streamlit as st
|
||||
import base64
|
||||
import os
|
||||
|
||||
try:
|
||||
# Read the image file and encode it as base64
|
||||
logo_path = os.path.join("assets", "workolik.png")
|
||||
if os.path.exists(logo_path):
|
||||
with open(logo_path, "rb") as img_file:
|
||||
img_data = base64.b64encode(img_file.read()).decode()
|
||||
|
||||
st.markdown(
|
||||
f"""
|
||||
<div class="sidebar-logo">
|
||||
<div class="sidebar-logo-icon">
|
||||
<img src="data:image/png;base64,{img_data}" alt="Workolik Logo" style="width: 100%; height: 100%; object-fit: contain;" />
|
||||
</div>
|
||||
<div class="sidebar-logo-text">{brand_name}</div>
|
||||
</div>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
else:
|
||||
raise FileNotFoundError("Logo file not found")
|
||||
except Exception as e:
|
||||
# Fallback to text logo if image fails to load
|
||||
st.markdown(
|
||||
f"""
|
||||
<div class="sidebar-logo">
|
||||
<div class="sidebar-logo-icon" style="background: linear-gradient(135deg, var(--brand), var(--brandDark)); display: flex; align-items: center; justify-content: center; color: white; font-weight: 700; font-size: 14px;">TW</div>
|
||||
<div class="sidebar-logo-text">{brand_name}</div>
|
||||
</div>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
|
||||
def render_store_selector() -> tuple[int | None, str | None]:
|
||||
"""Render a compact, classy store selector box.
|
||||
|
||||
Returns (tenant_id, label). Also persists selection in session_state.
|
||||
"""
|
||||
st.markdown(
|
||||
"""
|
||||
<div style="
|
||||
margin: 8px 0 12px 0; padding: 16px 18px;
|
||||
border: 1px solid var(--border);
|
||||
background: linear-gradient(135deg,#ffffff, #f8fbff);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 24px rgba(2,6,23,0.06);
|
||||
">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
|
||||
<div style="display:flex; align-items:center; gap:10px; color: var(--text);">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:999px;background:linear-gradient(135deg,#22C55E,#16A34A);box-shadow:0 4px 10px rgba(34,197,94,0.25);">🛍️</span>
|
||||
<div style="font-weight: 700; letter-spacing:-0.01em;">Choose the store you want to view</div>
|
||||
<span style="opacity:0.9">✨</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:#64748b;">👉 Click a card to select</div>
|
||||
</div>
|
||||
</div>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
# Sub caption + clear selection
|
||||
current_label = st.session_state.get("store_label")
|
||||
left_cap, right_clear = st.columns([6, 1])
|
||||
with left_cap:
|
||||
st.caption("Please choose a store before surfing !")
|
||||
if current_label:
|
||||
st.caption(f"Selected: {current_label}")
|
||||
with right_clear:
|
||||
if st.button("Clear", key="clear_store_sel"):
|
||||
st.session_state["tenant_id"] = None
|
||||
st.session_state["store_label"] = None
|
||||
st.experimental_rerun() if hasattr(st, "experimental_rerun") else st.rerun()
|
||||
|
||||
# We no longer use query params; selection happens in-session only
|
||||
chosen_from_query: str | None = None
|
||||
|
||||
# Grid of store boxes (soft gradient cards with per-store colors and emojis)
|
||||
emoji_map = {"PC": "🍷", "IP": "🍻", "ML": "🥂", "CL4": "🍸", "NL": "🥃", "CL6": "🍾", "RC": "🍹", "PL": "🍺"}
|
||||
|
||||
color_rgb = {"PC": (37,99,235), "IP": (22,163,74), "ML": (245,158,11), "CL4": (220,38,38), "NL": (124,58,237), "CL6": (234,179,8), "RC": (6, 182, 212), "PL": (236, 72, 153)}
|
||||
preselect_label = st.session_state.get("store_label")
|
||||
chosen_label = None
|
||||
# No search box; show all stores
|
||||
filtered_stores = STORES
|
||||
# Always render 3 columns per row (e.g., 3 + 3 for 6 stores)
|
||||
rows = [filtered_stores[i:i+3] for i in range(0, len(filtered_stores), 3)]
|
||||
for row in rows:
|
||||
cols = st.columns(3)
|
||||
for i, store in enumerate(row):
|
||||
with cols[i]:
|
||||
icon = emoji_map.get(store["code"], "🏬")
|
||||
r, g, b = color_rgb.get(store["code"], (14,165,233))
|
||||
is_selected = (preselect_label == store["label"]) or (chosen_from_query == store["label"]) # highlight current
|
||||
border_alpha = 0.48 if is_selected else 0.28
|
||||
shadow = "0 18px 42px rgba(2,6,23,0.16)" if is_selected else "0 12px 28px rgba(2,6,23,0.10)"
|
||||
border_width = "2px" if is_selected else "1px"
|
||||
check = " ✅" if is_selected else ""
|
||||
# Render a card-like button that sets selection without changing URL
|
||||
clicked = st.button(
|
||||
f"{icon} {store['label']}{check}",
|
||||
key=f"store_card_{store['code']}",
|
||||
use_container_width=True,
|
||||
type="secondary",
|
||||
)
|
||||
# Lightweight card styling via inline CSS targeting this button
|
||||
st.markdown(
|
||||
f"""
|
||||
<style>
|
||||
div[data-testid='stButton'] button#store_card_{store['code']} {{
|
||||
background: linear-gradient(135deg, rgba({r},{g},{b},0.12), rgba({r},{g},{b},0.20));
|
||||
border: {border_width} solid rgba({r},{g},{b},{border_alpha});
|
||||
border-radius: 18px; padding: 18px; box-shadow: {shadow};
|
||||
color: #0F172A; font-weight: 800; text-align: left;
|
||||
}}
|
||||
div[data-testid='stButton'] button#store_card_{store['code']}:hover {{
|
||||
transform: translateY(-2px); box-shadow: 0 22px 48px rgba(2,6,23,0.18);
|
||||
}}
|
||||
</style>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
if clicked:
|
||||
st.session_state["tenant_id"] = store["tenant_id"]
|
||||
st.session_state["store_label"] = store["label"]
|
||||
chosen_label = store["label"]
|
||||
st.rerun()
|
||||
|
||||
# Resolve tenant_id
|
||||
effective_label = chosen_label or preselect_label
|
||||
selected = next((s for s in STORES if s["label"] == effective_label), None)
|
||||
tenant_id = selected["tenant_id"] if selected else None
|
||||
|
||||
# Persist
|
||||
st.session_state["tenant_id"] = tenant_id
|
||||
st.session_state["store_label"] = selected["label"] if selected else None
|
||||
st.session_state["division_code"] = None
|
||||
|
||||
return tenant_id, (selected["label"] if selected else None)
|
||||
Reference in New Issue
Block a user