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:
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user