| """ |
| ui_components.py — HTML card builders for each legal source type + tab panel. |
| Doctrine.fr-inspired design: clean white cards, subtle borders, navy accents. |
| """ |
|
|
| |
| _CARD_STYLE = ( |
| "border:1px solid #e2e8f0;border-radius:10px;padding:16px 20px;" |
| "margin-bottom:12px;background:#ffffff;" |
| "box-shadow:0 1px 3px rgba(0,0,0,0.04);" |
| "transition:box-shadow 0.15s,border-color 0.15s" |
| ) |
|
|
| |
| _BADGE = { |
| "article": ("background:#dbeafe;color:#1e40af", "Code"), |
| "decision": ("background:#fce7f3;color:#9d174d", "Jurisprudence"), |
| "circulaire": ("background:#d1fae5;color:#065f46", "Circulaire"), |
| "reponse": ("background:#fef3c7;color:#92400e", "Q&R"), |
| } |
|
|
|
|
| def _badge(style: str, text: str) -> str: |
| return ( |
| f'<span style="{style};font-size:11px;font-weight:600;' |
| f'padding:3px 10px;border-radius:20px;letter-spacing:0.2px">{text}</span>' |
| ) |
|
|
|
|
| def _meta_line(*parts: str) -> str: |
| items = [p for p in parts if p] |
| return ( |
| '<div style="font-size:12px;color:#94a3b8;display:flex;align-items:center;gap:6px">' |
| + " · ".join(items) |
| + '</div>' |
| ) |
|
|
|
|
| def build_article_card(result: dict, related_decisions: list[dict] | None = None) -> str: |
| code = result.get("code_name", "Code") |
| num = result.get("num", result.get("id_legifrance", "")) |
| snippet = (result.get("chunk_text") or "")[:250] |
| lf_id = result.get("id_legifrance", "") |
| url = f"https://www.legifrance.gouv.fr/codes/article_lc/{lf_id}" if lf_id else "#" |
| etat = result.get("article_etat", "") |
|
|
| etat_html = "" |
| if etat: |
| color = "#059669" if etat == "VIGUEUR" else "#94a3b8" |
| etat_html = f'<span style="font-size:11px;color:{color};font-weight:500">{etat}</span>' |
|
|
| cross_ref_html = "" |
| if related_decisions: |
| mini_cards = "" |
| for dec in related_decisions: |
| dec_url = dec.get("url_judilibre", "#") |
| dec_date = dec.get("date_decision", "") |
| dec_jur = dec.get("jurisdiction", "") |
| dec_snip = dec.get("chunk_text", "")[:120] |
| mini_cards += f""" |
| <div style="border-left:3px solid #1e3a5f;padding:8px 12px;margin-top:8px; |
| font-size:12px;color:#334155;background:#f8fafc;border-radius:0 6px 6px 0"> |
| <strong>{dec_jur}</strong> · {dec_date} |
| <div style="color:#64748b;margin-top:3px">{dec_snip}…</div> |
| <a href="{dec_url}" target="_blank" |
| style="color:#1e3a5f;font-size:11px;text-decoration:none;font-weight:500">Voir la decision →</a> |
| </div>""" |
| n = len(related_decisions) |
| cross_ref_html = f""" |
| <details style="margin-top:10px"> |
| <summary style="cursor:pointer;color:#1e3a5f;font-size:13px;font-weight:600"> |
| {n} decision{"s" if n > 1 else ""} liee{"s" if n > 1 else ""} |
| </summary> |
| {mini_cards} |
| </details>""" |
|
|
| return f""" |
| <div style="{_CARD_STYLE}"> |
| <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap"> |
| {_badge(*_BADGE["article"][:1], code)} |
| <strong style="font-size:15px;color:#1e293b;letter-spacing:-0.2px">Art. {num}</strong> |
| {etat_html} |
| </div> |
| <p style="font-size:13.5px;color:#475569;margin:0 0 10px;line-height:1.65">{snippet}…</p> |
| {_meta_line(f'<a href="{url}" target="_blank" style="color:#1e3a5f;text-decoration:none;font-weight:500">Legifrance →</a>')} |
| {cross_ref_html} |
| </div>""" |
|
|
|
|
| def build_decision_card(result: dict) -> str: |
| juris = result.get("jurisdiction", "") |
| chamber = result.get("chamber", "") |
| date = result.get("date_decision", "") |
| fiche = result.get("fiche_arret") or "" |
| snippet = fiche[:250] if fiche else (result.get("chunk_text") or "")[:250] |
| url = result.get("url_judilibre", "#") |
| src_id = result.get("source_id", "") |
|
|
| label = juris + (f" · {chamber}" if chamber else "") |
|
|
| return f""" |
| <div style="{_CARD_STYLE}"> |
| <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap"> |
| {_badge(_BADGE["decision"][0], label)} |
| <strong style="font-size:14px;color:#1e293b">{date}</strong> |
| {f'<span style="font-size:11px;color:#94a3b8">n° {src_id}</span>' if src_id else ""} |
| </div> |
| <p style="font-size:13.5px;color:#475569;margin:0 0 10px;line-height:1.65">{snippet}…</p> |
| {_meta_line(f'<a href="{url}" target="_blank" style="color:#1e3a5f;text-decoration:none;font-weight:500">Cour de cassation →</a>')} |
| </div>""" |
|
|
|
|
| def build_circulaire_card(result: dict) -> str: |
| ministere = result.get("ministere", "") |
| numero = result.get("numero", result.get("source_id", "")) |
| objet = (result.get("objet") or result.get("chunk_text") or "")[:250] |
| date = (result.get("date_parution") or "")[:10] |
| url = result.get("url_legifrance", "#") |
|
|
| return f""" |
| <div style="{_CARD_STYLE}"> |
| <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap"> |
| {_badge(_BADGE["circulaire"][0], f"Min. {ministere}")} |
| <strong style="font-size:14px;color:#1e293b">Circ. n° {numero}</strong> |
| </div> |
| <p style="font-size:13.5px;color:#475569;margin:0 0 10px;line-height:1.65">{objet}…</p> |
| {_meta_line(date, f'<a href="{url}" target="_blank" style="color:#1e3a5f;text-decoration:none;font-weight:500">Legifrance →</a>')} |
| </div>""" |
|
|
|
|
| def build_reponse_card(result: dict) -> str: |
| ministere = result.get("ministere", "") |
| num_q = result.get("numero_question", result.get("source_id", "")) |
| question = (result.get("question_text") or result.get("chunk_text") or "")[:250] |
| date = (result.get("date_reponse") or "")[:10] |
| url = result.get("url_legifrance", "#") |
|
|
| return f""" |
| <div style="{_CARD_STYLE}"> |
| <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap"> |
| {_badge(_BADGE["reponse"][0], ministere)} |
| <strong style="font-size:14px;color:#1e293b">Q. n° {num_q}</strong> |
| </div> |
| <p style="font-size:13.5px;color:#475569;margin:0 0 10px;line-height:1.65">{question}…</p> |
| {_meta_line(date, f'<a href="{url}" target="_blank" style="color:#1e3a5f;text-decoration:none;font-weight:500">Legifrance →</a>')} |
| </div>""" |
|
|
|
|
| def build_tabs_html(results_dict: dict, loading_status: dict) -> str: |
| tabs_config = [ |
| ("articles", "Articles", build_article_card), |
| ("jurisprudence", "Jurisprudence", build_decision_card), |
| ("circulaires", "Circulaires", build_circulaire_card), |
| ("reponses", "Q&R", build_reponse_card), |
| ] |
|
|
| tab_buttons = "" |
| tab_panels = "" |
|
|
| for i, (key, label, builder) in enumerate(tabs_config): |
| results = results_dict.get(key, []) |
| count = len(results) |
|
|
| |
| if i == 0: |
| bg, color, weight, border = "#e8f0fe", "#1e3a5f", "600", "2px solid #1e3a5f" |
| else: |
| bg, color, weight, border = "transparent", "#64748b", "400", "2px solid transparent" |
|
|
| tab_buttons += f""" |
| <button onclick="showTab('{key}')" id="tab-btn-{key}" |
| style="padding:10px 20px;border:none;background:{bg}; |
| color:{color};font-weight:{weight}; |
| border-bottom:{border}; |
| cursor:pointer;font-size:14px;border-radius:6px 6px 0 0; |
| transition:all 0.15s;letter-spacing:-0.1px"> |
| {label} ({count}) |
| </button>""" |
|
|
| if not loading_status.get(key, False): |
| content = '<p style="color:#94a3b8;font-style:italic;padding:24px 0">Source temporairement indisponible</p>' |
| elif not results: |
| content = '<p style="color:#94a3b8;font-style:italic;padding:24px 0">Aucun resultat pour cette source.</p>' |
| else: |
| content = "".join(builder(r) for r in results) |
|
|
| display = "block" if i == 0 else "none" |
| tab_panels += f""" |
| <div id="tab-{key}" style="display:{display};padding:16px 0"> |
| {content} |
| </div>""" |
|
|
| js = """ |
| <script> |
| function showTab(key) { |
| ['articles','jurisprudence','circulaires','reponses'].forEach(k => { |
| document.getElementById('tab-' + k).style.display = (k === key) ? 'block' : 'none'; |
| var btn = document.getElementById('tab-btn-' + k); |
| btn.style.background = (k === key) ? '#e8f0fe' : 'transparent'; |
| btn.style.color = (k === key) ? '#1e3a5f' : '#64748b'; |
| btn.style.fontWeight = (k === key) ? '600' : '400'; |
| btn.style.borderBottom = (k === key) ? '2px solid #1e3a5f' : '2px solid transparent'; |
| }); |
| } |
| </script>""" |
|
|
| return f""" |
| <div style="font-family:'Inter',system-ui,sans-serif"> |
| <div style="border-bottom:1px solid #e2e8f0;display:flex;gap:2px;margin-bottom:4px"> |
| {tab_buttons} |
| </div> |
| {tab_panels} |
| {js} |
| </div>""" |
|
|