refactor(fe): convert remaining utility classes to semantic CSS in 12 files

- live/mod.rs: .live-body/head/grid, replace tailwind grid/flex
- screen_sub_panel: .scrn-actions/status
- waveform_player: .wave-wrap/progress/bar/info/time
- mic_level_meter: .mic-row/track/clip
- now_playing: .np-body/meta/tags/sep
- recordings_sub_panel: .rec-empty/list/item/info/meta/actions/footer
- active_speakers: .speak-list
- music_sub_panel: .music-body
- image_grid: .img-empty
- layout/header: .head-left/right/status
- mascot_chatbot: .mascot-controls/inline
This commit is contained in:
asepharyana
2026-07-05 03:19:56 +07:00
parent 5204a07976
commit d0cd61f931
11 changed files with 160 additions and 55 deletions
+106
View File
@@ -1546,3 +1546,109 @@ svg { display: inline-block; vertical-align: middle; }
/* ── Deemphasized text ──────────────────────────── */
.text-dim { color: var(--text-tertiary); opacity: 0.6; }
/* ================================================================
LIVE PANEL
================================================================ */
.live-body { display: flex; flex-direction: column; gap: var(--space-6); }
.live-head { display: flex; align-items: center; justify-content: space-between; }
.live-title { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.025em; }
.live-desc { font-size: 0.875rem; color: var(--text-secondary); margin-top: var(--space-1); }
.live-grid { display: grid; gap: var(--space-6); }
.live-grid-3 { grid-template-columns: repeat(3, 1fr); }
.live-span-2 { grid-column: span 2; }
@media (max-width: 1024px) {
.live-grid-3 { grid-template-columns: repeat(2, 1fr); }
.live-span-2 { grid-column: span 2; }
}
@media (max-width: 768px) {
.live-grid-3 { grid-template-columns: 1fr; }
.live-span-2 { grid-column: span 1; }
}
/* ── Screen Sub Panel ──────────────────────────── */
.scrn-actions { display: flex; gap: var(--space-2); }
.scrn-status {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.75rem;
color: var(--color-success);
}
/* ── Now Playing ───────────────────────────────── */
.np-body { display: flex; flex-direction: column; gap: var(--space-2); }
.np-meta { display: flex; align-items: center; justify-content: space-between; font-size: 0.75rem; color: var(--text-secondary); }
.np-tags { display: flex; align-items: center; gap: var(--space-2); }
.np-sep { border-top: var(--px) solid var(--surface-border); padding-top: var(--space-3); }
/* ── Waveform Player ───────────────────────────── */
.wave-wrap {
border: var(--px) solid var(--surface-border);
border-radius: var(--radius-md);
padding: var(--space-3);
}
.wave-progress {
height: var(--space-2);
border-radius: var(--radius-full);
overflow: hidden;
margin-bottom: var(--space-2);
}
.wave-bar { height: 100%; border-radius: var(--radius-full); background: var(--color-primary); transition: width 200ms ease; }
.wave-info { display: flex; align-items: center; justify-content: space-between; }
.wave-title { font-size: 0.875rem; font-weight: 500; max-width: 192px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wave-time { display: flex; align-items: center; gap: var(--space-2); font-size: 0.75rem; font-family: monospace; }
/* ── Mic Level Meter ───────────────────────────── */
.mic-row { display: flex; align-items: center; gap: var(--space-2); }
.mic-track {
position: relative;
flex: 1;
height: var(--space-2);
border-radius: var(--radius-full);
overflow: hidden;
}
.mic-clip { position: absolute; height: 100%; width: 2px; }
/* ── Recordings Sub Panel ──────────────────────── */
.rec-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: var(--space-8); gap: var(--space-2); }
.rec-list { display: flex; flex-direction: column; gap: var(--space-2); }
.rec-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3);
border-radius: var(--radius-md);
border: var(--px) solid var(--surface-border);
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.rec-item:hover { background: var(--surface-hover); border-color: var(--color-primary); }
.rec-info { flex: 1; min-width: 0; }
.rec-name { font-size: 0.875rem; font-weight: 500; }
.rec-meta { display: flex; align-items: center; gap: var(--space-2); font-size: 0.75rem; color: var(--text-secondary); margin-top: var(--space-1); }
.rec-actions { display: flex; align-items: center; gap: 0.375rem; flex-shrink: 0; }
.rec-footer { margin-top: var(--space-3); text-align: center; }
/* ── Active Speakers ───────────────────────────── */
.speak-list { display: flex; flex-direction: column; gap: var(--space-2); }
/* ── Music Sub Panel ───────────────────────────── */
.music-body { display: flex; flex-direction: column; gap: var(--space-2); }
/* ── Dashboard Panel ───────────────────────────── */
.dash-panel { display: flex; flex-direction: column; gap: var(--space-6); }
.dash-head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); }
.dash-title { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.025em; }
.dash-desc { font-size: 0.875rem; color: var(--text-secondary); margin-top: var(--space-1); }
/* ── Image Grid ────────────────────────────────── */
.img-empty { display: flex; align-items: center; justify-content: center; height: 128px; color: var(--text-secondary); font-style: italic; }
/* ── Header (layout) ───────────────────────────── */
.head-left { display: flex; align-items: center; gap: var(--space-2); }
.head-right { display: flex; align-items: center; gap: var(--space-4); margin-left: auto; }
.head-status { display: flex; align-items: center; gap: var(--space-1); font-size: 0.75rem; color: var(--text-secondary); }
/* ── Mascot ────────────────────────────────────── */
.mascot-controls { display: flex; align-items: center; gap: var(--space-2); }
.mascot-inline { display: flex; align-items: center; gap: var(--space-1); }
@@ -16,7 +16,7 @@ pub fn ActiveSpeakers(
when=empty_state
fallback=move || {
view! {
<div class="space-y-2">
<div class="speak-list">
<For
each=move || speakers.get()
key=|s| s.user_id.clone() + &s.username
@@ -59,23 +59,23 @@ pub fn MicLevelMeter(
view! {
<div class="mic-level-meter">
{label.map(|l| view! {
<label class="text-xs font-medium text-foreground mb-1.5">{l}</label>
<label style="font-size:0.75rem;font-weight:500;color:var(--text-secondary);display:block;margin-bottom:var(--space-1)">{l}</label>
})}
<div class="flex items-center gap-2">
<div class="mic-row">
{/* Main level bar */}
<div class="relative flex-1 h-2 rounded-full bg-surface border border-border/50 overflow-hidden">
<div class="mic-track">
<div
class=move || format!("h-full {} transition-all", level_color())
style=move || format!("width: {}%", level_percent())
></div>
{/* Peak indicator */}
<div
class="absolute h-full w-0.5 bg-destructive/70"
class="mic-clip"
style=move || format!("left: {}%", peak_percent())
></div>
</div>
{/* Percentage display */}
<span class="text-xs font-mono text-muted-foreground w-8 text-right">
<span style="font-size:0.75rem;font-family:monospace;width:2rem;text-align:right">
{move || format!("{}%", (level_percent() as u8))}
</span>
</div>
@@ -18,9 +18,9 @@ pub fn MusicSubPanel(
};
view! {
<div class="music-sub-panel card">
<div class="card">
<div class="card-header">
<div class="card-title flex items-center gap-2">
<div class="card-title" style="display:flex;align-items:center;gap:var(--space-2)">
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M9 8h6v8h-6z"></path>
@@ -28,19 +28,20 @@ pub fn MusicSubPanel(
"Music"
</div>
</div>
<div class="card-content space-y-3">
<div class="space-y-2">
<label class="text-xs font-medium text-foreground">"YouTube URL or Search"</label>
<div class="card-content music-body">
<div class="music-body">
<label style="font-size:0.75rem;font-weight:500;color:var(--text-secondary)">"YouTube URL or Search"</label>
<input
type="text"
class="input w-full text-sm"
class="input"
placeholder="youtube.com/watch?v=... or song name"
prop:value=url_input
on:input=move |ev| set_url_input.set(event_target_value(&ev))
/>
</div>
<button
class="btn btn-primary w-full"
class="btn btn-primary"
style="width:100%"
on:click=handle_queue_click
disabled=move || url_input.get().is_empty()
>
@@ -19,11 +19,11 @@ pub fn NowPlaying(
let has_stop = stop_cb.with_value(|v| v.is_some());
view! {
<div class="now-playing card">
<div class="card">
<div class="card-header">
<div class="card-title">"Now Playing"</div>
</div>
<div class="card-content space-y-3">
<div class="card-content np-body">
{move || {
media_state.get().map(|ms| {
let current = ms.current.as_ref().cloned();
@@ -36,14 +36,14 @@ pub fn NowPlaying(
let duration_ms = item.duration_ms.unwrap_or(0);
let duration_sec = duration_ms / 1000;
view! {
<div class="space-y-2">
<div class="np-body">
<div class="text-sm font-medium text-foreground truncate">
{title}
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<div class="np-meta">
<span>{format!("{}s", duration_sec)}</span>
</div>
<div class="flex items-center gap-2">
<div class="np-tags">
{has_skip.then(|| {
view! {
<button
@@ -88,14 +88,14 @@ pub fn RecordingsSubPanel(
let recs = recordings.get();
if recs.is_empty() && !loading.get() {
view! {
<div class="flex flex-col items-center justify-center py-8 gap-2">
<p class="text-sm text-muted-foreground">"No recordings yet."</p>
<p class="text-xs text-muted-foreground">"Join a voice channel to start recording."</p>
<div class="rec-empty">
<p style="font-size:0.875rem;color:var(--text-secondary)">"No recordings yet."</p>
<p style="font-size:0.75rem;color:var(--text-secondary)">"Join a voice channel to start recording."</p>
</div>
}.into_any()
} else {
view! {
<div class="space-y-2">
<div class="rec-list">
{recs.iter().map(|rec| {
let id = rec.id.clone();
let username = rec.username.clone();
@@ -105,12 +105,10 @@ pub fn RecordingsSubPanel(
let url = rec.download_url.clone().unwrap_or_default();
view! {
<div class="recording-item flex items-center gap-3 p-3 rounded-lg border border-border/50 hover:bg-accent/5 transition-colors">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-foreground truncate">
{username}
</div>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<div class="rec-item">
<div class="rec-info">
<div class="rec-name">{username}</div>
<div class="rec-meta">
<span>{channel_name}</span>
<span>"·"</span>
<span>{format_size(rec.size_bytes)}</span>
@@ -118,7 +116,7 @@ pub fn RecordingsSubPanel(
<span>{created_at}</span>
</div>
</div>
<div class="flex items-center" style="gap:0.375rem;flex-shrink:0">
<div class="rec-actions">
{has_url.then(|| {
view! {
<a
@@ -148,7 +146,7 @@ pub fn RecordingsSubPanel(
{move || {
(has_more.get() && !loading.get()).then(|| {
view! {
<div class="mt-3 text-center">
<div class="rec-footer">
<button
class="btn btn-sm btn-outline"
on:click=move |_| load(false)
@@ -12,9 +12,9 @@ pub fn ScreenSubPanel(
let has_stop = on_stop_stream.is_some();
view! {
<div class="screen-sub-panel card">
<div class="card">
<div class="card-header">
<div class="card-title flex items-center gap-2">
<div class="card-title" style="display:flex;align-items:center;gap:var(--space-2)">
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
@@ -23,12 +23,12 @@ pub fn ScreenSubPanel(
"Screenshare"
</div>
</div>
<div class="card-content space-y-3">
<p class="text-xs text-muted-foreground">
<div class="card-content" style="display:flex;flex-direction:column;gap:var(--space-3)">
<p style="font-size:0.75rem;color:var(--text-secondary)">
"Stream your screen to the voice channel for everyone to see."
</p>
<div class="flex gap-2">
<div class="scrn-actions">
{has_start.then(|| {
view! {
<button
@@ -71,7 +71,7 @@ pub fn ScreenSubPanel(
{move || {
is_streaming.get().then(|| {
view! {
<div class="rounded-md bg-success/10 px-2 py-1.5 text-xs text-success">
<div class="scrn-status">
"🔴 Live streaming..."
</div>
}
@@ -39,12 +39,12 @@ pub fn WaveformPlayer(
let _ = audio_url; // Mark as used for the audio_id
view! {
<div class="waveform-player border border-border/50 rounded-lg p-3 bg-surface/30">
<div class="wave-wrap">
<audio
id=audio_id.clone()
preload="auto"
src=audio_src
class="hidden"
style="display:none"
on:timeupdate=move |ev| {
if let Some(target) = ev.target() {
if let Ok(audio) = target.dyn_into::<web_sys::HtmlAudioElement>() {
@@ -62,14 +62,14 @@ pub fn WaveformPlayer(
}
></audio>
<div class="h-2 rounded-full bg-surface border border-border/50 overflow-hidden mb-2">
<div class="wave-progress">
<div
class="h-full rounded-full bg-primary transition-all duration-200"
class="wave-bar"
style=move || format!("width: {}%", progress_pct(current_time.get(), duration.get()))
></div>
</div>
<div class="flex items-center justify-between">
<div class="wave-info">
<button
class=move || format!("btn btn-sm {}", if is_playing.get() { "btn-secondary" } else { "btn-primary" })
on:click=toggle_play
@@ -77,9 +77,9 @@ pub fn WaveformPlayer(
{move || if is_playing.get() { "" } else { "" }}
</button>
<div class="flex items-center gap-2 text-xs text-muted-foreground font-mono">
<div class="wave-time">
<span>{move || format_time(current_time.get())}</span>
<span class="max-w-32 truncate">{title.clone()}</span>
<span class="wave-title">{title.clone()}</span>
</div>
</div>
</div>
@@ -76,19 +76,19 @@ pub fn LivePanel() -> impl IntoView {
{move || {
if auth.authenticated.get() {
view! {
<div class="live-panel space-y-6">
<div class="flex items-center justify-between">
<div class="live-body">
<div class="live-head">
<div>
<h2 class="text-2xl font-bold tracking-tight">"Voice & Media"</h2>
<p class="text-sm text-muted-foreground mt-1">
<h2 class="live-title">"Voice & Media"</h2>
<p class="live-desc">
"Monitor voice channels, play music, share your screen, and browse recordings."
</p>
</div>
</div>
{/* Top row: Voice connection + speakers + visualizer */}
<div class="grid gap-6 lg:grid-cols-3">
<div class="lg:col-span-2">
<div class="live-grid live-grid-3">
<div class="live-span-2">
<VoiceConnectionCard />
</div>
<div>
@@ -107,7 +107,7 @@ pub fn LivePanel() -> impl IntoView {
</div>
{/* Media controls: Now Playing + Music + Screen */}
<div class="grid gap-6 lg:grid-cols-3">
<div class="live-grid live-grid-3">
<div>
<NowPlaying media_rw=media_state />
</div>
@@ -99,14 +99,14 @@ pub fn MascotChatbot() -> impl IntoView {
view! {
<div class=move || if minimized.get() { "mascot-panel minimized" } else { "mascot-panel" }>
<div class="mascot-header">
<div class="flex items-center gap-2">
<div class="mascot-controls">
<div class="mascot-header-icon">"💬"</div>
<div>
<div class="mascot-title">"Mascot IMPHNEN"</div>
<div class="mascot-subtitle">{move || if loading.get() { "Mengetik..." } else { "Online" }}</div>
</div>
</div>
<div class="flex items-center gap-1">
<div class="mascot-inline">
<button class="mascot-icon-button" on:click=move |_| minimized.update(|v| *v = !*v)>
{move || if minimized.get() { "" } else { "" }}
</button>
@@ -33,7 +33,7 @@ pub fn Header() -> impl IntoView {
top: 0;
z-index: var(--z-header);
">
<div class="flex items-center gap-2">
<div class="head-left">
<span style="font-weight: 700; font-size: 1.125rem; color: var(--color-primary);">
"IMPHNEN"
</span>
@@ -42,8 +42,8 @@ pub fn Header() -> impl IntoView {
</span>
</div>
<div class="flex items-center gap-4 ml-auto">
<div class="flex items-center gap-1" style="font-size: 0.75rem; color: var(--text-secondary);">
<div class="head-right">
<div class="head-status">
<span
style="width: 8px; height: 8px; border-radius: 50%;"
style:background={move || indicator_color_memo.get()}