feat: wire web app infrastructure

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-02 17:23:48 +08:00
co-authored by Claude
parent 27503781fa
commit 593d2aee5e
9 changed files with 230 additions and 6 deletions
+5
View File
@@ -0,0 +1,5 @@
import { AppProviders } from "@/app/providers";
export function App() {
return <AppProviders />;
}
+44
View File
@@ -0,0 +1,44 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
message?: string;
}
/** Minimal error boundary that renders a dark fallback panel. */
export class AppErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, message: error.message };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("Uncaught application error:", error, info);
}
render() {
if (this.state.hasError) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-2 bg-neutral-950 p-8 text-neutral-100">
<h1 className="text-lg font-semibold">Something went wrong</h1>
{this.state.message && (
<p className="text-sm text-neutral-400">{this.state.message}</p>
)}
<button
type="button"
className="mt-2 rounded-md border border-neutral-700 px-3 py-1 text-sm text-neutral-300 hover:bg-neutral-800"
onClick={() => window.location.reload()}
>
Reload
</button>
</div>
);
}
return this.props.children;
}
}
+29
View File
@@ -0,0 +1,29 @@
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "react-router";
import { router } from "@/app/router";
import { AppErrorBoundary } from "@/app/AppErrorBoundary";
export function AppProviders() {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
}),
);
return (
<AppErrorBoundary>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AppErrorBoundary>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { createBrowserRouter } from "react-router";
import { WorkspacePage } from "@/pages/workspace/WorkspacePage";
export const router = createBrowserRouter([
{
path: "/",
element: <WorkspacePage />,
},
]);
+11
View File
@@ -0,0 +1,11 @@
/**
* Reserved API client for future backend calls.
*
* No real network requests are made in this task; later tasks will
* wire this helper to actual endpoints.
*/
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "";
export function apiClient(path: string, init?: RequestInit): Promise<Response> {
return fetch(`${baseUrl}${path}`, init);
}
+19
View File
@@ -0,0 +1,19 @@
import { create } from "zustand";
interface WorkspaceUiState {
activeFilePath: string;
terminalVisible: boolean;
sidebarCollapsed: boolean;
setActiveFilePath: (path: string) => void;
toggleTerminal: () => void;
toggleSidebar: () => void;
}
export const useWorkspaceUiStore = create<WorkspaceUiState>((set) => ({
activeFilePath: "src/main.tsx",
terminalVisible: true,
sidebarCollapsed: false,
setActiveFilePath: (path) => set({ activeFilePath: path }),
toggleTerminal: () => set((s) => ({ terminalVisible: !s.terminalVisible })),
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
}));
+4 -6
View File
@@ -1,14 +1,12 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import "@/styles/index.css"; import "@/styles/index.css";
import { App } from "@/app/App";
document.documentElement.classList.add("dark");
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<div className="min-h-screen bg-neutral-950 p-8 text-neutral-100"> <App />
<h1 className="text-2xl font-semibold">Codespace</h1>
<p className="mt-2 text-sm text-neutral-400">
Web frontend scaffold. IDE shell coming in later tasks.
</p>
</div>
</StrictMode>, </StrictMode>,
); );
+28
View File
@@ -0,0 +1,28 @@
import { findFileByPath, mockWorkspaceFiles } from "@/pages/workspace/mock-data";
import { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store";
export function WorkspacePage() {
const activeFilePath = useWorkspaceUiStore((s) => s.activeFilePath);
const activeFile = findFileByPath(mockWorkspaceFiles, activeFilePath);
return (
<div className="flex h-full flex-col bg-background text-foreground">
<header className="border-b border-border px-4 py-2">
<h1 className="text-sm font-semibold">Codespace Workspace</h1>
</header>
<div className="flex-1 overflow-auto p-4">
<p className="text-sm text-muted-foreground">
Active file: <code className="text-foreground">{activeFilePath}</code>
</p>
<p className="mt-2 text-sm text-muted-foreground">
Mock file match:{" "}
<code className="text-foreground">
{activeFile
? `${activeFile.name} (${activeFile.language ?? activeFile.kind})`
: "not found"}
</code>
</p>
</div>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
export type MockFileNode = {
name: string;
path: string;
kind: "file" | "dir";
language?: string;
content?: string;
children?: MockFileNode[];
};
const file = (
name: string,
path: string,
language: string,
content: string,
): MockFileNode => ({ name, path, kind: "file", language, content });
const dir = (
name: string,
path: string,
children: MockFileNode[],
): MockFileNode => ({ name, path, kind: "dir", children });
export const mockWorkspaceFiles: MockFileNode[] = [
file("package.json", "package.json", "json", `{
"name": "codespace",
"version": "0.0.0"
}
`),
file("README.md", "README.md", "markdown", `# Codespace
A self-hosted development environment.
`),
dir("src", "src", [
file(
"main.tsx",
"src/main.tsx",
"typescript",
`import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "@/app/App";
import "@/styles/index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
`,
),
]),
dir("internal", "internal", [
dir("api", "internal/api", [
file(
"router.go",
"internal/api/router.go",
"go",
`package api
// Router is reserved for future backend routes.
type Router struct{}
`,
),
]),
]),
];
/** Depth-first search for a node with the given slash-delimited path. */
export function findFileByPath(
files: MockFileNode[],
path: string,
): MockFileNode | undefined {
for (const node of files) {
if (node.path === path) return node;
if (node.children) {
const found = findFileByPath(node.children, path);
if (found) return found;
}
}
return undefined;
}