mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
feat(frontend): extract frontend into ironclaw_frontend crate with widget extension system
Moves all frontend static assets (app.js, style.css, index.html, i18n/*,
theme-init.js, favicon.ico) from src/channels/web/static/ into a dedicated
ironclaw_frontend crate. The crate also adds:
- Layout configuration types (branding, tab order, chat features, per-widget config)
- Widget manifest types with named slot system (tab, chat_header, sidebar, etc.)
- CSS scoping utility (auto-prefixes selectors with [data-widget="id"])
- Bundle assembly (injects layout config, widgets, and custom CSS into HTML)
- Frontend API endpoints (GET/PUT layout, list widgets, serve widget files)
- Browser-side IronClaw.registerWidget() API with authenticated fetch,
event subscription, theme access, and i18n
Widgets are stored in workspace at frontend/widgets/{id}/ and served via
the API. Layout config is stored at frontend/layout.json. The agent can
create/edit both using existing memory_write/memory_read tools.
Gateway handlers now reference ironclaw_frontend::assets constants instead
of include_str!() with local paths, completing the separation.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
Generated
+10
@@ -3429,6 +3429,7 @@ dependencies = [
|
||||
"iana-time-zone",
|
||||
"insta",
|
||||
"ironclaw_common",
|
||||
"ironclaw_frontend",
|
||||
"ironclaw_safety",
|
||||
"json5",
|
||||
"libsql",
|
||||
@@ -3496,6 +3497,15 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ironclaw_frontend"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ironclaw_safety"
|
||||
version = "0.2.0"
|
||||
|
||||
+2
-1
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = [".", "crates/ironclaw_common", "crates/ironclaw_safety"]
|
||||
members = [".", "crates/ironclaw_common", "crates/ironclaw_safety", "crates/ironclaw_frontend"]
|
||||
exclude = [
|
||||
"channels-src/discord",
|
||||
"channels-src/telegram",
|
||||
@@ -105,6 +105,7 @@ cron = "0.13"
|
||||
ironclaw_common = { path = "crates/ironclaw_common", version = "0.1.0" }
|
||||
|
||||
# Safety/sanitization
|
||||
ironclaw_frontend = { path = "crates/ironclaw_frontend", version = "0.1.0" }
|
||||
ironclaw_safety = { path = "crates/ironclaw_safety", version = "0.2.0" }
|
||||
regex = "1"
|
||||
aho-corasick = "1"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "ironclaw_frontend"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
description = "Frontend assets, layout configuration, and widget extension system for IronClaw"
|
||||
|
||||
[package.metadata.dist]
|
||||
dist = false
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Embedded static assets for the IronClaw web gateway.
|
||||
//!
|
||||
//! All frontend files are compiled into the binary via `include_str!()` /
|
||||
//! `include_bytes!()`. The web gateway serves these as the default baseline;
|
||||
//! workspace-stored customizations (layout config, widgets, CSS overrides)
|
||||
//! are layered on top at runtime.
|
||||
|
||||
// ==================== Core Files ====================
|
||||
|
||||
/// Main HTML page (SPA shell).
|
||||
pub const INDEX_HTML: &str = include_str!("../static/index.html");
|
||||
|
||||
/// Main application JavaScript.
|
||||
pub const APP_JS: &str = include_str!("../static/app.js");
|
||||
|
||||
/// Base stylesheet.
|
||||
pub const STYLE_CSS: &str = include_str!("../static/style.css");
|
||||
|
||||
/// Theme initialization script (runs synchronously in `<head>` to prevent FOUC).
|
||||
pub const THEME_INIT_JS: &str = include_str!("../static/theme-init.js");
|
||||
|
||||
/// Favicon.
|
||||
pub const FAVICON_ICO: &[u8] = include_bytes!("../static/favicon.ico");
|
||||
|
||||
// ==================== Internationalization ====================
|
||||
|
||||
/// i18n core library.
|
||||
pub const I18N_INDEX_JS: &str = include_str!("../static/i18n/index.js");
|
||||
|
||||
/// English translations.
|
||||
pub const I18N_EN_JS: &str = include_str!("../static/i18n/en.js");
|
||||
|
||||
/// Chinese (Simplified) translations.
|
||||
pub const I18N_ZH_CN_JS: &str = include_str!("../static/i18n/zh-CN.js");
|
||||
|
||||
/// i18n integration with the app.
|
||||
pub const I18N_APP_JS: &str = include_str!("../static/i18n-app.js");
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Frontend bundle assembly.
|
||||
//!
|
||||
//! Combines the embedded base HTML with workspace customizations (layout
|
||||
//! config, widgets, CSS overrides) into the final served page.
|
||||
|
||||
use crate::layout::LayoutConfig;
|
||||
use crate::widget::{WidgetManifest, scope_css};
|
||||
|
||||
/// A resolved frontend bundle ready for serving.
|
||||
///
|
||||
/// Contains the layout configuration, resolved widgets (with their JS/CSS
|
||||
/// content loaded), and any custom CSS overrides.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FrontendBundle {
|
||||
/// Layout configuration (branding, tabs, chat settings).
|
||||
pub layout: LayoutConfig,
|
||||
|
||||
/// Resolved widgets with their source code loaded.
|
||||
pub widgets: Vec<ResolvedWidget>,
|
||||
|
||||
/// Custom CSS to append after the base stylesheet.
|
||||
pub custom_css: Option<String>,
|
||||
}
|
||||
|
||||
/// A widget with its manifest and source files loaded.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedWidget {
|
||||
/// Widget metadata.
|
||||
pub manifest: WidgetManifest,
|
||||
|
||||
/// JavaScript source code (`index.js`).
|
||||
pub js: String,
|
||||
|
||||
/// Optional CSS source code (`style.css`), auto-scoped.
|
||||
pub css: Option<String>,
|
||||
}
|
||||
|
||||
/// Inject frontend customizations into the base HTML template.
|
||||
///
|
||||
/// Modifications:
|
||||
///
|
||||
/// **Before `</head>`:**
|
||||
/// - Branding CSS custom property overrides
|
||||
/// - Title override (replaces `<title>` content)
|
||||
///
|
||||
/// **Before `</body>`:**
|
||||
/// - Layout config as `window.__IRONCLAW_LAYOUT__`
|
||||
/// - Scoped widget `<style>` blocks
|
||||
/// - Widget `<script type="module">` tags
|
||||
/// - Custom CSS `<style>` block
|
||||
pub fn assemble_index(base_html: &str, bundle: &FrontendBundle) -> String {
|
||||
let mut head_injections = Vec::new();
|
||||
let mut body_injections = Vec::new();
|
||||
|
||||
// --- Head injections ---
|
||||
|
||||
// Branding CSS variables
|
||||
let css_vars = bundle.layout.branding.to_css_vars();
|
||||
if !css_vars.is_empty() {
|
||||
head_injections.push(format!("<style>{}</style>", css_vars));
|
||||
}
|
||||
|
||||
// --- Body injections ---
|
||||
|
||||
// Layout config as global variable
|
||||
if let Ok(layout_json) = serde_json::to_string(&bundle.layout) {
|
||||
body_injections.push(format!(
|
||||
"<script>window.__IRONCLAW_LAYOUT__ = {};</script>",
|
||||
layout_json
|
||||
));
|
||||
}
|
||||
|
||||
// Widget CSS (scoped) and JS
|
||||
for widget in &bundle.widgets {
|
||||
if let Some(ref css) = widget.css {
|
||||
let scoped = scope_css(css, &widget.manifest.id);
|
||||
if !scoped.trim().is_empty() {
|
||||
body_injections.push(format!(
|
||||
"<style data-widget=\"{}\">{}</style>",
|
||||
widget.manifest.id, scoped
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Widget JS as module script served from API
|
||||
body_injections.push(format!(
|
||||
"<script type=\"module\" src=\"/api/frontend/widget/{}/index.js\"></script>",
|
||||
widget.manifest.id
|
||||
));
|
||||
}
|
||||
|
||||
// Custom CSS
|
||||
if let Some(ref custom_css) = bundle.custom_css {
|
||||
if !custom_css.trim().is_empty() {
|
||||
body_injections.push(format!(
|
||||
"<style data-custom-css>{}</style>",
|
||||
custom_css
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Assemble ---
|
||||
|
||||
let mut result = base_html.to_string();
|
||||
|
||||
// Inject before </head>
|
||||
if !head_injections.is_empty() {
|
||||
let head_block = head_injections.join("\n");
|
||||
if let Some(pos) = result.rfind("</head>") {
|
||||
result.insert_str(pos, &format!("\n{}\n", head_block));
|
||||
}
|
||||
}
|
||||
|
||||
// Override <title> if branding title is set
|
||||
if let Some(ref title) = bundle.layout.branding.title {
|
||||
if let Some(start) = result.find("<title>") {
|
||||
if let Some(end) = result[start..].find("</title>") {
|
||||
let end = start + end + "</title>".len();
|
||||
result.replace_range(start..end, &format!("<title>{}</title>", title));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inject before </body>
|
||||
if !body_injections.is_empty() {
|
||||
let body_block = body_injections.join("\n");
|
||||
if let Some(pos) = result.rfind("</body>") {
|
||||
result.insert_str(pos, &format!("\n{}\n", body_block));
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::layout::*;
|
||||
use crate::widget::*;
|
||||
|
||||
const MINIMAL_HTML: &str =
|
||||
"<!DOCTYPE html><html><head><title>IronClaw</title></head><body></body></html>";
|
||||
|
||||
#[test]
|
||||
fn test_assemble_index_no_customizations() {
|
||||
let bundle = FrontendBundle::default();
|
||||
let result = assemble_index(MINIMAL_HTML, &bundle);
|
||||
// Layout config is always injected (even when default/empty)
|
||||
assert!(result.contains("window.__IRONCLAW_LAYOUT__"));
|
||||
// No branding overrides or custom CSS
|
||||
assert!(!result.contains("--color-primary"));
|
||||
assert!(!result.contains("data-custom-css"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assemble_index_branding_title() {
|
||||
let bundle = FrontendBundle {
|
||||
layout: LayoutConfig {
|
||||
branding: BrandingConfig {
|
||||
title: Some("Acme AI".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let result = assemble_index(MINIMAL_HTML, &bundle);
|
||||
assert!(result.contains("<title>Acme AI</title>"));
|
||||
assert!(!result.contains("<title>IronClaw</title>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assemble_index_branding_colors() {
|
||||
let bundle = FrontendBundle {
|
||||
layout: LayoutConfig {
|
||||
branding: BrandingConfig {
|
||||
colors: Some(BrandingColors {
|
||||
primary: Some("#0066cc".to_string()),
|
||||
accent: None,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let result = assemble_index(MINIMAL_HTML, &bundle);
|
||||
assert!(result.contains("--color-primary: #0066cc;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assemble_index_layout_config_injected() {
|
||||
let bundle = FrontendBundle {
|
||||
layout: LayoutConfig {
|
||||
tabs: TabConfig {
|
||||
hidden: Some(vec!["routines".to_string()]),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let result = assemble_index(MINIMAL_HTML, &bundle);
|
||||
assert!(result.contains("window.__IRONCLAW_LAYOUT__"));
|
||||
assert!(result.contains("routines"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assemble_index_widget_script() {
|
||||
let bundle = FrontendBundle {
|
||||
widgets: vec![ResolvedWidget {
|
||||
manifest: WidgetManifest {
|
||||
id: "dashboard".to_string(),
|
||||
name: "Dashboard".to_string(),
|
||||
slot: WidgetSlot::Tab,
|
||||
icon: None,
|
||||
position: None,
|
||||
},
|
||||
js: "console.log('hello');".to_string(),
|
||||
css: Some(".panel { color: red; }".to_string()),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let result = assemble_index(MINIMAL_HTML, &bundle);
|
||||
assert!(result.contains("src=\"/api/frontend/widget/dashboard/index.js\""));
|
||||
assert!(result.contains("data-widget=\"dashboard\""));
|
||||
assert!(result.contains("[data-widget=\"dashboard\"] .panel"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assemble_index_custom_css() {
|
||||
let bundle = FrontendBundle {
|
||||
custom_css: Some("body { background: #111; }".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let result = assemble_index(MINIMAL_HTML, &bundle);
|
||||
assert!(result.contains("data-custom-css"));
|
||||
assert!(result.contains("background: #111;"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Layout configuration types for frontend customization.
|
||||
//!
|
||||
//! A [`LayoutConfig`] is stored as `frontend/layout.json` in the workspace.
|
||||
//! It controls branding, tab visibility/order, chat features, and per-widget
|
||||
//! configuration. All fields are optional with sensible defaults.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Top-level layout configuration.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct LayoutConfig {
|
||||
/// Branding overrides (title, logo, colors).
|
||||
#[serde(default)]
|
||||
pub branding: BrandingConfig,
|
||||
|
||||
/// Tab bar configuration.
|
||||
#[serde(default)]
|
||||
pub tabs: TabConfig,
|
||||
|
||||
/// Chat panel configuration.
|
||||
#[serde(default)]
|
||||
pub chat: ChatConfig,
|
||||
|
||||
/// Per-widget instance configuration (keyed by widget ID).
|
||||
#[serde(default)]
|
||||
pub widgets: HashMap<String, WidgetInstanceConfig>,
|
||||
}
|
||||
|
||||
/// Branding overrides for the gateway UI.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct BrandingConfig {
|
||||
/// Page title (replaces default "IronClaw").
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
|
||||
/// Subtitle shown below the title.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub subtitle: Option<String>,
|
||||
|
||||
/// URL to a logo image.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub logo_url: Option<String>,
|
||||
|
||||
/// URL to a custom favicon.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub favicon_url: Option<String>,
|
||||
|
||||
/// Color overrides (injected as CSS custom properties on `:root`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub colors: Option<BrandingColors>,
|
||||
}
|
||||
|
||||
/// Color overrides for the UI theme.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct BrandingColors {
|
||||
/// Primary brand color (e.g., `"#0066cc"`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub primary: Option<String>,
|
||||
|
||||
/// Accent color.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub accent: Option<String>,
|
||||
}
|
||||
|
||||
/// Tab bar layout configuration.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TabConfig {
|
||||
/// Ordered list of tab IDs to display (built-in + widget tabs).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub order: Option<Vec<String>>,
|
||||
|
||||
/// Tab IDs to hide from the tab bar.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub hidden: Option<Vec<String>>,
|
||||
|
||||
/// Default tab to show on load.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_tab: Option<String>,
|
||||
}
|
||||
|
||||
/// Chat panel feature flags.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ChatConfig {
|
||||
/// Show suggestion chips below the input.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub suggestions: Option<bool>,
|
||||
|
||||
/// Enable image upload in the chat input.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub image_upload: Option<bool>,
|
||||
}
|
||||
|
||||
/// Per-widget instance configuration.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct WidgetInstanceConfig {
|
||||
/// Whether this widget is enabled.
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
|
||||
/// Arbitrary widget-specific configuration passed to `widget.init()`.
|
||||
#[serde(default)]
|
||||
pub config: serde_json::Value,
|
||||
}
|
||||
|
||||
impl BrandingConfig {
|
||||
/// Generate CSS custom property overrides for injection into `:root`.
|
||||
pub fn to_css_vars(&self) -> String {
|
||||
let mut vars = Vec::new();
|
||||
if let Some(ref colors) = self.colors {
|
||||
if let Some(ref primary) = colors.primary {
|
||||
vars.push(format!("--color-primary: {};", primary));
|
||||
}
|
||||
if let Some(ref accent) = colors.accent {
|
||||
vars.push(format!("--color-accent: {};", accent));
|
||||
}
|
||||
}
|
||||
if vars.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(":root {{ {} }}", vars.join(" "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_layout_config_default_is_empty() {
|
||||
let config = LayoutConfig::default();
|
||||
assert!(config.branding.title.is_none());
|
||||
assert!(config.tabs.order.is_none());
|
||||
assert!(config.widgets.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_layout_config_roundtrip() {
|
||||
let json = serde_json::json!({
|
||||
"branding": { "title": "Acme AI", "colors": { "primary": "#0066cc" } },
|
||||
"tabs": { "order": ["chat", "memory"], "hidden": ["routines"] },
|
||||
"widgets": { "dashboard": { "enabled": true, "config": { "refresh": 30 } } }
|
||||
});
|
||||
let config: LayoutConfig = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(config.branding.title.as_deref(), Some("Acme AI"));
|
||||
assert_eq!(config.tabs.hidden.as_ref().map(|h| h.len()), Some(1));
|
||||
assert!(config.widgets.get("dashboard").is_some_and(|w| w.enabled));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_branding_css_vars_empty() {
|
||||
let branding = BrandingConfig::default();
|
||||
assert!(branding.to_css_vars().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_branding_css_vars_with_colors() {
|
||||
let branding = BrandingConfig {
|
||||
colors: Some(BrandingColors {
|
||||
primary: Some("#0066cc".to_string()),
|
||||
accent: Some("#ff6b00".to_string()),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let css = branding.to_css_vars();
|
||||
assert!(css.contains("--color-primary: #0066cc;"));
|
||||
assert!(css.contains("--color-accent: #ff6b00;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_partial_deserialization() {
|
||||
let json = serde_json::json!({"branding": {"title": "Test"}});
|
||||
let config: LayoutConfig = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(config.branding.title.as_deref(), Some("Test"));
|
||||
assert!(config.chat.suggestions.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! IronClaw Frontend — assets, layout configuration, and widget extension system.
|
||||
//!
|
||||
//! This crate owns the complete frontend for the IronClaw web gateway:
|
||||
//!
|
||||
//! - **Embedded assets** (`assets` module): HTML, JS, CSS, i18n files compiled
|
||||
//! into the binary for zero-dependency serving.
|
||||
//! - **Layout configuration** (`layout` module): Branding, tab order, feature
|
||||
//! flags — customizable per-tenant via workspace.
|
||||
//! - **Widget system** (`widget` module): Self-contained frontend components
|
||||
//! that plug into named slots in the UI.
|
||||
//! - **Bundle assembly** (`bundle` module): Combines base assets with workspace
|
||||
//! customizations into the final served HTML.
|
||||
|
||||
pub mod assets;
|
||||
mod bundle;
|
||||
mod layout;
|
||||
mod widget;
|
||||
|
||||
pub use bundle::{FrontendBundle, ResolvedWidget, assemble_index};
|
||||
pub use layout::{
|
||||
BrandingColors, BrandingConfig, ChatConfig, LayoutConfig, TabConfig, WidgetInstanceConfig,
|
||||
};
|
||||
pub use widget::{WidgetManifest, WidgetSlot, scope_css};
|
||||
|
||||
/// Errors from frontend operations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FrontendError {
|
||||
#[error("Layout configuration is invalid: {reason}")]
|
||||
InvalidLayout { reason: String },
|
||||
|
||||
#[error("Widget '{id}' not found")]
|
||||
WidgetNotFound { id: String },
|
||||
|
||||
#[error("Widget manifest is invalid: {reason}")]
|
||||
InvalidManifest { reason: String },
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Widget system types and utilities.
|
||||
//!
|
||||
//! Widgets are self-contained frontend components that plug into named
|
||||
//! [`WidgetSlot`]s in the UI. Each widget has a manifest (`widget.json`)
|
||||
//! and implementation files (`index.js`, optional `style.css`).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Widget manifest — metadata about a widget component.
|
||||
///
|
||||
/// Stored as `frontend/widgets/{id}/manifest.json` in the workspace.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WidgetManifest {
|
||||
/// Unique widget identifier (must be a valid HTML attribute value).
|
||||
pub id: String,
|
||||
|
||||
/// Human-readable widget name.
|
||||
pub name: String,
|
||||
|
||||
/// Where this widget is rendered in the UI.
|
||||
pub slot: WidgetSlot,
|
||||
|
||||
/// Optional icon identifier (CSS class or emoji).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
|
||||
/// Positioning hint (e.g., `"after:memory"`, `"before:jobs"`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub position: Option<String>,
|
||||
}
|
||||
|
||||
/// Named insertion points in the UI where widgets can be rendered.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WidgetSlot {
|
||||
/// Full tab panel (adds a new tab to the tab bar).
|
||||
Tab,
|
||||
/// Banner area above the chat message list.
|
||||
ChatHeader,
|
||||
/// Area below the chat input.
|
||||
ChatFooter,
|
||||
/// Extra action buttons next to the send button.
|
||||
ChatActions,
|
||||
/// Right sidebar panel.
|
||||
Sidebar,
|
||||
/// Left side of the status bar.
|
||||
StatusLeft,
|
||||
/// Right side of the status bar.
|
||||
StatusRight,
|
||||
/// Additional section in the Settings tab.
|
||||
SettingsSection,
|
||||
}
|
||||
|
||||
/// Prefix every CSS selector with `[data-widget="{widget_id}"]` for style isolation.
|
||||
///
|
||||
/// This prevents widget styles from bleeding into the main app or other widgets.
|
||||
/// The widget container element gets `data-widget="{id}"` set by the runtime.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use ironclaw_frontend::scope_css;
|
||||
///
|
||||
/// let scoped = scope_css(".title { color: red; }", "my-widget");
|
||||
/// assert!(scoped.contains("[data-widget=\"my-widget\"] .title"));
|
||||
/// ```
|
||||
pub fn scope_css(css: &str, widget_id: &str) -> String {
|
||||
let prefix = format!("[data-widget=\"{}\"]", widget_id);
|
||||
let mut result = String::with_capacity(css.len() + css.len() / 4);
|
||||
let mut chars = css.chars().peekable();
|
||||
let mut in_block = false;
|
||||
let mut current_selector = String::new();
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'{' if !in_block => {
|
||||
// Scope each comma-separated selector
|
||||
let selectors: Vec<&str> = current_selector.split(',').collect();
|
||||
let scoped: Vec<String> = selectors
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let s = s.trim();
|
||||
if s.is_empty() || s.starts_with('@') {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{} {}", prefix, s)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
result.push_str(&scoped.join(", "));
|
||||
result.push_str(" {");
|
||||
current_selector.clear();
|
||||
in_block = true;
|
||||
}
|
||||
'}' if in_block => {
|
||||
result.push('}');
|
||||
in_block = false;
|
||||
}
|
||||
_ if in_block => {
|
||||
result.push(ch);
|
||||
}
|
||||
_ => {
|
||||
current_selector.push(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append any trailing content
|
||||
if !current_selector.is_empty() {
|
||||
result.push_str(¤t_selector);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_widget_manifest_roundtrip() {
|
||||
let json = serde_json::json!({
|
||||
"id": "dashboard",
|
||||
"name": "Analytics Dashboard",
|
||||
"slot": "tab",
|
||||
"icon": "chart-bar",
|
||||
"position": "after:memory"
|
||||
});
|
||||
let manifest: WidgetManifest = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(manifest.id, "dashboard");
|
||||
assert_eq!(manifest.slot, WidgetSlot::Tab);
|
||||
assert_eq!(manifest.icon.as_deref(), Some("chart-bar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_widget_slot_serialization() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&WidgetSlot::ChatHeader).unwrap(),
|
||||
"\"chat_header\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&WidgetSlot::SettingsSection).unwrap(),
|
||||
"\"settings_section\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scope_css_basic() {
|
||||
let input = ".title { color: red; }";
|
||||
let result = scope_css(input, "my-widget");
|
||||
assert!(result.contains("[data-widget=\"my-widget\"] .title"));
|
||||
assert!(result.contains("color: red;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scope_css_multiple_selectors() {
|
||||
let input = ".a, .b { margin: 0; }";
|
||||
let result = scope_css(input, "w");
|
||||
assert!(result.contains("[data-widget=\"w\"] .a"));
|
||||
assert!(result.contains("[data-widget=\"w\"] .b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scope_css_multiple_rules() {
|
||||
let input = ".a { color: red; } .b { color: blue; }";
|
||||
let result = scope_css(input, "w");
|
||||
assert!(result.contains("[data-widget=\"w\"] .a"));
|
||||
assert!(result.contains("[data-widget=\"w\"] .b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scope_css_empty() {
|
||||
assert_eq!(scope_css("", "w"), "");
|
||||
}
|
||||
}
|
||||
@@ -6324,3 +6324,152 @@ document.getElementById('settings-search-input').addEventListener('input', funct
|
||||
activePanel.appendChild(empty);
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== Widget Extension System ====================
|
||||
//
|
||||
// Provides a registration API for frontend widgets. Widgets are self-contained
|
||||
// components that plug into named slots in the UI (tabs, sidebar, status bar, etc.).
|
||||
//
|
||||
// Widget authors call IronClaw.registerWidget({ id, name, slot, init, ... })
|
||||
// from their module script. The init() function receives a container DOM element
|
||||
// and the IronClaw.api object for authenticated fetch, event subscription, etc.
|
||||
|
||||
window.IronClaw = window.IronClaw || {};
|
||||
IronClaw.widgets = new Map();
|
||||
IronClaw._widgetInitQueue = [];
|
||||
|
||||
/**
|
||||
* Register a widget component.
|
||||
* @param {Object} def - Widget definition
|
||||
* @param {string} def.id - Unique widget identifier
|
||||
* @param {string} def.name - Display name
|
||||
* @param {string} def.slot - Target slot ('tab', 'chat_header', etc.)
|
||||
* @param {string} [def.icon] - Icon identifier
|
||||
* @param {Function} def.init - Called with (container, api) when widget activates
|
||||
* @param {Function} [def.activate] - Called when widget becomes visible
|
||||
* @param {Function} [def.deactivate] - Called when widget is hidden
|
||||
* @param {Function} [def.destroy] - Called when widget is removed
|
||||
*/
|
||||
IronClaw.registerWidget = function(def) {
|
||||
if (!def.id || !def.init) {
|
||||
console.error('[IronClaw] Widget registration requires id and init:', def);
|
||||
return;
|
||||
}
|
||||
IronClaw.widgets.set(def.id, def);
|
||||
|
||||
if (def.slot === 'tab') {
|
||||
_addWidgetTab(def);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* API object exposed to widgets for safe interaction with the app.
|
||||
*/
|
||||
IronClaw.api = {
|
||||
/** Authenticated fetch wrapper — injects the session token. */
|
||||
fetch: function(path, opts) {
|
||||
opts = opts || {};
|
||||
opts.headers = Object.assign({}, opts.headers || {}, {
|
||||
'Authorization': 'Bearer ' + token
|
||||
});
|
||||
return fetch(path, opts);
|
||||
},
|
||||
|
||||
/** Subscribe to an SSE/WebSocket event type. Returns an unsubscribe function. */
|
||||
subscribe: function(eventType, handler) {
|
||||
if (!window._widgetEventHandlers) window._widgetEventHandlers = {};
|
||||
if (!window._widgetEventHandlers[eventType]) window._widgetEventHandlers[eventType] = [];
|
||||
window._widgetEventHandlers[eventType].push(handler);
|
||||
return function() {
|
||||
var handlers = window._widgetEventHandlers[eventType];
|
||||
if (handlers) {
|
||||
var idx = handlers.indexOf(handler);
|
||||
if (idx !== -1) handlers.splice(idx, 1);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
/** Current theme information. */
|
||||
theme: {
|
||||
get current() { return document.documentElement.dataset.theme || 'dark'; }
|
||||
},
|
||||
|
||||
/** Internationalization helper. */
|
||||
i18n: {
|
||||
t: function(key) { return (window.I18n && window.I18n.t) ? window.I18n.t(key) : key; }
|
||||
},
|
||||
|
||||
/** Navigate to a tab by ID. */
|
||||
navigate: function(tabId) {
|
||||
if (typeof switchTab === 'function') switchTab(tabId);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a widget as a new tab in the tab bar.
|
||||
* @private
|
||||
*/
|
||||
function _addWidgetTab(def) {
|
||||
var tabBar = document.querySelector('.tab-bar');
|
||||
var tabContent = document.querySelector('.tab-content') || document.getElementById('tab-content');
|
||||
if (!tabBar || !tabContent) {
|
||||
// DOM not ready yet — queue for later
|
||||
IronClaw._widgetInitQueue.push(def);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create tab button
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'tab-btn';
|
||||
btn.dataset.tab = def.id;
|
||||
btn.textContent = def.name;
|
||||
if (def.icon) {
|
||||
btn.dataset.icon = def.icon;
|
||||
}
|
||||
btn.addEventListener('click', function() {
|
||||
if (typeof switchTab === 'function') switchTab(def.id);
|
||||
});
|
||||
// Insert before the settings tab (last built-in tab) or at the end
|
||||
var settingsBtn = tabBar.querySelector('[data-tab="settings"]');
|
||||
if (settingsBtn) {
|
||||
tabBar.insertBefore(btn, settingsBtn);
|
||||
} else {
|
||||
tabBar.appendChild(btn);
|
||||
}
|
||||
|
||||
// Create container panel
|
||||
var panel = document.createElement('div');
|
||||
panel.className = 'tab-panel';
|
||||
panel.dataset.tab = def.id;
|
||||
panel.dataset.widget = def.id;
|
||||
panel.style.display = 'none';
|
||||
tabContent.appendChild(panel);
|
||||
|
||||
// Initialize the widget
|
||||
try {
|
||||
def.init(panel, IronClaw.api);
|
||||
} catch (e) {
|
||||
console.error('[IronClaw] Widget "' + def.id + '" init failed:', e);
|
||||
panel.innerHTML = '<div style="padding:2rem;color:var(--color-error,red);">Widget "' +
|
||||
def.id + '" failed to load: ' + (e.message || e) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Apply layout config if injected by the server
|
||||
if (window.__IRONCLAW_LAYOUT__) {
|
||||
(function() {
|
||||
var layout = window.__IRONCLAW_LAYOUT__;
|
||||
// Apply branding title
|
||||
if (layout.branding && layout.branding.title) {
|
||||
var titleEl = document.querySelector('.app-title');
|
||||
if (titleEl) titleEl.textContent = layout.branding.title;
|
||||
}
|
||||
// Apply tab visibility
|
||||
if (layout.tabs && layout.tabs.hidden) {
|
||||
layout.tabs.hidden.forEach(function(tabId) {
|
||||
var btn = document.querySelector('.tab-btn[data-tab="' + tabId + '"]');
|
||||
if (btn) btn.style.display = 'none';
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,140 @@
|
||||
//! Frontend extension API handlers.
|
||||
//!
|
||||
//! Provides endpoints for reading/writing layout configuration and
|
||||
//! discovering/serving widget files from the workspace.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::{StatusCode, header},
|
||||
response::IntoResponse,
|
||||
};
|
||||
|
||||
use ironclaw_frontend::{LayoutConfig, WidgetManifest};
|
||||
|
||||
use crate::channels::web::auth::AuthenticatedUser;
|
||||
use crate::channels::web::handlers::memory::resolve_workspace;
|
||||
use crate::channels::web::server::GatewayState;
|
||||
|
||||
/// `GET /api/frontend/layout` — return the current layout configuration.
|
||||
///
|
||||
/// Reads `frontend/layout.json` from the workspace. Returns an empty
|
||||
/// default config if the file doesn't exist.
|
||||
pub async fn frontend_layout_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
) -> Result<Json<LayoutConfig>, (StatusCode, String)> {
|
||||
let workspace = resolve_workspace(&state, &user).await?;
|
||||
|
||||
let layout = match workspace.read("frontend/layout.json").await {
|
||||
Ok(doc) => serde_json::from_str(&doc.content).unwrap_or_default(),
|
||||
Err(_) => LayoutConfig::default(),
|
||||
};
|
||||
|
||||
Ok(Json(layout))
|
||||
}
|
||||
|
||||
/// `PUT /api/frontend/layout` — update the layout configuration.
|
||||
///
|
||||
/// Writes the provided layout config to `frontend/layout.json` in workspace.
|
||||
pub async fn frontend_layout_update_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Json(layout): Json<LayoutConfig>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let workspace = resolve_workspace(&state, &user).await?;
|
||||
|
||||
let content = serde_json::to_string_pretty(&layout).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Invalid layout config: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
workspace
|
||||
.write("frontend/layout.json", &content)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to write layout config: {e}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to write layout config".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
/// `GET /api/frontend/widgets` — list all widget manifests.
|
||||
///
|
||||
/// Scans `frontend/widgets/` in workspace for directories containing
|
||||
/// `manifest.json` and returns their parsed manifests.
|
||||
pub async fn frontend_widgets_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
) -> Result<Json<Vec<WidgetManifest>>, (StatusCode, String)> {
|
||||
let workspace = resolve_workspace(&state, &user).await?;
|
||||
|
||||
let entries = workspace.list("frontend/widgets/").await.unwrap_or_default();
|
||||
|
||||
let mut manifests = Vec::new();
|
||||
for entry in entries {
|
||||
if !entry.is_directory {
|
||||
continue;
|
||||
}
|
||||
let manifest_path = format!("frontend/widgets/{}/manifest.json", entry.name());
|
||||
if let Ok(doc) = workspace.read(&manifest_path).await {
|
||||
if let Ok(manifest) = serde_json::from_str::<WidgetManifest>(&doc.content) {
|
||||
manifests.push(manifest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(manifests))
|
||||
}
|
||||
|
||||
/// `GET /api/frontend/widget/{id}/{*file}` — serve a widget file.
|
||||
///
|
||||
/// Serves JS/CSS files from `frontend/widgets/{id}/{file}` in workspace
|
||||
/// with appropriate MIME types.
|
||||
pub async fn frontend_widget_file_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path((id, file)): Path<(String, String)>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
// Reject path traversal
|
||||
if id.contains("..") || file.contains("..") {
|
||||
return Err((StatusCode::BAD_REQUEST, "Invalid path".to_string()));
|
||||
}
|
||||
|
||||
let workspace = resolve_workspace(&state, &user).await?;
|
||||
let path = format!("frontend/widgets/{}/{}", id, file);
|
||||
|
||||
let doc = workspace.read(&path).await.map_err(|_| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("Widget file not found: {path}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Determine MIME type from extension
|
||||
let content_type = if file.ends_with(".js") {
|
||||
"application/javascript"
|
||||
} else if file.ends_with(".css") {
|
||||
"text/css"
|
||||
} else if file.ends_with(".json") {
|
||||
"application/json"
|
||||
} else {
|
||||
"text/plain"
|
||||
};
|
||||
|
||||
Ok((
|
||||
[
|
||||
(header::CONTENT_TYPE, content_type),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
doc.content,
|
||||
))
|
||||
}
|
||||
@@ -20,4 +20,5 @@ pub mod extensions;
|
||||
pub mod settings;
|
||||
#[allow(dead_code)]
|
||||
pub mod static_files;
|
||||
pub mod frontend;
|
||||
pub mod webhooks;
|
||||
|
||||
@@ -13,20 +13,20 @@ use crate::channels::web::types::*;
|
||||
// --- Static file handlers ---
|
||||
|
||||
pub async fn index_handler() -> Html<&'static str> {
|
||||
Html(include_str!("../static/index.html"))
|
||||
Html(ironclaw_frontend::assets::INDEX_HTML)
|
||||
}
|
||||
|
||||
pub async fn css_handler() -> impl IntoResponse {
|
||||
(
|
||||
[(header::CONTENT_TYPE, "text/css")],
|
||||
include_str!("../static/style.css"),
|
||||
ironclaw_frontend::assets::STYLE_CSS,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn js_handler() -> impl IntoResponse {
|
||||
(
|
||||
[(header::CONTENT_TYPE, "application/javascript")],
|
||||
include_str!("../static/app.js"),
|
||||
ironclaw_frontend::assets::APP_JS,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,10 @@ use crate::channels::web::handlers::routines::{
|
||||
routines_delete_handler, routines_detail_handler, routines_list_handler,
|
||||
routines_summary_handler, routines_toggle_handler, routines_trigger_handler,
|
||||
};
|
||||
use crate::channels::web::handlers::frontend::{
|
||||
frontend_layout_handler, frontend_layout_update_handler, frontend_widget_file_handler,
|
||||
frontend_widgets_handler,
|
||||
};
|
||||
use crate::channels::web::handlers::skills::{
|
||||
skills_install_handler, skills_list_handler, skills_remove_handler, skills_search_handler,
|
||||
};
|
||||
@@ -580,6 +584,16 @@ pub async fn start_server(
|
||||
"/api/tokens/{id}",
|
||||
axum::routing::delete(super::handlers::tokens::tokens_revoke_handler),
|
||||
)
|
||||
// Frontend extension API
|
||||
.route(
|
||||
"/api/frontend/layout",
|
||||
get(frontend_layout_handler).put(frontend_layout_update_handler),
|
||||
)
|
||||
.route("/api/frontend/widgets", get(frontend_widgets_handler))
|
||||
.route(
|
||||
"/api/frontend/widget/{id}/{*file}",
|
||||
get(frontend_widget_file_handler),
|
||||
)
|
||||
// Gateway control plane
|
||||
.route("/api/gateway/status", get(gateway_status_handler))
|
||||
// OpenAI-compatible API
|
||||
@@ -726,6 +740,11 @@ pub async fn start_server(
|
||||
}
|
||||
|
||||
// --- Static file handlers ---
|
||||
//
|
||||
// All frontend assets are embedded in the `ironclaw_frontend` crate.
|
||||
// These handlers serve them with appropriate MIME types and cache headers.
|
||||
|
||||
use ironclaw_frontend::assets;
|
||||
|
||||
async fn index_handler() -> impl IntoResponse {
|
||||
(
|
||||
@@ -733,7 +752,7 @@ async fn index_handler() -> impl IntoResponse {
|
||||
(header::CONTENT_TYPE, "text/html; charset=utf-8"),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
include_str!("static/index.html"),
|
||||
assets::INDEX_HTML,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -743,7 +762,7 @@ async fn css_handler() -> impl IntoResponse {
|
||||
(header::CONTENT_TYPE, "text/css"),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
include_str!("static/style.css"),
|
||||
assets::STYLE_CSS,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -753,7 +772,7 @@ async fn js_handler() -> impl IntoResponse {
|
||||
(header::CONTENT_TYPE, "application/javascript"),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
include_str!("static/app.js"),
|
||||
assets::APP_JS,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -763,7 +782,7 @@ async fn theme_init_handler() -> impl IntoResponse {
|
||||
(header::CONTENT_TYPE, "application/javascript"),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
include_str!("static/theme-init.js"),
|
||||
assets::THEME_INIT_JS,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -773,7 +792,7 @@ async fn favicon_handler() -> impl IntoResponse {
|
||||
(header::CONTENT_TYPE, "image/x-icon"),
|
||||
(header::CACHE_CONTROL, "public, max-age=86400"),
|
||||
],
|
||||
include_bytes!("static/favicon.ico").as_slice(),
|
||||
assets::FAVICON_ICO,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -783,7 +802,7 @@ async fn i18n_index_handler() -> impl IntoResponse {
|
||||
(header::CONTENT_TYPE, "application/javascript"),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
include_str!("static/i18n/index.js"),
|
||||
assets::I18N_INDEX_JS,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -793,7 +812,7 @@ async fn i18n_en_handler() -> impl IntoResponse {
|
||||
(header::CONTENT_TYPE, "application/javascript"),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
include_str!("static/i18n/en.js"),
|
||||
assets::I18N_EN_JS,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -803,7 +822,7 @@ async fn i18n_zh_handler() -> impl IntoResponse {
|
||||
(header::CONTENT_TYPE, "application/javascript"),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
include_str!("static/i18n/zh-CN.js"),
|
||||
assets::I18N_ZH_CN_JS,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -813,7 +832,7 @@ async fn i18n_app_handler() -> impl IntoResponse {
|
||||
(header::CONTENT_TYPE, "application/javascript"),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
include_str!("static/i18n-app.js"),
|
||||
assets::I18N_APP_JS,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user