SJLee-0525
[TEST] test29
8374119
"""
์‹คํŒจ ๊ธฐ๋ก ํ‘œ์‹œ ์ปดํฌ๋„ŒํŠธ
์‹œ๋„ ๊ธฐ๋ก์„ HTML๋กœ ๋ Œ๋”๋ง
๐Ÿ‘จโ€๐Ÿ’ป ๋‹ด๋‹น: ๊ฐœ๋ฐœ์ž B
"""
import random
import gradio as gr
from frontend.renderers.radar_chart import render_radar_chart
class HistoryDisplayComponent:
"""์‹คํŒจ ๊ธฐ๋ก ํ‘œ์‹œ ์ปดํฌ๋„ŒํŠธ"""
def __init__(self):
self.history_html = None
self.give_up_trigger_btn = None
def render(self):
"""
์‹คํŒจ ๊ธฐ๋ก UI ๋ Œ๋”๋ง
๊ธฐ๋ก์ด ์—†์œผ๋ฉด ์•„๋ฌด๊ฒƒ๋„ ํ‘œ์‹œํ•˜์ง€ ์•Š์Œ
Returns:
gr.HTML: history_html ์ปดํฌ๋„ŒํŠธ
"""
self.history_html = gr.HTML(value="")
# visible=False๋ฉด DOM์— ์ƒ์„ฑ๋˜์ง€ ์•Š์•„ JS๋กœ ํด๋ฆญ ๋ถˆ๊ฐ€ํ•  ์ˆ˜ ์žˆ์Œ
# visible=True๋กœ ํ•˜๊ณ  CSS๋กœ ์ˆจ๊น€
self.give_up_trigger_btn = gr.Button(
value="Give Up Trigger",
visible=True,
elem_id="hidden-give-up-btn",
elem_classes=["hidden-trigger"]
)
return self.history_html, self.give_up_trigger_btn
@staticmethod
def render_empty_history() -> str:
"""
๋นˆ ๊ธฐ๋ก ํ™”๋ฉด ๋ Œ๋”๋ง - ๋นˆ ์ƒํƒœ UI ํ‘œ์‹œ
Returns:
str: HTML ๋ฌธ์ž์—ด
"""
widget_id = f"hist{random.randint(1000, 9999)}"
return f"""
<style>
.give-up-btn {{
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 76px !important;
height: 26px !important;
margin: 0 !important;
padding: 0 !important;
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%) !important;
color: white !important;
border: none !important;
border-radius: 12px !important;
font-size: 0.8rem !important;
font-weight: 600 !important;
cursor: pointer !important;
transition: all 0.2s ease !important;
}}
.give-up-btn:hover {{
scale: 1.05 !important;
transition: all 0.2s ease !important;
}}
.hidden-trigger {{
display: none !important;
}}
/* ๋‹คํฌ๋ชจ๋“œ ์ง€์› */
.dark #{widget_id} {{
background: var(--dark-surface, #1a1a1b) !important;
border-color: var(--dark-border, #3a3a3c) !important;
}}
.dark #{widget_id} .history-header {{
background: var(--dark-surface-light, #2d2d2d) !important;
border-color: var(--dark-border, #3a3a3c) !important;
}}
.dark #{widget_id} .history-header .history-title {{
color: var(--dark-text, #ffffff) !important;
}}
.dark #{widget_id} .empty-message {{
color: var(--dark-text-secondary, #818384) !important;
}}
</style>
<div id="{widget_id}" class="history-widget" style='
margin-top: 24px;
background: #ffffff;
border-radius: 16px;
border: 1px solid #c5dae8;
overflow: hidden;
box-shadow: 0 4px 12px rgba(77, 184, 255, 0.1);
'>
<!-- ํ—ค๋” -->
<div class="history-header" style='
padding: 12px 16px;
background: #f0f7fc;
border-bottom: 1px solid #c5dae8;
display: flex;
justify-content: space-between;
align-items: center;
'>
<span class="history-title" style='font-weight: 600; color: #2c3e50;'>History</span>
<div style='display: flex; align-items: center; gap: 8px;'>
<button id="give-up-btn" class="give-up-btn" onclick="document.getElementById('hidden-give-up-btn').click()">GIVE UP</button>
<span style='
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 26px;
background: linear-gradient(135deg, #4db8ff 0%, #5bc0eb 100%);
color: white;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 600;
'>0</span>
</div>
</div>
<!-- ๋นˆ ์ƒํƒœ ๋ฉ”์‹œ์ง€ -->
<div class="empty-message" style='
padding: 48px 24px;
text-align: center;
color: #9ca3af;
font-size: 0.95rem;
'>
No attempts yet. Record your voice to start!
</div>
</div>
"""
@staticmethod
def render_failure_history(history: list) -> str:
"""
์‹คํŒจ ๊ธฐ๋ก ๋ฆฌ์ŠคํŠธ ๋ Œ๋”๋ง - ์ขŒ์šฐ ๋ถ„ํ•  ๋ ˆ์ด์•„์›ƒ (์ขŒ: ๊ธฐ๋ก ๋ชฉ๋ก, ์šฐ: ์˜ค๊ฐ ๊ทธ๋ž˜ํ”„)
CSS ๊ธฐ๋ฐ˜ ํ† ๊ธ€ (radio button hidden)
Args:
history (list): ์‹คํŒจ ๊ธฐ๋ก ๋ฆฌ์ŠคํŠธ
[{"timestamp": str, "text": str, "audio": str, "metrics": dict}, ...]
Returns:
str: HTML ๋ฌธ์ž์—ด
"""
if not history:
return HistoryDisplayComponent.render_empty_history()
# ๊ณ ์œ  ID ์ƒ์„ฑ
widget_id = f"hist{random.randint(1000, 9999)}"
# ์—ญ์ˆœ (์ตœ์‹ ์ด ์œ„๋กœ)
reversed_history = list(reversed(history))
# CSS ์Šคํƒ€์ผ
html = f"""
<style>
#{widget_id} input[type="radio"] {{
display: none;
}}
#{widget_id} .graph-panel {{
display: none;
}}
#{widget_id} .content-area {{
flex-direction: row;
}}
#{widget_id} .history-list {{
border-right: 1px solid #e5e7eb;
}}
#{widget_id} .graph-area {{
flex: 0 0 360px;
}}
/* ์ปจํ…Œ์ด๋„ˆ๊ฐ€ 720px ๋ฏธ๋งŒ์ผ ๋•Œ ์ƒํ•˜ ๋ฐฐ์น˜ (๊ทธ๋ž˜ํ”„ ์œ„) */
@container (max-width: 719px) {{
#{widget_id} .content-area {{
flex-direction: column-reverse;
}}
#{widget_id} .history-list {{
border-right: none;
border-top: 1px solid #e5e7eb;
max-height: none;
height: auto;
overflow-y: visible;
}}
#{widget_id} .graph-area {{
flex: none;
width: 100%;
}}
}}
.give-up-btn {{
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 76px !important;
height: 26px !important;
margin: 0 !important;
padding: 0 !important;
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%) !important;
color: white !important;
border: none !important;
border-radius: 12px !important;
font-size: 0.8rem !important;
font-weight: 600 !important;
cursor: pointer !important;
transition: all 0.2s ease !important;
}}
.give-up-btn:hover {{
scale: 1.05 !important;
transition: all 0.2s ease !important;
}}
"""
# ๊ฐ ๋ผ๋””์˜ค ๋ฒ„ํŠผ ์„ ํƒ ์‹œ ํ•ด๋‹น ๊ทธ๋ž˜ํ”„ ํŒจ๋„ ํ‘œ์‹œ + ์•„์ดํ…œ ์Šคํƒ€์ผ
for idx in range(len(reversed_history)):
html += f"""
#{widget_id} #select-{widget_id}-{idx}:checked ~ .content-area .graph-panel-{idx} {{
display: flex;
}}
#{widget_id} #select-{widget_id}-{idx}:checked ~ .content-area .item-{idx} {{
background: #e3f5ff;
}}
#{widget_id} #select-{widget_id}-{idx}:checked ~ .content-area .item-{idx} .badge {{
background: #4db8ff;
}}
"""
html += f"""
/* ๋‹คํฌ๋ชจ๋“œ ์ง€์› */
.dark #{widget_id} {{
background: var(--dark-surface, #1a1a1b) !important;
border-color: var(--dark-border, #3a3a3c) !important;
}}
.dark #{widget_id} .history-header {{
background: var(--dark-surface-light, #2d2d2d) !important;
border-color: var(--dark-border, #3a3a3c) !important;
}}
.dark #{widget_id} .history-header .history-title {{
color: var(--dark-text, #ffffff) !important;
}}
.dark #{widget_id} .history-list {{
border-color: var(--dark-border, #3a3a3c) !important;
}}
.dark #{widget_id} .history-item {{
border-color: var(--dark-border, #3a3a3c) !important;
}}
.dark #{widget_id} .history-item .item-text {{
color: var(--dark-text, #ffffff) !important;
}}
.dark #{widget_id} .history-item .item-time {{
color: var(--dark-text-secondary, #818384) !important;
}}
.dark #{widget_id} .history-item .item-user-text {{
color: var(--dark-text-secondary, #818384) !important;
}}
.dark #{widget_id} .graph-area {{
background: var(--dark-surface-light, #2d2d2d) !important;
}}
.dark #{widget_id} .metric-label {{
color: var(--dark-text-secondary, #818384) !important;
}}
.dark #{widget_id} .graph-advice {{
background: linear-gradient(135deg, #1e3a4c 0%, #1a3040 100%) !important;
color: #bae6fd !important;
}}
.dark #{widget_id} #select-{widget_id}-0:checked ~ .content-area .item-0,
.dark #{widget_id} #select-{widget_id}-1:checked ~ .content-area .item-1,
.dark #{widget_id} #select-{widget_id}-2:checked ~ .content-area .item-2,
.dark #{widget_id} #select-{widget_id}-3:checked ~ .content-area .item-3,
.dark #{widget_id} #select-{widget_id}-4:checked ~ .content-area .item-4 {{
background: rgba(77, 184, 255, 0.2) !important;
}}
/* ์ˆจ๊ฒจ์ง„ ํŠธ๋ฆฌ๊ฑฐ ๋ฒ„ํŠผ */
.hidden-trigger {{
display: none !important;
}}
</style>
<div id="{widget_id}" class="history-widget" style='
margin-top: 24px;
background: #ffffff;
border-radius: 16px;
border: 1px solid #c5dae8;
overflow: hidden;
container-type: inline-size;
box-shadow: 0 4px 12px rgba(77, 184, 255, 0.1);
'>
<!-- ํ—ค๋” -->
<div class="history-header" style='
padding: 12px 16px;
background: #f0f7fc;
border-bottom: 1px solid #c5dae8;
display: flex;
justify-content: space-between;
align-items: center;
'>
<span class="history-title" style='font-weight: 600; color: #2c3e50;'>History</span>
<div style='display: flex; align-items: center; gap: 8px;'>
<button id="give-up-btn" class="give-up-btn" onclick="document.getElementById('hidden-give-up-btn').click()">GIVE UP</button>
<span style='
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 26px;
background: linear-gradient(135deg, #4db8ff 0%, #5bc0eb 100%);
color: white;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 600;
'>{len(history)}</span>
</div>
</div>
"""
# ๋ผ๋””์˜ค ๋ฒ„ํŠผ ์ƒ์„ฑ (์ฒซ ๋ฒˆ์งธ๊ฐ€ ๊ธฐ๋ณธ ์„ ํƒ)
for idx in range(len(reversed_history)):
checked = "checked" if idx == 0 else ""
html += f'<input type="radio" name="select-{widget_id}" id="select-{widget_id}-{idx}" {checked}>'
html += f"""
<!-- ์ขŒ์šฐ ๋ถ„ํ•  ์ปจํ…์ธ  (๋ฐ˜์‘ํ˜•) -->
<div class="content-area" style='
display: flex;
'>
<!-- ์ขŒ์ธก: ๊ธฐ๋ก ๋ฆฌ์ŠคํŠธ -->
<div class="history-list" style='
flex: 1;
min-width: 0;
max-height: 560px;
overflow-y: auto;
'>
"""
# ๊ธฐ๋ก ๋ฆฌ์ŠคํŠธ
for idx, entry in enumerate(reversed_history):
# overall ์ ์ˆ˜ ์‚ฌ์šฉ
metrics = entry.get('metrics', {})
overall_score = metrics.get('overall', 0)
score = f"{overall_score:.1f}" if isinstance(overall_score, float) else str(overall_score)
# user_text (STT ๊ฒฐ๊ณผ) ๊ฐ€์ ธ์˜ค๊ธฐ
user_text = entry.get('user_text', '')
display_text = f'{user_text}' if user_text else '-'
html += f"""
<label for="select-{widget_id}-{idx}" class="history-item item-{idx}" style='
display: flex;
align-items: center;
padding: 16px 14px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
gap: 12px;
transition: background 0.15s;
'>
<div class="badge" style='
min-width: 48px;
width: 48px;
max-width: 48px;
height: 28px;
background: #8ba4bd;
color: white;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
font-weight: 700;
flex-shrink: 0;
'>{score}</div>
<div class="item-user-text" style='
flex: 1;
min-width: 0;
font-size: 0.9rem;
font-weight: 700;
color: #27272A;
line-height: 1.4;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
text-overflow: ellipsis;
white-space: nowrap;
'>{display_text}</div>
</label>
"""
html += f"""
</div>
<!-- ์šฐ์ธก: ์˜ค๊ฐ ๊ทธ๋ž˜ํ”„ ์˜์—ญ (์ƒํ•˜ ๋ฐฐ์น˜ ์‹œ ์œ„๋กœ) -->
<div class="graph-area" style='
min-height: 560px;
background: #f0f7fc;
position: relative;
'>
"""
# ์ ์ˆ˜ ์ƒ‰์ƒ ๊ฒฐ์ • ํ•จ์ˆ˜
def get_score_color(val):
return "#e8a054" if val < 85 else "#4db8ff"
# ๊ฐ ๊ธฐ๋ก์— ๋Œ€ํ•œ ๊ทธ๋ž˜ํ”„ ํŒจ๋„ ์ƒ์„ฑ
for idx, entry in enumerate(reversed_history):
metrics = entry.get('metrics', {})
radar_svg = render_radar_chart(metrics, size=280) if metrics else ""
html += f"""
<div class="graph-panel graph-panel-{idx}" style='
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
flex-direction: column;
align-items: center;
justify-content: start;
gap: 12px;
padding: 12px;
'>
<div style='display: flex; flex-direction: column; width: 100%; max-width: 420px; gap: 12px;'>
<!-- ๋ ˆ์ด๋” ์ฐจํŠธ -->
<div style='display: flex; justify-content: center; flex: 1; align-items: center;'>
{radar_svg}
</div>
<!-- ๋ฉ”ํŠธ๋ฆญ ์ˆ˜์น˜ -->
<div style='
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
width: 100%;
'>
<div style='text-align: center;'>
<div class="metric-label" style='font-size: 0.6rem; color: #666;'>Pronunciation</div>
<div style='font-size: 1rem; font-weight: 700; color: {get_score_color(metrics.get("pronunciation", 0))};'>{metrics.get('pronunciation', '-')}</div>
</div>
<div style='text-align: center;'>
<div class="metric-label" style='font-size: 0.6rem; color: #666;'>Pitch</div>
<div style='font-size: 1rem; font-weight: 700; color: {get_score_color(metrics.get("pitch", 0))};'>{metrics.get('pitch', '-')}</div>
</div>
<div style='text-align: center;'>
<div class="metric-label" style='font-size: 0.6rem; color: #666;'>Line Accuracy</div>
<div style='font-size: 1rem; font-weight: 700; color: {get_score_color(metrics.get("tone", 0))};'>{metrics.get('tone', '-')}</div>
</div>
<div style='text-align: center;'>
<div class="metric-label" style='font-size: 0.6rem; color: #666;'>Rhythm</div>
<div style='font-size: 1rem; font-weight: 700; color: {get_score_color(metrics.get("rhythm", 0))};'>{metrics.get('rhythm', '-')}</div>
</div>
<div style='text-align: center;'>
<div class="metric-label" style='font-size: 0.6rem; color: #666;'>Energy</div>
<div style='font-size: 1rem; font-weight: 700; color: {get_score_color(metrics.get("energy", 0))};'>{metrics.get('energy', '-')}</div>
</div>
</div>
</div>
<!-- ์˜ค๋””์˜ค ํ”Œ๋ ˆ์ด์–ด -->
<audio controls style='width: 100%; height: 32px;' preload="metadata">
<source src='/gradio_api/file={entry.get("audio", "")}' type='audio/wav'>
</audio>
<!-- Advice -->
<div class="graph-advice" style='
flex: 1;
width: 100%;
padding: 10px 12px;
background: #e3f5ff;
border-radius: 8px;
font-size: 0.85rem;
color: #075985;
line-height: 1.5;
'>{entry.get('advice', '')}</div>
</div>
"""
html += """
</div>
</div>
</div>
"""
return html