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 { 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">
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -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) => (
|
</div>
|
||||||
<BlogItem key={blog.id} blog={blog} />
|
</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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BlogItem({ blog }: { blog: any }) {
|
interface BlogItemProps {
|
||||||
return <div>
|
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}`}>
|
<Link href={`/blog/${blog.slug}`}>
|
||||||
<Button variant="link" asChild>
|
<div className={`flex flex-col gap-4 hover:opacity-75 cursor-pointer transition-opacity ${featured ? 'md:col-span-2' : ''
|
||||||
<h2 className="text-2xl font-bold">{blog.title}</h2>
|
}`}>
|
||||||
</Button>
|
{/* 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>
|
</Link>
|
||||||
<p className="text-sm text-gray-500">{blog.excerpt}</p>
|
);
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,84 +63,197 @@ 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
|
||||||
</div>
|
href="#"
|
||||||
|
className="flex flex-col items-center gap-2 font-medium"
|
||||||
{error && (
|
>
|
||||||
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
|
<div className="flex size-8 items-center justify-center rounded-md">
|
||||||
{error}
|
<GalleryVerticalEnd className="size-6" />
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid gap-6">
|
|
||||||
<div className="grid gap-3">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="username"
|
|
||||||
disabled={isLoading}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>用户名</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input id="username" placeholder="请输入用户名" {...field} required />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)} />
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3">
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.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>
|
</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>
|
||||||
|
|
||||||
<FormControl>
|
{error && (
|
||||||
<Input id="password" type="password" placeholder="请输入密码" {...field} required />
|
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
|
||||||
</FormControl>
|
{error}
|
||||||
<FormMessage />
|
</div>
|
||||||
</FormItem>
|
)}
|
||||||
)} />
|
|
||||||
|
|
||||||
</div>
|
<div className="flex flex-col gap-6">
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
<div className="grid gap-3">
|
||||||
{isLoading ? "登录中..." : "登录"}
|
<FormField
|
||||||
</Button>
|
control={usernameForm.control}
|
||||||
<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">
|
name="username"
|
||||||
<span className="bg-background text-muted-foreground relative z-10 px-2">
|
render={({ field }) => (
|
||||||
或者使用
|
<FormItem>
|
||||||
</span>
|
<FormLabel htmlFor="username">用户名</FormLabel>
|
||||||
</div>
|
<FormControl>
|
||||||
<Button variant="outline" className="w-full" disabled={isLoading}>
|
<Input
|
||||||
GitHub 登录
|
id="username"
|
||||||
</Button>
|
type="text"
|
||||||
</div>
|
placeholder="请输入用户名"
|
||||||
<div className="text-center text-sm">
|
{...field}
|
||||||
还没有账户?{" "}
|
required
|
||||||
<a href="#" className="underline underline-offset-4">
|
/>
|
||||||
注册
|
</FormControl>
|
||||||
</a>
|
<FormMessage />
|
||||||
</div>
|
</FormItem>
|
||||||
</form>
|
)}
|
||||||
</Form>
|
/>
|
||||||
|
</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={passwordForm.control}
|
||||||
|
name="password"
|
||||||
|
disabled={isLoading}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel htmlFor="password">密码</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
{...field}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading ? "登录中..." : "登录"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={goBackToUsername}
|
||||||
|
className="text-sm underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
返回修改用户名
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</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,28 +48,9 @@ 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">
|
<LoginForm />
|
||||||
<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 />
|
|
||||||
</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>
|
||||||
</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,
|
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
|
{children}
|
||||||
}
|
<FooterSection />
|
||||||
>
|
</div>
|
||||||
<AppSidebar variant="inset" />
|
|
||||||
<SidebarInset>
|
|
||||||
{children}
|
|
||||||
</SidebarInset>
|
|
||||||
</SidebarProvider>
|
|
||||||
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -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 * 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";
|
||||||
|
|
||||||
|
interface Tag {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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() {
|
export default function MePage() {
|
||||||
const { isAuthenticated, isLoading, user } = useUser()
|
const { isLoading, user } = useUser();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
const [settings, setSettings] = useState({
|
||||||
if (!isLoading && !isAuthenticated) {
|
emailNotifications: true,
|
||||||
router.push('/login');
|
pushNotifications: false,
|
||||||
}
|
marketingEmails: true,
|
||||||
}, [isAuthenticated, isLoading, router, user]);
|
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 (
|
return (
|
||||||
<div>
|
<div className="flex items-center justify-center h-screen">
|
||||||
<h1>Me</h1>
|
<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>
|
||||||
)
|
|
||||||
|
<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
43
app/nav.tsx
43
app/nav.tsx
@ -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>
|
||||||
<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>
|
|
||||||
</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
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"}>
|
<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
6849
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user