feat(client): add profile bio markdown editor
Signed-off-by: Noa Virellia <noa@requiem.garden>
This commit is contained in:
112
client/src/components/profile/edit-profile-dialog.tsx
Normal file
112
client/src/components/profile/edit-profile-dialog.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useForm } from '@tanstack/react-form';
|
||||
import { toast } from 'sonner';
|
||||
import z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
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 function EditProfileDialog() {
|
||||
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 (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="w-full mt-4" size="lg">编辑个人资料</Button>
|
||||
</DialogTrigger>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑个人资料</DialogTitle>
|
||||
</DialogHeader>
|
||||
<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>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">取消</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit">保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ const formSchema = z.object({
|
||||
subtitle: z.string().min(1),
|
||||
});
|
||||
|
||||
export default function SettingsForm() {
|
||||
export default function EditProfileForm() {
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
email: '',
|
||||
@@ -57,7 +57,6 @@ export default function SettingsForm() {
|
||||
e.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
className="space-y-3 max-w-5xl mr-auto py-10"
|
||||
>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
@@ -69,7 +68,6 @@ export default function SettingsForm() {
|
||||
value={form.getFieldValue('email')}
|
||||
onChange={e => form.setFieldValue('email', e.target.value)}
|
||||
/>
|
||||
|
||||
<FieldError />
|
||||
</Field>
|
||||
<Field>
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Mail } from 'lucide-react';
|
||||
import Markdown from 'react-markdown';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useUserInfo } from '@/hooks/data/useUserInfo';
|
||||
import { base64ToUtf8 } from '@/lib/utils';
|
||||
import { EditProfileDialog } from './edit-profile-dialog';
|
||||
|
||||
export function MainProfile() {
|
||||
const { data: user } = useUserInfo();
|
||||
@@ -17,17 +19,16 @@ export function MainProfile() {
|
||||
<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>
|
||||
<EditProfileDialog />
|
||||
<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">
|
||||
<section className="rounded-md border border-muted w-full min-h-72 mt-4 p-6 prose dark:prose-invert max-w-[1012px] self-center">
|
||||
{/* Bio */}
|
||||
<Markdown>{base64ToUtf8(user.bio)}</Markdown>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ export function useUserInfo() {
|
||||
nickname: string;
|
||||
subtitle: string;
|
||||
avatar: string;
|
||||
checkin: string | null;
|
||||
bio: string;
|
||||
}
|
||||
>('/user/info');
|
||||
return response.data;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap");
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import type { ClassValue } from 'clsx';
|
||||
// eslint-disable-next-line unicorn/prefer-node-protocol
|
||||
import { Buffer } from 'buffer';
|
||||
import { clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function base64ToUtf8(base64: string): string {
|
||||
return new TextDecoder('utf-8').decode(
|
||||
Uint8Array.from(Buffer.from(base64, 'base64')),
|
||||
);
|
||||
}
|
||||
|
||||
export function utf8ToBase64(utf8: string): string {
|
||||
return Buffer.from(utf8, 'utf-8').toString('base64');
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import NixOSLogo from '@/assets/nixos.svg?react';
|
||||
const paramsSchema = z.object({
|
||||
email: z.string().optional(),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute('/magicLinkSent')({
|
||||
component: RouteComponent,
|
||||
validateSearch: zodValidator(paramsSchema),
|
||||
@@ -16,7 +15,8 @@ function RouteComponent() {
|
||||
const { email } = Route.useSearch();
|
||||
return email !== undefined
|
||||
? (
|
||||
<div className="
|
||||
<div
|
||||
className="
|
||||
bg-background flex min-h-svh flex-row items-center justify-center gap-6 p-6 md:p-10"
|
||||
>
|
||||
<NixOSLogo className="size-12" />
|
||||
@@ -29,5 +29,7 @@ function RouteComponent() {
|
||||
{email}
|
||||
</div>
|
||||
)
|
||||
: <Navigate to="/login" />;
|
||||
: (
|
||||
<Navigate to="/authorize" />
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user