feat(client): add profile bio markdown editor

Signed-off-by: Noa Virellia <noa@requiem.garden>
This commit is contained in:
2026-01-20 13:28:10 +08:00
parent 8e792ced68
commit b8a2e24bd0
9 changed files with 249 additions and 13 deletions

View 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>
);
}

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -12,7 +12,7 @@ export function useUserInfo() {
nickname: string;
subtitle: string;
avatar: string;
checkin: string | null;
bio: string;
}
>('/user/info');
return response.data;

View File

@@ -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 *));

View File

@@ -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');
}

View File

@@ -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" />
);
}