feat(client): profile-wip
Signed-off-by: Noa Virellia <noa@requiem.garden>
This commit is contained in:
@@ -38,20 +38,20 @@ function QrSection({ eventId, enabled }: { eventId: string; enabled: boolean })
|
||||
const { data } = useCheckinCode(eventId, enabled);
|
||||
return data
|
||||
? (
|
||||
<>
|
||||
<div className="border-2 px-4 py-8 border-muted rounded-xl flex flex-col items-center justify-center p-4">
|
||||
<QRCode data={data.data.checkin_code} className="size-60" />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="flex flex-1 items-center ml-2 font-mono text-3xl tracking-widest text-primary/80 justify-center">
|
||||
{data.data.checkin_code}
|
||||
<>
|
||||
<div className="border-2 px-4 py-8 border-muted rounded-xl flex flex-col items-center justify-center p-4">
|
||||
<QRCode data={data.data.checkin_code} className="size-60" />
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)
|
||||
<DialogFooter>
|
||||
<div className="flex flex-1 items-center ml-2 font-mono text-3xl tracking-widest text-primary/80 justify-center">
|
||||
{data.data.checkin_code}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<QrSectionSkeleton />
|
||||
);
|
||||
<QrSectionSkeleton />
|
||||
);
|
||||
}
|
||||
|
||||
function QrSectionSkeleton() {
|
||||
|
||||
@@ -14,7 +14,7 @@ export function withFallback<P extends object>(
|
||||
};
|
||||
|
||||
Wrapped.displayName = `withFallback(${Component.displayName! || Component.name || 'Component'
|
||||
})`;
|
||||
})`;
|
||||
|
||||
return Wrapped;
|
||||
}
|
||||
|
||||
104
client/src/components/profile/form.tsx
Normal file
104
client/src/components/profile/form.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
useForm,
|
||||
} from '@tanstack/react-form';
|
||||
import {
|
||||
toast,
|
||||
} from 'sonner';
|
||||
import {
|
||||
z,
|
||||
} from 'zod';
|
||||
import {
|
||||
Button,
|
||||
} from '@/components/ui/button';
|
||||
import {
|
||||
Field,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from '@/components/ui/field';
|
||||
import {
|
||||
Input,
|
||||
} from '@/components/ui/input';
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string(),
|
||||
nickname: z.string().min(1),
|
||||
subtitle: z.string().min(1),
|
||||
});
|
||||
|
||||
export default function SettingsForm() {
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
email: '',
|
||||
nickname: '',
|
||||
subtitle: '',
|
||||
},
|
||||
validators: {
|
||||
onBlur: formSchema,
|
||||
},
|
||||
onSubmit: async ({
|
||||
value,
|
||||
}) => {
|
||||
try {
|
||||
toast(
|
||||
<code className="text-white">{JSON.stringify(value, null, 2)}</code>,
|
||||
);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Form submission error', error);
|
||||
toast.error('Failed to submit the form. Please try again.');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
className="space-y-3 max-w-5xl mr-auto py-10"
|
||||
>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="noa@requiem.garden"
|
||||
|
||||
value={form.getFieldValue('email')}
|
||||
onChange={e => form.setFieldValue('email', e.target.value)}
|
||||
/>
|
||||
|
||||
<FieldError />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="nickname">昵称</FieldLabel>
|
||||
<Input
|
||||
id="nickname"
|
||||
name="nickname"
|
||||
placeholder="Noa Virellia"
|
||||
|
||||
value={form.getFieldValue('nickname')}
|
||||
onChange={e => form.setFieldValue('nickname', e.target.value)}
|
||||
/>
|
||||
|
||||
<FieldError />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="subtitle">副标题</FieldLabel>
|
||||
<Input
|
||||
id="subtitle"
|
||||
name="subtitle"
|
||||
placeholder="天生骄傲"
|
||||
|
||||
value={form.getFieldValue('subtitle')}
|
||||
onChange={e => form.setFieldValue('subtitle', e.target.value)}
|
||||
/>
|
||||
|
||||
<FieldError />
|
||||
</Field>
|
||||
<Button type="submit">提交</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
34
client/src/components/profile/main-profile.tsx
Normal file
34
client/src/components/profile/main-profile.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Mail } from 'lucide-react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useUserInfo } from '@/hooks/data/useUserInfo';
|
||||
|
||||
export function MainProfile() {
|
||||
const { data: user } = useUserInfo();
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex w-full flex-row gap-4 mt-2">
|
||||
<Avatar className="size-16 rounded-full border-2 border-muted">
|
||||
<AvatarImage src={user.avatar} alt={user.nickname} />
|
||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-1 flex-col justify-center">
|
||||
<span className="font-semibold text-2xl" aria-hidden="true">{user.nickname}</span>
|
||||
<span className="text-[20px] text-muted-foreground" aria-hidden="true">{user.subtitle}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full mt-4" variant="outline" size="lg">
|
||||
编辑个人资料
|
||||
</Button>
|
||||
<section className="px-2 mt-4">
|
||||
<div className="flex flex-row gap-2 items-center text-sm">
|
||||
<Mail className="h-4 w-4 stroke-muted-foreground" />
|
||||
{user.email}
|
||||
</div>
|
||||
</section>
|
||||
<section className="rounded-md border border-muted w-full min-h-72 mt-4">
|
||||
{/* Bio */}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
import {
|
||||
IconDashboard,
|
||||
IconSettings,
|
||||
} from '@tabler/icons-react';
|
||||
import * as React from 'react';
|
||||
|
||||
import NixOSLogo from '@/assets/nixos.svg?react';
|
||||
import { NavMain } from '@/components/nav-main';
|
||||
import { NavSecondary } from '@/components/nav-secondary';
|
||||
import { NavMain } from '@/components/sidebar/nav-main';
|
||||
import { NavSecondary } from '@/components/sidebar/nav-secondary';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -16,30 +11,9 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { navData } from '@/lib/navData';
|
||||
import { NavUser } from './nav-user';
|
||||
|
||||
const data = {
|
||||
user: {
|
||||
name: 'shadcn',
|
||||
email: 'm@example.com',
|
||||
avatar: '/avatars/shadcn.jpg',
|
||||
},
|
||||
navMain: [
|
||||
{
|
||||
title: '工作台',
|
||||
url: '/',
|
||||
icon: IconDashboard,
|
||||
},
|
||||
],
|
||||
navSecondary: [
|
||||
{
|
||||
title: '设置',
|
||||
url: '#',
|
||||
icon: IconSettings,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
return (
|
||||
<Sidebar collapsible="offcanvas" {...props}>
|
||||
@@ -59,8 +33,8 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain items={data.navMain} />
|
||||
<NavSecondary items={data.navSecondary} className="mt-auto" />
|
||||
<NavMain items={navData.navMain} />
|
||||
<NavSecondary items={navData.navSecondary} className="mt-auto" />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser />
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import type { Icon } from '@tabler/icons-react';
|
||||
import * as React from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
@@ -27,12 +28,16 @@ export function NavSecondary({
|
||||
<SidebarMenu>
|
||||
{items.map(item => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild>
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
<Link to={item.url}>
|
||||
{({ isActive }) => {
|
||||
return (
|
||||
<SidebarMenuButton isActive={isActive} tooltip={item.title}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
);
|
||||
}}
|
||||
</Link>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
@@ -24,8 +24,8 @@ import {
|
||||
} from '@/components/ui/sidebar';
|
||||
import { useUserInfo } from '@/hooks/data/useUserInfo';
|
||||
import { useLogout } from '@/hooks/useLogout';
|
||||
import { withFallback } from './hoc/with-fallback';
|
||||
import { Skeleton } from './ui/skeleton';
|
||||
import { withFallback } from '../hoc/with-fallback';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
|
||||
function NavUser_() {
|
||||
const { isMobile } = useSidebar();
|
||||
@@ -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 ?? '工作台';
|
||||
|
||||
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() {
|
||||
orientation="vertical"
|
||||
className="mx-2 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<h1 className="text-base font-medium">工作台</h1>
|
||||
<h1 className="text-base font-medium">{currentTitle}</h1>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -190,7 +190,7 @@ function FieldError({
|
||||
}: React.ComponentProps<'div'> & {
|
||||
errors?: Array<{ message?: string } | undefined>;
|
||||
}) {
|
||||
const content = useMemo(async () => {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
@@ -17,5 +17,6 @@ export function useUserInfo() {
|
||||
>('/user/info');
|
||||
return response.data;
|
||||
},
|
||||
staleTime: 10 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
21
client/src/lib/navData.ts
Normal file
21
client/src/lib/navData.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
IconDashboard,
|
||||
IconUser,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
export const navData = {
|
||||
navMain: [
|
||||
{
|
||||
title: '工作台',
|
||||
url: '/',
|
||||
icon: IconDashboard,
|
||||
},
|
||||
],
|
||||
navSecondary: [
|
||||
{
|
||||
title: '个人资料',
|
||||
url: '/profile',
|
||||
icon: IconUser,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import { Route as MagicLinkSentRouteImport } from './routes/magicLinkSent'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as SidebarLayoutRouteImport } from './routes/_sidebarLayout'
|
||||
import { Route as SidebarLayoutIndexRouteImport } from './routes/_sidebarLayout/index'
|
||||
import { Route as SidebarLayoutProfileRouteImport } from './routes/_sidebarLayout/profile'
|
||||
|
||||
const MagicLinkSentRoute = MagicLinkSentRouteImport.update({
|
||||
id: '/magicLinkSent',
|
||||
@@ -33,15 +34,22 @@ const SidebarLayoutIndexRoute = SidebarLayoutIndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => SidebarLayoutRoute,
|
||||
} as any)
|
||||
const SidebarLayoutProfileRoute = SidebarLayoutProfileRouteImport.update({
|
||||
id: '/profile',
|
||||
path: '/profile',
|
||||
getParentRoute: () => SidebarLayoutRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/login': typeof LoginRoute
|
||||
'/magicLinkSent': typeof MagicLinkSentRoute
|
||||
'/profile': typeof SidebarLayoutProfileRoute
|
||||
'/': typeof SidebarLayoutIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/login': typeof LoginRoute
|
||||
'/magicLinkSent': typeof MagicLinkSentRoute
|
||||
'/profile': typeof SidebarLayoutProfileRoute
|
||||
'/': typeof SidebarLayoutIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
@@ -49,18 +57,20 @@ export interface FileRoutesById {
|
||||
'/_sidebarLayout': typeof SidebarLayoutRouteWithChildren
|
||||
'/login': typeof LoginRoute
|
||||
'/magicLinkSent': typeof MagicLinkSentRoute
|
||||
'/_sidebarLayout/profile': typeof SidebarLayoutProfileRoute
|
||||
'/_sidebarLayout/': typeof SidebarLayoutIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/login' | '/magicLinkSent' | '/'
|
||||
fullPaths: '/login' | '/magicLinkSent' | '/profile' | '/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/login' | '/magicLinkSent' | '/'
|
||||
to: '/login' | '/magicLinkSent' | '/profile' | '/'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/_sidebarLayout'
|
||||
| '/login'
|
||||
| '/magicLinkSent'
|
||||
| '/_sidebarLayout/profile'
|
||||
| '/_sidebarLayout/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
@@ -100,14 +110,23 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof SidebarLayoutIndexRouteImport
|
||||
parentRoute: typeof SidebarLayoutRoute
|
||||
}
|
||||
'/_sidebarLayout/profile': {
|
||||
id: '/_sidebarLayout/profile'
|
||||
path: '/profile'
|
||||
fullPath: '/profile'
|
||||
preLoaderRoute: typeof SidebarLayoutProfileRouteImport
|
||||
parentRoute: typeof SidebarLayoutRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface SidebarLayoutRouteChildren {
|
||||
SidebarLayoutProfileRoute: typeof SidebarLayoutProfileRoute
|
||||
SidebarLayoutIndexRoute: typeof SidebarLayoutIndexRoute
|
||||
}
|
||||
|
||||
const SidebarLayoutRouteChildren: SidebarLayoutRouteChildren = {
|
||||
SidebarLayoutProfileRoute: SidebarLayoutProfileRoute,
|
||||
SidebarLayoutIndexRoute: SidebarLayoutIndexRoute,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { AppSidebar } from '@/components/sidebar/app-sidebar';
|
||||
import { SiteHeader } from '@/components/site-header';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
|
||||
|
||||
14
client/src/routes/_sidebarLayout/profile.tsx
Normal file
14
client/src/routes/_sidebarLayout/profile.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { MainProfile } from '@/components/profile/main-profile';
|
||||
|
||||
export const Route = createFileRoute('/_sidebarLayout/profile')({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<div className="flex min-h-[560px] flex-col gap-6 px-4 py-6">
|
||||
<MainProfile />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user