Skip to content

Commit aa39187

Browse files
committed
feat(settings): implement project management interface with project switching functionality
feat(specs): add search and filter capabilities to SpecsPage feat(spec-detail): integrate markdown rendering for spec content using react-markdown fix(router): add SettingsPage route to main navigation chore(deps): update package.json and pnpm-lock.yaml with new dependencies for markdown and rehype plugins docs(readme): update README with Phase 4 completion details and feature status
1 parent 9e85916 commit aa39187

File tree

9 files changed

+514
-48
lines changed

9 files changed

+514
-48
lines changed

packages/ui-vite/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
"lucide-react": "^0.553.0",
1818
"react": "^19.2.0",
1919
"react-dom": "^19.2.0",
20+
"react-markdown": "^9.1.0",
2021
"react-router-dom": "^7.10.1",
22+
"rehype-raw": "^7.0.0",
23+
"remark-gfm": "^4.0.1",
2124
"tailwind-merge": "^3.4.0"
2225
},
2326
"devDependencies": {

packages/ui-vite/src/components/Layout.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Outlet, Link, useLocation } from 'react-router-dom';
2-
import { BarChart3, FileText, Network } from 'lucide-react';
2+
import { BarChart3, FileText, Network, Settings } from 'lucide-react';
33
import { cn } from '../lib/utils';
44

55
export function Layout() {
@@ -9,6 +9,7 @@ export function Layout() {
99
{ path: '/specs', label: 'Specs', icon: FileText },
1010
{ path: '/stats', label: 'Stats', icon: BarChart3 },
1111
{ path: '/dependencies', label: 'Dependencies', icon: Network },
12+
{ path: '/settings', label: 'Settings', icon: Settings },
1213
];
1314

1415
return (

packages/ui-vite/src/lib/api.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,17 @@ export interface DependencyGraph {
4848
edges: DependencyEdge[];
4949
}
5050

51+
export interface Project {
52+
id: string;
53+
name: string;
54+
path: string;
55+
}
56+
57+
export interface ProjectsResponse {
58+
current: Project | null;
59+
available: Project[];
60+
}
61+
5162
class APIError extends Error {
5263
status: number;
5364

@@ -105,4 +116,15 @@ export const api = {
105116
body: JSON.stringify(updates),
106117
});
107118
},
119+
120+
async getProjects(): Promise<ProjectsResponse> {
121+
const data = await fetchAPI<ProjectsResponse>('/api/projects');
122+
return data;
123+
},
124+
125+
async switchProject(projectId: string): Promise<void> {
126+
await fetchAPI(`/api/projects/${encodeURIComponent(projectId)}/switch`, {
127+
method: 'POST',
128+
});
129+
},
108130
};
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { useState, useEffect } from 'react';
2+
import { Folder, RefreshCw } from 'lucide-react';
3+
import { api, type ProjectsResponse } from '../lib/api';
4+
5+
export function SettingsPage() {
6+
const [projects, setProjects] = useState<ProjectsResponse | null>(null);
7+
const [loading, setLoading] = useState(true);
8+
const [error, setError] = useState<string | null>(null);
9+
const [switching, setSwitching] = useState(false);
10+
11+
const loadProjects = () => {
12+
setLoading(true);
13+
api.getProjects()
14+
.then(setProjects)
15+
.catch((err) => setError(err.message))
16+
.finally(() => setLoading(false));
17+
};
18+
19+
useEffect(() => {
20+
loadProjects();
21+
}, []);
22+
23+
const handleSwitchProject = async (projectId: string) => {
24+
if (projectId === projects?.current?.id) return;
25+
26+
setSwitching(true);
27+
try {
28+
await api.switchProject(projectId);
29+
await loadProjects();
30+
// Reload the page to refresh all data
31+
window.location.reload();
32+
} catch (err: any) {
33+
setError(err.message);
34+
} finally {
35+
setSwitching(false);
36+
}
37+
};
38+
39+
if (loading) {
40+
return <div className="text-center py-12">Loading settings...</div>;
41+
}
42+
43+
if (error) {
44+
return (
45+
<div className="text-center py-12">
46+
<div className="text-destructive mb-4">Error: {error}</div>
47+
<button
48+
onClick={loadProjects}
49+
className="text-sm text-primary hover:underline"
50+
>
51+
Try again
52+
</button>
53+
</div>
54+
);
55+
}
56+
57+
return (
58+
<div>
59+
<div className="flex items-center justify-between mb-6">
60+
<h2 className="text-2xl font-bold">Settings</h2>
61+
<button
62+
onClick={loadProjects}
63+
disabled={loading}
64+
className="flex items-center gap-2 px-3 py-1.5 text-sm border rounded-lg hover:bg-secondary transition-colors disabled:opacity-50"
65+
>
66+
<RefreshCw className="w-4 h-4" />
67+
Refresh
68+
</button>
69+
</div>
70+
71+
{/* Project Management */}
72+
<div className="border rounded-lg p-6">
73+
<h3 className="text-lg font-medium mb-4">Project</h3>
74+
75+
{projects?.current && (
76+
<div className="mb-6 p-4 bg-secondary rounded-lg">
77+
<div className="flex items-center gap-3">
78+
<Folder className="w-5 h-5 text-primary" />
79+
<div className="flex-1">
80+
<div className="font-medium">{projects.current.name}</div>
81+
<div className="text-sm text-muted-foreground">{projects.current.path}</div>
82+
</div>
83+
<span className="text-xs px-2 py-1 bg-primary/10 text-primary rounded">
84+
Current
85+
</span>
86+
</div>
87+
</div>
88+
)}
89+
90+
{projects?.available && projects.available.length > 0 && (
91+
<div>
92+
<h4 className="text-sm font-medium mb-3">Available Projects</h4>
93+
<div className="space-y-2">
94+
{projects.available.map((project) => (
95+
<div
96+
key={project.id}
97+
className="flex items-center gap-3 p-3 border rounded-lg hover:bg-secondary/50 transition-colors"
98+
>
99+
<Folder className="w-4 h-4 text-muted-foreground" />
100+
<div className="flex-1">
101+
<div className="text-sm font-medium">{project.name}</div>
102+
<div className="text-xs text-muted-foreground">{project.path}</div>
103+
</div>
104+
<button
105+
onClick={() => handleSwitchProject(project.id)}
106+
disabled={switching || project.id === projects.current?.id}
107+
className="px-3 py-1 text-sm border rounded hover:bg-background transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
108+
>
109+
{project.id === projects.current?.id ? 'Current' : 'Switch'}
110+
</button>
111+
</div>
112+
))}
113+
</div>
114+
</div>
115+
)}
116+
117+
{(!projects?.available || projects.available.length === 0) && (
118+
<p className="text-sm text-muted-foreground">
119+
No other projects available. Projects are discovered from recent workspaces.
120+
</p>
121+
)}
122+
</div>
123+
124+
{/* Additional Settings Sections (Placeholders) */}
125+
<div className="mt-6 border rounded-lg p-6">
126+
<h3 className="text-lg font-medium mb-4">Appearance</h3>
127+
<p className="text-sm text-muted-foreground">
128+
Theme and display settings will be added in Phase 6.
129+
</p>
130+
</div>
131+
132+
<div className="mt-6 border rounded-lg p-6">
133+
<h3 className="text-lg font-medium mb-4">Advanced</h3>
134+
<p className="text-sm text-muted-foreground">
135+
Advanced configuration options will be added in Phase 6.
136+
</p>
137+
</div>
138+
</div>
139+
);
140+
}

packages/ui-vite/src/pages/SpecDetailPage.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { useState, useEffect } from 'react';
22
import { useParams, Link } from 'react-router-dom';
33
import { ArrowLeft } from 'lucide-react';
4+
import ReactMarkdown from 'react-markdown';
5+
import remarkGfm from 'remark-gfm';
46
import { api, type SpecDetail } from '../lib/api';
57

68
export function SpecDetailPage() {
@@ -115,7 +117,9 @@ export function SpecDetailPage() {
115117
)}
116118

117119
<div className="prose prose-sm dark:prose-invert max-w-none">
118-
<pre className="whitespace-pre-wrap">{spec.content}</pre>
120+
<ReactMarkdown remarkPlugins={[remarkGfm]}>
121+
{spec.content}
122+
</ReactMarkdown>
119123
</div>
120124
</div>
121125
);

packages/ui-vite/src/pages/SpecsPage.tsx

Lines changed: 131 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1-
import { useState, useEffect } from 'react';
1+
import { useState, useEffect, useMemo } from 'react';
22
import { Link } from 'react-router-dom';
3+
import { Search, Filter } from 'lucide-react';
34
import { api, type Spec } from '../lib/api';
45

56
export function SpecsPage() {
67
const [specs, setSpecs] = useState<Spec[]>([]);
78
const [loading, setLoading] = useState(true);
89
const [error, setError] = useState<string | null>(null);
10+
const [searchQuery, setSearchQuery] = useState('');
11+
const [statusFilter, setStatusFilter] = useState<string>('all');
12+
const [priorityFilter, setPriorityFilter] = useState<string>('all');
13+
const [tagFilter, setTagFilter] = useState<string>('all');
914

1015
useEffect(() => {
1116
api.getSpecs()
@@ -14,6 +19,52 @@ export function SpecsPage() {
1419
.finally(() => setLoading(false));
1520
}, []);
1621

22+
// Get unique values for filters
23+
const uniqueStatuses = useMemo(() =>
24+
Array.from(new Set(specs.map(s => s.status))),
25+
[specs]
26+
);
27+
const uniquePriorities = useMemo(() =>
28+
Array.from(new Set(specs.map(s => s.priority).filter(Boolean))),
29+
[specs]
30+
);
31+
const uniqueTags = useMemo(() =>
32+
Array.from(new Set(specs.flatMap(s => s.tags || []))),
33+
[specs]
34+
);
35+
36+
// Filter specs based on search and filters
37+
const filteredSpecs = useMemo(() => {
38+
return specs.filter(spec => {
39+
// Search filter
40+
if (searchQuery) {
41+
const query = searchQuery.toLowerCase();
42+
const matchesSearch =
43+
spec.name.toLowerCase().includes(query) ||
44+
spec.title.toLowerCase().includes(query) ||
45+
(spec.tags?.some(tag => tag.toLowerCase().includes(query)));
46+
if (!matchesSearch) return false;
47+
}
48+
49+
// Status filter
50+
if (statusFilter !== 'all' && spec.status !== statusFilter) {
51+
return false;
52+
}
53+
54+
// Priority filter
55+
if (priorityFilter !== 'all' && spec.priority !== priorityFilter) {
56+
return false;
57+
}
58+
59+
// Tag filter
60+
if (tagFilter !== 'all' && !spec.tags?.includes(tagFilter)) {
61+
return false;
62+
}
63+
64+
return true;
65+
});
66+
}, [specs, searchQuery, statusFilter, priorityFilter, tagFilter]);
67+
1768
if (loading) {
1869
return <div className="text-center py-12">Loading specs...</div>;
1970
}
@@ -34,12 +85,86 @@ export function SpecsPage() {
3485
<div className="flex items-center justify-between mb-6">
3586
<h2 className="text-2xl font-bold">Specs</h2>
3687
<div className="text-sm text-muted-foreground">
37-
{specs.length} {specs.length === 1 ? 'spec' : 'specs'}
88+
{filteredSpecs.length} of {specs.length} {specs.length === 1 ? 'spec' : 'specs'}
89+
</div>
90+
</div>
91+
92+
{/* Search Bar */}
93+
<div className="mb-4">
94+
<div className="relative">
95+
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
96+
<input
97+
type="text"
98+
placeholder="Search by name, title, or tags..."
99+
value={searchQuery}
100+
onChange={(e) => setSearchQuery(e.target.value)}
101+
className="w-full pl-10 pr-4 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary"
102+
/>
38103
</div>
39104
</div>
40105

41-
<div className="space-y-2">
42-
{specs.map((spec) => (
106+
{/* Filters */}
107+
<div className="flex flex-wrap gap-3 mb-6 items-center">
108+
<div className="flex items-center gap-2">
109+
<Filter className="w-4 h-4 text-muted-foreground" />
110+
<span className="text-sm font-medium">Filters:</span>
111+
</div>
112+
113+
<select
114+
value={statusFilter}
115+
onChange={(e) => setStatusFilter(e.target.value)}
116+
className="px-3 py-1.5 text-sm border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary"
117+
>
118+
<option value="all">All Statuses</option>
119+
{uniqueStatuses.map(status => (
120+
<option key={status} value={status}>{status}</option>
121+
))}
122+
</select>
123+
124+
<select
125+
value={priorityFilter}
126+
onChange={(e) => setPriorityFilter(e.target.value)}
127+
className="px-3 py-1.5 text-sm border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary"
128+
>
129+
<option value="all">All Priorities</option>
130+
{uniquePriorities.map(priority => (
131+
<option key={priority} value={priority}>{priority}</option>
132+
))}
133+
</select>
134+
135+
<select
136+
value={tagFilter}
137+
onChange={(e) => setTagFilter(e.target.value)}
138+
className="px-3 py-1.5 text-sm border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary"
139+
>
140+
<option value="all">All Tags</option>
141+
{uniqueTags.map(tag => (
142+
<option key={tag} value={tag}>{tag}</option>
143+
))}
144+
</select>
145+
146+
{(searchQuery || statusFilter !== 'all' || priorityFilter !== 'all' || tagFilter !== 'all') && (
147+
<button
148+
onClick={() => {
149+
setSearchQuery('');
150+
setStatusFilter('all');
151+
setPriorityFilter('all');
152+
setTagFilter('all');
153+
}}
154+
className="text-sm text-primary hover:underline"
155+
>
156+
Clear all
157+
</button>
158+
)}
159+
</div>
160+
161+
{filteredSpecs.length === 0 ? (
162+
<div className="text-center py-12 text-muted-foreground">
163+
No specs match your filters
164+
</div>
165+
) : (
166+
<div className="space-y-2">
167+
{filteredSpecs.map((spec) => (
43168
<Link
44169
key={spec.name}
45170
to={`/specs/${spec.name}`}
@@ -91,7 +216,8 @@ export function SpecsPage() {
91216
)}
92217
</Link>
93218
))}
94-
</div>
219+
</div>
220+
)}
95221
</div>
96222
);
97223
}

0 commit comments

Comments
 (0)