feat(web): polish sidebar shell and authentication UI
This commit is contained in:
+260
-58
@@ -1,6 +1,22 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
LayoutDashboard,
|
||||||
|
ListTodo,
|
||||||
|
LogOut,
|
||||||
|
Menu,
|
||||||
|
Moon,
|
||||||
|
Settings,
|
||||||
|
Sparkles,
|
||||||
|
Sun,
|
||||||
|
X
|
||||||
|
} from "lucide-react";
|
||||||
import { Navigate, Route, Routes, useNavigate } from "react-router-dom";
|
import { Navigate, Route, Routes, useNavigate } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { EmailLoginPage } from "@/pages/email-login-page";
|
import { EmailLoginPage } from "@/pages/email-login-page";
|
||||||
import { OAuthCallbackPage } from "@/pages/oauth-callback-page";
|
import { OAuthCallbackPage } from "@/pages/oauth-callback-page";
|
||||||
import { TodoShellPage } from "@/pages/todo-shell-page";
|
import { TodoShellPage } from "@/pages/todo-shell-page";
|
||||||
@@ -11,6 +27,26 @@ import {
|
|||||||
saveSession,
|
saveSession,
|
||||||
type WebSession
|
type WebSession
|
||||||
} from "@/services/session-storage";
|
} from "@/services/session-storage";
|
||||||
|
import {
|
||||||
|
applyThemeMode,
|
||||||
|
loadThemeMode,
|
||||||
|
saveThemeMode,
|
||||||
|
type ThemeMode
|
||||||
|
} from "@/services/theme-storage";
|
||||||
|
|
||||||
|
type SidebarItem = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIDEBAR_ITEMS: SidebarItem[] = [
|
||||||
|
{ key: "dashboard", label: "概览面板", icon: LayoutDashboard },
|
||||||
|
{ key: "todo", label: "待办事项", icon: ListTodo },
|
||||||
|
{ key: "ai", label: "AI 建议", icon: Sparkles },
|
||||||
|
{ key: "notice", label: "提醒中心", icon: Bell },
|
||||||
|
{ key: "settings", label: "系统设置", icon: Settings }
|
||||||
|
];
|
||||||
|
|
||||||
function toWebSession(payload: EmailLoginResult): WebSession {
|
function toWebSession(payload: EmailLoginResult): WebSession {
|
||||||
return {
|
return {
|
||||||
@@ -26,8 +62,16 @@ function toWebSession(payload: EmailLoginResult): WebSession {
|
|||||||
function App() {
|
function App() {
|
||||||
const [session, setSession] = useState<WebSession | null>(() => loadSession());
|
const [session, setSession] = useState<WebSession | null>(() => loadSession());
|
||||||
const [loggingOut, setLoggingOut] = useState(false);
|
const [loggingOut, setLoggingOut] = useState(false);
|
||||||
|
const [themeMode, setThemeMode] = useState<ThemeMode>(() => loadThemeMode());
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyThemeMode(themeMode);
|
||||||
|
saveThemeMode(themeMode);
|
||||||
|
}, [themeMode]);
|
||||||
|
|
||||||
async function handleLogout(): Promise<void> {
|
async function handleLogout(): Promise<void> {
|
||||||
if (!session || loggingOut) {
|
if (!session || loggingOut) {
|
||||||
return;
|
return;
|
||||||
@@ -37,75 +81,233 @@ function App() {
|
|||||||
setLoggingOut(true);
|
setLoggingOut(true);
|
||||||
await revokeRefreshToken(session.refreshToken);
|
await revokeRefreshToken(session.refreshToken);
|
||||||
} catch {
|
} catch {
|
||||||
// 登出流程以本地会话清理为最终兜底,避免页面卡在登录态。
|
// 无论接口成功与否,都要清理本地会话,避免页面卡在登录态。
|
||||||
} finally {
|
} finally {
|
||||||
clearSession();
|
clearSession();
|
||||||
setSession(null);
|
setSession(null);
|
||||||
setLoggingOut(false);
|
setLoggingOut(false);
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
navigate("/login/email", { replace: true });
|
navigate("/login/email", { replace: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
function handleToggleTheme(): void {
|
||||||
<div className="min-h-screen bg-[#f6f8f7] text-[#122117]">
|
setThemeMode((currentTheme) => (currentTheme === "dark" ? "light" : "dark"));
|
||||||
<header className="border-b border-[#d7e2db] bg-white/90 backdrop-blur">
|
}
|
||||||
<div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-4">
|
|
||||||
<div className="flex items-center gap-2">
|
function renderSidebarContent(options: { collapsed: boolean; mobile: boolean }) {
|
||||||
<div className="h-8 w-8 rounded-lg bg-[#0a7a5a]" />
|
const { collapsed, mobile } = options;
|
||||||
<span className="text-lg font-semibold tracking-tight">TodoList</span>
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
|
{mobile ? (
|
||||||
|
<div className="flex h-14 shrink-0 items-center justify-end border-b border-border/70 px-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
onClick={() => setMobileSidebarOpen(false)}
|
||||||
|
aria-label="关闭侧边栏"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{session ? (
|
) : null}
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-sm text-[#3a5a4a]">{session.user.email}</span>
|
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||||
<Button
|
<nav className="space-y-1">
|
||||||
type="button"
|
{SIDEBAR_ITEMS.map((item) => {
|
||||||
size="sm"
|
const ItemIcon = item.icon;
|
||||||
variant="outline"
|
return (
|
||||||
onClick={handleLogout}
|
<button
|
||||||
disabled={loggingOut}
|
key={item.key}
|
||||||
>
|
type="button"
|
||||||
{loggingOut ? "退出中..." : "退出登录"}
|
className={cn(
|
||||||
</Button>
|
"group flex w-full items-center rounded-xl border border-transparent text-left transition-colors",
|
||||||
|
"hover:border-primary/25 hover:bg-primary/10",
|
||||||
|
"gap-3 px-3 py-2.5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ItemIcon className="size-5 shrink-0 text-primary" />
|
||||||
|
{collapsed ? null : (
|
||||||
|
<>
|
||||||
|
<span className="text-sm whitespace-nowrap text-foreground">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto whitespace-nowrap rounded-full border border-border bg-card px-2 py-0.5 text-[10px] text-muted-foreground">
|
||||||
|
即将上线
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 space-y-2 border-t border-border/70 p-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"w-full border-primary/25 text-primary hover:bg-primary/10",
|
||||||
|
"justify-start gap-2 px-3"
|
||||||
|
)}
|
||||||
|
onClick={handleToggleTheme}
|
||||||
|
>
|
||||||
|
{themeMode === "dark" ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||||
|
{collapsed ? null : (
|
||||||
|
<span className="whitespace-nowrap">
|
||||||
|
{themeMode === "dark" ? "浅色模式" : "深色模式"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"w-full border-primary/25 text-primary hover:bg-primary/10",
|
||||||
|
"justify-start gap-2 px-3"
|
||||||
|
)}
|
||||||
|
onClick={handleLogout}
|
||||||
|
disabled={!session || loggingOut}
|
||||||
|
>
|
||||||
|
<LogOut className="size-4" />
|
||||||
|
{collapsed ? null : (
|
||||||
|
<span className="whitespace-nowrap">{loggingOut ? "退出中..." : "退出登录"}</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-dvh overflow-hidden bg-background text-foreground md:h-screen">
|
||||||
|
<header className="relative z-50 shrink-0 border-b border-border/70 bg-background/80 backdrop-blur-xl">
|
||||||
|
<div className="flex h-16 items-center justify-between px-4 md:px-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-primary md:hidden"
|
||||||
|
onClick={() => setMobileSidebarOpen(true)}
|
||||||
|
aria-label="打开侧边栏"
|
||||||
|
>
|
||||||
|
<Menu className="size-12" />
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
src="/favicon.png"
|
||||||
|
alt="TodoList"
|
||||||
|
className="h-9 w-9 shrink-0 rounded-xl shadow-sm"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-base font-semibold tracking-tight text-foreground">
|
||||||
|
TodoList
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<span className="text-sm text-[#3a5a4a]">未登录</span>
|
<span className="hidden max-w-[280px] truncate text-sm text-muted-foreground md:block">
|
||||||
)}
|
{session ? session.user.email : "未登录"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="mx-auto w-full max-w-6xl px-4 py-8">
|
|
||||||
<Routes>
|
{mobileSidebarOpen ? (
|
||||||
<Route
|
<button
|
||||||
path="/login/email"
|
type="button"
|
||||||
element={
|
className="fixed inset-x-0 bottom-0 top-16 z-30 bg-black/40 backdrop-blur-[2px] md:hidden"
|
||||||
<EmailLoginPage
|
aria-label="关闭侧边栏遮罩"
|
||||||
onLoginSuccess={(payload) => {
|
onClick={() => setMobileSidebarOpen(false)}
|
||||||
const nextSession = toWebSession(payload);
|
/>
|
||||||
saveSession(nextSession);
|
) : null}
|
||||||
setSession(nextSession);
|
|
||||||
navigate("/");
|
<aside
|
||||||
}}
|
className={cn(
|
||||||
/>
|
"fixed bottom-0 left-0 top-16 z-40 w-72 border-r border-border/80 bg-card/95 backdrop-blur-xl transition-transform duration-300 md:hidden",
|
||||||
}
|
mobileSidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||||
/>
|
)}
|
||||||
<Route
|
>
|
||||||
path="/auth/callback/:provider"
|
{renderSidebarContent({ collapsed: false, mobile: true })}
|
||||||
element={
|
</aside>
|
||||||
<OAuthCallbackPage
|
|
||||||
onBootstrapSession={(nextSession) => {
|
<div className="flex min-h-0 h-[calc(100dvh-4rem)] md:h-[calc(100vh-4rem)]">
|
||||||
setSession(nextSession);
|
<aside
|
||||||
}}
|
className={cn(
|
||||||
/>
|
"relative hidden h-full border-r border-border/80 bg-card/88 backdrop-blur-xl transition-[width] duration-300 md:flex md:flex-col",
|
||||||
}
|
sidebarCollapsed ? "md:w-14" : "md:w-72"
|
||||||
/>
|
)}
|
||||||
<Route
|
>
|
||||||
path="/"
|
{renderSidebarContent({ collapsed: sidebarCollapsed, mobile: false })}
|
||||||
element={
|
<Button
|
||||||
session ? <TodoShellPage session={session} /> : <Navigate to="/login/email" replace />
|
type="button"
|
||||||
}
|
size="icon-sm"
|
||||||
/>
|
variant="outline"
|
||||||
<Route path="*" element={<Navigate to={session ? "/" : "/login/email"} replace />} />
|
className={cn(
|
||||||
</Routes>
|
"absolute left-full top-1/2 z-20 -ml-px h-14 w-6 -translate-y-1/2 rounded-none border border-border/80",
|
||||||
</main>
|
"bg-card/88 text-muted-foreground backdrop-blur-xl transition-colors duration-200 hover:bg-muted/80 hover:text-foreground",
|
||||||
|
"focus-visible:ring-2 focus-visible:ring-ring/45 focus-visible:ring-offset-0"
|
||||||
|
)}
|
||||||
|
onClick={() => setSidebarCollapsed((current) => !current)}
|
||||||
|
aria-label={sidebarCollapsed ? "展开侧边栏" : "收起侧边栏"}
|
||||||
|
>
|
||||||
|
{sidebarCollapsed ? (
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||||
|
<main className="min-h-0 flex-1 overflow-y-auto px-4 py-6 md:px-6 md:py-8">
|
||||||
|
<div className="mx-auto w-full max-w-6xl">
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/login/email"
|
||||||
|
element={
|
||||||
|
<EmailLoginPage
|
||||||
|
onLoginSuccess={(payload) => {
|
||||||
|
const nextSession = toWebSession(payload);
|
||||||
|
saveSession(nextSession);
|
||||||
|
setSession(nextSession);
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
|
navigate("/");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/auth/callback/:provider"
|
||||||
|
element={
|
||||||
|
<OAuthCallbackPage
|
||||||
|
onBootstrapSession={(nextSession) => {
|
||||||
|
setSession(nextSession);
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
session ? (
|
||||||
|
<TodoShellPage session={session} />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/login/email" replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="*"
|
||||||
|
element={<Navigate to={session ? "/" : "/login/email"} replace />}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-10
@@ -1,21 +1,77 @@
|
|||||||
@import "@fontsource-variable/geist";
|
@import "@fontsource-variable/geist";
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
html {
|
||||||
|
height: 100%;
|
||||||
|
font-size: 120%;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius: 0.9rem;
|
||||||
--background: #f6f8f7;
|
--background: 246 100% 98%;
|
||||||
--foreground: #122117;
|
--foreground: 248 40% 18%;
|
||||||
--primary: #0a7a5a;
|
--card: 0 0% 100%;
|
||||||
--primary-foreground: #ffffff;
|
--border: 248 53% 89%;
|
||||||
--border: #d7e2db;
|
--input: 248 48% 84%;
|
||||||
|
--ring: 246 53% 53%;
|
||||||
|
--primary: 246 53% 53%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 250 70% 94%;
|
||||||
|
--secondary-foreground: 248 40% 25%;
|
||||||
|
--muted: 249 78% 95%;
|
||||||
|
--muted-foreground: 248 17% 45%;
|
||||||
|
--accent: 173 56% 66%;
|
||||||
|
--accent-foreground: 248 40% 25%;
|
||||||
|
--destructive: 343 40% 50%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
color-scheme: light;
|
||||||
font-family: "Geist Variable", "Noto Sans SC", sans-serif;
|
font-family: "Geist Variable", "Noto Sans SC", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 248 46% 10%;
|
||||||
|
--foreground: 240 44% 96%;
|
||||||
|
--card: 249 41% 15%;
|
||||||
|
--border: 249 24% 30%;
|
||||||
|
--input: 249 23% 28%;
|
||||||
|
--ring: 246 76% 68%;
|
||||||
|
--primary: 246 76% 68%;
|
||||||
|
--primary-foreground: 248 43% 12%;
|
||||||
|
--secondary: 249 30% 24%;
|
||||||
|
--secondary-foreground: 240 38% 92%;
|
||||||
|
--muted: 249 29% 20%;
|
||||||
|
--muted-foreground: 247 16% 74%;
|
||||||
|
--accent: 173 52% 58%;
|
||||||
|
--accent-foreground: 248 43% 12%;
|
||||||
|
--destructive: 343 64% 58%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
height: 100%;
|
||||||
background: var(--background);
|
overflow: hidden;
|
||||||
color: var(--foreground);
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
1100px 620px at -12% -25%,
|
||||||
|
hsl(var(--primary) / 0.2),
|
||||||
|
hsl(var(--primary) / 0) 60%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
900px 540px at 112% -14%,
|
||||||
|
hsl(var(--accent) / 0.35),
|
||||||
|
hsl(var(--accent) / 0) 58%
|
||||||
|
),
|
||||||
|
hsl(var(--background));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
transition:
|
||||||
|
background-color 220ms ease,
|
||||||
|
color 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { createRoot } from "react-dom/client";
|
|||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
|
import { applyThemeMode, loadThemeMode } from "@/services/theme-storage";
|
||||||
|
|
||||||
|
applyThemeMode(loadThemeMode());
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
@@ -84,31 +84,39 @@ export function EmailLoginPage({ onLoginSuccess }: EmailLoginPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-md rounded-xl border border-[#d7e2db] bg-white p-6 shadow-sm">
|
<div className="mx-auto w-full max-w-md rounded-2xl border border-border bg-card/92 p-6 shadow-[0_24px_60px_-36px_hsl(var(--primary)/0.65)] backdrop-blur">
|
||||||
<h1 className="text-2xl font-semibold text-[#122117]">邮箱验证码登录</h1>
|
<div className="mb-4 flex items-center gap-3">
|
||||||
<p className="mt-2 text-sm text-[#3a5a4a]">
|
<img src="/favicon.png" alt="TodoList" className="h-10 w-10 rounded-xl shadow-sm" />
|
||||||
输入邮箱后获取验证码,再完成登录。你也可以直接使用第三方账号登录。
|
<div>
|
||||||
</p>
|
<h1 className="text-2xl font-semibold text-foreground">邮箱验证码登录</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form className="mt-6 space-y-3" onSubmit={handleSendCode}>
|
<form className="mt-6 space-y-3" onSubmit={handleSendCode}>
|
||||||
<label className="block text-sm font-medium text-[#244236]" htmlFor="email">
|
<label className="block text-sm font-medium text-secondary-foreground" htmlFor="email">
|
||||||
邮箱
|
邮箱
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div className="flex items-stretch gap-2">
|
||||||
id="email"
|
<input
|
||||||
type="email"
|
id="email"
|
||||||
className="w-full rounded-md border border-[#bfd0c7] px-3 py-2 text-sm outline-none focus:border-[#0a7a5a]"
|
type="email"
|
||||||
placeholder="you@example.com"
|
className="min-w-0 flex-1 rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary focus:ring-3 focus:ring-ring/25"
|
||||||
value={email}
|
placeholder="you@example.com"
|
||||||
onChange={(event) => setEmail(event.target.value)}
|
value={email}
|
||||||
/>
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
<Button type="submit" disabled={!canSendCode} className="w-full">
|
/>
|
||||||
{sendingCode ? "发送中..." : codeCooldown > 0 ? `${codeCooldown} 秒后重发` : "发送验证码"}
|
<Button
|
||||||
</Button>
|
type="submit"
|
||||||
|
disabled={!canSendCode}
|
||||||
|
className="shrink-0 bg-primary px-4 text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
{sendingCode ? "发送中..." : codeCooldown > 0 ? `${codeCooldown}s` : "发送验证码"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form className="mt-4 space-y-3" onSubmit={handleLogin}>
|
<form className="mt-4 space-y-3" onSubmit={handleLogin}>
|
||||||
<label className="block text-sm font-medium text-[#244236]" htmlFor="code">
|
<label className="block text-sm font-medium text-secondary-foreground" htmlFor="code">
|
||||||
验证码
|
验证码
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -116,7 +124,7 @@ export function EmailLoginPage({ onLoginSuccess }: EmailLoginPageProps) {
|
|||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
className="w-full rounded-md border border-[#bfd0c7] px-3 py-2 text-sm outline-none focus:border-[#0a7a5a]"
|
className="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary focus:ring-3 focus:ring-ring/25"
|
||||||
placeholder="6位数字验证码"
|
placeholder="6位数字验证码"
|
||||||
value={code}
|
value={code}
|
||||||
onChange={(event) => setCode(event.target.value)}
|
onChange={(event) => setCode(event.target.value)}
|
||||||
@@ -124,7 +132,7 @@ export function EmailLoginPage({ onLoginSuccess }: EmailLoginPageProps) {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!canLogin}
|
disabled={!canLogin}
|
||||||
className="w-full bg-[#0a7a5a] text-white hover:bg-[#0a7a5a]/90"
|
className="w-full bg-gradient-to-r from-primary to-accent text-primary-foreground hover:opacity-95"
|
||||||
>
|
>
|
||||||
{loggingIn ? "登录中..." : "立即登录"}
|
{loggingIn ? "登录中..." : "立即登录"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -132,24 +140,36 @@ export function EmailLoginPage({ onLoginSuccess }: EmailLoginPageProps) {
|
|||||||
|
|
||||||
<div className="mt-6 grid grid-cols-1 gap-2">
|
<div className="mt-6 grid grid-cols-1 gap-2">
|
||||||
<a href={`${resolveApiBaseUrl()}/auth/oauth/github`}>
|
<a href={`${resolveApiBaseUrl()}/auth/oauth/github`}>
|
||||||
<Button type="button" variant="outline" className="w-full">
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full border-border bg-card text-foreground"
|
||||||
|
>
|
||||||
使用 GitHub 登录
|
使用 GitHub 登录
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
<a href={`${resolveApiBaseUrl()}/auth/oauth/qq`}>
|
<a href={`${resolveApiBaseUrl()}/auth/oauth/qq`}>
|
||||||
<Button type="button" variant="outline" className="w-full">
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full border-border bg-card text-foreground"
|
||||||
|
>
|
||||||
使用 QQ 登录
|
使用 QQ 登录
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
<a href={`${resolveApiBaseUrl()}/auth/oauth/wechat`}>
|
<a href={`${resolveApiBaseUrl()}/auth/oauth/wechat`}>
|
||||||
<Button type="button" variant="outline" className="w-full">
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full border-border bg-card text-foreground"
|
||||||
|
>
|
||||||
使用微信登录
|
使用微信登录
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{message ? <p className="mt-4 text-sm text-[#0a7a5a]">{message}</p> : null}
|
{message ? <p className="mt-4 text-sm text-primary">{message}</p> : null}
|
||||||
{error ? <p className="mt-2 text-sm text-[#b42318]">{error}</p> : null}
|
{error ? <p className="mt-2 text-sm text-destructive">{error}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,13 +49,16 @@ export function OAuthCallbackPage({ onBootstrapSession }: OAuthCallbackPageProps
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-md rounded-xl border border-[#d7e2db] bg-white p-6 shadow-sm">
|
<div className="mx-auto w-full max-w-md rounded-2xl border border-border bg-card/92 p-6 shadow-[0_24px_60px_-36px_hsl(var(--primary)/0.55)] backdrop-blur">
|
||||||
<h1 className="text-2xl font-semibold text-[#122117]">OAuth 回调处理中</h1>
|
<div className="mb-4 flex items-center gap-3">
|
||||||
<p className="mt-2 text-sm text-[#3a5a4a]">
|
<img src="/favicon.png" alt="TodoList" className="h-10 w-10 rounded-xl shadow-sm" />
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">OAuth 回调处理中</h1>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
{parseResult.ok ? "已收到回调参数,点击继续进入工作台。" : parseResult.reason}
|
{parseResult.ok ? "已收到回调参数,点击继续进入工作台。" : parseResult.reason}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
className="mt-6 w-full bg-[#0a7a5a] text-white hover:bg-[#0a7a5a]/90"
|
className="mt-6 w-full bg-gradient-to-r from-primary to-accent text-primary-foreground hover:opacity-95"
|
||||||
onClick={handleContinue}
|
onClick={handleContinue}
|
||||||
>
|
>
|
||||||
{parseResult.ok ? "继续" : "返回邮箱登录"}
|
{parseResult.ok ? "继续" : "返回邮箱登录"}
|
||||||
|
|||||||
@@ -6,11 +6,28 @@ type TodoShellPageProps = {
|
|||||||
|
|
||||||
export function TodoShellPage({ session }: TodoShellPageProps) {
|
export function TodoShellPage({ session }: TodoShellPageProps) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-[#d7e2db] bg-white p-6 shadow-sm">
|
<div className="rounded-2xl border border-border bg-card/90 p-6 shadow-[0_24px_70px_-42px_hsl(var(--primary)/0.6)] backdrop-blur">
|
||||||
<h1 className="text-2xl font-semibold text-[#122117]">TodoList 工作台</h1>
|
<h1 className="text-2xl font-semibold text-foreground">TodoList 工作台</h1>
|
||||||
<p className="mt-2 text-sm text-[#3a5a4a]">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
{session ? `当前登录邮箱:${session.user.email}` : "当前未建立登录会话,请先完成登录。"}
|
{session ? `当前登录邮箱:${session.user.email}` : "当前未建立登录会话,请先完成登录。"}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mt-6 grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-xl border border-border bg-muted/40 p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">今日重点</p>
|
||||||
|
<p className="mt-2 text-lg font-semibold text-foreground">待接入</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border bg-muted/40 p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">临近截止</p>
|
||||||
|
<p className="mt-2 text-lg font-semibold text-foreground">待接入</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border bg-muted/40 p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">任务分析</p>
|
||||||
|
<p className="mt-2 text-lg font-semibold text-foreground">待接入</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-xs text-muted-foreground">
|
||||||
|
当前为界面阶段,统计卡片将在任务数据接入后显示真实结果。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
const THEME_STORAGE_KEY = "todolist.web.theme";
|
||||||
|
|
||||||
|
export type ThemeMode = "light" | "dark";
|
||||||
|
|
||||||
|
function isThemeMode(value: string | null): value is ThemeMode {
|
||||||
|
return value === "light" || value === "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadThemeMode(): ThemeMode {
|
||||||
|
const savedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||||
|
if (isThemeMode(savedTheme)) {
|
||||||
|
return savedTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||||
|
return "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveThemeMode(mode: ThemeMode): void {
|
||||||
|
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyThemeMode(mode: ThemeMode): void {
|
||||||
|
document.documentElement.classList.toggle("dark", mode === "dark");
|
||||||
|
document.documentElement.style.colorScheme = mode;
|
||||||
|
}
|
||||||
@@ -1,8 +1,43 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
|
darkMode: "class",
|
||||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {}
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border) / <alpha-value>)",
|
||||||
|
input: "hsl(var(--input) / <alpha-value>)",
|
||||||
|
ring: "hsl(var(--ring) / <alpha-value>)",
|
||||||
|
background: "hsl(var(--background) / <alpha-value>)",
|
||||||
|
foreground: "hsl(var(--foreground) / <alpha-value>)",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary) / <alpha-value>)",
|
||||||
|
foreground: "hsl(var(--primary-foreground) / <alpha-value>)"
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary) / <alpha-value>)",
|
||||||
|
foreground: "hsl(var(--secondary-foreground) / <alpha-value>)"
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
|
||||||
|
foreground: "hsl(var(--muted-foreground) / <alpha-value>)"
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
|
||||||
|
foreground: "hsl(var(--accent-foreground) / <alpha-value>)"
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
|
||||||
|
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)"
|
||||||
|
},
|
||||||
|
card: "hsl(var(--card) / <alpha-value>)"
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
plugins: []
|
plugins: []
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user