""" components/cards.py -------------------- 所有卡片元件的單一來源。 公開 API: create_chapter_card(data) → dmc.Card (章節卡片,原 Chapter_card.py) render_card_from_node(card_node) → dmc.Card (結構化 node 卡片,新版) generate_custom_markdown_card(...) → dmc.Card (舊版 Markdown 字串卡片,保留供過渡期使用) 遷移建議: - 新頁面請使用 render_card_from_node(接受 JSON 結構化資料) - generate_custom_markdown_card 標記為 deprecated,待舊頁面遷移完成後刪除 """ from __future__ import annotations from typing import Any from dash import html, dcc import dash_mantine_components as dmc from dash_iconify import DashIconify from components.render_utils import _inline_math, _block_math, _runs_to_children try: import dash_latex DashLatex = dash_latex.DashLatex except Exception: DashLatex = None # --------------------------------------------------------------------------- # 1. 章節卡片(原 Chapter_card.py) # --------------------------------------------------------------------------- def create_chapter_card(data: dict) -> dmc.Card: """ 產生章節總覽用的卡片。 data 欄位: title str 卡片標題 badges List[Tuple[str, str]] [(label, color), ...] description List[str] 說明文字(最多 4 行) link str 「單元學習」按鈕連結 test str 「小測驗」按鈕連結 trick str (選用) 「技巧學習」按鈕連結 """ buttons = [ dmc.Anchor( href=data["link"], children=dmc.Button( "單元學習", variant="light", color="orange", size="sm", radius="xl" ), ) ] if "trick" in data: buttons.append( dmc.Anchor( href=data["trick"], children=dmc.Button( "技巧學習", variant="outline", color="gray", size="sm", radius="xl" ), ) ) buttons.append( dmc.Anchor( href=data["test"], children=dmc.Button( "小測驗", variant="filled", color="blue", size="sm", radius="xl" ), ) ) # 說明文字補足到 4 行,保持卡片高度一致 desc_items = [ dmc.Text(line, size="sm", c="dimmed") for line in data["description"] ] desc_items += [ dmc.Text(" ", size="sm") for _ in range(4 - len(data["description"])) ] return dmc.Card( children=[ dmc.Text(data["title"], fw=700, size="lg"), dmc.Group( gap="xs", mt="xs", mb="sm", children=[ dmc.Badge(label, color=color, radius="md") for label, color in data["badges"] ], ), dmc.Stack(gap=0, style={"minHeight": 100}, children=desc_items), dmc.Flex( gap="sm", wrap="wrap", justify="flex-start", mt="sm", children=buttons, ), ], withBorder=True, shadow="sm", radius="md", style={ "width": { "base": 280, "sm": 320, "md": 350, "lg": 400, } }, ) # --------------------------------------------------------------------------- # 2. 結構化 node 卡片(原 generate_common_card.py:render_card_from_node) # --------------------------------------------------------------------------- def render_card_from_node(card_node: dict) -> dmc.Card: """ 把 Test2ChMd 產生的卡片 JSON node 渲染成 DMC Card。 card_node 格式: { "kind": str, # 卡片種類(例如「定義」「定理」) "headline": str, # 標題文字 "body": List[block], # 內容 block 清單 } body block 支援的 type: paragraph / list / math / url / picture / horizontal_rule """ kind = (card_node.get("kind") or "卡片").strip() headline = (card_node.get("headline") or "").strip() header = dmc.CardSection( dmc.Group( [ dmc.Badge(kind, color="violet", variant="filled"), dmc.Text(headline, fw=700, style={"whiteSpace": "pre-wrap"}), ], gap="sm", ), withBorder=True, inheritPadding=True, py=6, style={ "backgroundColor": dmc.DEFAULT_THEME["colors"]["violet"][7], "color": "white", }, ) body_children: list[Any] = [] for b in card_node.get("body", []): t = (b.get("type") or "").lower() if t == "paragraph": body_children.append( html.Div(_runs_to_children(b.get("runs") or [])) ) elif t == "list": items = [ dmc.ListItem(_runs_to_children(it.get("runs") or [])) for it in b.get("content", []) ] body_children.append( dmc.List(items, listStyleType="none", spacing="xs", withPadding=True) ) elif t == "math": body_children.append(_block_math(b.get("latex", ""))) elif t == "url": body_children.append( dcc.Markdown( f"[{b.get('text')}]({b.get('href')})", link_target="_blank" ) ) elif t == "picture": body_children.append( html.Img( src=b.get("src"), title=b.get("alt") or "", style={ "display": "inline-block", "margin": "6px", "maxWidth": "100%", "height": "auto", }, ) ) elif t == "horizontal_rule": body_children.append(dmc.Divider(my=12)) return dmc.Card( [header, dmc.Stack(body_children, gap="xs", px="md", py="sm")], withBorder=True, shadow="md", radius="md", mb=14, ) # --------------------------------------------------------------------------- # 3. 舊版 Markdown 字串卡片(deprecated,保留至頁面遷移完成) # --------------------------------------------------------------------------- def generate_custom_markdown_card( title: str, paragraphs: list[dict], card_color: str = "#f8f9fa", title_bg_color: str = dmc.DEFAULT_THEME["colors"]["green"][7], latex_list: list[str] | None = None, ) -> dmc.Card: """ .. deprecated:: 請改用 render_card_from_node。 此函式將在所有舊頁面遷移完成後刪除。 paragraphs 格式: [{"text": str, "align": "left|center|right", "color": str}, ...] latex_list 格式: ["latex_str_1", "latex_str_2", ...] (有序清單) """ def _render_paragraph(p: dict) -> html.Div: return html.Div( dcc.Markdown(p["text"], mathjax=True), style={ "textAlign": p.get("align", "left"), "color": p.get("color", "black"), "marginBottom": "0.5rem", }, ) content: list[Any] = [_render_paragraph(p) for p in paragraphs] if latex_list: # 需要 dash_latex;若未安裝則退回純文字 list_items = [] for latex in latex_list: if DashLatex: list_items.append( dmc.ListItem(DashLatex(f"${latex}$"), mt=10) ) else: list_items.append( dmc.ListItem(dcc.Markdown(f"${latex}$", mathjax=True), mt=10) ) content.append( dmc.List( children=list_items, listStyleType="ordered", spacing="sm", ) ) return dmc.Card( children=[ dmc.CardSection( dmc.Group( children=[ dmc.Text( title, fw=500, style={"color": dmc.DEFAULT_THEME["colors"]["gray"][0]}, ) ] ), withBorder=True, inheritPadding=True, py=3, style={"backgroundColor": title_bg_color}, ), html.Div(children=content, style={"padding": "10px"}), ], withBorder=True, shadow="md", radius="md", style={"backgroundColor": card_color}, )