Skip to content

Commit 46b57ac

Browse files
committed
feat: add tools navigation and utilities with JSON View, Mock Tool 1, and Mock Tool 2
1 parent 203cc5c commit 46b57ac

11 files changed

Lines changed: 520 additions & 14 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ Reference docs (for agents):
119119
- In TypeScript source files, omit `.js` extensions for local relative imports.
120120
- Avoid meaningless blank lines; use blank lines to separate semantic blocks, not after every statement. Keep a blank line before `return`.
121121
- Keep strict TypeScript settings satisfied; avoid weakening types to silence errors.
122+
- **Inline over premature extraction**: keep trivial logic at the call site (e.g. JSX `onClick={() => setInput('')}`); extract a named function only when the logic grows, is reused, or becomes hard to read inline. Do not add one-line wrappers like `handleClear()` that only forward to a single call.
122123
- **CLI**: use `cac` for command parsing; `terminal-link` for clickable URLs; `console.info` for startup messages.
123124
- **CLI server**: use `hono` with `@hono/node-server`; `serveStatic` from `dist/web/` (resolved inline in `startWebUiServer()`).
124125
- **Web UI**: configure Vite in `packages/web/vite.config.ts`; React Compiler via `@rolldown/plugin-babel` + `reactCompilerPreset()` from `@vitejs/plugin-react`; Tailwind via `@tailwindcss/vite` and imports in `src/main.css`.

packages/web/src/components/layout/app-shell.tsx

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,60 @@
1-
import { Outlet, useLocation } from 'react-router';
1+
import { Link, Outlet, useLocation } from 'react-router';
22
import { AppSidebar } from '@/components/layout/app-sidebar';
3-
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
3+
import {
4+
Breadcrumb,
5+
BreadcrumbItem,
6+
BreadcrumbLink,
7+
BreadcrumbList,
8+
BreadcrumbPage,
9+
BreadcrumbSeparator,
10+
} from '@/components/ui/breadcrumb';
411
import { Separator } from '@/components/ui/separator';
512
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
613
import { TooltipProvider } from '@/components/ui/tooltip';
14+
import { getToolById } from '@/tools/registry';
715

816
const pageTitles: Record<string, string> = {
917
'/dashboard': 'Dashboard',
1018
'/settings': 'Settings',
19+
'/tools': 'Tools',
1120
};
1221

13-
export function AppShell() {
22+
function AppBreadcrumb() {
1423
const { pathname } = useLocation();
24+
25+
if (pathname.startsWith('/tools/')) {
26+
const toolId = pathname.slice('/tools/'.length);
27+
const tool = getToolById(toolId);
28+
29+
return (
30+
<Breadcrumb>
31+
<BreadcrumbList>
32+
<BreadcrumbItem>
33+
<BreadcrumbLink render={<Link to="/tools" />}>Tools</BreadcrumbLink>
34+
</BreadcrumbItem>
35+
<BreadcrumbSeparator />
36+
<BreadcrumbItem>
37+
<BreadcrumbPage>{tool?.name ?? 'Tool'}</BreadcrumbPage>
38+
</BreadcrumbItem>
39+
</BreadcrumbList>
40+
</Breadcrumb>
41+
);
42+
}
43+
1544
const pageTitle = pageTitles[pathname] ?? 'Foundry';
1645

46+
return (
47+
<Breadcrumb>
48+
<BreadcrumbList>
49+
<BreadcrumbItem>
50+
<BreadcrumbPage>{pageTitle}</BreadcrumbPage>
51+
</BreadcrumbItem>
52+
</BreadcrumbList>
53+
</Breadcrumb>
54+
);
55+
}
56+
57+
export function AppShell() {
1758
return (
1859
<SidebarProvider className="h-dvh min-h-0 overflow-hidden">
1960
<TooltipProvider>
@@ -30,13 +71,7 @@ export function AppShell() {
3071
data-vertical:h-4
3172
"
3273
/>
33-
<Breadcrumb>
34-
<BreadcrumbList>
35-
<BreadcrumbItem>
36-
<BreadcrumbPage>{pageTitle}</BreadcrumbPage>
37-
</BreadcrumbItem>
38-
</BreadcrumbList>
39-
</Breadcrumb>
74+
<AppBreadcrumb />
4075
</header>
4176
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
4277
<Outlet />

packages/web/src/components/layout/app-sidebar.tsx

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,33 @@
1-
import { LayoutDashboardIcon, SettingsIcon } from 'lucide-react';
1+
import { LayoutDashboardIcon, SettingsIcon, WrenchIcon } from 'lucide-react';
22
import { NavLink, useLocation } from 'react-router';
33
import {
44
Sidebar,
55
SidebarContent,
66
SidebarGroup,
77
SidebarGroupContent,
8+
SidebarGroupLabel,
89
SidebarHeader,
910
SidebarMenu,
1011
SidebarMenuButton,
1112
SidebarMenuItem,
1213
SidebarRail,
1314
} from '@/components/ui/sidebar';
1415

15-
const navItems = [
16+
const generalNavItems = [
1617
{ title: 'Dashboard', to: '/dashboard', icon: LayoutDashboardIcon },
1718
{ title: 'Settings', to: '/settings', icon: SettingsIcon },
1819
] as const;
1920

21+
const toolsNavItems = [{ title: 'Tools', to: '/tools', icon: WrenchIcon }] as const;
22+
23+
function isNavItemActive(pathname: string, to: string) {
24+
if (to === '/tools') {
25+
return pathname.startsWith('/tools');
26+
}
27+
28+
return pathname === to;
29+
}
30+
2031
export function AppSidebar() {
2132
const { pathname } = useLocation();
2233

@@ -38,11 +49,30 @@ export function AppSidebar() {
3849
<SidebarGroup>
3950
<SidebarGroupContent>
4051
<SidebarMenu>
41-
{navItems.map((item) => (
52+
{generalNavItems.map((item) => (
53+
<SidebarMenuItem key={item.to}>
54+
<SidebarMenuButton
55+
render={<NavLink to={item.to} />}
56+
isActive={isNavItemActive(pathname, item.to)}
57+
tooltip={item.title}
58+
>
59+
<item.icon />
60+
<span>{item.title}</span>
61+
</SidebarMenuButton>
62+
</SidebarMenuItem>
63+
))}
64+
</SidebarMenu>
65+
</SidebarGroupContent>
66+
</SidebarGroup>
67+
<SidebarGroup>
68+
<SidebarGroupLabel>Tools</SidebarGroupLabel>
69+
<SidebarGroupContent>
70+
<SidebarMenu>
71+
{toolsNavItems.map((item) => (
4272
<SidebarMenuItem key={item.to}>
4373
<SidebarMenuButton
4474
render={<NavLink to={item.to} />}
45-
isActive={pathname === item.to}
75+
isActive={isNavItemActive(pathname, item.to)}
4676
tooltip={item.title}
4777
>
4878
<item.icon />
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { ArrowLeftIcon } from 'lucide-react';
2+
import { Suspense } from 'react';
3+
import { Link, useParams } from 'react-router';
4+
import { Skeleton } from '@/components/ui/skeleton';
5+
import { getToolById } from '@/tools/registry';
6+
7+
function ToolLoadingFallback() {
8+
return (
9+
<div className="flex flex-col gap-3">
10+
<Skeleton className="h-8 w-full" />
11+
<Skeleton className="h-64 w-full" />
12+
</div>
13+
);
14+
}
15+
16+
export function ToolDetailPage() {
17+
const { toolId } = useParams();
18+
const tool = toolId ? getToolById(toolId) : undefined;
19+
20+
if (!tool) {
21+
return (
22+
<div className="flex flex-col gap-4 p-6">
23+
<Link
24+
to="/tools"
25+
className="
26+
text-muted-foreground
27+
hover:text-foreground
28+
inline-flex w-fit items-center gap-1.5 text-sm transition-colors
29+
"
30+
>
31+
<ArrowLeftIcon className="size-4" />
32+
Back to Tools
33+
</Link>
34+
<div className="bg-card text-card-foreground rounded-xl border p-6">
35+
<h1 className="text-xl font-semibold">Tool not found</h1>
36+
<p className="text-muted-foreground mt-2 text-sm">
37+
The requested tool does not exist.
38+
</p>
39+
</div>
40+
</div>
41+
);
42+
}
43+
44+
const ToolComponent = tool.component;
45+
46+
return (
47+
<div className="flex flex-col gap-4 p-6">
48+
<Link
49+
to="/tools"
50+
className="
51+
text-muted-foreground
52+
hover:text-foreground
53+
inline-flex w-fit items-center gap-1.5 text-sm transition-colors
54+
"
55+
>
56+
<ArrowLeftIcon className="size-4" />
57+
Back to Tools
58+
</Link>
59+
<div>
60+
<h1 className="text-2xl font-semibold tracking-tight">{tool.name}</h1>
61+
<p className="text-muted-foreground text-sm">{tool.description}</p>
62+
</div>
63+
<div
64+
className="bg-card text-card-foreground min-h-80 rounded-xl border p-4"
65+
>
66+
<Suspense fallback={<ToolLoadingFallback />}>
67+
<ToolComponent />
68+
</Suspense>
69+
</div>
70+
</div>
71+
);
72+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Link } from 'react-router';
2+
import { tools } from '@/tools/registry';
3+
import { cn } from '@/lib/utils';
4+
5+
export function ToolsIndexPage() {
6+
return (
7+
<div className="flex flex-col gap-4 p-6">
8+
<div>
9+
<h1 className="text-2xl font-semibold tracking-tight">Tools</h1>
10+
<p className="text-muted-foreground text-sm">
11+
Small utilities for everyday dev tasks.
12+
</p>
13+
</div>
14+
<div
15+
className="
16+
grid gap-4
17+
sm:grid-cols-2
18+
lg:grid-cols-3
19+
"
20+
>
21+
{tools.map((tool) => (
22+
<Link
23+
key={tool.id}
24+
to={`/tools/${tool.id}`}
25+
className={cn(
26+
`
27+
bg-card text-card-foreground flex flex-col gap-3 rounded-xl
28+
border p-4 transition-colors
29+
`,
30+
`hover:border-primary/30 hover:shadow-sm`,
31+
)}
32+
>
33+
<tool.icon className="text-muted-foreground size-8" />
34+
<div className="flex flex-col gap-1">
35+
<span className="font-medium">{tool.name}</span>
36+
<span className="text-muted-foreground text-sm">{tool.description}</span>
37+
</div>
38+
</Link>
39+
))}
40+
</div>
41+
</div>
42+
);
43+
}

packages/web/src/routes.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { createHashRouter, Navigate } from 'react-router';
22
import { AppShell } from '@/components/layout/app-shell';
33
import { DashboardPage } from '@/pages/dashboard';
44
import { SettingsPage } from '@/pages/settings';
5+
import { ToolDetailPage } from '@/pages/tools/detail';
6+
import { ToolsIndexPage } from '@/pages/tools/index';
57

68
export const router = createHashRouter([
79
{
@@ -11,6 +13,13 @@ export const router = createHashRouter([
1113
{ index: true, element: <Navigate to="dashboard" replace /> },
1214
{ path: 'dashboard', element: <DashboardPage /> },
1315
{ path: 'settings', element: <SettingsPage /> },
16+
{
17+
path: 'tools',
18+
children: [
19+
{ index: true, element: <ToolsIndexPage /> },
20+
{ path: ':toolId', element: <ToolDetailPage /> },
21+
],
22+
},
1423
],
1524
},
1625
]);
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { useMemo, useState } from 'react';
2+
import { Button } from '@/components/ui/button';
3+
4+
export default function JsonViewTool() {
5+
const [input, setInput] = useState('');
6+
7+
const result = useMemo(() => {
8+
if (!input.trim()) {
9+
return { ok: true as const, formatted: '' };
10+
}
11+
12+
try {
13+
const parsed: unknown = JSON.parse(input);
14+
15+
return { ok: true as const, formatted: JSON.stringify(parsed, null, 2) };
16+
} catch (error) {
17+
const message = error instanceof Error ? error.message : 'Invalid JSON';
18+
19+
return { ok: false as const, error: message };
20+
}
21+
}, [input]);
22+
23+
return (
24+
<div className="flex flex-col gap-4">
25+
<div className="flex flex-wrap gap-2">
26+
<Button
27+
type="button"
28+
variant="outline"
29+
size="sm"
30+
disabled={!result.ok || !input.trim()}
31+
onClick={() => {
32+
if (result.ok) {
33+
setInput(result.formatted);
34+
}
35+
}}
36+
>
37+
Format
38+
</Button>
39+
<Button type="button" variant="outline" size="sm" onClick={() => setInput('')}>
40+
Clear
41+
</Button>
42+
</div>
43+
<div
44+
className="
45+
grid min-h-80 gap-4
46+
lg:grid-cols-2
47+
"
48+
>
49+
<div className="flex flex-col gap-2">
50+
<label htmlFor="json-input" className="text-sm font-medium">
51+
Input
52+
</label>
53+
<textarea
54+
id="json-input"
55+
value={input}
56+
onChange={(event) => setInput(event.target.value)}
57+
placeholder='{"key": "value"}'
58+
spellCheck={false}
59+
className="
60+
bg-background
61+
focus-visible:border-ring focus-visible:ring-ring/30
62+
min-h-70 flex-1 resize-y rounded-xl border p-3 font-mono text-sm
63+
outline-none
64+
focus-visible:ring-3
65+
"
66+
/>
67+
</div>
68+
<div className="flex flex-col gap-2">
69+
<span className="text-sm font-medium">Output</span>
70+
<div
71+
className="
72+
bg-muted/30 min-h-70 flex-1 overflow-auto rounded-xl border p-3
73+
"
74+
>
75+
{input.trim()
76+
? (result.ok
77+
? (
78+
<pre className="font-mono text-sm whitespace-pre-wrap">{result.formatted}</pre>
79+
)
80+
: (
81+
<p className="text-destructive text-sm">{result.error}</p>
82+
))
83+
: (
84+
<p className="text-muted-foreground text-sm">Enter JSON to see formatted output.</p>
85+
)}
86+
</div>
87+
</div>
88+
</div>
89+
</div>
90+
);
91+
}

0 commit comments

Comments
 (0)