refactor(fe): replace utility classes with semantic CSS across components

- add ~400 lines of clean semantic CSS classes (msg-*, panel-*,
  search-*, filter-*, feed-*, auth-*, sidebar-*, etc.)
- convert message_card.rs: .message-row -> .msg-row, .message-card
  -> .msg-card, remove all tailwind utility classes
- convert messages/mod.rs: .card -> .panel-card, .search-row ->
  .search-bar, .filter-chip -> .filter-chip-v2, .message-stats ->
  .stats-bar
- convert message_feed.rs: .space-y-4 -> .feed-wrap, .empty-state
  -> .feed-empty
- convert auth.rs: inline styles -> .auth-* semantic classes
- convert sidebar: .btn.btn-ghost -> .sidebar-btn.is-active
- keep utility classes in CSS for backward compatibility
  with non-refactored components (dashboard, live)
This commit is contained in:
asepharyana
2026-07-05 03:12:57 +07:00
parent 42ad1b4231
commit 5204a07976
7 changed files with 1291 additions and 1116 deletions
File diff suppressed because it is too large Load Diff
+6 -7
View File
@@ -84,6 +84,10 @@ pub fn App() -> impl IntoView {
async move {
match config_api::get_config().await {
Ok(cfg) => {
web_sys::console::log_2(
&"[config] fetched OK".into(),
&format!("monitorGuildId={:?}", cfg.monitor_guild_id).into(),
);
config.monitor_guild_id.set(cfg.monitor_guild_id);
}
Err(e) => {
@@ -166,14 +170,9 @@ fn TabButton(tab: Tab, ui: UiContext, label: &'static str) -> impl IntoView {
view! {
<button
class:btn=true
class:btn-ghost=true
class:btn-active=move || active_tab.get() == tab1
class="sidebar-btn"
class:is-active=move || active_tab.get() == tab1
on:click=move |_| active_tab.set(tab4.clone())
style:background=move || if active_tab.get() == tab2 { "var(--surface-overlay)" } else { "" }
style:color=move || if active_tab.get() == tab3 { "var(--color-primary)" } else { "" }
style:width="100%"
style:justify-content="flex-start"
>
{label}
</button>
+8 -12
View File
@@ -52,21 +52,17 @@ pub fn AuthOverlay() -> impl IntoView {
view! {
<div class="modal-overlay">
<div class="modal-content" style="width: 380px;">
<div class="modal-body" style="text-align: center;">
<div style="font-size: 3rem; margin-bottom: 1rem;">
<div class="modal-content auth-box">
<div class="modal-body">
<div class="auth-lock">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</div>
<h2 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem;">
"Akses Dashboard"
</h2>
<p style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 1.5rem;">
"Masukkan password admin untuk melanjutkan"
</p>
<form on:submit=handle_submit style="display: flex; flex-direction: column; gap: 0.75rem;">
<h2 class="auth-title">"Akses Dashboard"</h2>
<p class="auth-desc">"Masukkan password admin untuk melanjutkan"</p>
<form class="auth-form" on:submit=handle_submit>
<input
type="password"
class="input"
@@ -75,11 +71,11 @@ pub fn AuthOverlay() -> impl IntoView {
on:input=move |ev| set_password.set(event_target_value(&ev))
/>
{move || error.get().map(|e| view! {
<p style="color: var(--color-error); font-size: 0.75rem;">{e}</p>
<p class="auth-error">{e}</p>
})}
<button
type="submit"
class="btn btn-primary w-full btn-lg"
class="btn btn-primary btn-lg"
disabled=move || loading.get()
>
{move || if loading.get() { "Memproses..." } else { "Masuk" }}
@@ -189,23 +189,23 @@ pub fn MessageRow(
let on_click_re = move |_| on_reanalyze(reanalyze_id.clone());
view! {
<div class="message-row">
<div class="msg-row">
{/* Header */}
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
<span class="message-timestamp" title=time_ago(message.created_at)>
<div class="msg-row-bar">
<span class="msg-row-time" title=time_ago(message.created_at)>
{fmt_time(message.created_at)}
</span>
{message.edited_at.is_some().then(|| view! {
<span class="flex items-center gap-0.5 text-xs text-secondary">
<span class="msg-row-badge edited">
"✎ edited"
</span>
})}
{message.deleted_at.is_some().then(|| view! {
<span class="flex items-center gap-0.5 text-xs text-destructive">
<span class="msg-row-badge deleted">
"🗑 deleted"
</span>
})}
<div class="ml-auto flex items-center gap-1">
<div class="msg-row-status">
<StatusBadgeInline status=ai_st.clone() />
{message.ai_severity.as_ref().filter(|s| **s != AiSeverity::None).map(|sev| view! {
<span class=format!("badge text-xs {}", severity_class(sev))>{format!("{:?}", sev)}</span>
@@ -213,19 +213,11 @@ pub fn MessageRow(
</div>
</div>
{/* Reply - field removed from MessageRecord */}
{/* Forward - field removed from MessageRecord */}
{/* Crosspost - field removed from MessageRecord */}
{/* Content */}
{show.then(|| {
let rendered = render_emojis(display);
let cls_str = if message.deleted_at.is_some() { "text-secondary/60" } else { "" };
let class_str = format!("whitespace-pre-wrap break-words text-sm leading-6 {}", cls_str);
view! {
<p class=class_str>
<p class="msg-row-body" class:is-deleted=message.deleted_at.is_some()>
{rendered.into_iter().collect::<Vec<_>>()}
</p>
}
@@ -233,7 +225,7 @@ pub fn MessageRow(
{/* Stickers */}
{(!stickers.is_empty()).then(|| view! {
<div class="flex flex-wrap gap-2">
<div class="msg-media-row">
{stickers.iter().map(|s| {
let url_owned = s.url.clone().unwrap_or_default();
let name_owned = s.name.clone().unwrap_or_default();
@@ -242,11 +234,11 @@ pub fn MessageRow(
<div>
{if has_url {
view! {
<img src=url_owned alt=name_owned class="h-12 w-12 rounded-lg border border-border object-contain bg-surface/50" loading="lazy" />
<img src=url_owned alt=name_owned class="msg-sticker" loading="lazy" />
}.into_any()
} else {
view! {
<div class="flex h-12 w-12 items-center justify-center rounded-lg border border-border bg-surface/50">
<div class="msg-sticker-placeholder">
"😊"
</div>
}.into_any()
@@ -265,15 +257,15 @@ pub fn MessageRow(
let url2 = a.url.clone();
let name1 = a.name.clone();
view! {
<a href=url1 target="_blank" class="shrink-0 overflow-hidden rounded-lg border border-border">
<img src=url2 alt=name1 class="h-16 w-16 object-cover hover:scale-105 transition-transform" loading="lazy" />
<a href=url1 target="_blank" class="msg-thumb-link">
<img src=url2 alt=name1 class="msg-thumb" loading="lazy" />
</a>
}
}).collect::<Vec<_>>();
let overflow = if imgs.len() > 4 {
let extra = imgs.len() - 4;
view! {
<div class="flex h-16 w-16 items-center justify-center rounded-lg border border-border bg-surface text-xs text-secondary">
<div class="msg-media-overflow">
<span>{"+"} {extra}</span> <span class="ml-0.5">"🖼"</span>
</div>
}.into_any()
@@ -281,7 +273,7 @@ pub fn MessageRow(
().into_any()
};
view! {
<div class="flex gap-2 overflow-x-auto">
<div class="msg-media-row is-scroll">
{images_view}
{overflow}
</div>
@@ -296,13 +288,13 @@ pub fn MessageRow(
let videos_view = vids_local.iter().take(4).map(|a| {
let url = a.url.clone();
view! {
<video src=url controls class="h-28 w-48 shrink-0 rounded-lg border border-border object-cover bg-black" preload="metadata"></video>
<video src=url controls class="msg-video" preload="metadata"></video>
}
}).collect::<Vec<_>>();
let overflow = if vids.len() > 4 {
let extra = vids.len() - 4;
view! {
<div class="flex h-28 w-16 items-center justify-center rounded-lg border border-border bg-surface text-xs text-secondary">
<div class="msg-media-overflow tall">
<span>{"+"} {extra}</span> <span class="ml-0.5">""</span>
</div>
}.into_any()
@@ -310,7 +302,7 @@ pub fn MessageRow(
().into_any()
};
view! {
<div class="flex gap-2 overflow-x-auto">
<div class="msg-media-row is-scroll">
{videos_view}
{overflow}
</div>
@@ -323,7 +315,7 @@ pub fn MessageRow(
{if !cats.is_empty() {
let cats_local = cats.clone();
view! {
<div class="flex flex-wrap gap-1">
<div class="msg-cats">
{cats_local.iter().map(|c| view! {
<span class="badge badge-secondary text-xs">{c.clone()}</span>
}).collect::<Vec<_>>()}
@@ -335,17 +327,17 @@ pub fn MessageRow(
{/* AI Analysis */}
{message.ai_analysis.as_ref().map(|analysis| {
let border_str = if ai_st == AiStatus::Flagged { "border-l-3 bg-warning/5" } else { "border-l-3 bg-success/5" };
let f_cls = if ai_st == AiStatus::Flagged { "flagged" } else { "clean" };
let icon = if ai_st == AiStatus::Flagged { "🚨" } else { "" };
let analysis_summary_str = analysis_summary.clone();
let analysis_str = analysis.clone();
view! {
<div class=format!("rounded-lg px-3 py-2 {}", border_str)>
<div class="flex items-start gap-2 text-xs">
<span class="mt-0.5 shrink-0">{icon}</span>
<div class="min-w-0 flex-1">
<span class="block font-medium mb-1">{analysis_summary_str}</span>
<div class="text-xs leading-relaxed whitespace-pre-wrap">{analysis_str}</div>
<div class=format!("msg-analysis {}", f_cls)>
<div class="msg-analysis-row">
<span class="msg-analysis-icon">{icon}</span>
<div class="msg-analysis-body">
<span class="msg-analysis-summary">{analysis_summary_str}</span>
<div class="msg-analysis-text">{analysis_str}</div>
</div>
</div>
</div>
@@ -356,14 +348,14 @@ pub fn MessageRow(
{message.ai_error.as_ref().map(|e| {
let error_str = e.clone();
view! {
<div class="rounded-lg bg-warning/5 px-3 py-2 text-xs text-warning">
<div class="msg-error">
<span>"AI error: "{error_str}</span>
</div>
}
})}
{/* Re-analyze */}
<div class="flex items-center gap-2">
<div class="msg-actions">
<button
class=format!("btn btn-sm {}", if ai_st == AiStatus::Error { "btn-destructive" } else { "btn-outline" })
on:click=on_click_re
@@ -373,7 +365,7 @@ pub fn MessageRow(
" Re-analyze"
</button>
{(ai_st == AiStatus::Error).then(|| view! {
<span class="text-xs text-secondary/70">"Click to retry"</span>
<span class="msg-retry-hint">"Click to retry"</span>
})}
</div>
</div>
@@ -404,39 +396,30 @@ pub fn MessageCard(
format!("# {}", c.channel_name.as_deref().unwrap_or("?"))
}
});
let card_cls = if deleted {
"border-destructive/20 opacity-60"
} else {
""
};
let card_cls = if deleted { " is-deleted" } else { "" };
view! {
<article class=format!("message-card shadow-sm transition-all {}", card_cls)>
<div class="flex gap-3 p-4">
<img src=avatar alt="" class="h-10 w-10 shrink-0 rounded-full object-cover ring-2 ring-primary/30" />
<div class="min-w-0 flex-1">
<div class="flex items-baseline gap-2 mb-2">
<span class="font-semibold text-sm">{first.username.clone()}</span>
<article class=format!("msg-card{}", card_cls)>
<div class="msg-card-inner">
<img src=avatar alt="" class="msg-card-avatar" />
<div class="msg-card-body">
<div class="msg-card-head">
<span class="msg-card-name">{first.username.clone()}</span>
{loc_label.as_ref().map(|l| {
let label_str = l.clone();
view! {
<span class="flex items-center gap-1 text-xs text-secondary/50 bg-surface/50 px-1.5 py-0.5 rounded-full">
"#" " " {label_str}
</span>
<span class="msg-card-channel">{label_str}</span>
}
})}
<span class="text-xs text-secondary/60">
<span class="msg-card-time">
{time_ago(first.created_at)}
{has_multi.then(|| format!(" · {} msgs", messages.len()))}
</span>
</div>
<div class=if has_multi { "space-y-2.5" } else { "" }>
<div class="msg-card-messages" class:separated=has_multi>
{messages.into_iter().enumerate().map(|(i, msg)| {
let sep = has_multi && i > 0;
view! {
<div class=if sep { "pt-2.5 border-t border-border/30" } else { "" }>
<MessageRow message=msg on_reanalyze=on_reanalyze.clone() />
</div>
}
}).collect::<Vec<_>>()}
</div>
@@ -450,16 +433,16 @@ pub fn MessageCard(
#[component]
pub fn MessageCardSkeleton() -> impl IntoView {
view! {
<article class="message-card">
<div class="flex gap-3 p-4">
<div class="skeleton skeleton-circular" style="width:40px;height:40px"></div>
<div class="min-w-0 flex-1 space-y-3">
<div class="skeleton" style="height:20px;width:192px"></div>
<div class="skeleton" style="height:16px;width:100%"></div>
<div class="skeleton" style="height:16px;width:75%"></div>
<div class="flex gap-2">
<div class="skeleton" style="height:24px;width:64px;border-radius:9999px"></div>
<div class="skeleton" style="height:24px;width:80px;border-radius:9999px"></div>
<article class="msg-card">
<div class="msg-skel">
<div class="msg-skel-avatar"></div>
<div class="msg-skel-lines">
<div class="msg-skel-line" style="width:192px"></div>
<div class="msg-skel-line" style="width:100%"></div>
<div class="msg-skel-line" style="width:75%"></div>
<div class="msg-skel-badges">
<div class="msg-skel-badge" style="width:64px"></div>
<div class="msg-skel-badge" style="width:80px"></div>
</div>
</div>
</div>
@@ -93,7 +93,7 @@ pub fn MessageFeed(
// Loading state
if loading {
return view! {
<div class="space-y-4">
<div class="flex flex-col gap-4">
{std::iter::repeat_with(|| {
use super::message_card::MessageCardSkeleton;
view! { <MessageCardSkeleton /> }
@@ -105,8 +105,8 @@ pub fn MessageFeed(
if messages.is_empty() {
return view! {
<div class="empty-state">
<div class="empty-state-title">
<div class="feed-empty">
<div class="feed-empty-title">
{if empty_text.is_empty() { "No messages" } else { empty_text }}
</div>
</div>
@@ -119,7 +119,7 @@ pub fn MessageFeed(
let loading_more_val = loading_more;
view! {
<div class="space-y-4">
<div class="feed-wrap">
{groups.into_iter().map(|group| {
let cb = on_reanalyze.clone();
view! {
@@ -131,7 +131,7 @@ pub fn MessageFeed(
{has_more_val.then(|| {
view! {
<>
<div node_ref=sentinel_ref class="h-4">
<div node_ref=sentinel_ref class="feed-sentinel">
{loading_more_val.then(|| {
use super::message_card::MessageCardSkeleton;
view! { <MessageCardSkeleton /> }
@@ -141,7 +141,7 @@ pub fn MessageFeed(
{(!loading_more_val).then(|| {
let load_more_cb = on_load_more.clone();
view! {
<div class="mt-4 text-center">
<div class="feed-loader">
<button
class="btn btn-outline btn-sm"
on:click=move |_| {
@@ -80,12 +80,21 @@ pub fn use_messages() -> MessagesState {
match get_messages(&guild_id, Some(30), None, None).await {
Ok(PageResult { data, next_cursor }) => {
web_sys::console::log_3(
&"[messages] fetch OK".into(),
&format!("count={}", data.len()).into(),
&format!("cursor={:?}", next_cursor).into(),
);
messages_signal.set(data);
cursor_signal.set(next_cursor);
current_guild_signal.set(Some(guild_id));
set_loading.set(false);
}
Err(e) => {
web_sys::console::log_2(
&"[messages] fetch ERROR".into(),
&format!("{}", e).into(),
);
error_signal.set(Some(format!("Failed to fetch messages: {}", e)));
set_loading.set(false);
}
@@ -186,10 +186,10 @@ pub fn MessagesPanel() -> impl IntoView {
view! {
<div class="messages-panel">
{/* Header card */}
<div class="card">
<div class="card-header">
<div class="card-title">"Messages"</div>
<p class="card-description">
<div class="panel-card">
<div class="panel-card-head">
<div class="panel-card-title">"Messages"</div>
<p class="panel-card-desc">
"Messages are automatically captured from all text channels. Real-time updates arrive via WebSocket."
</p>
</div>
@@ -197,7 +197,7 @@ pub fn MessagesPanel() -> impl IntoView {
{/* Stats badges */}
{move || (total() > 0).then(|| view! {
<div class="message-stats">
<div class="stats-bar">
<span class="badge badge-outline text-xs">{total()} " total" {state.has_more.get().then_some("+")}</span>
<span class="badge badge-success text-xs">{clean()} " clean"</span>
<span class="badge badge-primary text-xs">{flagged()} " flagged"</span>
@@ -213,13 +213,11 @@ pub fn MessagesPanel() -> impl IntoView {
})}
{/* Search + filters row */}
<div class="search-row">
<div class="relative flex-1" style="min-width:200px">
{/* Search icon as SVG */}
<svg width="16" height="16" style="position:absolute;left:0.75rem;top:50%;transform:translateY(-50%);color:var(--color-primary)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.35-4.35"></path></svg>
<div class="search-bar">
<div class="search-wrap">
<svg width="16" height="16" class="search-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.35-4.35"></path></svg>
<input
class="input"
style="padding-left:2.25rem;border-radius:9999px"
class="search-input"
placeholder="Search message content..."
prop:value=search_query
on:input=move |ev| set_search_query.set(event_target_value(&ev))
@@ -258,20 +256,18 @@ pub fn MessagesPanel() -> impl IntoView {
}
disabled=move || retrying_all.get()
>
{/* Rotate CCW icon as SVN */}
<svg class=format!("mr-1.5 h-3.5 w-3.5{}", if retrying_all.get() { " animate-spin" } else { "" }) xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"></path><path d="M21 17a9 9 0 00-9-9 9 9 0 00-6 2.3L3 13"></path></svg>
<svg class=format!("mr-1.5 h-3.5 w-3.5{}", if retrying_all.get() { " icon-spin" } else { "" }) xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"></path><path d="M21 17a9 9 0 00-9-9 9 9 0 00-6 2.3L3 13"></path></svg>
{move || if retrying_all.get() { "Retrying...".to_string() } else { format!("Retry All Errors ({})", err_count) }}
</button>
}
})
}}
<div class="ml-auto flex items-center" style="gap:0.375rem">
{/* Filter icon as SVG since lucide-leptos Filter unavailable */}
<div class="filter-group">
<svg width="16" height="16" style="color:var(--color-primary)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg>
{FILTERS.iter().map(|f| {
let f_ptr: &'static str = f;
view! {
<button class="filter-chip" class:active=move || ai_filter.get() == f_ptr on:click=move |_| set_filter(f_ptr) >{*f}</button>
<button class="filter-chip-v2" class:is-active=move || ai_filter.get() == f_ptr on:click=move |_| set_filter(f_ptr) >{*f}</button>
}
}).collect::<Vec<_>>()}
</div>
@@ -281,7 +277,7 @@ pub fn MessagesPanel() -> impl IntoView {
{move || show_search.get().then(|| {
let n = search_results.get().len();
view! {
<div class="text-sm text-secondary">
<div class="search-count">
"Found " {n} " result" {if n != 1 { "s" } else { "" }}
</div>
}