use std::collections::HashMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CatalogEntry {
pub name: String,
pub category: String,
pub description: String,
pub filename: String,
#[serde(default)]
pub installed: bool,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct CloneReport {
pub installed: Vec<String>,
pub skipped: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct InstallUiRequest {
pub names: Vec<String>,
#[serde(default)]
pub overwrite: bool,
}
macro_rules! ui_sources {
( $( ($name:expr, $file:expr, $cat:expr, $desc:expr) ),* $(,)? ) => {
&[
$(
($name, include_str!(concat!("ui/", $file)), $file, $cat, $desc),
)*
]
};
}
static UI_SOURCES: &[(&str, &str, &str, &str, &str)] = ui_sources![
(
"button",
"button.tsx",
"primitives",
"Accessible button with variant and size props"
),
(
"input",
"input.tsx",
"primitives",
"Text input with consistent styling"
),
(
"textarea",
"textarea.tsx",
"primitives",
"Multi-line text input"
),
(
"label",
"label.tsx",
"primitives",
"Form label with peer-disabled support"
),
(
"checkbox",
"checkbox.tsx",
"primitives",
"Checkbox with onCheckedChange API"
),
(
"radio-group",
"radio-group.tsx",
"primitives",
"Radio group with single selection"
),
(
"switch",
"switch.tsx",
"primitives",
"Toggle switch with checked/onCheckedChange"
),
(
"slider",
"slider.tsx",
"primitives",
"Range slider with onValueChange"
),
(
"badge",
"badge.tsx",
"display",
"Inline status badge with variants"
),
(
"avatar",
"avatar.tsx",
"display",
"Avatar with image and fallback"
),
("progress", "progress.tsx", "display", "Progress bar 0–100"),
(
"skeleton",
"skeleton.tsx",
"display",
"Loading skeleton placeholder"
),
(
"separator",
"separator.tsx",
"display",
"Horizontal or vertical divider"
),
("kbd", "kbd.tsx", "display", "Keyboard shortcut display"),
(
"alert",
"alert.tsx",
"display",
"Alert banner with title and description"
),
(
"card",
"card.tsx",
"layout",
"Card with header, content, and footer"
),
(
"table",
"table.tsx",
"layout",
"Styled HTML table with all sub-parts"
),
(
"tabs",
"tabs.tsx",
"layout",
"Tab panels with internal active state"
),
(
"accordion",
"accordion.tsx",
"layout",
"Collapsible accordion, single or multiple"
),
(
"collapsible",
"collapsible.tsx",
"layout",
"Simple open/close collapsible container"
),
(
"scroll-area",
"scroll-area.tsx",
"layout",
"Styled scrollable container"
),
(
"breadcrumb",
"breadcrumb.tsx",
"navigation",
"Breadcrumb nav with all sub-parts"
),
(
"pagination",
"pagination.tsx",
"navigation",
"Page pagination with previous/next"
),
(
"toggle",
"toggle.tsx",
"navigation",
"Pressable toggle button"
),
(
"toggle-group",
"toggle-group.tsx",
"navigation",
"Toggle group with single or multiple selection"
),
(
"dialog",
"dialog.tsx",
"overlay",
"Modal dialog with backdrop and close button"
),
(
"alert-dialog",
"alert-dialog.tsx",
"overlay",
"Confirmation dialog, no outside-click dismiss"
),
(
"sheet",
"sheet.tsx",
"overlay",
"Slide-in panel from any edge"
),
("drawer", "drawer.tsx", "overlay", "Bottom drawer sheet"),
(
"popover",
"popover.tsx",
"overlay",
"Anchored popover panel"
),
(
"hover-card",
"hover-card.tsx",
"overlay",
"Content card shown on hover"
),
(
"tooltip",
"tooltip.tsx",
"overlay",
"Tooltip shown on hover/focus"
),
(
"dropdown-menu",
"dropdown-menu.tsx",
"overlay",
"Dropdown menu with items, checkboxes, radios"
),
(
"select",
"select.tsx",
"complex",
"Custom select with item list"
),
(
"sonner",
"sonner.tsx",
"complex",
"Toast notifications with queue"
),
(
"input-otp",
"input-otp.tsx",
"complex",
"OTP input with auto-advance slots"
),
(
"calendar",
"calendar.tsx",
"complex",
"Month calendar with date selection"
),
(
"data-table",
"data-table.tsx",
"complex",
"Table with sorting, filtering, pagination"
),
];
pub struct CatalogService;
impl CatalogService {
pub fn list_ui() -> Vec<CatalogEntry> {
UI_SOURCES
.iter()
.map(|(name, _, filename, category, description)| CatalogEntry {
name: name.to_string(),
category: category.to_string(),
description: description.to_string(),
filename: filename.to_string(),
installed: false,
})
.collect()
}
pub fn list_ui_with_presence(shared_ui_dir: &PathBuf) -> Vec<CatalogEntry> {
UI_SOURCES
.iter()
.map(|(name, _, filename, category, description)| {
let installed = shared_ui_dir.join(filename).exists();
CatalogEntry {
name: name.to_string(),
category: category.to_string(),
description: description.to_string(),
filename: filename.to_string(),
installed,
}
})
.collect()
}
pub fn check_presence(shared_ui_dir: &PathBuf) -> HashMap<String, bool> {
UI_SOURCES
.iter()
.map(|(name, _, filename, _, _)| {
let installed = shared_ui_dir.join(filename).exists();
(name.to_string(), installed)
})
.collect()
}
pub fn install_ui(
names: &[String],
shared_ui_dir: &PathBuf,
overwrite: bool,
) -> Result<CloneReport, String> {
std::fs::create_dir_all(shared_ui_dir)
.map_err(|e| format!("Failed to create shared/ui dir: {e}"))?;
let source_map: HashMap<&str, (&str, &str)> = UI_SOURCES
.iter()
.map(|(name, src, filename, _, _)| (*name, (*src, *filename)))
.collect();
let mut report = CloneReport::default();
for name in names {
let Some((src, filename)) = source_map.get(name.as_str()) else {
continue; };
let dest = shared_ui_dir.join(filename);
if dest.exists() && !overwrite {
report.skipped.push(name.clone());
continue;
}
std::fs::write(&dest, src).map_err(|e| format!("Failed to write {filename}: {e}"))?;
report.installed.push(name.clone());
}
Ok(report)
}
pub fn get_source(name: &str) -> Option<&'static str> {
UI_SOURCES
.iter()
.find(|(n, _, _, _, _)| *n == name)
.map(|(_, src, _, _, _)| *src)
}
}