feat: mobile tool workshop with list-detail pattern
- Add mobile viewport detection to ToolWorkshopPage - Implement mobile list view with MobileListView component - Implement mobile detail view with MobileDetailView component - Implement mobile edit view with MobileEditView component - Add MobileFAB for creating new tool types - Fix IconName type issues in mobile components - TypeScript check passes, build succeeds
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import { Icon } from "./icon";
|
||||
|
||||
interface Field {
|
||||
label: string;
|
||||
value: string | number | boolean | null;
|
||||
type?: "text" | "code" | "json" | "boolean";
|
||||
}
|
||||
|
||||
interface MobileDetailViewProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
fields: Field[];
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export const MobileDetailView: React.FC<MobileDetailViewProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
fields,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onBack,
|
||||
}) => {
|
||||
const renderValue = (field: Field) => {
|
||||
if (field.value === null || field.value === undefined) {
|
||||
return <span className="text-muted">Not set</span>;
|
||||
}
|
||||
|
||||
if (field.type === "boolean") {
|
||||
return field.value ? (
|
||||
<span className="badge badge-success">Yes</span>
|
||||
) : (
|
||||
<span className="badge badge-secondary">No</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "code" || field.type === "json") {
|
||||
return (
|
||||
<pre className="mobile-detail-code">
|
||||
{typeof field.value === "string" ? field.value : JSON.stringify(field.value, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
return <span>{String(field.value)}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mobile-detail-view">
|
||||
<header className="mobile-detail-header">
|
||||
<button
|
||||
className="mobile-detail-back"
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<Icon name="arrow-left" size="md" />
|
||||
</button>
|
||||
<div className="mobile-detail-header-content">
|
||||
<h1 className="mobile-detail-title">{title}</h1>
|
||||
{subtitle && <p className="mobile-detail-subtitle">{subtitle}</p>}
|
||||
</div>
|
||||
<div className="mobile-detail-actions">
|
||||
<button
|
||||
className="mobile-detail-action"
|
||||
onClick={onEdit}
|
||||
type="button"
|
||||
aria-label="Edit"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
className="mobile-detail-action mobile-detail-action-danger"
|
||||
onClick={onDelete}
|
||||
type="button"
|
||||
aria-label="Delete"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mobile-detail-fields">
|
||||
{fields.map((field, index) => (
|
||||
<div key={index} className="mobile-detail-field">
|
||||
<label className="mobile-detail-field-label">{field.label}</label>
|
||||
<div className="mobile-detail-field-value">{renderValue(field)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useState } from "react";
|
||||
import { Icon } from "./icon";
|
||||
|
||||
interface FormField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: "text" | "textarea" | "number" | "select" | "checkbox" | "code";
|
||||
value: string | number | boolean;
|
||||
options?: { value: string; label: string }[];
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
interface MobileEditViewProps {
|
||||
title: string;
|
||||
fields?: FormField[];
|
||||
onSave: (data: Record<string, string | number | boolean>) => void;
|
||||
onCancel: () => void;
|
||||
isSaving?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const MobileEditView: React.FC<MobileEditViewProps> = ({
|
||||
title,
|
||||
fields,
|
||||
onSave,
|
||||
onCancel,
|
||||
isSaving = false,
|
||||
children,
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<Record<string, string | number | boolean>>(
|
||||
() => {
|
||||
const initial: Record<string, string | number | boolean> = {};
|
||||
fields?.forEach((field) => {
|
||||
initial[field.name] = field.value;
|
||||
});
|
||||
return initial;
|
||||
}
|
||||
);
|
||||
|
||||
const handleChange = (name: string, value: string | number | boolean) => {
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mobile-edit-view">
|
||||
<header className="mobile-edit-header">
|
||||
<button
|
||||
className="mobile-edit-cancel"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<h1 className="mobile-edit-title">{title}</h1>
|
||||
<button
|
||||
className="mobile-edit-save"
|
||||
onClick={() => onSave(formData)}
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form className="mobile-edit-form" onSubmit={handleSubmit}>
|
||||
{children || fields?.map((field) => (
|
||||
<div key={field.name} className="mobile-edit-field">
|
||||
<label className="mobile-edit-field-label" htmlFor={field.name}>
|
||||
{field.label}
|
||||
{field.required && <span className="required">*</span>}
|
||||
</label>
|
||||
|
||||
{field.type === "textarea" && (
|
||||
<textarea
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={String(formData[field.name] ?? "")}
|
||||
onChange={(e) => handleChange(field.name, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
rows={field.rows || 4}
|
||||
className="mobile-edit-input mobile-edit-textarea"
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.type === "select" && (
|
||||
<select
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={String(formData[field.name] ?? "")}
|
||||
onChange={(e) => handleChange(field.name, e.target.value)}
|
||||
required={field.required}
|
||||
className="mobile-edit-input"
|
||||
>
|
||||
{field.options?.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{field.type === "checkbox" && (
|
||||
<label className="mobile-edit-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
checked={Boolean(formData[field.name])}
|
||||
onChange={(e) => handleChange(field.name, e.target.checked)}
|
||||
/>
|
||||
<span>{field.label}</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{field.type === "code" && (
|
||||
<textarea
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={String(formData[field.name] ?? "")}
|
||||
onChange={(e) => handleChange(field.name, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
rows={field.rows || 8}
|
||||
className="mobile-edit-input mobile-edit-code"
|
||||
style={{ fontFamily: "monospace" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(field.type === "text" || field.type === "number") && (
|
||||
<input
|
||||
type={field.type === "number" ? "number" : "text"}
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={String(formData[field.name] ?? "")}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
field.name,
|
||||
field.type === "number"
|
||||
? Number(e.target.value)
|
||||
: e.target.value
|
||||
)
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
className="mobile-edit-input"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Icon } from "./icon";
|
||||
|
||||
interface MobileFABProps {
|
||||
onClick: () => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const MobileFAB: React.FC<MobileFABProps> = ({
|
||||
onClick,
|
||||
label = "Create new",
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
className="mobile-fab"
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon name="add" size="md" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Icon } from "./icon";
|
||||
import type { IconName } from "../utils/icons";
|
||||
|
||||
interface MobileListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface MobileListViewProps {
|
||||
items: MobileListItem[];
|
||||
onItemClick: (id: string) => void;
|
||||
onItemDelete?: (id: string) => void;
|
||||
onItemDuplicate?: (id: string) => void;
|
||||
emptyMessage?: string;
|
||||
searchPlaceholder?: string;
|
||||
onSearch?: (query: string) => void;
|
||||
}
|
||||
|
||||
export const MobileListView: React.FC<MobileListViewProps> = ({
|
||||
items,
|
||||
onItemClick,
|
||||
onItemDelete,
|
||||
onItemDuplicate,
|
||||
emptyMessage = "No items found",
|
||||
searchPlaceholder = "Search...",
|
||||
onSearch,
|
||||
}) => {
|
||||
return (
|
||||
<div className="mobile-list-view">
|
||||
{onSearch && (
|
||||
<div className="mobile-list-search">
|
||||
<input
|
||||
type="search"
|
||||
placeholder={searchPlaceholder}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
className="mobile-list-search-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="mobile-list-empty">
|
||||
<Icon name="folder" size="lg" />
|
||||
<p>{emptyMessage}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mobile-list-items">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className="mobile-list-item"
|
||||
onClick={() => onItemClick(item.id)}
|
||||
type="button"
|
||||
>
|
||||
{item.icon && (
|
||||
<div className="mobile-list-item-icon">
|
||||
<Icon name={item.icon as IconName} size="md" />
|
||||
</div>
|
||||
)}
|
||||
<div className="mobile-list-item-content">
|
||||
<div className="mobile-list-item-title">{item.title}</div>
|
||||
{item.subtitle && (
|
||||
<div className="mobile-list-item-subtitle">{item.subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mobile-list-item-actions" style={{ transform: "rotate(180deg)" }}>
|
||||
<Icon name="arrow-left" size="sm" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,40 +1,87 @@
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { Icon } from "./icon";
|
||||
import { ToolsBottomSheet } from "./tools-bottom-sheet";
|
||||
import type { IconName } from "../utils/icons";
|
||||
|
||||
interface MobileNavProps {
|
||||
sessionCount?: number;
|
||||
}
|
||||
|
||||
const MOBILE_NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: IconName;
|
||||
isGroup?: boolean;
|
||||
}
|
||||
|
||||
const MOBILE_NAV_ITEMS: NavItem[] = [
|
||||
{ to: "/", label: "Home", icon: "dashboard" },
|
||||
{ to: "/projects", label: "Projects", icon: "projects" },
|
||||
{ to: "/sessions", label: "Sessions", icon: "terminal" },
|
||||
{ to: "/tool-workshop", label: "Tools", icon: "settings" },
|
||||
{ to: "/tools", label: "Tools", icon: "settings", isGroup: true },
|
||||
{ to: "/settings", label: "Settings", icon: "settings" },
|
||||
];
|
||||
|
||||
export const MobileNav: React.FC<MobileNavProps> = ({ sessionCount }) => {
|
||||
const location = useLocation();
|
||||
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
|
||||
|
||||
const isToolsActive =
|
||||
location.pathname === "/tool-workshop" ||
|
||||
location.pathname === "/config-profiles";
|
||||
|
||||
const handleNavClick = (item: NavItem) => {
|
||||
if (item.isGroup) {
|
||||
setToolsSheetOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="mobile-nav" role="navigation" aria-label="Mobile navigation">
|
||||
{MOBILE_NAV_ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`mobile-nav-item ${isActive ? "active" : ""}`
|
||||
<>
|
||||
<nav className="mobile-nav" role="navigation" aria-label="Mobile navigation">
|
||||
{MOBILE_NAV_ITEMS.map((item) => {
|
||||
if (item.isGroup) {
|
||||
return (
|
||||
<button
|
||||
key={item.to}
|
||||
className={`mobile-nav-item ${isToolsActive ? "active" : ""}`}
|
||||
onClick={() => handleNavClick(item)}
|
||||
type="button"
|
||||
>
|
||||
<div className="mobile-nav-icon-wrapper">
|
||||
<Icon name={item.icon} size="md" />
|
||||
</div>
|
||||
<span className="mobile-nav-label">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
end={item.to === "/"}
|
||||
>
|
||||
<div className="mobile-nav-icon-wrapper">
|
||||
<Icon name={item.icon} size="md" />
|
||||
{item.to === "/sessions" && sessionCount ? (
|
||||
<span className="mobile-nav-badge">{sessionCount}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="mobile-nav-label">{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`mobile-nav-item ${isActive ? "active" : ""}`
|
||||
}
|
||||
end={item.to === "/"}
|
||||
>
|
||||
<div className="mobile-nav-icon-wrapper">
|
||||
<Icon name={item.icon} size="md" />
|
||||
{item.to === "/sessions" && sessionCount ? (
|
||||
<span className="mobile-nav-badge">{sessionCount}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="mobile-nav-label">{item.label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<ToolsBottomSheet
|
||||
isOpen={toolsSheetOpen}
|
||||
onClose={() => setToolsSheetOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Icon } from "./icon";
|
||||
|
||||
interface ToolsBottomSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TOOLS_ITEMS = [
|
||||
{ to: "/tool-workshop", label: "Tool Workshop" },
|
||||
{ to: "/config-profiles", label: "Config Profiles" },
|
||||
];
|
||||
|
||||
export const ToolsBottomSheet: React.FC<ToolsBottomSheetProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSelect = (to: string) => {
|
||||
onClose();
|
||||
navigate(to);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mobile-bottom-sheet-overlay"
|
||||
onClick={onClose}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className="mobile-bottom-sheet"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-label="Tools menu"
|
||||
>
|
||||
<div className="mobile-bottom-sheet-header">
|
||||
<div className="mobile-bottom-sheet-handle" />
|
||||
<h3 className="mobile-bottom-sheet-title">Tools</h3>
|
||||
</div>
|
||||
<div className="mobile-bottom-sheet-content">
|
||||
{TOOLS_ITEMS.map((item) => (
|
||||
<button
|
||||
key={item.to}
|
||||
className={`mobile-bottom-sheet-item ${
|
||||
location.pathname === item.to ? "active" : ""
|
||||
}`}
|
||||
onClick={() => handleSelect(item.to)}
|
||||
type="button"
|
||||
>
|
||||
<span className="mobile-bottom-sheet-item-label">{item.label}</span>
|
||||
{location.pathname === item.to && <Icon name="success" size="sm" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user