This commit is contained in:
tsuki 2025-08-18 18:47:30 +08:00
parent 667b96b33e
commit 0d511cff15
19 changed files with 8347 additions and 844 deletions

6
.eslintrc.json Normal file
View File

@ -0,0 +1,6 @@
{
"extends": [
"next/core-web-vitals",
"next/typescript"
]
}

View File

@ -11,9 +11,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { IconLoader, IconDeviceFloppy, IconEye, IconArrowLeft } from "@tabler/icons-react" import { IconLoader, IconDeviceFloppy, IconEye, IconArrowLeft, IconX, IconPlus } from "@tabler/icons-react"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { toast } from "sonner" import { toast } from "sonner"
import { SidebarTrigger } from "@/components/ui/sidebar"
const GET_BLOG = gql` const GET_BLOG = gql`
query GetBlog($id: UUID!) { query GetBlog($id: UUID!) {
@ -34,6 +35,28 @@ const GET_BLOG = gql`
isActive isActive
createdAt createdAt
updatedAt updatedAt
tags {
id
name
slug
color
}
}
}
`
const GET_TAGS_AND_CATEGORIES = gql`
query GetTagsAndCategories {
blogTags {
id
name
slug
color
}
blogCategories {
id
name
slug
} }
} }
` `
@ -67,6 +90,7 @@ interface BlogData {
excerpt: string excerpt: string
content: any content: any
categoryId?: string categoryId?: string
tagIds?: string[]
status: 'draft' | 'published' | 'archived' status: 'draft' | 'published' | 'archived'
featuredImage?: string featuredImage?: string
metaTitle?: string metaTitle?: string
@ -75,6 +99,19 @@ interface BlogData {
isActive: boolean isActive: boolean
} }
interface Tag {
id: string
name: string
slug: string
color?: string
}
interface Category {
id: string
name: string
slug: string
}
export default function EditorComponent() { export default function EditorComponent() {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
@ -87,12 +124,15 @@ export default function EditorComponent() {
excerpt: '', excerpt: '',
content: null, content: null,
status: 'draft', status: 'draft',
tagIds: [],
isFeatured: false, isFeatured: false,
isActive: true isActive: true
}) })
const [isLoading, setIsLoading] = React.useState(false) const [isLoading, setIsLoading] = React.useState(false)
const { data: tagsAndCategoriesData, loading: tagsAndCategoriesLoading } = useQuery(GET_TAGS_AND_CATEGORIES)
const { data: blogQuery, loading: blogLoading } = useQuery(GET_BLOG, { const { data: blogQuery, loading: blogLoading } = useQuery(GET_BLOG, {
variables: { id: blogId }, variables: { id: blogId },
skip: !isEditing, skip: !isEditing,
@ -104,6 +144,7 @@ export default function EditorComponent() {
excerpt: data.blog.excerpt || '', excerpt: data.blog.excerpt || '',
content: data.blog.content, content: data.blog.content,
categoryId: data.blog.categoryId, categoryId: data.blog.categoryId,
tagIds: data.blog.tags?.map((tag: Tag) => tag.id) || [],
status: data.blog.status || 'draft', status: data.blog.status || 'draft',
featuredImage: data.blog.featuredImage, featuredImage: data.blog.featuredImage,
metaTitle: data.blog.metaTitle || '', metaTitle: data.blog.metaTitle || '',
@ -149,6 +190,7 @@ export default function EditorComponent() {
excerpt: blogData.excerpt || undefined, excerpt: blogData.excerpt || undefined,
content: blogData.content, content: blogData.content,
categoryId: blogData.categoryId || undefined, categoryId: blogData.categoryId || undefined,
tagIds: blogData.tagIds?.length ? blogData.tagIds : undefined,
status: publishStatus || blogData.status, status: publishStatus || blogData.status,
featuredImage: blogData.featuredImage || undefined, featuredImage: blogData.featuredImage || undefined,
metaTitle: blogData.metaTitle || undefined, metaTitle: blogData.metaTitle || undefined,
@ -187,7 +229,7 @@ export default function EditorComponent() {
setBlogData(prev => ({ ...prev, content })) setBlogData(prev => ({ ...prev, content }))
} }
if (blogLoading) { if (blogLoading || tagsAndCategoriesLoading) {
return ( return (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<IconLoader className="h-8 w-8 animate-spin" /> <IconLoader className="h-8 w-8 animate-spin" />
@ -195,6 +237,9 @@ export default function EditorComponent() {
) )
} }
const availableTags = tagsAndCategoriesData?.blogTags || []
const availableCategories = tagsAndCategoriesData?.blogCategories || []
return ( return (
<div className="flex h-full"> <div className="flex h-full">
{/* Main Editor Area */} {/* Main Editor Area */}
@ -203,6 +248,7 @@ export default function EditorComponent() {
<div className="border-b sticky top-0 z-10"> <div className="border-b sticky top-0 z-10">
<div className="flex h-14 items-center justify-between px-6"> <div className="flex h-14 items-center justify-between px-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -281,6 +327,126 @@ export default function EditorComponent() {
{/* Sidebar */} {/* Sidebar */}
<div className="w-80 border-l overflow-auto"> <div className="w-80 border-l overflow-auto">
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
{/* Categories */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium"></CardTitle>
</CardHeader>
<CardContent>
<Select
value={blogData.categoryId || 'none'}
onValueChange={(value) =>
setBlogData(prev => ({ ...prev, categoryId: value === 'none' ? undefined : value }))
}
>
<SelectTrigger>
<SelectValue placeholder="选择分类" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{availableCategories.map((category: Category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
{/* Tags */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium"></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* Selected Tags */}
{blogData.tagIds && blogData.tagIds.length > 0 && (
<div className="flex flex-wrap gap-2">
{blogData.tagIds.map((tagId) => {
const tag = availableTags.find((t: Tag) => t.id === tagId)
if (!tag) return null
return (
<Badge
key={tagId}
variant="secondary"
className="flex items-center gap-1"
style={{
backgroundColor: tag.color ? `${tag.color}20` : undefined,
borderColor: tag.color || undefined
}}
>
{tag.name}
<Button
size="sm"
variant="ghost"
className="h-auto p-0 hover:bg-transparent"
onClick={() => {
setBlogData(prev => ({
...prev,
tagIds: prev.tagIds?.filter(id => id !== tagId) || []
}))
}}
>
<IconX className="h-3 w-3" />
</Button>
</Badge>
)
})}
</div>
)}
{/* Available Tags */}
<div className="space-y-2">
<Label className="text-xs font-medium text-gray-600"></Label>
<div className="max-h-32 overflow-y-auto border rounded-md">
{availableTags.map((tag: Tag) => (
<div
key={tag.id}
className="flex items-center space-x-2 p-2 hover:bg-gray-50 border-b last:border-b-0"
>
<Checkbox
id={`tag-${tag.id}`}
checked={blogData.tagIds?.includes(tag.id) || false}
onCheckedChange={(checked) => {
setBlogData(prev => {
const currentTags = prev.tagIds || []
if (checked) {
return {
...prev,
tagIds: [...currentTags, tag.id]
}
} else {
return {
...prev,
tagIds: currentTags.filter(id => id !== tag.id)
}
}
})
}}
/>
<Label
htmlFor={`tag-${tag.id}`}
className="flex-1 text-sm cursor-pointer flex items-center gap-2"
>
{tag.color && (
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: tag.color }}
/>
)}
{tag.name}
</Label>
</div>
))}
</div>
</div>
</div>
</CardContent>
</Card>
{/* Publish Section */} {/* Publish Section */}
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">

View File

@ -134,39 +134,25 @@ function UserAvatar() {
<DropdownMenuShortcut>P</DropdownMenuShortcut> <DropdownMenuShortcut>P</DropdownMenuShortcut>
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/me/account" className="flex items-center">
<User className="mr-2 h-4 w-4" />
<span>Account</span>
<DropdownMenuShortcut>A</DropdownMenuShortcut>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/me/notifications" className="flex items-center">
<Settings className="mr-2 h-4 w-4" />
<span>Notifications</span>
<DropdownMenuShortcut>N</DropdownMenuShortcut>
</Link>
</DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> {/* <DropdownMenuGroup> */}
<DropdownMenuItem asChild> {/* <DropdownMenuItem asChild>
<Link href="/me/upgrade" className="flex items-center"> <Link href="/me/upgrade" className="flex items-center">
<Crown className="mr-2 h-4 w-4" /> <Crown className="mr-2 h-4 w-4" />
<span>Upgrade to Pro</span> <span>Upgrade to Pro</span>
<DropdownMenuShortcut>U</DropdownMenuShortcut> <DropdownMenuShortcut>U</DropdownMenuShortcut>
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem> */}
<DropdownMenuItem asChild> {/* <DropdownMenuItem asChild>
<Link href="/me/billing" className="flex items-center"> <Link href="/me/billing" className="flex items-center">
<Crown className="mr-2 h-4 w-4" /> <Crown className="mr-2 h-4 w-4" />
<span>Billing</span> <span>Billing</span>
<DropdownMenuShortcut>B</DropdownMenuShortcut> <DropdownMenuShortcut>B</DropdownMenuShortcut>
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem> */}
</DropdownMenuGroup> {/* </DropdownMenuGroup> */}
<DropdownMenuSeparator /> {/* <DropdownMenuSeparator />
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href="/help" className="flex items-center"> <Link href="/help" className="flex items-center">
<Settings className="mr-2 h-4 w-4" /> <Settings className="mr-2 h-4 w-4" />
@ -176,7 +162,7 @@ function UserAvatar() {
<DropdownMenuItem disabled> <DropdownMenuItem disabled>
API Documentation API Documentation
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator /> */}
<DropdownMenuItem className="flex items-center text-red-600 focus:text-red-600"> <DropdownMenuItem className="flex items-center text-red-600 focus:text-red-600">
<LogOut className="mr-2 h-4 w-4" /> <LogOut className="mr-2 h-4 w-4" />
<span>Log out</span> <span>Log out</span>
@ -203,21 +189,21 @@ function UserAvatar() {
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const router = useRouter() const router = useRouter()
const mainNavItems = [ const mainNavItems = [
{ // {
icon: Home, // icon: Home,
label: "Home", // label: "Home",
onClick: () => console.log("Navigate to home") // onClick: () => console.log("Navigate to home")
}, // },
{ {
icon: Book, icon: Book,
label: "Docs", label: "Blogs",
onClick: () => router.push("/blog") onClick: () => router.push("/blog")
}, },
{ // {
icon: Plus, // icon: Plus,
label: "New", // label: "New",
onClick: () => console.log("Create new project") // onClick: () => console.log("Create new project")
} // }
] ]
return ( return (

View File

@ -1,13 +1,19 @@
import { Command } from "lucide-react"
import { Navigation } from "../nav" import { Navigation } from "../nav"
import FooterSection from "@/components/footer"
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
return ( return (
<div className="flex flex-col min-h-screen"> <div className="flex flex-col min-h-screen">
<div className="container mx-auto py-8 max-w-4xl"> <div className="sticky top-0 z-50 bg-background flex flex-row items-center px-4">
<Command size={24} className="mr-4" />
<Navigation />
</div>
<div className="flex-1">
{children} {children}
</div> </div>
<FooterSection />
</div> </div>
) )
} }

View File

@ -1,50 +1,194 @@
"use client" "use client"
import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { gql, useQuery } from "@apollo/client"; import { gql, useQuery } from "@apollo/client";
import Link from "next/link"; import Link from "next/link";
import { useEffect } from "react"; import { formatDistanceToNow } from "date-fns";
const BLOG = gql` const BLOG = gql`
query Blog { query Blog {
blogs(filter: {
blogs { isActive: true
status: "published"
},
sort: {
field: "createdAt"
direction: "DESC"
}
) {
items { items {
id id
title title
excerpt excerpt
slug slug
createdAt
createdBy
categoryId
featuredImage
} }
} }
}
`
const INFO = gql`
query Info($tagId: String!, $categoryId: String!, $authorId: String!) {
blogTag(id: $tagId) {
name
slug
color
}
blogCategory(id: $categoryId) {
name
slug
}
userWithGroups(id: $authorId) {
user {
username
}
}
} }
` `
export default function Blog() { export default function Blog() {
const { data, loading, error } = useQuery(BLOG); const { data, loading, error } = useQuery(BLOG);
const items = data?.blogs?.items || []; const items = data?.blogs?.items || [];
return <div> if (loading) {
<h1 className="text-5xl font-bold my-12">Blog</h1> return (
{loading && <div>Loading...</div>} <div className="w-full py-20 lg:py-40">
{error && <div>Error: {error.message}</div>} <div className="container mx-auto">
{!loading && items.length === 0 && <div>No blogs found</div>} <div className="text-center">Loading...</div>
{items.map((blog: any) => (
<BlogItem key={blog.id} blog={blog} />
))}
</div>;
}
function BlogItem({ blog }: { blog: any }) {
return <div>
<Link href={`/blog/${blog.slug}`}>
<Button variant="link" asChild>
<h2 className="text-2xl font-bold">{blog.title}</h2>
</Button>
</Link>
<p className="text-sm text-gray-500">{blog.excerpt}</p>
</div> </div>
</div>
);
}
if (error) {
return (
<div className="w-full py-20 lg:py-40">
<div className="container mx-auto">
<div className="text-center text-red-500">Error: {error.message}</div>
</div>
</div>
);
}
return (
<div className="w-full py-20 lg:py-40">
<div className="container mx-auto flex flex-col gap-14">
<div className="flex w-full flex-col sm:flex-row sm:justify-between sm:items-center gap-8">
<h4 className="text-3xl md:text-5xl tracking-tighter max-w-xl font-regular">
Latest articles
</h4>
</div>
{items.length === 0 ? (
<div className="text-center text-muted-foreground">No articles found</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{items.map((blog: any, index: number) => (
<BlogItem
key={blog.id}
blog={blog}
featured={index === 0}
/>
))}
</div>
)}
</div>
</div>
);
}
interface BlogItemProps {
blog: {
id: string;
title: string;
excerpt: string;
slug: string;
createdAt: string;
createdBy: string;
categoryId: string;
featuredImage?: string;
};
featured?: boolean;
}
function BlogItem({ blog, featured = false }: BlogItemProps) {
const timeAgo = formatDistanceToNow(new Date(blog.createdAt), { addSuffix: true });
// 使用 INFO 查询获取详细信息
const { data: infoData, loading: infoLoading } = useQuery(INFO, {
variables: {
tagId: blog.categoryId || "",
categoryId: blog.categoryId,
authorId: blog.createdBy
},
skip: !blog.categoryId || !blog.createdBy,
});
const category = infoData?.blogCategory;
const author = infoData?.userWithGroups?.user;
const tag = infoData?.blogTag;
return (
<Link href={`/blog/${blog.slug}`}>
<div className={`flex flex-col gap-4 hover:opacity-75 cursor-pointer transition-opacity ${featured ? 'md:col-span-2' : ''
}`}>
{/* Featured Image */}
<div className="bg-muted rounded-md aspect-video overflow-hidden">
{blog.featuredImage ? (
<img
src={blog.featuredImage}
alt={blog.title}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-muted flex items-center justify-center text-muted-foreground">
No image
</div>
)}
</div>
{/* Meta Information */}
<div className="flex flex-row gap-4 items-center">
<Badge
variant="secondary"
style={{
backgroundColor: tag?.color || undefined,
color: tag?.color ? '#ffffff' : undefined
}}
>
{category?.name || tag?.name || 'Article'}
</Badge>
<div className="flex flex-row gap-2 text-sm items-center">
<span className="text-muted-foreground">By</span>
<Avatar className="h-6 w-6">
<AvatarFallback>
{author?.username?.charAt(0)?.toUpperCase() || 'A'}
</AvatarFallback>
</Avatar>
<span>{infoLoading ? 'Loading...' : (author?.username || 'Anonymous')}</span>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">{timeAgo}</span>
</div>
</div>
{/* Content */}
<div className={`flex flex-col ${featured ? 'gap-2' : 'gap-1'}`}>
<h3 className={`max-w-3xl tracking-tight ${featured ? 'text-4xl' : 'text-2xl'
}`}>
{blog.title}
</h3>
<p className="max-w-3xl text-muted-foreground text-base">
{blog.excerpt || 'No excerpt available...'}
</p>
</div>
</div>
</Link>
);
} }

View File

@ -11,6 +11,7 @@ import { zodResolver } from "@hookform/resolvers/zod"
import { useUser } from "../user-context" import { useUser } from "../user-context"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useState } from "react" import { useState } from "react"
import { GalleryVerticalEnd } from "lucide-react"
const schema = z.object({ const schema = z.object({
username: z.string().min(1, "用户名不能为空"), username: z.string().min(1, "用户名不能为空"),
@ -20,26 +21,40 @@ const schema = z.object({
export function LoginForm({ export function LoginForm({
className, className,
...props ...props
}: React.ComponentProps<"form">) { }: React.ComponentProps<"div">) {
const { login } = useUser(); const { login } = useUser();
const router = useRouter(); const router = useRouter();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [step, setStep] = useState<'username' | 'password'>('username');
const [username, setUsername] = useState('');
const form = useForm<z.infer<typeof schema>>({ const usernameForm = useForm<{ username: string }>({
resolver: zodResolver(schema), resolver: zodResolver(z.object({ username: z.string().min(1, "用户名不能为空") })),
defaultValues: { defaultValues: { username: "" },
username: "", });
password: "",
},
})
async function onSubmit(values: z.infer<typeof schema>) { const passwordForm = useForm<{ username: string; password: string }>({
resolver: zodResolver(z.object({
username: z.string(),
password: z.string().min(1, "密码不能为空")
})),
defaultValues: { username: "", password: "" },
});
async function onUsernameSubmit(values: { username: string }) {
setUsername(values.username);
setStep('password');
setError(null);
// 设置密码表单的用户名字段
passwordForm.setValue('username', values.username);
}
async function onPasswordSubmit(values: { username: string; password: string }) {
try { try {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
await login(values); await login({ username, password: values.password });
// clearMap();
router.push('/'); router.push('/');
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '登录失败,请重试'); setError(err instanceof Error ? err.message : '登录失败,请重试');
@ -48,14 +63,35 @@ export function LoginForm({
} }
} }
function goBackToUsername() {
setStep('username');
setError(null);
passwordForm.reset();
}
return ( return (
<Form {...form}> <div className={cn("flex flex-col gap-6", className)} {...props}>
<form className={cn("flex flex-col gap-6", className)} {...props} onSubmit={form.handleSubmit(onSubmit)}> {step === 'username' ? (
<div className="flex flex-col items-center gap-2 text-center"> <Form {...usernameForm}>
<h1 className="text-2xl font-bold"></h1> <form onSubmit={usernameForm.handleSubmit(onUsernameSubmit)}>
<p className="text-muted-foreground text-sm text-balance"> <div className="flex flex-col gap-6">
<div className="flex flex-col items-center gap-2">
</p> <a
href="#"
className="flex flex-col items-center gap-2 font-medium"
>
<div className="flex size-8 items-center justify-center rounded-md">
<GalleryVerticalEnd className="size-6" />
</div>
<span className="sr-only">Acme Inc.</span>
</a>
<h1 className="text-xl font-bold"> Acme Inc.</h1>
<div className="text-center text-sm">
{" "}
<a href="#" className="underline underline-offset-4">
</a>
</div>
</div> </div>
{error && ( {error && (
@ -64,68 +100,160 @@ export function LoginForm({
</div> </div>
)} )}
<div className="grid gap-6"> <div className="flex flex-col gap-6">
<div className="grid gap-3"> <div className="grid gap-3">
<FormField <FormField
control={form.control} control={usernameForm.control}
name="username" name="username"
disabled={isLoading}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel htmlFor="username"></FormLabel>
<FormControl> <FormControl>
<Input id="username" placeholder="请输入用户名" {...field} required /> <Input
id="username"
type="text"
placeholder="请输入用户名"
{...field}
required
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} /> )}
/>
</div>
<Button type="submit" className="w-full">
</Button>
</div>
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
<span className="bg-background text-muted-foreground relative z-10 px-2">
</span>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Button variant="outline" type="button" className="w-full">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
fill="currentColor"
/>
</svg>
Continue with Apple
</Button>
<Button variant="outline" type="button" className="w-full">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
fill="currentColor"
/>
</svg>
Continue with Google
</Button>
</div>
</div>
</form>
</Form>
) : (
<Form {...passwordForm}>
<form onSubmit={passwordForm.handleSubmit(onPasswordSubmit)}>
<div className="flex flex-col gap-6">
<div className="flex flex-col items-center gap-2">
<a
href="#"
className="flex flex-col items-center gap-2 font-medium"
>
<div className="flex size-8 items-center justify-center rounded-md">
<GalleryVerticalEnd className="size-6" />
</div>
<span className="sr-only">Acme Inc.</span>
</a>
<h1 className="text-xl font-bold"></h1>
<div className="text-center text-sm">
<span className="font-medium">{username}</span>
</div>
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
{error}
</div>
)}
<div className="flex flex-col gap-6">
<div className="grid gap-3">
<FormField
control={passwordForm.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="username"></FormLabel>
<FormControl>
<Input
id="username"
type="text"
{...field}
readOnly
className="bg-muted cursor-not-allowed"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div> </div>
<div className="grid gap-3"> <div className="grid gap-3">
<FormField <FormField
control={form.control} control={passwordForm.control}
name="password" name="password"
disabled={isLoading} disabled={isLoading}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<div className="flex items-center"> <FormLabel htmlFor="password"></FormLabel>
<FormLabel></FormLabel>
<a
href="#"
className="ml-auto text-sm underline-offset-4 hover:underline"
>
</a>
</div>
<FormControl> <FormControl>
<Input id="password" type="password" placeholder="请输入密码" {...field} required /> <Input
id="password"
type="password"
placeholder="请输入密码"
{...field}
required
autoFocus
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} /> )}
/>
</div> </div>
<Button type="submit" className="w-full" disabled={isLoading}> <Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "登录中..." : "登录"} {isLoading ? "登录中..." : "登录"}
</Button> </Button>
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
<span className="bg-background text-muted-foreground relative z-10 px-2">
使
</span>
</div> </div>
<Button variant="outline" className="w-full" disabled={isLoading}> <div className="text-center">
GitHub <Button
type="button"
variant="ghost"
onClick={goBackToUsername}
className="text-sm underline-offset-4 hover:underline"
>
</Button> </Button>
</div> </div>
<div className="text-center text-sm">
{" "}
<a href="#" className="underline underline-offset-4">
</a>
</div> </div>
</form> </form>
</Form> </Form>
)}
<div className="text-muted-foreground text-center text-xs text-balance">
By clicking continue, you agree to our{" "}
<a href="#" className="underline underline-offset-4 hover:text-primary">
Terms of Service
</a>{" "}
and{" "}
<a href="#" className="underline underline-offset-4 hover:text-primary">
Privacy Policy
</a>.
</div>
</div>
) )
} }

View File

@ -48,29 +48,10 @@ export default function LoginPage() {
// } // }
return ( return (
<div className="grid min-h-svh lg:grid-cols-2"> <div className="min-h-svh flex items-center justify-center p-6">
<div className="flex flex-col gap-4 p-6 md:p-10"> <div className="w-full max-w-sm">
<div className="flex justify-center gap-2 md:justify-start">
<a href="#" className="flex items-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md">
<GalleryVerticalEnd className="size-4" />
</div>
Acme Inc.
</a>
</div>
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-xs">
<LoginForm /> <LoginForm />
</div> </div>
</div> </div>
</div>
<div className="bg-muted relative hidden lg:block">
<img
src="/placeholder.svg"
alt="Image"
className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
/>
</div>
</div>
); );
} }

View File

@ -1,128 +0,0 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import { Eye, EyeOff, Save } from "lucide-react"
export default function Page() {
const [showPassword, setShowPassword] = useState(false)
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label></Label>
<p className="text-sm text-muted-foreground">zhangsan2024</p>
</div>
<Button variant="outline" size="sm">
</Button>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label></Label>
<p className="text-sm text-muted-foreground">zhangsan@example.com</p>
</div>
<Button variant="outline" size="sm">
</Button>
</div>
<Separator />
<div className="space-y-2">
<Label htmlFor="current-password"></Label>
<div className="relative">
<Input id="current-password" type={showPassword ? "text" : "password"} placeholder="输入当前密码" />
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="new-password"></Label>
<Input id="new-password" type="password" placeholder="输入新密码" />
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password"></Label>
<Input id="confirm-password" type="password" placeholder="再次输入新密码" />
</div>
</div>
<Button className="gap-2">
<Save className="h-4 w-4" />
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label></Label>
<Select defaultValue="zh-cn">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="zh-cn"></SelectItem>
<SelectItem value="zh-tw"></SelectItem>
<SelectItem value="en">English</SelectItem>
<SelectItem value="ja"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Select defaultValue="asia-shanghai">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="asia-shanghai">Asia/Shanghai (UTC+8)</SelectItem>
<SelectItem value="asia-tokyo">Asia/Tokyo (UTC+9)</SelectItem>
<SelectItem value="america-new-york">America/New_York (UTC-5)</SelectItem>
<SelectItem value="europe-london">Europe/London (UTC+0)</SelectItem>
</SelectContent>
</Select>
</div>
<Button></Button>
</CardContent>
</Card>
</div>
)
}

View File

@ -5,6 +5,9 @@ import {
SidebarProvider, SidebarProvider,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
import { Navigation } from "../nav"
import { Command } from "lucide-react"
import FooterSection from "@/components/footer"
export default async function Layout({ children }: { children: React.ReactNode }) { export default async function Layout({ children }: { children: React.ReactNode }) {
@ -17,19 +20,13 @@ export default async function Layout({ children }: { children: React.ReactNode }
} }
return ( return (
<SidebarProvider <div className="flex flex-col">
style={ <div className="sticky top-0 z-50 bg-background flex flex-row items-center px-4">
{ <Command size={24} className="mr-4" />
"--sidebar-width": "calc(var(--spacing) * 72)", <Navigation />
"--header-height": "calc(var(--spacing) * 12)", </div>
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
{children} {children}
</SidebarInset> <FooterSection />
</SidebarProvider> </div>
) )
} }

View File

@ -1,67 +0,0 @@
"use client"
import { IconCirclePlusFilled, IconMail, type Icon } from "@tabler/icons-react"
import { Button } from "@/components/ui/button"
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
import { useEffect, useState } from "react"
import { usePathname } from "next/navigation"
import Link from "next/link"
export function NavMain({
items,
}: {
items: {
title: string
url: string
icon?: Icon
}[]
}) {
const pathname = usePathname()
return (
<SidebarGroup>
<SidebarGroupContent className="flex flex-col gap-2">
<SidebarMenu>
<SidebarMenuItem className="flex items-center gap-2">
<SidebarMenuButton
tooltip="Quick Create"
className="bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear"
>
<IconCirclePlusFilled />
<span>Quick Create</span>
</SidebarMenuButton>
<Button
size="icon"
className="size-8 group-data-[collapsible=icon]:opacity-0"
variant="outline"
>
<IconMail />
<span className="sr-only">Inbox</span>
</Button>
</SidebarMenuItem>
</SidebarMenu>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<Link href={item.url}>
<SidebarMenuButton tooltip={item.title} isActive={pathname === item.url} >
{item.icon && <item.icon />}
<span>{item.title}</span>
</SidebarMenuButton>
</Link>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup >
)
}

View File

@ -1,101 +0,0 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Separator } from "@/components/ui/separator"
import { Mail, Bell, Phone, Save } from "lucide-react"
export default function Page() {
const [notifications, setNotifications] = useState({
email: true,
push: false,
sms: true,
marketing: false,
})
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="flex items-center gap-2">
<Mail className="h-4 w-4" />
</Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch
checked={notifications.email}
onCheckedChange={(checked) => setNotifications({ ...notifications, email: checked })}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="flex items-center gap-2">
<Bell className="h-4 w-4" />
</Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch
checked={notifications.push}
onCheckedChange={(checked) => setNotifications({ ...notifications, push: checked })}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="flex items-center gap-2">
<Phone className="h-4 w-4" />
</Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch
checked={notifications.sms}
onCheckedChange={(checked) => setNotifications({ ...notifications, sms: checked })}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label></Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch
checked={notifications.marketing}
onCheckedChange={(checked) => setNotifications({ ...notifications, marketing: checked })}
/>
</div>
</div>
<Button className="gap-2">
<Save className="h-4 w-4" />
</Button>
</CardContent>
</Card>
</div>
)
}

View File

@ -1,22 +1,763 @@
"use client" "use client";
import { redirect, useRouter } from "next/navigation" import * as React from "react";
import { useUser } from "../user-context" import { useState, useRef, useEffect } from "react";
import { useEffect } from "react" import { useUser } from "../user-context";
import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
import {
Settings,
User,
Bell,
Shield,
Database,
Mail,
Globe,
Palette,
Monitor,
Moon,
Sun,
Check,
ChevronDown,
Search,
X,
Save,
RotateCcw
} from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
export default function MePage() { interface Tag {
const { isAuthenticated, isLoading, user } = useUser() id: string;
const router = useRouter(); label: string;
color?: string;
}
useEffect(() => { interface TagInputProps {
if (!isLoading && !isAuthenticated) { onChange?: (tags: Array<Tag>) => void;
router.push('/login'); defaultTags?: Array<Tag>;
suggestions?: Array<Tag>;
maxTags?: number;
label?: string;
placeholder?: string;
error?: string;
}
function useTags({
onChange,
defaultTags = [],
maxTags = 10,
}: {
onChange?: (tags: Tag[]) => void;
defaultTags?: Tag[];
maxTags?: number;
}) {
const [tags, setTags] = useState<Tag[]>(defaultTags);
function addTag(tag: Tag) {
if (tags.length >= maxTags) return;
const newTags = [...tags, tag];
setTags(newTags);
onChange?.(newTags);
return newTags;
} }
}, [isAuthenticated, isLoading, router, user]);
function removeTag(tagId: string) {
const newTags = tags.filter((t) => t.id !== tagId);
setTags(newTags);
onChange?.(newTags);
return newTags;
}
function removeLastTag() {
if (tags.length === 0) return;
return removeTag(tags[tags.length - 1].id);
}
return {
tags,
setTags,
addTag,
removeTag,
removeLastTag,
hasReachedMax: tags.length >= maxTags,
};
}
function useClickOutside<T extends HTMLElement = HTMLElement>(
ref: React.RefObject<T>,
handler: (event: MouseEvent | TouchEvent) => void,
mouseEvent: 'mousedown' | 'mouseup' = 'mousedown'
): void {
useEffect(() => {
const listener = (event: MouseEvent | TouchEvent) => {
const el = ref?.current;
const target = event.target;
if (!el || !target || el.contains(target as Node)) {
return;
}
handler(event);
};
document.addEventListener(mouseEvent, listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener(mouseEvent, listener);
document.removeEventListener('touchstart', listener);
};
}, [ref, handler, mouseEvent]);
}
const tagStyles = {
base: "inline-flex items-center gap-1.5 px-2 py-0.5 text-sm rounded-md transition-colors duration-150",
colors: {
blue: "bg-blue-50 text-blue-700 border border-blue-200 hover:border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-700/30 dark:hover:border-blue-600/50",
purple: "bg-purple-50 text-purple-700 border border-purple-200 hover:border-purple-300 dark:bg-purple-900/30 dark:text-purple-300 dark:border-purple-700/30 dark:hover:border-purple-600/50",
green: "bg-green-50 text-green-700 border border-green-200 hover:border-green-300 dark:bg-green-900/30 dark:text-green-300 dark:border-green-700/30 dark:hover:border-green-600/50",
},
};
function TagInput({
onChange,
defaultTags = [],
suggestions = [
{ id: "nextjs", label: "Next.js" },
{ id: "react", label: "React" },
{ id: "tailwind", label: "Tailwind" },
],
maxTags = 10,
label = "Tags",
placeholder = "Add tags...",
error,
}: TagInputProps) {
const { tags, addTag, removeTag, removeLastTag } = useTags({
onChange,
defaultTags,
maxTags,
});
const [input, setInput] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const filteredSuggestions = suggestions
.filter(
(suggestion: Tag) =>
typeof suggestion.label === "string" &&
typeof input === "string" &&
suggestion.label.toLowerCase().indexOf(input.toLowerCase()) >= 0 &&
!tags.some((tag: Tag) => tag.id === suggestion.id)
)
.slice(0, 5);
const canAddNewTag =
input.length > 0 &&
!suggestions.some((s) => s.label.toLowerCase() === input.toLowerCase());
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Backspace" && input === "" && tags.length > 0) {
removeLastTag();
} else if (e.key === "Enter" && input) {
e.preventDefault();
if (isOpen && filteredSuggestions[selectedIndex]) {
addTag(filteredSuggestions[selectedIndex]);
setInput("");
setIsOpen(false);
} else if (canAddNewTag) {
addTag({ id: input, label: input });
setInput("");
setIsOpen(false);
}
} else if (e.key === "Escape") {
setIsOpen(false);
}
}
useClickOutside(containerRef as React.RefObject<HTMLElement>, () => setIsOpen(false));
return ( return (
<div> <div className="w-full space-y-2" ref={containerRef}>
<h1>Me</h1> {label && (
<Label className="text-sm font-medium text-foreground" htmlFor={label}>
{label}
</Label>
)}
<div
className={cn(
"min-h-[2.5rem] p-1.5 rounded-lg border border-border bg-background",
"focus-within:ring-2 focus-within:ring-ring/20",
"flex items-center flex-row flex-wrap gap-1.5 relative"
)}
>
{tags.map((tag) => (
<span
key={tag.id}
className={cn(
tagStyles.base,
tag.color || tagStyles.colors.blue
)}
>
{tag.label}
<button
type="button"
onClick={() => removeTag(tag.id)}
className="text-current/60 hover:text-current transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</span>
))}
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => {
setInput(e.target.value);
setIsOpen(true);
setSelectedIndex(0);
}}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder={tags.length === 0 ? placeholder : ""}
className="flex-1 min-w-[120px] bg-transparent h-7 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
/>
{isOpen && (input || filteredSuggestions.length > 0) && (
<div className="absolute left-0 right-0 top-full mt-1 z-50 max-h-[300px] overflow-y-auto bg-popover border border-border rounded-lg shadow-lg overflow-hidden">
<div className="px-2 py-1.5 border-b border-border">
<span className="text-xs font-medium text-muted-foreground">
</span>
</div> </div>
) <div className="p-1.5 flex flex-wrap gap-1.5">
{filteredSuggestions.map((suggestion, index) => (
<button
type="button"
key={suggestion.id}
onClick={() => {
addTag(suggestion);
setInput("");
setIsOpen(false);
}}
className={cn(
tagStyles.base,
selectedIndex === index
? tagStyles.colors.blue
: "bg-muted text-muted-foreground border border-border hover:border-border/80"
)}
>
{suggestion.label}
{selectedIndex === index && <Check className="w-3.5 h-3.5" />}
</button>
))}
{canAddNewTag && (
<button
type="button"
onClick={() => {
const colorKeys = Object.keys(tagStyles.colors) as Array<keyof typeof tagStyles.colors>;
const randomColor = tagStyles.colors[colorKeys[Math.floor(Math.random() * colorKeys.length)]];
addTag({
id: input,
label: input,
color: randomColor,
});
setInput("");
setIsOpen(false);
}}
className={cn(
tagStyles.base,
"bg-muted text-muted-foreground border border-border hover:border-border/80"
)}
>
"{input}"
</button>
)}
</div>
</div>
)}
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
);
}
interface SettingsSectionProps {
icon: React.ReactNode;
title: string;
description: string;
children: React.ReactNode;
}
function SettingsSection({ icon, title, description, children }: SettingsSectionProps) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-3">
{icon}
{title}
</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{children}
</CardContent>
</Card>
);
}
interface SettingItemProps {
label: string;
description?: string;
children: React.ReactNode;
badge?: string;
}
function SettingItem({ label, description, children, badge }: SettingItemProps) {
return (
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<Label className="text-sm font-medium">{label}</Label>
{badge && <Badge variant="secondary" className="text-xs">{badge}</Badge>}
</div>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
<div className="flex items-center space-x-2">
{children}
</div>
</div>
);
}
export default function MePage() {
const { isLoading, user } = useUser();
const router = useRouter();
const [settings, setSettings] = useState({
emailNotifications: true,
pushNotifications: false,
marketingEmails: true,
twoFactorAuth: false,
sessionTimeout: "30",
theme: "system",
language: "zh",
timezone: "Asia/Shanghai",
autoSave: true,
dataRetention: "90",
apiAccess: false,
debugMode: false,
});
const [profile, setProfile] = useState({
name: user?.name || "",
email: user?.email || "",
role: "用户",
department: "IT",
bio: "热爱技术的开发者,专注于创造优秀的用户体验。",
});
const [tags, setTags] = useState<Tag[]>([
{ id: "frontend", label: "前端开发", color: tagStyles.colors.blue },
{ id: "react", label: "React", color: tagStyles.colors.green },
]);
// 认证检查已在 layout.tsx 中处理,无需在此重复检查
useEffect(() => {
if (user) {
setProfile(prev => ({
...prev,
name: user.name || "",
email: user.email || "",
}));
}
}, [user]);
const handleSettingChange = (key: string, value: any) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const handleProfileChange = (key: string, value: string) => {
setProfile(prev => ({ ...prev, [key]: value }));
};
const handleSave = () => {
console.log("Settings saved:", settings);
console.log("Profile saved:", profile);
console.log("Tags saved:", tags);
// TODO: 实际保存到后端
};
const handleReset = () => {
setSettings({
emailNotifications: true,
pushNotifications: false,
marketingEmails: true,
twoFactorAuth: false,
sessionTimeout: "30",
theme: "system",
language: "zh",
timezone: "Asia/Shanghai",
autoSave: true,
dataRetention: "90",
apiAccess: false,
debugMode: false,
});
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<div className="container mx-auto p-6 max-w-6xl">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="text-muted-foreground mt-2">
</p>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" onClick={handleReset}>
<RotateCcw className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleSave}>
<Save className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Profile Section */}
<div className="lg:col-span-1">
<SettingsSection
icon={<User className="w-5 h-5 text-blue-600" />}
title="个人资料"
description="管理您的个人信息和账户设置"
>
<div className="flex items-center space-x-4 mb-6">
<Avatar className="w-16 h-16">
<AvatarImage src={user?.avatar || "/placeholder-avatar.jpg"} />
<AvatarFallback className="text-lg font-semibold">
{profile.name.split(' ').map(n => n[0] || '用').join('').slice(0, 2)}
</AvatarFallback>
</Avatar>
<div>
<h3 className="font-semibold text-lg">{profile.name || '用户'}</h3>
<p className="text-sm text-muted-foreground">{profile.role}</p>
<Badge variant="outline" className="mt-1">{profile.department}</Badge>
</div>
</div>
<div className="space-y-4">
<div>
<Label htmlFor="name"></Label>
<Input
id="name"
value={profile.name}
onChange={(e) => handleProfileChange('name', e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="email"></Label>
<Input
id="email"
type="email"
value={profile.email}
onChange={(e) => handleProfileChange('email', e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="department"></Label>
<Select value={profile.department} onValueChange={(value) => handleProfileChange('department', value)}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="IT"></SelectItem>
<SelectItem value="HR"></SelectItem>
<SelectItem value="Finance"></SelectItem>
<SelectItem value="Marketing"></SelectItem>
<SelectItem value="Design"></SelectItem>
<SelectItem value="Product"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="bio"></Label>
<Textarea
id="bio"
value={profile.bio}
onChange={(e) => handleProfileChange('bio', e.target.value)}
className="mt-1"
rows={3}
/>
</div>
<div>
<TagInput
label="技能标签"
placeholder="添加技能标签..."
defaultTags={tags}
suggestions={[
{ id: "javascript", label: "JavaScript" },
{ id: "typescript", label: "TypeScript" },
{ id: "python", label: "Python" },
{ id: "react", label: "React" },
{ id: "nextjs", label: "Next.js" },
{ id: "nodejs", label: "Node.js" },
{ id: "docker", label: "Docker" },
{ id: "kubernetes", label: "Kubernetes" },
{ id: "aws", label: "AWS" },
{ id: "ui-design", label: "UI设计" },
]}
onChange={setTags}
maxTags={8}
/>
</div>
</div>
</SettingsSection>
</div>
{/* Settings Sections */}
<div className="lg:col-span-2 space-y-6">
{/* Notification Settings */}
<SettingsSection
icon={<Bell className="w-5 h-5 text-green-600" />}
title="通知设置"
description="配置您希望接收的通知类型"
>
<SettingItem
label="邮件通知"
description="接收重要系统更新的邮件通知"
>
<Switch
checked={settings.emailNotifications}
onCheckedChange={(checked) => handleSettingChange('emailNotifications', checked)}
/>
</SettingItem>
<SettingItem
label="推送通知"
description="在浏览器中接收实时推送通知"
>
<Switch
checked={settings.pushNotifications}
onCheckedChange={(checked) => handleSettingChange('pushNotifications', checked)}
/>
</SettingItem>
<SettingItem
label="营销邮件"
description="接收产品更新和营销信息"
>
<Switch
checked={settings.marketingEmails}
onCheckedChange={(checked) => handleSettingChange('marketingEmails', checked)}
/>
</SettingItem>
</SettingsSection>
{/* Security Settings */}
<SettingsSection
icon={<Shield className="w-5 h-5 text-red-600" />}
title="安全设置"
description="管理您的账户安全和访问控制"
>
<SettingItem
label="双因素认证"
description="为您的账户添加额外的安全层"
badge="推荐"
>
<Switch
checked={settings.twoFactorAuth}
onCheckedChange={(checked) => handleSettingChange('twoFactorAuth', checked)}
/>
</SettingItem>
<SettingItem
label="会话超时"
description="设置自动登出的时间间隔"
>
<Select value={settings.sessionTimeout} onValueChange={(value) => handleSettingChange('sessionTimeout', value)}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="15">15</SelectItem>
<SelectItem value="30">30</SelectItem>
<SelectItem value="60">1</SelectItem>
<SelectItem value="120">2</SelectItem>
</SelectContent>
</Select>
</SettingItem>
<SettingItem
label="API访问"
description="允许第三方应用访问您的数据"
>
<Switch
checked={settings.apiAccess}
onCheckedChange={(checked) => handleSettingChange('apiAccess', checked)}
/>
</SettingItem>
</SettingsSection>
{/* System Preferences */}
<SettingsSection
icon={<Settings className="w-5 h-5 text-purple-600" />}
title="系统偏好"
description="自定义您的系统界面和行为"
>
<SettingItem
label="主题"
description="选择您偏好的界面主题"
>
<Select value={settings.theme} onValueChange={(value) => handleSettingChange('theme', value)}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">
<div className="flex items-center gap-2">
<Sun className="w-4 h-4" />
</div>
</SelectItem>
<SelectItem value="dark">
<div className="flex items-center gap-2">
<Moon className="w-4 h-4" />
</div>
</SelectItem>
<SelectItem value="system">
<div className="flex items-center gap-2">
<Monitor className="w-4 h-4" />
</div>
</SelectItem>
</SelectContent>
</Select>
</SettingItem>
<SettingItem
label="语言"
description="选择界面显示语言"
>
<Select value={settings.language} onValueChange={(value) => handleSettingChange('language', value)}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="zh"></SelectItem>
<SelectItem value="en">English</SelectItem>
<SelectItem value="ja"></SelectItem>
<SelectItem value="ko"></SelectItem>
</SelectContent>
</Select>
</SettingItem>
<SettingItem
label="时区"
description="设置您的本地时区"
>
<Select value={settings.timezone} onValueChange={(value) => handleSettingChange('timezone', value)}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="UTC">UTC</SelectItem>
<SelectItem value="Asia/Shanghai">Asia/Shanghai</SelectItem>
<SelectItem value="Asia/Tokyo">Asia/Tokyo</SelectItem>
<SelectItem value="America/New_York">America/New_York</SelectItem>
<SelectItem value="Europe/London">Europe/London</SelectItem>
</SelectContent>
</Select>
</SettingItem>
<SettingItem
label="自动保存"
description="自动保存您的工作进度"
>
<Switch
checked={settings.autoSave}
onCheckedChange={(checked) => handleSettingChange('autoSave', checked)}
/>
</SettingItem>
</SettingsSection>
{/* Data Management */}
<SettingsSection
icon={<Database className="w-5 h-5 text-orange-600" />}
title="数据管理"
description="管理您的数据存储和隐私设置"
>
<SettingItem
label="数据保留期"
description="设置数据自动删除的时间"
>
<Select value={settings.dataRetention} onValueChange={(value) => handleSettingChange('dataRetention', value)}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="30">30</SelectItem>
<SelectItem value="90">90</SelectItem>
<SelectItem value="180">180</SelectItem>
<SelectItem value="365">1</SelectItem>
</SelectContent>
</Select>
</SettingItem>
<SettingItem
label="调试模式"
description="启用详细的系统日志记录"
badge="开发"
>
<Switch
checked={settings.debugMode}
onCheckedChange={(checked) => handleSettingChange('debugMode', checked)}
/>
</SettingItem>
</SettingsSection>
</div>
</div>
</div>
</div>
);
} }

View File

@ -1,149 +0,0 @@
"use client"
import { useState } from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Camera, CalendarIcon, Save } from "lucide-react"
import { cn } from "@/lib/utils"
import { format } from "date-fns"
export default function Page() {
const [date, setDate] = useState<Date>()
const [profileData, setProfileData] = useState({
name: "张三",
email: "zhangsan@example.com",
phone: "+86 138 0013 8000",
bio: "这是我的个人简介,我是一名前端开发工程师,热爱技术和创新。",
location: "北京市朝阳区",
website: "https://zhangsan.dev",
company: "科技有限公司",
})
return (
<div className="space-y-6 p-4">
<div>
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center space-x-4">
<Avatar className="h-20 w-20">
<AvatarImage src="/placeholder.svg?height=80&width=80" />
<AvatarFallback></AvatarFallback>
</Avatar>
<div className="space-y-2">
<Button variant="outline" size="sm" className="gap-2 bg-transparent">
<Camera className="h-4 w-4" />
</Button>
<p className="text-sm text-muted-foreground">400x400px JPGPNG </p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input
id="name"
value={profileData.name}
onChange={(e) => setProfileData({ ...profileData, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email"></Label>
<div className="flex items-center space-x-2">
<Input
id="email"
type="email"
value={profileData.email}
onChange={(e) => setProfileData({ ...profileData, email: e.target.value })}
/>
<Badge variant="secondary"></Badge>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="phone"></Label>
<Input
id="phone"
value={profileData.phone}
onChange={(e) => setProfileData({ ...profileData, phone: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="company"></Label>
<Input
id="company"
value={profileData.company}
onChange={(e) => setProfileData({ ...profileData, company: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="location"></Label>
<Input
id="location"
value={profileData.location}
onChange={(e) => setProfileData({ ...profileData, location: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="website"></Label>
<Input
id="website"
value={profileData.website}
onChange={(e) => setProfileData({ ...profileData, website: e.target.value })}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="bio"></Label>
<Textarea
id="bio"
placeholder="介绍一下您自己..."
className="min-h-[100px]"
value={profileData.bio}
onChange={(e) => setProfileData({ ...profileData, bio: e.target.value })}
/>
<p className="text-sm text-muted-foreground">{profileData.bio.length}/500 </p>
</div>
<div className="space-y-2">
<Label></Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn("w-full justify-start text-left font-normal", !date && "text-muted-foreground")}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{date ? format(date, "yyyy年MM月dd日") : "选择日期"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar mode="single" selected={date} onSelect={setDate} initialFocus />
</PopoverContent>
</Popover>
</div>
<Button className="gap-2">
<Save className="h-4 w-4" />
</Button>
</CardContent>
</Card>
</div>
)
}

View File

@ -1,125 +0,0 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import { Save } from "lucide-react"
export default function Page() {
const [security, setSecurity] = useState({
twoFactor: true,
loginAlerts: true,
dataExport: false,
})
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label></Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<div className="flex items-center gap-2">
<Badge variant={security.twoFactor ? "default" : "secondary"}>
{security.twoFactor ? "已启用" : "未启用"}
</Badge>
<Switch
checked={security.twoFactor}
onCheckedChange={(checked) => setSecurity({ ...security, twoFactor: checked })}
/>
</div>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label></Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch
checked={security.loginAlerts}
onCheckedChange={(checked) => setSecurity({ ...security, loginAlerts: checked })}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label></Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch
checked={security.dataExport}
onCheckedChange={(checked) => setSecurity({ ...security, dataExport: checked })}
/>
</div>
</div>
<div className="space-y-4 pt-4 border-t">
<h4 className="font-medium"></h4>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 border rounded-lg">
<div className="space-y-1">
<p className="font-medium"></p>
<p className="text-sm text-muted-foreground">Chrome on Windows </p>
</div>
<Badge variant="outline"></Badge>
</div>
<div className="flex items-center justify-between p-3 border rounded-lg">
<div className="space-y-1">
<p className="font-medium">iPhone</p>
<p className="text-sm text-muted-foreground">Safari on iOS 2</p>
</div>
<Button variant="outline" size="sm">
</Button>
</div>
</div>
</div>
<Button className="gap-2">
<Save className="h-4 w-4" />
</Button>
</CardContent>
</Card>
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label></Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Button variant="destructive" size="sm">
</Button>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -57,37 +57,12 @@ export function Navigation() {
<NavigationMenu viewport={false} className="h-16"> <NavigationMenu viewport={false} className="h-16">
<NavigationMenuList> <NavigationMenuList>
<NavigationMenuItem> <NavigationMenuItem>
<NavigationMenuTrigger>Home</NavigationMenuTrigger>
<NavigationMenuContent> <NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<ul className="grid gap-2 md:w-[400px] lg:w-[500px] lg:grid-cols-[.75fr_1fr]"> <Link href="/">Home</Link>
<li className="row-span-3">
<NavigationMenuLink asChild>
<a
className="from-muted/50 to-muted flex h-full w-full flex-col justify-end rounded-md bg-linear-to-b p-6 no-underline outline-hidden select-none focus:shadow-md"
href="/"
>
<div className="mt-4 mb-2 text-lg font-medium">
shadcn/ui
</div>
<p className="text-muted-foreground text-sm leading-tight">
Beautifully designed components built with Tailwind CSS.
</p>
</a>
</NavigationMenuLink> </NavigationMenuLink>
</li>
<ListItem href="/docs" title="Introduction">
Re-usable components built using Radix UI and Tailwind CSS.
</ListItem>
<ListItem href="/docs/installation" title="Installation">
How to install dependencies and structure your app.
</ListItem>
<ListItem href="/docs/primitives/typography" title="Typography">
Styles for headings, paragraphs, lists...etc
</ListItem>
</ul>
</NavigationMenuContent>
</NavigationMenuItem> </NavigationMenuItem>
<NavigationMenuItem> {/* <NavigationMenuItem>
<NavigationMenuTrigger>Components</NavigationMenuTrigger> <NavigationMenuTrigger>Components</NavigationMenuTrigger>
<NavigationMenuContent> <NavigationMenuContent>
<ul className="grid w-[400px] gap-2 md:w-[500px] md:grid-cols-2 lg:w-[600px]"> <ul className="grid w-[400px] gap-2 md:w-[500px] md:grid-cols-2 lg:w-[600px]">
@ -102,13 +77,13 @@ export function Navigation() {
))} ))}
</ul> </ul>
</NavigationMenuContent> </NavigationMenuContent>
</NavigationMenuItem> </NavigationMenuItem> */}
<NavigationMenuItem> <NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}> <NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href="/docs">Docs</Link> <Link href="/blog">Blogs</Link>
</NavigationMenuLink> </NavigationMenuLink>
</NavigationMenuItem> </NavigationMenuItem>
<NavigationMenuItem> {/* <NavigationMenuItem>
<NavigationMenuTrigger>List</NavigationMenuTrigger> <NavigationMenuTrigger>List</NavigationMenuTrigger>
<NavigationMenuContent> <NavigationMenuContent>
<ul className="grid w-[300px] gap-4"> <ul className="grid w-[300px] gap-4">
@ -185,7 +160,7 @@ export function Navigation() {
</li> </li>
</ul> </ul>
</NavigationMenuContent> </NavigationMenuContent>
</NavigationMenuItem> </NavigationMenuItem> */}
</NavigationMenuList> </NavigationMenuList>
</NavigationMenu> </NavigationMenu>
) )

163
components/footer.tsx Normal file
View File

@ -0,0 +1,163 @@
import Link from 'next/link'
const links = [
{
title: 'Features',
href: '#',
},
{
title: 'Solution',
href: '#',
},
{
title: 'Customers',
href: '#',
},
{
title: 'Pricing',
href: '#',
},
{
title: 'Help',
href: '#',
},
{
title: 'About',
href: '#',
},
]
export default function FooterSection() {
return (
<footer className="py-16">
<div className="mx-auto max-w-5xl px-6">
<Link
href="/"
aria-label="go home"
className="mx-auto block size-fit">
</Link>
<div className="my-8 flex flex-wrap justify-center gap-6">
{links.map((link, index) => (
<Link
key={index}
href={link.href}
className="text-muted-foreground hover:text-primary block duration-150">
<span>{link.title}</span>
</Link>
))}
</div>
<div className="my-8 flex flex-wrap justify-center gap-6 text-sm">
<Link
href="#"
target="_blank"
rel="noopener noreferrer"
aria-label="X/Twitter"
className="text-muted-foreground hover:text-primary block">
<svg
className="size-6"
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24">
<path
fill="currentColor"
d="M10.488 14.651L15.25 21h7l-7.858-10.478L20.93 3h-2.65l-5.117 5.886L8.75 3h-7l7.51 10.015L2.32 21h2.65zM16.25 19L5.75 5h2l10.5 14z"></path>
</svg>
</Link>
<Link
href="#"
target="_blank"
rel="noopener noreferrer"
aria-label="LinkedIn"
className="text-muted-foreground hover:text-primary block">
<svg
className="size-6"
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24">
<path
fill="currentColor"
d="M19 3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zm-.5 15.5v-5.3a3.26 3.26 0 0 0-3.26-3.26c-.85 0-1.84.52-2.32 1.3v-1.11h-2.79v8.37h2.79v-4.93c0-.77.62-1.4 1.39-1.4a1.4 1.4 0 0 1 1.4 1.4v4.93zM6.88 8.56a1.68 1.68 0 0 0 1.68-1.68c0-.93-.75-1.69-1.68-1.69a1.69 1.69 0 0 0-1.69 1.69c0 .93.76 1.68 1.69 1.68m1.39 9.94v-8.37H5.5v8.37z"></path>
</svg>
</Link>
<Link
href="#"
target="_blank"
rel="noopener noreferrer"
aria-label="Facebook"
className="text-muted-foreground hover:text-primary block">
<svg
className="size-6"
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22 12c0-5.52-4.48-10-10-10S2 6.48 2 12c0 4.84 3.44 8.87 8 9.8V15H8v-3h2V9.5C10 7.57 11.57 6 13.5 6H16v3h-2c-.55 0-1 .45-1 1v2h3v3h-3v6.95c5.05-.5 9-4.76 9-9.95"></path>
</svg>
</Link>
<Link
href="#"
target="_blank"
rel="noopener noreferrer"
aria-label="Threads"
className="text-muted-foreground hover:text-primary block">
<svg
className="size-6"
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24">
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M19.25 8.505c-1.577-5.867-7-5.5-7-5.5s-7.5-.5-7.5 8.995s7.5 8.996 7.5 8.996s4.458.296 6.5-3.918c.667-1.858.5-5.573-6-5.573c0 0-3 0-3 2.5c0 .976 1 2 2.5 2s3.171-1.027 3.5-3c1-6-4.5-6.5-6-4"
color="currentColor"></path>
</svg>
</Link>
<Link
href="#"
target="_blank"
rel="noopener noreferrer"
aria-label="Instagram"
className="text-muted-foreground hover:text-primary block">
<svg
className="size-6"
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24">
<path
fill="currentColor"
d="M7.8 2h8.4C19.4 2 22 4.6 22 7.8v8.4a5.8 5.8 0 0 1-5.8 5.8H7.8C4.6 22 2 19.4 2 16.2V7.8A5.8 5.8 0 0 1 7.8 2m-.2 2A3.6 3.6 0 0 0 4 7.6v8.8C4 18.39 5.61 20 7.6 20h8.8a3.6 3.6 0 0 0 3.6-3.6V7.6C20 5.61 18.39 4 16.4 4zm9.65 1.5a1.25 1.25 0 0 1 1.25 1.25A1.25 1.25 0 0 1 17.25 8A1.25 1.25 0 0 1 16 6.75a1.25 1.25 0 0 1 1.25-1.25M12 7a5 5 0 0 1 5 5a5 5 0 0 1-5 5a5 5 0 0 1-5-5a5 5 0 0 1 5-5m0 2a3 3 0 0 0-3 3a3 3 0 0 0 3 3a3 3 0 0 0 3-3a3 3 0 0 0-3-3"></path>
</svg>
</Link>
<Link
href="#"
target="_blank"
rel="noopener noreferrer"
aria-label="TikTok"
className="text-muted-foreground hover:text-primary block">
<svg
className="size-6"
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24">
<path
fill="currentColor"
d="M16.6 5.82s.51.5 0 0A4.28 4.28 0 0 1 15.54 3h-3.09v12.4a2.59 2.59 0 0 1-2.59 2.5c-1.42 0-2.6-1.16-2.6-2.6c0-1.72 1.66-3.01 3.37-2.48V9.66c-3.45-.46-6.47 2.22-6.47 5.64c0 3.33 2.76 5.7 5.69 5.7c3.14 0 5.69-2.55 5.69-5.7V9.01a7.35 7.35 0 0 0 4.3 1.38V7.3s-1.88.09-3.24-1.48"></path>
</svg>
</Link>
</div>
<span className="text-muted-foreground block text-center text-sm"> © {new Date().getFullYear()} Tailark Mist, All rights reserved</span>
</div>
</footer>
)
}

View File

@ -127,31 +127,26 @@ export default function TableOfContents({ className }: TableOfContentsProps) {
<div className={"max-h-[60vh] overflow-y-auto"}> <div className={"max-h-[60vh] overflow-y-auto"}>
<ul className="relative space-y-1"> <ul className="relative space-y-1">
{toc.map((item) => ( {toc.map((item) => (
<li <li key={item.id}>
key={item.id}
className={cn({
'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 font-medium': activeId === item.id
})}
>
<button <button
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
scrollToHeading(item.id); scrollToHeading(item.id);
}} }}
className={cn( className={cn(
"block w-full text-left py-1.5 px-2 text-xs rounded transition-colors duration-200", "block w-full text-left py-1.5 px-2 text-xs rounded transition-all duration-200",
"hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer", "hover:bg-gray-100/50 dark:hover:bg-gray-800/50 cursor-pointer",
"truncate overflow-hidden whitespace-nowrap",
{ {
'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 font-medium': 'text-white font-bold': activeId === item.id,
activeId === item.id, 'text-gray-500 dark:text-gray-400 font-normal': activeId !== item.id,
'text-gray-600 dark:text-gray-400':
activeId !== item.id,
} }
)} )}
style={{ style={{
paddingLeft: `${(item.level - 1) * 8 + 8}px`, paddingLeft: `${(item.level - 1) * 8 + 8}px`,
}} }}
type="button" type="button"
title={item.text} // Add tooltip for full text when truncated
> >
{item.text} {item.text}
</button> </button>

6849
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -92,6 +92,8 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "9.33.0",
"eslint-config-next": "15.4.6",
"raw-loader": "^4.0.2", "raw-loader": "^4.0.2",
"sass": "^1.90.0", "sass": "^1.90.0",
"tailwindcss": "^4", "tailwindcss": "^4",