feat(desktop): add CSS, router, pages, and app entry with provider nesting

- globals.css with Tailwind + design tokens from @multica/ui
- Hash router with dashboard shell, issues, my-issues, runtimes, skills pages
- Login page with email OTP flow (no Google OAuth)
- IssueDetailPage wrapper extracting route param for IssueDetail
- App.tsx with ThemeProvider > QueryProvider > RouterProvider nesting
- main.tsx without StrictMode to avoid Zustand double-render issues

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-04-09 14:35:51 +08:00
parent dafd51e327
commit 77f48d9f26
8 changed files with 238 additions and 29 deletions

View File

@@ -1,16 +1,12 @@
<!doctype html>
<html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Multica</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
/>
</head>
<body>
<div id="root"></div>
<body class="h-full overflow-hidden antialiased font-sans">
<div id="root" class="h-full"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,17 +1,16 @@
function App(): React.JSX.Element {
import { RouterProvider } from "react-router-dom";
import { ThemeProvider } from "./components/theme-provider";
import { Toaster } from "@multica/ui/components/ui/sonner";
import { QueryProvider } from "@multica/core/provider";
import { router } from "./router";
export default function App() {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
fontFamily: "system-ui, sans-serif",
}}
>
<h1>Multica Desktop</h1>
</div>
<ThemeProvider>
<QueryProvider>
<RouterProvider router={router} />
<Toaster />
</QueryProvider>
</ThemeProvider>
);
}
export default App;

View File

@@ -0,0 +1,28 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "@multica/ui/styles/tokens.css";
@custom-variant dark (&:is(.dark *));
@source "../../../../packages/ui/**/*.tsx";
@source "../../../../packages/core/**/*.tsx";
@source "../../../../packages/views/**/*.tsx";
@source "./**/*.tsx";
@layer base {
* {
@apply border-border outline-ring/50;
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
*::-webkit-scrollbar { width: 6px; height: 6px; }
*::-webkit-scrollbar-track { background: var(--scrollbar-track); }
*::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; }
*::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}

View File

@@ -1,9 +1,5 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./globals.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);

View File

@@ -0,0 +1,8 @@
import { useParams } from "react-router-dom";
import { IssueDetail } from "@multica/views/issues/components";
export function IssueDetailPage() {
const { id } = useParams<{ id: string }>();
if (!id) return null;
return <IssueDetail issueId={id} />;
}

View File

@@ -0,0 +1,139 @@
import { useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useAuthStore } from "@/platform/auth";
import { useWorkspaceStore } from "@/platform/workspace";
import { api } from "@/platform/api";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@multica/ui/components/ui/card";
import { Input } from "@multica/ui/components/ui/input";
import { Button } from "@multica/ui/components/ui/button";
import { Label } from "@multica/ui/components/ui/label";
import { MulticaIcon } from "../components/multica-icon";
import { TitleBar } from "../components/title-bar";
export function LoginPage() {
const navigate = useNavigate();
const [step, setStep] = useState<"email" | "code">("email");
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSendCode = useCallback(async () => {
setLoading(true);
setError("");
try {
await useAuthStore.getState().sendCode(email);
setStep("code");
} catch {
setError("Failed to send code");
} finally {
setLoading(false);
}
}, [email]);
const handleVerify = useCallback(async () => {
setLoading(true);
setError("");
try {
await useAuthStore.getState().verifyCode(email, code);
const wsList = await api.listWorkspaces();
useWorkspaceStore.getState().hydrateWorkspace(wsList);
navigate("/issues", { replace: true });
} catch {
setError("Invalid code");
} finally {
setLoading(false);
}
}, [email, code, navigate]);
return (
<div className="flex h-screen flex-col">
<TitleBar />
<div className="flex flex-1 items-center justify-center">
<Card className="w-[380px]">
<CardHeader className="text-center">
<div className="mx-auto mb-4">
<MulticaIcon bordered size="lg" />
</div>
<CardTitle>Sign in to Multica</CardTitle>
<CardDescription>
{step === "email"
? "Enter your email to get a login code"
: `We sent a code to ${email}`}
</CardDescription>
</CardHeader>
<CardContent>
{step === "email" ? (
<form
onSubmit={(e) => {
e.preventDefault();
handleSendCode();
}}
>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoFocus
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button className="w-full" disabled={!email || loading}>
{loading ? "Sending..." : "Send Code"}
</Button>
</div>
</form>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
handleVerify();
}}
>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="code">Verification Code</Label>
<Input
id="code"
placeholder="Enter 6-digit code"
value={code}
onChange={(e) => setCode(e.target.value)}
autoFocus
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button className="w-full" disabled={!code || loading}>
{loading ? "Verifying..." : "Verify"}
</Button>
<Button
type="button"
variant="ghost"
className="w-full"
onClick={() => {
setStep("email");
setCode("");
setError("");
}}
>
Back
</Button>
</div>
</form>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,12 @@
export function PlaceholderPage({ title }: { title: string }) {
return (
<div className="flex flex-1 items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold">{title}</h1>
<p className="mt-2 text-muted-foreground">
Coming soon requires page extraction to @multica/views.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { createHashRouter, Navigate } from "react-router-dom";
import { DashboardShell } from "./components/dashboard-shell";
import { LoginPage } from "./pages/login";
import { IssueDetailPage } from "./pages/issue-detail-page";
import { PlaceholderPage } from "./pages/placeholder";
// Extracted pages from @multica/views
import { IssuesPage } from "@multica/views/issues/components";
import { MyIssuesPage } from "@multica/views/my-issues";
import { RuntimesPage } from "@multica/views/runtimes";
import { SkillsPage } from "@multica/views/skills";
export const router = createHashRouter([
{
path: "/",
element: <DashboardShell />,
children: [
{ index: true, element: <Navigate to="/issues" replace /> },
{ path: "issues", element: <IssuesPage /> },
{ path: "issues/:id", element: <IssueDetailPage /> },
{ path: "my-issues", element: <MyIssuesPage /> },
{ path: "runtimes", element: <RuntimesPage /> },
{ path: "skills", element: <SkillsPage /> },
{ path: "agents", element: <PlaceholderPage title="Agents" /> },
{ path: "inbox", element: <PlaceholderPage title="Inbox" /> },
{ path: "settings", element: <PlaceholderPage title="Settings" /> },
{ path: "board", element: <PlaceholderPage title="Board" /> },
],
},
{ path: "/login", element: <LoginPage /> },
]);