skill_web/store/
ui.rs

1//! UI state store
2//!
3//! Manages transient UI state like sidebar visibility, notifications, and modals.
4
5use serde::{Deserialize, Serialize};
6use yewdux::prelude::*;
7
8/// Notification severity level
9#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
10pub enum NotificationLevel {
11    #[default]
12    Info,
13    Success,
14    Warning,
15    Error,
16}
17
18impl NotificationLevel {
19    pub fn as_str(&self) -> &'static str {
20        match self {
21            Self::Info => "info",
22            Self::Success => "success",
23            Self::Warning => "warning",
24            Self::Error => "error",
25        }
26    }
27
28    pub fn icon(&self) -> &'static str {
29        match self {
30            Self::Info => "info",
31            Self::Success => "check-circle",
32            Self::Warning => "alert-triangle",
33            Self::Error => "x-circle",
34        }
35    }
36}
37
38/// Notification message
39#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
40pub struct Notification {
41    /// Unique ID
42    pub id: String,
43    /// Title
44    pub title: String,
45    /// Message content
46    pub message: String,
47    /// Severity level
48    pub level: NotificationLevel,
49    /// Auto-dismiss after milliseconds (None = persistent)
50    pub auto_dismiss_ms: Option<u32>,
51    /// Whether the notification can be dismissed
52    pub dismissible: bool,
53    /// Optional action button text
54    pub action_text: Option<String>,
55    /// Timestamp (ISO 8601)
56    pub timestamp: String,
57}
58
59impl Notification {
60    pub fn info(title: impl Into<String>, message: impl Into<String>) -> Self {
61        Self::new(title, message, NotificationLevel::Info)
62    }
63
64    pub fn success(title: impl Into<String>, message: impl Into<String>) -> Self {
65        Self::new(title, message, NotificationLevel::Success)
66    }
67
68    pub fn warning(title: impl Into<String>, message: impl Into<String>) -> Self {
69        Self::new(title, message, NotificationLevel::Warning)
70    }
71
72    pub fn error(title: impl Into<String>, message: impl Into<String>) -> Self {
73        Self::new(title, message, NotificationLevel::Error)
74    }
75
76    fn new(title: impl Into<String>, message: impl Into<String>, level: NotificationLevel) -> Self {
77        Self {
78            id: uuid::Uuid::new_v4().to_string(),
79            title: title.into(),
80            message: message.into(),
81            level,
82            auto_dismiss_ms: Some(5000),
83            dismissible: true,
84            action_text: None,
85            timestamp: chrono::Utc::now().to_rfc3339(),
86        }
87    }
88
89    pub fn persistent(mut self) -> Self {
90        self.auto_dismiss_ms = None;
91        self
92    }
93
94    pub fn with_action(mut self, text: impl Into<String>) -> Self {
95        self.action_text = Some(text.into());
96        self
97    }
98}
99
100/// Modal dialog state
101#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
102pub struct ModalState {
103    /// Whether any modal is open
104    pub open: bool,
105    /// Current modal type
106    pub modal_type: Option<ModalType>,
107    /// Modal data (JSON-serializable)
108    pub data: Option<String>,
109}
110
111/// Types of modal dialogs
112#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
113pub enum ModalType {
114    /// Confirm an action
115    Confirm,
116    /// Install a skill
117    InstallSkill,
118    /// Uninstall a skill
119    UninstallSkill,
120    /// Configure an instance
121    ConfigureInstance,
122    /// View execution details
123    ExecutionDetails,
124    /// Export data
125    Export,
126    /// Import data
127    Import,
128    /// About dialog
129    About,
130}
131
132/// Command palette state
133#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
134pub struct CommandPaletteState {
135    /// Whether the command palette is open
136    pub open: bool,
137    /// Current search query
138    pub query: String,
139    /// Selected index
140    pub selected_index: usize,
141}
142
143/// UI store state
144#[derive(Clone, Debug, Default, PartialEq, Store)]
145pub struct UiStore {
146    /// Whether the sidebar is open (for mobile)
147    pub sidebar_open: bool,
148    /// Whether the sidebar is collapsed (for desktop)
149    pub sidebar_collapsed: bool,
150    /// Active notifications
151    pub notifications: Vec<Notification>,
152    /// Modal state
153    pub modal: ModalState,
154    /// Command palette state
155    pub command_palette: CommandPaletteState,
156    /// Currently focused element ID
157    pub focused_element: Option<String>,
158    /// Whether dark mode is active (resolved from settings)
159    pub dark_mode: bool,
160    /// Loading overlay visible
161    pub loading_overlay: bool,
162    /// Loading message
163    pub loading_message: Option<String>,
164    /// Mobile breakpoint active
165    pub is_mobile: bool,
166    /// Touch device detected
167    pub is_touch_device: bool,
168}
169
170impl UiStore {
171    /// Maximum notifications to show
172    const MAX_NOTIFICATIONS: usize = 5;
173
174    /// Get visible notifications (limited)
175    pub fn visible_notifications(&self) -> &[Notification] {
176        let end = self.notifications.len().min(Self::MAX_NOTIFICATIONS);
177        &self.notifications[..end]
178    }
179
180    /// Check if any modal is open
181    pub fn has_modal(&self) -> bool {
182        self.modal.open
183    }
184
185    /// Check if command palette is open
186    pub fn is_command_palette_open(&self) -> bool {
187        self.command_palette.open
188    }
189}
190
191/// UI store actions
192pub enum UiAction {
193    // Sidebar
194    ToggleSidebar,
195    SetSidebarOpen(bool),
196    ToggleSidebarCollapsed,
197    SetSidebarCollapsed(bool),
198    // Notifications
199    AddNotification(Notification),
200    DismissNotification(String),
201    ClearNotifications,
202    // Modal
203    OpenModal(ModalType, Option<String>),
204    CloseModal,
205    // Command palette
206    OpenCommandPalette,
207    CloseCommandPalette,
208    SetCommandPaletteQuery(String),
209    SelectCommandPaletteItem(usize),
210    CommandPaletteUp,
211    CommandPaletteDown,
212    // Loading
213    ShowLoading(Option<String>),
214    HideLoading,
215    // Responsive
216    SetIsMobile(bool),
217    SetIsTouchDevice(bool),
218    // Theme
219    SetDarkMode(bool),
220    // Focus
221    SetFocusedElement(Option<String>),
222}
223
224impl Reducer<UiStore> for UiAction {
225    fn apply(self, mut store: std::rc::Rc<UiStore>) -> std::rc::Rc<UiStore> {
226        let state = std::rc::Rc::make_mut(&mut store);
227
228        match self {
229            // Sidebar
230            UiAction::ToggleSidebar => {
231                state.sidebar_open = !state.sidebar_open;
232            }
233            UiAction::SetSidebarOpen(open) => {
234                state.sidebar_open = open;
235            }
236            UiAction::ToggleSidebarCollapsed => {
237                state.sidebar_collapsed = !state.sidebar_collapsed;
238            }
239            UiAction::SetSidebarCollapsed(collapsed) => {
240                state.sidebar_collapsed = collapsed;
241            }
242            // Notifications
243            UiAction::AddNotification(notification) => {
244                // Add to front
245                state.notifications.insert(0, notification);
246                // Limit total notifications
247                if state.notifications.len() > 20 {
248                    state.notifications.truncate(20);
249                }
250            }
251            UiAction::DismissNotification(id) => {
252                state.notifications.retain(|n| n.id != id);
253            }
254            UiAction::ClearNotifications => {
255                state.notifications.clear();
256            }
257            // Modal
258            UiAction::OpenModal(modal_type, data) => {
259                state.modal = ModalState {
260                    open: true,
261                    modal_type: Some(modal_type),
262                    data,
263                };
264            }
265            UiAction::CloseModal => {
266                state.modal = ModalState::default();
267            }
268            // Command palette
269            UiAction::OpenCommandPalette => {
270                state.command_palette = CommandPaletteState {
271                    open: true,
272                    query: String::new(),
273                    selected_index: 0,
274                };
275            }
276            UiAction::CloseCommandPalette => {
277                state.command_palette = CommandPaletteState::default();
278            }
279            UiAction::SetCommandPaletteQuery(query) => {
280                state.command_palette.query = query;
281                state.command_palette.selected_index = 0;
282            }
283            UiAction::SelectCommandPaletteItem(index) => {
284                state.command_palette.selected_index = index;
285            }
286            UiAction::CommandPaletteUp => {
287                if state.command_palette.selected_index > 0 {
288                    state.command_palette.selected_index -= 1;
289                }
290            }
291            UiAction::CommandPaletteDown => {
292                state.command_palette.selected_index += 1;
293            }
294            // Loading
295            UiAction::ShowLoading(message) => {
296                state.loading_overlay = true;
297                state.loading_message = message;
298            }
299            UiAction::HideLoading => {
300                state.loading_overlay = false;
301                state.loading_message = None;
302            }
303            // Responsive
304            UiAction::SetIsMobile(is_mobile) => {
305                state.is_mobile = is_mobile;
306                // Auto-close sidebar on mobile
307                if is_mobile {
308                    state.sidebar_open = false;
309                }
310            }
311            UiAction::SetIsTouchDevice(is_touch) => {
312                state.is_touch_device = is_touch;
313            }
314            // Theme
315            UiAction::SetDarkMode(dark) => {
316                state.dark_mode = dark;
317            }
318            // Focus
319            UiAction::SetFocusedElement(element) => {
320                state.focused_element = element;
321            }
322        }
323
324        store
325    }
326}