Skip to content
Merged
190 changes: 188 additions & 2 deletions crates/frontend/input.css
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,31 @@
transform: translateY(0) scale(0.97);
}

/* Danger variant of the terminal button (destructive actions on the gateway
admin pages) — keeps the terminal layout, swaps in a clear red treatment so
deletes don't read as ordinary buttons. Pair with the base .btn-terminal. */
.btn-terminal-danger {
border-color: rgba(239, 68, 68, 0.42);
background: rgba(239, 68, 68, 0.08);
color: #dc2626;
}
.btn-terminal-danger:hover {
border-color: rgba(239, 68, 68, 0.65);
background: rgba(239, 68, 68, 0.16);
color: #b91c1c;
}
[data-theme="dark"] .btn-terminal-danger,
[theme="dark"] .btn-terminal-danger {
color: #fca5a5;
border-color: rgba(239, 68, 68, 0.42);
background: rgba(239, 68, 68, 0.13);
}
[data-theme="dark"] .btn-terminal-danger:hover,
[theme="dark"] .btn-terminal-danger:hover {
color: #fecaca;
background: rgba(239, 68, 68, 0.22);
}

.btn-terminal-primary {
background: var(--primary);
color: #fff;
Expand Down Expand Up @@ -806,14 +831,175 @@
.btn-fluent-search-hero:disabled,
.btn-fluent-ghost:disabled,
.btn-fluent-icon:disabled,
.btn-fluent-danger:disabled,
.btn-fluent-primary.disabled,
.btn-fluent-secondary.disabled,
.btn-fluent-search-hero.disabled,
.btn-fluent-ghost.disabled,
.btn-fluent-icon.disabled {
opacity: 0.4;
.btn-fluent-icon.disabled,
.btn-fluent-danger.disabled {
opacity: 0.45;
cursor: not-allowed;
pointer-events: none;
filter: saturate(0.55);
}

/* ============================================================
Admin foundation (Phase 0): shared building blocks reused by
every admin page — danger button, status pills, empty states,
and the detail drawer.
============================================================ */

/* Danger button for destructive actions (delete / reject) — these used to
be styled as plain secondary buttons, so they didn't read as dangerous. */
.btn-fluent-danger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0.5rem 1rem;
border-radius: var(--radius);
border: 1px solid transparent;
background: #dc2626;
color: #fff;
font-weight: 600;
box-shadow: var(--shadow-2);
transition: transform var(--motion-fast) var(--ease-snap),
box-shadow var(--motion-fast) var(--ease-snap),
background-color var(--motion-fast) var(--ease-snap);
}
.btn-fluent-danger:hover {
background: #b91c1c;
color: #fff;
box-shadow: var(--shadow-4);
transform: scale(1.02);
}
.btn-fluent-danger:active {
transform: scale(0.98);
}
[data-theme=dark] .btn-fluent-danger {
background: #ef4444;
}
[data-theme=dark] .btn-fluent-danger:hover {
background: #dc2626;
}

/* Copy button "copied" flash. */
.btn-copy-inline--done {
color: var(--primary);
}

/* Status pill (StatusBadge component): one tone+icon map for all admin. */
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.12rem 0.5rem;
border-radius: 999px;
border: 1px solid transparent;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
line-height: 1.6;
}
.status-badge__icon {
font-size: 0.82em;
opacity: 0.85;
}
.status-badge--pending { background: rgba(245, 158, 11, 0.14); color: #b45309; border-color: rgba(245, 158, 11, 0.3); }
.status-badge--approved { background: rgba(14, 165, 233, 0.14); color: #0369a1; border-color: rgba(14, 165, 233, 0.3); }
.status-badge--running { background: rgba(99, 102, 241, 0.14); color: #4338ca; border-color: rgba(99, 102, 241, 0.3); }
.status-badge--done { background: rgba(16, 185, 129, 0.14); color: #047857; border-color: rgba(16, 185, 129, 0.3); }
.status-badge--failed { background: rgba(239, 68, 68, 0.14); color: #b91c1c; border-color: rgba(239, 68, 68, 0.3); }
.status-badge--rejected { background: rgba(100, 116, 139, 0.14); color: #475569; border-color: rgba(100, 116, 139, 0.3); }
.status-badge--neutral { background: var(--surface-alt); color: var(--muted); border-color: var(--border); }
[data-theme=dark] .status-badge--pending { color: #fcd34d; background: rgba(245, 158, 11, 0.18); border-color: rgba(245, 158, 11, 0.34); }
[data-theme=dark] .status-badge--approved { color: #7dd3fc; background: rgba(14, 165, 233, 0.18); border-color: rgba(14, 165, 233, 0.34); }
[data-theme=dark] .status-badge--running { color: #a5b4fc; background: rgba(99, 102, 241, 0.2); border-color: rgba(99, 102, 241, 0.36); }
[data-theme=dark] .status-badge--done { color: #6ee7b7; background: rgba(16, 185, 129, 0.18); border-color: rgba(16, 185, 129, 0.34); }
[data-theme=dark] .status-badge--failed { color: #fca5a5; background: rgba(239, 68, 68, 0.2); border-color: rgba(239, 68, 68, 0.36); }
[data-theme=dark] .status-badge--rejected { color: #cbd5e1; background: rgba(100, 116, 139, 0.2); border-color: rgba(100, 116, 139, 0.36); }

/* Empty / error placeholder (EmptyState component). */
.admin-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 2.5rem 1.25rem;
text-align: center;
color: var(--muted);
}
.admin-empty__icon { font-size: 2rem; opacity: 0.4; }
.admin-empty__title { margin: 0; font-size: 0.95rem; font-weight: 600; color: var(--text); }
.admin-empty__hint { margin: 0; font-size: 0.82rem; color: var(--muted); max-width: 28rem; }
.admin-empty--error .admin-empty__icon { color: #ef4444; opacity: 0.6; }

/* Right-side detail drawer (Drawer component). */
.admin-drawer-root {
position: fixed;
inset: 0;
z-index: 130;
pointer-events: none;
visibility: hidden;
}
.admin-drawer-root--open {
pointer-events: auto;
visibility: visible;
}
.admin-drawer-backdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.4);
opacity: 0;
transition: opacity var(--motion-base) var(--ease-snap);
}
.admin-drawer-root--open .admin-drawer-backdrop { opacity: 1; }
.admin-drawer {
position: absolute;
top: 0;
right: 0;
height: 100%;
width: min(34rem, 94vw);
display: flex;
flex-direction: column;
background: var(--surface);
border-left: 1px solid var(--border);
box-shadow: var(--shadow-16);
transform: translateX(100%);
transition: transform var(--motion-base) var(--ease-spring);
}
.admin-drawer-root--open .admin-drawer { transform: translateX(0); }
.admin-drawer__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border);
background: var(--surface-alt);
}
.admin-drawer__title { margin: 0; font-size: 1rem; font-weight: 700; color: var(--text); }
.admin-drawer__close {
flex-shrink: 0;
width: 2rem;
height: 2rem;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface);
color: var(--muted);
cursor: pointer;
transition: color var(--motion-fast) var(--ease-snap),
background-color var(--motion-fast) var(--ease-snap);
}
.admin-drawer__close:hover { color: var(--text); background: var(--surface-alt); }
.admin-drawer__body { flex: 1; overflow-y: auto; padding: 1.25rem; }

@media (prefers-reduced-motion: reduce) {
.admin-drawer,
.admin-drawer-backdrop { transition: none; }
}

/* Button elevation (vNext: shimmer streak removed) */
Expand Down
64 changes: 64 additions & 0 deletions crates/frontend/src/components/copy_button.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//! Small copy-to-clipboard button shared across admin tables.
//!
//! Replaces the per-page `copy_icon_button` + `copy_text` FFI duplicated in
//! admin.rs / admin_llm_gateway.rs / admin_kiro_gateway.rs, and adds a brief
//! "copied" checkmark so the click registers.

use wasm_bindgen::prelude::*;
use yew::prelude::*;

#[wasm_bindgen(inline_js = r#"
export function sf_copy_text(text) {
if (navigator.clipboard) {
navigator.clipboard.writeText(text).catch(function(){});
}
}
"#)]
extern "C" {
fn sf_copy_text(text: &str);
}

/// Props for [`CopyButton`].
#[derive(Properties, PartialEq)]
pub struct CopyButtonProps {
/// Text written to the clipboard on click.
pub text: AttrValue,
/// Extra classes merged onto the button.
#[prop_or_default]
pub class: Classes,
/// Tooltip / accessible label.
#[prop_or(AttrValue::Static("复制"))]
pub title: AttrValue,
}

/// Inline copy button that flips to a checkmark for ~1.2s after copying.
#[function_component(CopyButton)]
pub fn copy_button(props: &CopyButtonProps) -> Html {
let copied = use_state(|| false);
let onclick = {
let text = props.text.clone();
let copied = copied.clone();
Callback::from(move |_: MouseEvent| {
sf_copy_text(&text);
copied.set(true);
let copied = copied.clone();
gloo_timers::callback::Timeout::new(1200, move || copied.set(false)).forget();
})
};
let icon = if *copied { "fa-check" } else { "fa-copy" };
html! {
<button
type="button"
class={classes!(
"btn-copy-inline",
props.class.clone(),
copied.then_some("btn-copy-inline--done")
)}
onclick={onclick}
title={props.title.clone()}
aria-label={props.title.clone()}
>
<i class={classes!("fas", icon)} aria-hidden="true"></i>
</button>
}
}
55 changes: 55 additions & 0 deletions crates/frontend/src/components/drawer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//! Right-side slide-out panel for record detail / edit views.
//!
//! Admin detail editors used to render inline *below* the table, so selecting a
//! row pushed the table out of view and the editor drowned the list on mobile.
//! A drawer keeps the table in place and overlays the detail, with a backdrop
//! to dismiss. Always mounted so it can animate; gated by `open`.

use yew::prelude::*;

/// Props for [`Drawer`].
#[derive(Properties, PartialEq)]
pub struct DrawerProps {
/// Whether the drawer is visible.
pub open: bool,
/// Fired by the backdrop and the close button.
pub on_close: Callback<MouseEvent>,
/// Header title.
#[prop_or_default]
pub title: AttrValue,
/// Extra classes on the panel (e.g. a width override).
#[prop_or_default]
pub class: Classes,
/// Drawer body content.
pub children: Html,
}

/// A dismissible right-side panel.
#[function_component(Drawer)]
pub fn drawer(props: &DrawerProps) -> Html {
let open_class = props.open.then_some("admin-drawer-root--open");
html! {
<div
class={classes!("admin-drawer-root", open_class)}
aria-hidden={if props.open { "false" } else { "true" }}
>
<div class={classes!("admin-drawer-backdrop")} onclick={props.on_close.clone()} />
<aside class={classes!("admin-drawer", props.class.clone())} role="dialog" aria-modal="true">
<header class={classes!("admin-drawer__header")}>
<h3 class={classes!("admin-drawer__title")}>{ props.title.clone() }</h3>
<button
type="button"
class={classes!("admin-drawer__close")}
onclick={props.on_close.clone()}
aria-label="关闭"
>
<i class="fas fa-xmark" aria-hidden="true"></i>
</button>
</header>
<div class={classes!("admin-drawer__body")}>
{ props.children.clone() }
</div>
</aside>
</div>
}
}
43 changes: 43 additions & 0 deletions crates/frontend/src/components/empty_state.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//! Centered placeholder for empty / error admin lists and tables.
//!
//! Admin tables used to render an empty `<tbody>` (often with pagination still
//! showing), so "no data" was indistinguishable from "still loading" or "the
//! request failed". This gives a single, legible empty/error state with an
//! optional action slot (e.g. a Retry button).

use yew::prelude::*;

/// Props for [`EmptyState`].
#[derive(Properties, PartialEq)]
pub struct EmptyStateProps {
/// FontAwesome icon class (e.g. `fa-inbox`, `fa-triangle-exclamation`).
#[prop_or(AttrValue::Static("fa-inbox"))]
pub icon: AttrValue,
/// Primary message.
pub title: AttrValue,
/// Optional secondary hint line.
#[prop_or_default]
pub hint: Option<AttrValue>,
/// `"neutral"` (default) or `"error"` for failure states.
#[prop_or(AttrValue::Static("neutral"))]
pub tone: AttrValue,
/// Optional action (e.g. a Retry button) rendered under the hint.
#[prop_or_default]
pub children: Html,
}

/// A centered icon + title + hint + optional action block.
#[function_component(EmptyState)]
pub fn empty_state(props: &EmptyStateProps) -> Html {
let tone_class = (props.tone == "error").then_some("admin-empty--error");
html! {
<div class={classes!("admin-empty", tone_class)}>
<i class={classes!("fas", props.icon.clone(), "admin-empty__icon")} aria-hidden="true"></i>
<p class={classes!("admin-empty__title")}>{ props.title.clone() }</p>
if let Some(hint) = props.hint.clone() {
<p class={classes!("admin-empty__hint")}>{ hint }</p>
}
{ props.children.clone() }
</div>
}
}
4 changes: 4 additions & 0 deletions crates/frontend/src/components/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
pub mod admin_local_media_uploads;
pub mod article_card;
pub mod audio_player;
pub mod copy_button;
pub mod date_range_picker;
pub mod drawer;
pub mod empty_state;
pub mod error_banner;
pub mod footer;
pub mod form;
Expand All @@ -24,6 +27,7 @@ pub mod search_suggest;
pub mod skeleton;
pub mod spotlight;
pub mod stats_card;
pub mod status_badge;
pub mod stream_chunk_batcher;
pub mod synced_lyrics;
pub mod tab_bar;
Expand Down
Loading
Loading