Compare commits

..

1 Commits

Author SHA1 Message Date
342345392c feat(client): add storybook and workbench profile flow
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-31 12:29:35 +08:00
20 changed files with 74 additions and 174 deletions

View File

@@ -1,18 +0,0 @@
{
"file_scan_exclusions": [
"src/components/ui",
".tanstack",
"node_modules",
"dist",
// default values below
"**/.git",
"**/.svn",
"**/.hg",
"**/CVS",
"**/.DS_Store",
"**/Thumbs.db",
"**/.classpath",
"**/.settings",
],
}

View File

@@ -1,9 +1,6 @@
import type { ServiceUserUserInfoData } from '@/client';
import { useForm } from '@tanstack/react-form';
import {
useEffect,
useState,
} from 'react';
import { useState } from 'react';
import { toast } from 'sonner';
import z from 'zod';
import { Button } from '@/components/ui/button';
@@ -24,6 +21,7 @@ import {
import {
Input,
} from '@/components/ui/input';
import { useUpdateUser } from '@/hooks/data/useUpdateUser';
import { Switch } from '../ui/switch';
const formSchema = z.object({
@@ -33,7 +31,9 @@ const formSchema = z.object({
avatar: z.url().or(z.literal('')),
allow_public: z.boolean(),
});
export function EditProfileDialogView({ user, updateProfile }: { user: ServiceUserUserInfoData; updateProfile: (data: ServiceUserUserInfoData) => Promise<void> }) {
export function EditProfileDialog({ user }: { user: ServiceUserUserInfoData }) {
const { mutateAsync } = useUpdateUser();
const form = useForm({
defaultValues: {
avatar: user.avatar,
@@ -49,7 +49,7 @@ export function EditProfileDialogView({ user, updateProfile }: { user: ServiceUs
value,
}) => {
try {
await updateProfile(value);
await mutateAsync({ body: value });
toast.success('个人资料更新成功');
}
catch (error) {
@@ -61,14 +61,11 @@ export function EditProfileDialogView({ user, updateProfile }: { user: ServiceUs
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open) {
const id = setTimeout(() => {
form.reset();
}, 200);
return () => clearTimeout(id);
}
}, [open, form]);
if (!open) {
setTimeout(() => {
form.reset();
}, 200);
}
return (
<Dialog open={open} onOpenChange={setOpen}>

View File

@@ -1,15 +0,0 @@
import type { ServiceUserUserInfoData } from '@/client';
import { useUpdateUser } from '@/hooks/data/useUpdateUser';
import { EditProfileDialogView } from './edit-profile.dialog.view';
export function EditProfileDialogContainer({ data }: { data: ServiceUserUserInfoData }) {
const { mutateAsync } = useUpdateUser();
return (
<EditProfileDialogView
user={data}
updateProfile={async (data) => {
await mutateAsync({ body: data });
}}
/>
);
}

View File

@@ -1,17 +0,0 @@
import { useUpdateUser } from '@/hooks/data/useUpdateUser';
import { useOtherUserInfo } from '@/hooks/data/useUserInfo';
import { utf8ToBase64 } from '@/lib/utils';
import { ProfileView } from './profile.view';
export function ProfileContainer({ userId }: { userId: string }) {
const { data } = useOtherUserInfo(userId);
const { mutateAsync } = useUpdateUser();
return (
<ProfileView
user={data.data!}
onSaveBio={async (bio) => {
await mutateAsync({ body: { bio: utf8ToBase64(bio) } });
}}
/>
);
}

View File

@@ -11,13 +11,15 @@ import { useMemo, useState } from 'react';
import Markdown from 'react-markdown';
import { toast } from 'sonner';
import { Avatar, AvatarImage } from '@/components/ui/avatar';
import { base64ToUtf8 } from '@/lib/utils';
import { useUpdateUser } from '@/hooks/data/useUpdateUser';
import { base64ToUtf8, utf8ToBase64 } from '@/lib/utils';
import { Button } from '../ui/button';
import { EditProfileDialogContainer } from './edit-profile.dialog.container';
import { EditProfileDialog } from './edit-profile-dialog';
export function ProfileView({ user, onSaveBio }: { user: ServiceUserUserInfoData; onSaveBio: (bio: string) => Promise<void> }) {
export function Profile({ user }: { user: ServiceUserUserInfoData }) {
const [bio, setBio] = useState<string | undefined>(() => base64ToUtf8(user.bio ?? ''));
const [enableBioEdit, setEnableBioEdit] = useState(false);
const { mutateAsync } = useUpdateUser();
const IdentIcon = useMemo(() => {
const avatar = createAvatar(identicon, {
@@ -46,7 +48,7 @@ export function ProfileView({ user, onSaveBio }: { user: ServiceUserUserInfoData
{user.email}
</div>
</div>
<EditProfileDialogContainer data={user} />
<EditProfileDialog user={user} />
</div>
</div>
<section className="relative rounded-md border border-muted w-full flex-1 lg:flex-auto min-h-72 lg:h-full mt-4 lg:mt-0 prose dark:prose-invert max-w-[1012px] self-center">
@@ -70,7 +72,7 @@ export function ProfileView({ user, onSaveBio }: { user: ServiceUserUserInfoData
else {
if (!isNil(bio)) {
try {
await onSaveBio(bio);
await mutateAsync({ body: { bio: utf8ToBase64(bio) } });
setEnableBioEdit(false);
}
catch (error) {

View File

@@ -1,4 +1,3 @@
import type { NavData } from '@/lib/navData';
import * as React from 'react';
import NixOSLogo from '@/assets/nixos.svg?react';
import { NavMain } from '@/components/sidebar/nav-main';
@@ -12,9 +11,10 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { navData } from '@/lib/navData';
import { NavUser } from './nav-user';
export function AppSidebar({ navData, ...props }: React.ComponentProps<typeof Sidebar> & { navData: NavData }) {
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return (
<Sidebar collapsible="offcanvas" {...props}>
<SidebarHeader>

View File

@@ -26,14 +26,15 @@ import {
useSidebar,
} from '@/components/ui/sidebar';
import { useUserInfo } from '@/hooks/data/useUserInfo';
import { logout } from '@/lib/token';
import { useLogout } from '@/hooks/useLogout';
import { withFallback } from '../hoc/with-fallback';
import { Skeleton } from '../ui/skeleton';
export function NavUser_() {
function NavUser_() {
const { isMobile } = useSidebar();
const { data } = useUserInfo();
const user = data.data!;
const { logout } = useLogout();
const IdentIcon = useMemo(() => {
const avatar = createAvatar(identicon, {
@@ -84,7 +85,7 @@ export function NavUser_() {
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={_e => logout()}>
<DropdownMenuItem onClick={logout}>
<IconLogout />
</DropdownMenuItem>

View File

@@ -1,7 +1,18 @@
import { useRouterState } from '@tanstack/react-router';
import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { navData } from '@/lib/navData';
export function SiteHeader() {
const pathname = useRouterState({ select: state => state.location.pathname });
const allNavItems = [...navData.navMain, ...navData.navSecondary];
const currentTitle
= allNavItems.find(item =>
item.url === '/'
? pathname === '/'
: pathname.startsWith(item.url),
)?.title ?? '工作台';
export function SiteHeader({ title }: { title: string }) {
return (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
@@ -10,7 +21,7 @@ export function SiteHeader({ title }: { title: string }) {
orientation="vertical"
className="mx-2 data-[orientation=vertical]:h-4"
/>
<h1 className="text-base font-medium">{title}</h1>
<h1 className="text-base font-medium">{currentTitle}</h1>
</div>
</header>
);

View File

@@ -1,17 +1,12 @@
import type { ServiceUserUserInfoData } from '@/client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { getUserInfoByUserIdQueryKey, getUserInfoQueryKey, patchUserUpdateMutation } from '@/client/@tanstack/react-query.gen';
import { getUserInfoQueryKey, patchUserUpdateMutation } from '@/client/@tanstack/react-query.gen';
export function useUpdateUser() {
const queryClient = useQueryClient();
const data: { data: ServiceUserUserInfoData | undefined } | undefined = queryClient.getQueryData(getUserInfoQueryKey());
return useMutation({
...patchUserUpdateMutation(),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: getUserInfoQueryKey() });
if ((data?.data?.user_id) != null) {
await queryClient.invalidateQueries({ queryKey: getUserInfoByUserIdQueryKey({ path: { user_id: data.data.user_id } }) });
}
},
});
}

View File

@@ -0,0 +1,14 @@
import { useNavigate } from '@tanstack/react-router';
import { useCallback } from 'react';
import { clearTokens } from '@/lib/token';
export function useLogout() {
const navigate = useNavigate();
const logout = useCallback(() => {
clearTokens();
void navigate({ to: '/authorize' });
}, [navigate]);
return { logout };
}

View File

@@ -25,4 +25,3 @@ export const navData = {
},
],
};
export type NavData = typeof navData;

View File

@@ -44,9 +44,9 @@ export async function doRefreshToken(refreshToken: string): Promise<ServiceAuthT
return data?.data;
}
export function logout(message: string = '已登出') {
export function logout(message: string = 'Logged out') {
clearTokens();
void router.navigate({ to: '/authorize' }).then(() => {
toast.info(message);
toast.error(message);
});
}

View File

@@ -12,9 +12,11 @@ const queryClient = new QueryClient({
const status
// eslint-disable-next-line ts/no-unsafe-member-access
= error?.response?.status ?? error?.status;
if (status >= 400 && status < 500) {
return false;
}
return failureCount < 3;
},
},

View File

@@ -1,23 +1,13 @@
import { createFileRoute, Outlet, useRouterState } from '@tanstack/react-router';
import { createFileRoute, Outlet } from '@tanstack/react-router';
import { AppSidebar } from '@/components/sidebar/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { navData } from '@/lib/navData';
export const Route = createFileRoute('/_workbenchLayout')({
component: RouteComponent,
});
function RouteComponent() {
const pathname = useRouterState({ select: state => state.location.pathname });
const allNavItems = [...navData.navMain, ...navData.navSecondary];
const title
= allNavItems.find(item =>
item.url === '/'
? pathname === '/'
: pathname.startsWith(item.url),
)?.title ?? '工作台';
return (
<SidebarProvider
style={
@@ -27,9 +17,9 @@ function RouteComponent() {
} as React.CSSProperties
}
>
<AppSidebar navData={navData} variant="inset" />
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader title={title} />
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<Outlet />

View File

@@ -1,14 +1,20 @@
import { createFileRoute } from '@tanstack/react-router';
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { ProfileContainer } from '@/components/profile/profile.container';
import { Profile } from '@/components/profile/profile';
import { ProfileError } from '@/components/profile/profile.error';
import { ProfileSkeleton } from '@/components/profile/profile.skeleton';
import { useOtherUserInfo } from '@/hooks/data/useUserInfo';
export const Route = createFileRoute('/_workbenchLayout/profile/$userId')({
component: RouteComponent,
});
function ProfileByUserId({ userId }: { userId: string }) {
const { data } = useOtherUserInfo(userId);
return <Profile user={data.data!} />;
}
function RouteComponent() {
const { userId } = Route.useParams();
return (
@@ -20,7 +26,7 @@ function RouteComponent() {
}}
>
<Suspense fallback={<ProfileSkeleton />}>
<ProfileContainer userId={userId} />
<ProfileByUserId userId={userId} />
</Suspense>
</ErrorBoundary>
</div>

View File

@@ -1,8 +1,4 @@
import { createFileRoute, Navigate } from '@tanstack/react-router';
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { ProfileError } from '@/components/profile/profile.error';
import { ProfileSkeleton } from '@/components/profile/profile.skeleton';
import { useUserInfo } from '@/hooks/data/useUserInfo';
export const Route = createFileRoute('/_workbenchLayout/profile/')({
@@ -12,10 +8,6 @@ export const Route = createFileRoute('/_workbenchLayout/profile/')({
function RouteComponent() {
const { data } = useUserInfo();
return (
<ErrorBoundary fallback={<ProfileError reason="获取用户个人资料失败" />}>
<Suspense fallback={<ProfileSkeleton />}>
<Navigate to="/profile/$userId" params={{ userId: data.data!.user_id! }} />
</Suspense>
</ErrorBoundary>
<Navigate to="/profile/$userId" params={{ userId: data.data!.user_id! }} />
);
}

View File

@@ -41,7 +41,7 @@ function RouteComponent() {
},
});
}
}, [token, mutation.isIdle, mutation, oauthParams.client_id, oauthParams.redirect_uri, oauthParams.state]);
}, [token, mutation.isIdle]);
return (
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="w-full max-w-sm">

View File

@@ -1,12 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { NavUser_ } from '@/components/sidebar/nav-user';
const meta = {
title: 'NavUser',
component: NavUser_,
} satisfies Meta<typeof NavUser_>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {};

View File

@@ -1,14 +1,14 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Profile } from '@/components/profile/profile';
import { ProfileError } from '@/components/profile/profile.error';
import { ProfileSkeleton } from '@/components/profile/profile.skeleton';
import { ProfileView } from '@/components/profile/profile.view';
const queryClient = new QueryClient();
const meta = {
title: 'Profile/View',
component: ProfileView,
title: 'Profile',
component: Profile,
decorators: [
Story => (
<QueryClientProvider client={queryClient}>
@@ -16,7 +16,7 @@ const meta = {
</QueryClientProvider>
),
],
} satisfies Meta<typeof ProfileView>;
} satisfies Meta<typeof Profile>;
export default meta;
type Story = StoryObj<typeof meta>;
@@ -31,25 +31,21 @@ export const Primary: Story = {
bio: '',
avatar: 'https://avatars.githubusercontent.com/u/54884471?v=4',
},
onSaveBio: async () => Promise.resolve(),
},
};
export const Skeleton: Story = {
render: () => <ProfileSkeleton />,
args: {
user: {},
onSaveBio: async () => Promise.resolve(),
},
};
export const Error: Story = {
render: () => <ProfileError reason="用户个人资料未公开" />,
render: () => <ProfileError reason="User profile is not public" />,
args: {
user: {
allow_public: false,
},
onSaveBio: async () => Promise.resolve(),
},
};

View File

@@ -1,43 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { EditProfileDialogView } from '@/components/profile/edit-profile.dialog.view';
const meta = {
title: 'Profile/EditDialog',
component: EditProfileDialogView,
decorators: [
Story => (
<div style={{
height: '100vh',
maxWidth: '256px',
margin: 'auto',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Story />
</div>
),
],
} satisfies Meta<typeof EditProfileDialogView>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {
user: {
username: 'nvirellia',
nickname: 'Noa Virellia',
subtitle: '天生骄傲',
email: 'noa@requiem.garden',
bio: '',
avatar: 'https://avatars.githubusercontent.com/u/54884471?v=4',
},
updateProfile: async () => { },
},
parameters: {
layout: 'fullscreen',
},
};