sync
This commit is contained in:
parent
667b96b33e
commit
0d511cff15
6
.eslintrc.json
Normal file
6
.eslintrc.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"next/typescript"
|
||||
]
|
||||
}
|
||||
@ -11,9 +11,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
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 { toast } from "sonner"
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar"
|
||||
|
||||
const GET_BLOG = gql`
|
||||
query GetBlog($id: UUID!) {
|
||||
@ -34,6 +35,28 @@ const GET_BLOG = gql`
|
||||
isActive
|
||||
createdAt
|
||||
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
|
||||
content: any
|
||||
categoryId?: string
|
||||
tagIds?: string[]
|
||||
status: 'draft' | 'published' | 'archived'
|
||||
featuredImage?: string
|
||||
metaTitle?: string
|
||||
@ -75,6 +99,19 @@ interface BlogData {
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
interface Tag {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export default function EditorComponent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
@ -87,12 +124,15 @@ export default function EditorComponent() {
|
||||
excerpt: '',
|
||||
content: null,
|
||||
status: 'draft',
|
||||
tagIds: [],
|
||||
isFeatured: false,
|
||||
isActive: true
|
||||
})
|
||||
|
||||
const [isLoading, setIsLoading] = React.useState(false)
|
||||
|
||||
const { data: tagsAndCategoriesData, loading: tagsAndCategoriesLoading } = useQuery(GET_TAGS_AND_CATEGORIES)
|
||||
|
||||
const { data: blogQuery, loading: blogLoading } = useQuery(GET_BLOG, {
|
||||
variables: { id: blogId },
|
||||
skip: !isEditing,
|
||||
@ -104,6 +144,7 @@ export default function EditorComponent() {
|
||||
excerpt: data.blog.excerpt || '',
|
||||
content: data.blog.content,
|
||||
categoryId: data.blog.categoryId,
|
||||
tagIds: data.blog.tags?.map((tag: Tag) => tag.id) || [],
|
||||
status: data.blog.status || 'draft',
|
||||
featuredImage: data.blog.featuredImage,
|
||||
metaTitle: data.blog.metaTitle || '',
|
||||
@ -149,6 +190,7 @@ export default function EditorComponent() {
|
||||
excerpt: blogData.excerpt || undefined,
|
||||
content: blogData.content,
|
||||
categoryId: blogData.categoryId || undefined,
|
||||
tagIds: blogData.tagIds?.length ? blogData.tagIds : undefined,
|
||||
status: publishStatus || blogData.status,
|
||||
featuredImage: blogData.featuredImage || undefined,
|
||||
metaTitle: blogData.metaTitle || undefined,
|
||||
@ -187,7 +229,7 @@ export default function EditorComponent() {
|
||||
setBlogData(prev => ({ ...prev, content }))
|
||||
}
|
||||
|
||||
if (blogLoading) {
|
||||
if (blogLoading || tagsAndCategoriesLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<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 (
|
||||
<div className="flex h-full">
|
||||
{/* Main Editor Area */}
|
||||
@ -203,6 +248,7 @@ export default function EditorComponent() {
|
||||
<div className="border-b sticky top-0 z-10">
|
||||
<div className="flex h-14 items-center justify-between px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@ -281,6 +327,126 @@ export default function EditorComponent() {
|
||||
{/* Sidebar */}
|
||||
<div className="w-80 border-l overflow-auto">
|
||||
<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 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
|
||||
@ -134,39 +134,25 @@ function UserAvatar() {
|
||||
<DropdownMenuShortcut>⌘P</DropdownMenuShortcut>
|
||||
</Link>
|
||||
</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>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
{/* <DropdownMenuGroup> */}
|
||||
{/* <DropdownMenuItem asChild>
|
||||
<Link href="/me/upgrade" className="flex items-center">
|
||||
<Crown className="mr-2 h-4 w-4" />
|
||||
<span>Upgrade to Pro</span>
|
||||
<DropdownMenuShortcut>⌘U</DropdownMenuShortcut>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
</DropdownMenuItem> */}
|
||||
{/* <DropdownMenuItem asChild>
|
||||
<Link href="/me/billing" className="flex items-center">
|
||||
<Crown className="mr-2 h-4 w-4" />
|
||||
<span>Billing</span>
|
||||
<DropdownMenuShortcut>⌘B</DropdownMenuShortcut>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
</DropdownMenuItem> */}
|
||||
{/* </DropdownMenuGroup> */}
|
||||
{/* <DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/help" className="flex items-center">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
@ -176,7 +162,7 @@ function UserAvatar() {
|
||||
<DropdownMenuItem disabled>
|
||||
API Documentation
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSeparator /> */}
|
||||
<DropdownMenuItem className="flex items-center text-red-600 focus:text-red-600">
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Log out</span>
|
||||
@ -203,21 +189,21 @@ function UserAvatar() {
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const router = useRouter()
|
||||
const mainNavItems = [
|
||||
{
|
||||
icon: Home,
|
||||
label: "Home",
|
||||
onClick: () => console.log("Navigate to home")
|
||||
},
|
||||
// {
|
||||
// icon: Home,
|
||||
// label: "Home",
|
||||
// onClick: () => console.log("Navigate to home")
|
||||
// },
|
||||
{
|
||||
icon: Book,
|
||||
label: "Docs",
|
||||
label: "Blogs",
|
||||
onClick: () => router.push("/blog")
|
||||
},
|
||||
{
|
||||
icon: Plus,
|
||||
label: "New",
|
||||
onClick: () => console.log("Create new project")
|
||||
}
|
||||
// {
|
||||
// icon: Plus,
|
||||
// label: "New",
|
||||
// onClick: () => console.log("Create new project")
|
||||
// }
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@ -1,13 +1,19 @@
|
||||
import { Command } from "lucide-react"
|
||||
import { Navigation } from "../nav"
|
||||
import FooterSection from "@/components/footer"
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
return (
|
||||
<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}
|
||||
</div>
|
||||
<FooterSection />
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
@ -1,50 +1,194 @@
|
||||
"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 Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
const BLOG = gql`
|
||||
query Blog {
|
||||
|
||||
blogs {
|
||||
blogs(filter: {
|
||||
isActive: true
|
||||
status: "published"
|
||||
},
|
||||
sort: {
|
||||
field: "createdAt"
|
||||
direction: "DESC"
|
||||
}
|
||||
) {
|
||||
items {
|
||||
id
|
||||
title
|
||||
excerpt
|
||||
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() {
|
||||
|
||||
const { data, loading, error } = useQuery(BLOG);
|
||||
|
||||
const items = data?.blogs?.items || [];
|
||||
|
||||
return <div>
|
||||
<h1 className="text-5xl font-bold my-12">Blog</h1>
|
||||
{loading && <div>Loading...</div>}
|
||||
{error && <div>Error: {error.message}</div>}
|
||||
{!loading && items.length === 0 && <div>No blogs found</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>
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="w-full py-20 lg:py-40">
|
||||
<div className="container mx-auto">
|
||||
<div className="text-center">Loading...</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useUser } from "../user-context"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { GalleryVerticalEnd } from "lucide-react"
|
||||
|
||||
const schema = z.object({
|
||||
username: z.string().min(1, "用户名不能为空"),
|
||||
@ -20,26 +21,40 @@ const schema = z.object({
|
||||
export function LoginForm({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"form">) {
|
||||
}: React.ComponentProps<"div">) {
|
||||
const { login } = useUser();
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
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>>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
})
|
||||
const usernameForm = useForm<{ username: string }>({
|
||||
resolver: zodResolver(z.object({ username: z.string().min(1, "用户名不能为空") })),
|
||||
defaultValues: { username: "" },
|
||||
});
|
||||
|
||||
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 {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
await login(values);
|
||||
// clearMap();
|
||||
await login({ username, password: values.password });
|
||||
router.push('/');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '登录失败,请重试');
|
||||
@ -48,14 +63,35 @@ export function LoginForm({
|
||||
}
|
||||
}
|
||||
|
||||
function goBackToUsername() {
|
||||
setStep('username');
|
||||
setError(null);
|
||||
passwordForm.reset();
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form className={cn("flex flex-col gap-6", className)} {...props} onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<h1 className="text-2xl font-bold">登录您的账户</h1>
|
||||
<p className="text-muted-foreground text-sm text-balance">
|
||||
请输入您的用户名和密码登录
|
||||
</p>
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
{step === 'username' ? (
|
||||
<Form {...usernameForm}>
|
||||
<form onSubmit={usernameForm.handleSubmit(onUsernameSubmit)}>
|
||||
<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">欢迎来到 Acme Inc.</h1>
|
||||
<div className="text-center text-sm">
|
||||
还没有账户?{" "}
|
||||
<a href="#" className="underline underline-offset-4">
|
||||
注册
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@ -64,68 +100,160 @@ export function LoginForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
control={usernameForm.control}
|
||||
name="username"
|
||||
disabled={isLoading}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>用户名</FormLabel>
|
||||
<FormLabel htmlFor="username">用户名</FormLabel>
|
||||
<FormControl>
|
||||
<Input id="username" placeholder="请输入用户名" {...field} required />
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
{...field}
|
||||
required
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</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 className="grid gap-3">
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
control={passwordForm.control}
|
||||
name="password"
|
||||
disabled={isLoading}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center">
|
||||
<FormLabel>密码</FormLabel>
|
||||
<a
|
||||
href="#"
|
||||
className="ml-auto text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
忘记密码?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<FormLabel htmlFor="password">密码</FormLabel>
|
||||
<FormControl>
|
||||
<Input id="password" type="password" placeholder="请输入密码" {...field} required />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
{...field}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? "登录中..." : "登录"}
|
||||
</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>
|
||||
<Button variant="outline" className="w-full" disabled={isLoading}>
|
||||
GitHub 登录
|
||||
<div className="text-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={goBackToUsername}
|
||||
className="text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
返回修改用户名
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center text-sm">
|
||||
还没有账户?{" "}
|
||||
<a href="#" className="underline underline-offset-4">
|
||||
注册
|
||||
</a>
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -48,29 +48,10 @@ export default function LoginPage() {
|
||||
// }
|
||||
|
||||
return (
|
||||
<div className="grid min-h-svh lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-4 p-6 md:p-10">
|
||||
<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">
|
||||
<div className="min-h-svh flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-sm">
|
||||
<LoginForm />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -5,6 +5,9 @@ import {
|
||||
SidebarProvider,
|
||||
} from "@/components/ui/sidebar"
|
||||
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 }) {
|
||||
|
||||
@ -17,19 +20,13 @@ export default async function Layout({ children }: { children: React.ReactNode }
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": "calc(var(--spacing) * 72)",
|
||||
"--header-height": "calc(var(--spacing) * 12)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<AppSidebar variant="inset" />
|
||||
<SidebarInset>
|
||||
<div className="flex flex-col">
|
||||
<div className="sticky top-0 z-50 bg-background flex flex-row items-center px-4">
|
||||
<Command size={24} className="mr-4" />
|
||||
<Navigation />
|
||||
</div>
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
|
||||
<FooterSection />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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 >
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
769
app/me/page.tsx
769
app/me/page.tsx
@ -1,22 +1,763 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { redirect, useRouter } from "next/navigation"
|
||||
import { useUser } from "../user-context"
|
||||
import { useEffect } from "react"
|
||||
import * as React from "react";
|
||||
import { useState, useRef, 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() {
|
||||
const { isAuthenticated, isLoading, user } = useUser()
|
||||
const router = useRouter();
|
||||
interface Tag {
|
||||
id: string;
|
||||
label: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push('/login');
|
||||
interface TagInputProps {
|
||||
onChange?: (tags: Array<Tag>) => void;
|
||||
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 (
|
||||
<div>
|
||||
<h1>Me</h1>
|
||||
<div className="w-full space-y-2" ref={containerRef}>
|
||||
{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 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>
|
||||
);
|
||||
}
|
||||
@ -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,支持 JPG、PNG 格式</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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
41
app/nav.tsx
41
app/nav.tsx
@ -57,37 +57,12 @@ export function Navigation() {
|
||||
<NavigationMenu viewport={false} className="h-16">
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger>Home</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<ul className="grid gap-2 md:w-[400px] lg:w-[500px] lg:grid-cols-[.75fr_1fr]">
|
||||
<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 asChild className={navigationMenuTriggerStyle()}>
|
||||
<Link href="/">Home</Link>
|
||||
</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>
|
||||
<NavigationMenuTrigger>Components</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<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>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuItem> */}
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||
<Link href="/docs">Docs</Link>
|
||||
<Link href="/blog">Blogs</Link>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
{/* <NavigationMenuItem>
|
||||
<NavigationMenuTrigger>List</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<ul className="grid w-[300px] gap-4">
|
||||
@ -185,7 +160,7 @@ export function Navigation() {
|
||||
</li>
|
||||
</ul>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuItem> */}
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
)
|
||||
|
||||
163
components/footer.tsx
Normal file
163
components/footer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -127,31 +127,26 @@ export default function TableOfContents({ className }: TableOfContentsProps) {
|
||||
<div className={"max-h-[60vh] overflow-y-auto"}>
|
||||
<ul className="relative space-y-1">
|
||||
{toc.map((item) => (
|
||||
<li
|
||||
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
|
||||
})}
|
||||
>
|
||||
<li key={item.id}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
scrollToHeading(item.id);
|
||||
}}
|
||||
className={cn(
|
||||
"block w-full text-left py-1.5 px-2 text-xs rounded transition-colors duration-200",
|
||||
"hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer",
|
||||
"block w-full text-left py-1.5 px-2 text-xs rounded transition-all duration-200",
|
||||
"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':
|
||||
activeId === item.id,
|
||||
'text-gray-600 dark:text-gray-400':
|
||||
activeId !== item.id,
|
||||
'text-white font-bold': activeId === item.id,
|
||||
'text-gray-500 dark:text-gray-400 font-normal': activeId !== item.id,
|
||||
}
|
||||
)}
|
||||
style={{
|
||||
paddingLeft: `${(item.level - 1) * 8 + 8}px`,
|
||||
}}
|
||||
type="button"
|
||||
title={item.text} // Add tooltip for full text when truncated
|
||||
>
|
||||
{item.text}
|
||||
</button>
|
||||
|
||||
6849
package-lock.json
generated
6849
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -92,6 +92,8 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "9.33.0",
|
||||
"eslint-config-next": "15.4.6",
|
||||
"raw-loader": "^4.0.2",
|
||||
"sass": "^1.90.0",
|
||||
"tailwindcss": "^4",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user