This commit is contained in:
eric
2025-11-13 05:05:53 +08:00
parent cbdc65f56e
commit 891af61146
691 changed files with 83952 additions and 0 deletions

View File

@@ -0,0 +1,605 @@
'use client'
export const runtime = 'edge'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { Button } from "@/registry/new-york/ui/button"
import { useToast } from "@/registry/new-york/hooks/use-toast"
import { Icons } from "@/components/icons"
import { NavigationItem, NavigationCategory } from '@/types/navigation'
import { AddItemForm } from "../../../../components/AddItemForm"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogDescription,
DialogFooter,
} from "@/registry/new-york/ui/dialog"
import { Input } from "@/registry/new-york/ui/input"
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd'
import { Skeleton } from "@/registry/new-york/ui/skeleton"
import { Badge } from "@/registry/new-york/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/registry/new-york/ui/select"
interface EditingItem {
index: number
item: NavigationSubItem
}
interface NavigationSubItem {
id: string
title: string
href: string
icon?: string
description?: string
enabled: boolean
}
export default function CategoryItemsPage() {
const params = useParams()
const router = useRouter()
const { toast } = useToast()
const [navigation, setNavigation] = useState<NavigationItem | null>(null)
const [category, setCategory] = useState<NavigationCategory | null>(null)
const [searchQuery, setSearchQuery] = useState("")
const [editingItem, setEditingItem] = useState<EditingItem | null>(null)
const [deletingItem, setDeletingItem] = useState<EditingItem | null>(null)
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState<'all' | 'enabled' | 'disabled'>('all')
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
useEffect(() => {
if (!params?.id || !params?.categoryId) {
router.push('/admin/navigation')
return
}
fetchData()
}, [params?.id, params?.categoryId])
const fetchData = async () => {
if (!params?.id || !params?.categoryId) return
try {
setLoading(true)
const response = await fetch(`/api/navigation/${params.id}`)
if (!response.ok) throw new Error('Failed to fetch')
const data = await response.json()
setNavigation(data)
const foundCategory = data.subCategories?.find(
(cat: NavigationCategory) => cat.id === params.categoryId
)
if (!foundCategory) throw new Error('Category not found')
setCategory(foundCategory)
} catch (error) {
toast({
title: "错误",
description: "加载数据失败",
variant: "destructive"
})
} finally {
setLoading(false)
}
}
const addItem = async (values: NavigationSubItem) => {
if (!params?.id || !navigation || !category) return
try {
const updatedCategory = {
...category,
items: [...(category.items || []), values]
}
const updatedNavigation = {
...(navigation as NavigationItem),
subCategories: (navigation as NavigationItem).subCategories?.map(cat =>
cat.id === category.id ? updatedCategory : cat
)
}
const response = await fetch(`/api/navigation/${params.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedNavigation)
})
if (!response.ok) throw new Error('Failed to save')
const data = await response.json()
setNavigation(data)
setCategory(updatedCategory)
toast({
title: "成功",
description: "添加成功"
})
} catch (error) {
toast({
title: "错误",
description: "保存失败",
variant: "destructive"
})
}
}
const updateItem = async (index: number, values: NavigationSubItem) => {
if (!params?.id || !navigation || !category) return
try {
const updatedItems = [...(category.items || [])]
updatedItems[index] = values
const updatedCategory = {
...category,
items: updatedItems
}
if (!navigation) return;
const updatedNavigation = {
...navigation,
subCategories: navigation.subCategories?.map(cat =>
cat.id === category.id ? updatedCategory : cat
),
};
const response = await fetch(`/api/navigation/${params.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedNavigation)
})
if (!response.ok) throw new Error('Failed to update')
const data = await response.json()
setNavigation(data)
setCategory(updatedCategory)
setEditingItem(null)
toast({
title: "成功",
description: "更新成功"
})
} catch (error) {
toast({
title: "错误",
description: "更新失败",
variant: "destructive"
})
}
}
const deleteItem = async (index: number) => {
if (!params?.id || !navigation || !category) return
try {
const updatedItems = [...(category.items || [])]
updatedItems.splice(index, 1)
const updatedCategory = {
...category,
items: updatedItems
}
const updatedNavigation = {
...navigation,
subCategories: navigation.subCategories?.map(cat =>
cat.id === category.id ? updatedCategory : cat
)
}
const response = await fetch(`/api/navigation/${params.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedNavigation)
})
if (!response.ok) throw new Error('Failed to delete')
const data = await response.json()
setNavigation(data)
setCategory(updatedCategory)
setDeletingItem(null)
toast({
title: "成功",
description: "删除成功"
})
} catch (error) {
toast({
title: "错误",
description: "删除失败",
variant: "destructive"
})
}
}
const handleDragEnd = (result: DropResult) => {
if (!result.destination) return;
const newItems = Array.from(category?.items || []);
const [movedItem] = newItems.splice(result.source.index, 1);
newItems.splice(result.destination.index, 0, movedItem);
// Optimistically update the UI
setCategory((prevCategory) => prevCategory ? { ...prevCategory, items: newItems } : null);
// Update the server
updateItemsOrder(newItems);
};
const updateItemsOrder = async (newItems: NavigationSubItem[]) => {
if (!params?.id || !category) return;
try {
const updatedCategory = {
...category,
items: newItems,
};
if (!navigation) return;
const updatedNavigation = {
...navigation,
subCategories: navigation.subCategories?.map(cat =>
cat.id === category.id ? updatedCategory : cat
),
};
const response = await fetch(`/api/navigation/${params.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedNavigation),
});
if (!response.ok) throw new Error('Failed to update order');
const data = await response.json();
setNavigation(data);
} catch (error) {
toast({
title: "错误",
description: "更新顺序失败",
variant: "destructive",
});
}
};
const moveItem = async (fromIndex: number, toIndex: number) => {
if (!params?.id || !navigation || !category?.items) return
try {
const newItems = [...category.items]
const [movedItem] = newItems.splice(fromIndex, 1)
newItems.splice(toIndex, 0, movedItem)
const updatedCategory = {
...category,
items: newItems
}
const updatedNavigation = {
...(navigation as NavigationItem),
subCategories: (navigation as NavigationItem).subCategories?.map(cat =>
cat.id === category.id ? updatedCategory : cat
)
}
const response = await fetch(`/api/navigation/${params.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedNavigation)
})
if (!response.ok) throw new Error('Failed to move item')
const data = await response.json()
setNavigation(data)
setCategory(updatedCategory)
toast({
title: "成功",
description: "移动成功"
})
} catch (error) {
toast({
title: "错误",
description: "移动失败",
variant: "destructive"
})
}
}
const moveToTop = async (index: number) => {
if (!category?.items || index <= 0) return
moveItem(index, 0)
}
const moveToBottom = async (index: number) => {
if (!category?.items || index >= category.items.length - 1) return
moveItem(index, category.items.length - 1)
}
const filteredItems = category?.items?.filter(item => {
const matchesSearch = item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.href.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.description?.toLowerCase().includes(searchQuery.toLowerCase())
const matchesFilter = filter === 'all' ? true : filter === 'enabled' ? item.enabled : !item.enabled
return matchesSearch && matchesFilter
}) || []
return (
<div className="space-y-4">
{loading ? (
<>
<div className="flex items-center gap-4 mb-4">
<Skeleton className="h-8 w-8" />
<div className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-6 w-64" />
</div>
</div>
<div className="grid gap-2">
{[...Array(5)].map((_, index) => (
<div
key={index}
className="flex items-center justify-between py-2 px-4 bg-card rounded-lg border shadow-sm"
>
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded-lg" />
<div className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-32" />
</div>
</div>
<div className="flex items-center gap-1">
<Skeleton className="h-8 w-8" />
<Skeleton className="h-8 w-8" />
<Skeleton className="h-8 w-8" />
</div>
</div>
))}
</div>
</>
) : (
<>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => router.back()}
className="h-8 w-8"
title="返回"
>
<Icons.arrowLeft className="h-4 w-4" />
</Button>
<div>
<div className="text-sm text-muted-foreground mb-1">
{navigation?.title}
{category?.parentId && navigation?.subCategories?.find(cat => cat.id === category.parentId)?.title && (
<>
{' > '}{navigation.subCategories.find(cat => cat.id === category.parentId)?.title}
</>
)}
{' > '}{category?.title}
</div>
</div>
<div className="relative flex-1 max-w-sm">
<Icons.search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索站点..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
onClick={() => setSearchQuery("")}
className="absolute right-1 top-1 h-7 w-7 p-0"
>
<Icons.x className="h-4 w-4" />
</Button>
)}
</div>
<Select
value={filter}
onValueChange={(value: 'all' | 'enabled' | 'disabled') => setFilter(value)}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="按状态筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="enabled"></SelectItem>
<SelectItem value="disabled"></SelectItem>
</SelectContent>
</Select>
</div>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button>
<Icons.plus className="mr-2 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<AddItemForm
onSubmit={async (values) => {
await addItem(values);
setIsAddDialogOpen(false);
}}
onCancel={() => setIsAddDialogOpen(false)}
/>
</DialogContent>
</Dialog>
</div>
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="droppable">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="grid gap-2">
{filteredItems.map((item, index) => (
<Draggable key={item.id} draggableId={item.id} index={index}>
{(provided) => (
<div
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
className="group relative"
>
<div className="flex items-center justify-between py-2 px-4 bg-card rounded-lg border shadow-sm transition-colors hover:bg-accent/10">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10">
{item.icon ? (
<img src={item.icon} alt={item.title} className="w-4 h-4 object-contain" />
) : (
<Icons.link className="h-4 w-4 text-primary" />
)}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium leading-none">{item.title}</span>
{!item.enabled && (
<Badge variant="secondary" className="text-xs"></Badge>
)}
</div>
{item.description && (
<div className="text-xs text-muted-foreground mt-1">
{item.description}
</div>
)}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => window.open(item.href, '_blank')}
title="访问链接"
>
<Icons.globe className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setEditingItem({ index, item })}
title="编辑"
>
<Icons.pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setDeletingItem({ index, item })}
title="删除"
>
<Icons.trash className="h-4 w-4" />
</Button>
{index > 0 && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => moveToTop(index)}
title="置顶"
>
<Icons.chevronsUp className="h-4 w-4" />
</Button>
)}
{index < (category?.items?.length || 0) - 1 && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => moveToBottom(index)}
title="置底"
>
<Icons.chevronsDown className="h-4 w-4" />
</Button>
)}
</div>
</div>
</div>
)}
</Draggable>
))}
{filteredItems.length === 0 && (
<div className="text-center py-10 text-muted-foreground">
{category?.items?.length === 0 ? (
<p></p>
) : (
<p></p>
)}
</div>
)}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
<Dialog open={!!editingItem} onOpenChange={(open) => !open && setEditingItem(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<AddItemForm
defaultValues={editingItem?.item}
onSubmit={(values) => {
if (editingItem) {
return updateItem(editingItem.index, values)
}
return Promise.resolve()
}}
onCancel={() => setEditingItem(null)}
/>
</DialogContent>
</Dialog>
<Dialog open={!!deletingItem} onOpenChange={(open) => !open && setDeletingItem(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
&ldquo;{deletingItem?.item.title}&rdquo;
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setDeletingItem(null)}
>
</Button>
<Button
variant="destructive"
onClick={() => {
if (deletingItem) {
deleteItem(deletingItem.index)
}
}}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,553 @@
'use client'
export const runtime = 'edge'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { Button } from "@/registry/new-york/ui/button"
import { useToast } from "@/registry/new-york/hooks/use-toast"
import {
Plus,
Folder,
Search,
X,
ArrowLeft,
List,
Pencil,
Trash,
ChevronsUp,
ChevronsDown
} from 'lucide-react'
import { NavigationItem, NavigationCategory } from '@/types/navigation'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
DialogDescription,
} from "@/registry/new-york/ui/dialog"
import { AddCategoryForm } from '../../components/AddCategoryForm'
import { Input } from "@/registry/new-york/ui/input"
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd'
import { Badge } from "@/registry/new-york/ui/badge"
import { Skeleton } from "@/registry/new-york/ui/skeleton"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/registry/new-york/ui/select"
export default function CategoriesPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const { toast } = useToast()
const [navigation, setNavigation] = useState<NavigationItem | null>(null)
const [searchQuery, setSearchQuery] = useState("")
const [editingCategory, setEditingCategory] = useState<{ index: number; category: NavigationCategory } | null>(null)
const [deletingCategory, setDeletingCategory] = useState<{ index: number; category: NavigationCategory } | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [statusFilter, setStatusFilter] = useState<'all' | 'enabled' | 'disabled'>('all')
useEffect(() => {
if (!params?.id) {
router.push('/admin/navigation')
return
}
fetchNavigation()
}, [params?.id])
const fetchNavigation = async () => {
if (!params?.id) {
throw new Error('Navigation ID not found')
}
try {
setIsLoading(true)
const response = await fetch(`/api/navigation/${params.id}`)
if (!response.ok) throw new Error('Failed to fetch')
const data = await response.json()
setNavigation(data)
} catch (error) {
toast({
title: "错误",
description: "加载数据失败",
variant: "destructive"
})
} finally {
setIsLoading(false)
}
}
const addCategory = async (values: {
title: string,
icon: string,
description?: string,
enabled: boolean
}) => {
if (!params?.id || !navigation) return
try {
const newCategory: NavigationCategory = {
id: crypto.randomUUID(),
title: values.title,
icon: values.icon,
description: values.description,
enabled: values.enabled,
items: []
}
const updatedNavigation: NavigationItem = {
...navigation,
subCategories: [...(navigation.subCategories || []), newCategory]
}
const response = await fetch(`/api/navigation/${params.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedNavigation)
})
if (!response.ok) throw new Error('Failed to save')
await fetchNavigation()
toast({
title: "成功",
description: "添加成功"
})
setIsAddDialogOpen(false)
} catch (error) {
console.error('Save error:', error)
toast({
title: "错误",
description: error instanceof Error ? error.message : "保存失败",
variant: "destructive"
})
}
}
const editCategory = async (values: {
title: string,
icon: string,
description?: string,
enabled: boolean
}) => {
if (!params?.id || !navigation || !editingCategory) return
try {
const updatedCategories = navigation.subCategories?.map((cat, index) =>
index === editingCategory.index
? {
...cat,
title: values.title,
icon: values.icon,
description: values.description,
enabled: values.enabled
}
: cat
) || []
const updatedNavigation: NavigationItem = {
...navigation,
subCategories: updatedCategories
}
const response = await fetch(`/api/navigation/${params.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedNavigation)
})
if (!response.ok) throw new Error('Failed to save')
const updatedData = await response.json()
setNavigation(updatedData)
setEditingCategory(null)
toast({
title: "成功",
description: "更新成功"
})
} catch (error) {
toast({
title: "错误",
description: "保存失败",
variant: "destructive"
})
}
}
const deleteCategory = async (categoryId: string) => {
if (!params?.id) {
throw new Error('Navigation ID not found')
}
try {
const response = await fetch(`/api/navigation/${params.id}/categories`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ categoryId })
})
if (!response.ok) throw new Error('Failed to delete')
await fetchNavigation()
toast({
title: "成功",
description: "删除成功"
})
} catch (error) {
toast({
title: "错误",
description: "删除失败",
variant: "destructive"
})
}
}
const moveCategory = async (fromIndex: number, toIndex: number) => {
if (!params?.id) {
throw new Error('Navigation ID not found')
}
if (!navigation?.subCategories) return
const newCategories = [...navigation.subCategories]
const [removed] = newCategories.splice(fromIndex, 1)
newCategories.splice(toIndex, 0, removed)
const updatedNavigation = {
...navigation,
subCategories: newCategories
}
try {
const response = await fetch(`/api/navigation/${params.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedNavigation)
})
if (!response.ok) throw new Error('Failed to save order')
const updatedData = await response.json()
setNavigation(updatedData)
} catch (error) {
toast({
title: "错误",
description: "保存顺序失败",
variant: "destructive"
})
}
}
const handleDragEnd = (result: DropResult) => {
const { destination, source } = result
if (!destination || destination.index === source.index || !navigation?.subCategories) return
moveCategory(source.index, destination.index)
}
const moveToTop = async (id: string) => {
if (!params?.id) {
throw new Error('Navigation ID not found')
}
if (!navigation?.subCategories) return
const index = navigation.subCategories.findIndex(cat => cat.id === id)
if (index > 0) {
moveCategory(index, 0)
}
}
const moveToBottom = async (id: string) => {
if (!params?.id) {
throw new Error('Navigation ID not found')
}
if (!navigation?.subCategories) return
const index = navigation.subCategories.findIndex(cat => cat.id === id)
if (index < (navigation.subCategories.length - 1)) {
moveCategory(index, navigation.subCategories.length - 1)
}
}
const filteredCategories = navigation?.subCategories?.filter(category => {
const matchesSearch = category.title.toLowerCase().includes(searchQuery.toLowerCase())
const matchesStatus = statusFilter === 'all'
? true
: statusFilter === 'enabled'
? category.enabled
: !category.enabled
return matchesSearch && matchesStatus
}) || []
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => router.back()}
className="h-8 w-8"
title="返回"
>
<ArrowLeft className="h-4 w-4" />
</Button>
{isLoading ? (
<Skeleton className="h-7 w-32" />
) : (
<h2 className="text-lg font-semibold flex items-center gap-2">
{navigation?.title || '未命名导航'}
</h2>
)}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索分类..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
disabled={isLoading}
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
onClick={() => setSearchQuery("")}
className="absolute right-1 top-1 h-7 w-7 p-0"
disabled={isLoading}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<Select
value={statusFilter}
onValueChange={(value: 'all' | 'enabled' | 'disabled') => setStatusFilter(value)}
disabled={isLoading}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="按状态筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="enabled"></SelectItem>
<SelectItem value="disabled"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button disabled={isLoading}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<AddCategoryForm
onSubmit={addCategory}
onCancel={() => setIsAddDialogOpen(false)}
/>
</DialogContent>
</Dialog>
</div>
{isLoading ? (
<div className="grid gap-2">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-4 p-4 rounded-lg border">
<Skeleton className="h-8 w-8 rounded-lg" />
<div className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-32" />
</div>
</div>
))}
</div>
) : (
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="droppable">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="grid gap-2">
{filteredCategories.map((category, index) => (
<Draggable key={category.id} draggableId={category.id} index={index}>
{(provided) => (
<div
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
className="group relative"
>
<div
className="flex items-center justify-between py-2 px-4 bg-card rounded-lg border shadow-sm transition-colors hover:bg-accent/10"
>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10">
<Folder className="w-5 h-5 text-primary" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium leading-none">{category.title}</span>
<Badge
variant={(category.enabled ?? true) ? "default" : "secondary"}
className={
(category.enabled ?? true)
? "text-xs bg-green-100 text-green-800 hover:bg-green-100"
: "text-xs bg-gray-100 text-gray-600 hover:bg-gray-100"
}
>
{(category.enabled ?? true) ? "已启用" : "已禁用"}
</Badge>
</div>
{category.description && (
<div className="text-xs text-muted-foreground mt-1">
{category.description}
</div>
)}
<div className="text-xs text-muted-foreground mt-1">
{category.items?.length || 0}
</div>
</div>
</div>
<div className="flex items-center gap-1">
{/* 置顶置底按钮 - 在hover时显示 */}
<div className="hidden group-hover:flex items-center gap-1 mr-2">
{index > 0 && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => moveToTop(category.id)}
title="置顶"
>
<ChevronsUp className="h-4 w-4" />
</Button>
)}
{index < (navigation?.subCategories?.length || 0) - 1 && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => moveToBottom(category.id)}
title="置底"
>
<ChevronsDown className="h-4 w-4" />
</Button>
)}
</div>
{/* 常规操作按钮 */}
<Button
variant="ghost"
size="icon"
onClick={() => {
if (params?.id) {
router.push(`/admin/navigation/${params.id}/categories/${category.id}/items`)
}
}}
title="管理子项目"
>
<List className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setEditingCategory({ index, category })}
title="编辑"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeletingCategory({ index, category })}
title="删除"
>
<Trash className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
</Draggable>
))}
{filteredCategories.length === 0 && (
<div className="text-center py-10 text-muted-foreground">
{navigation?.subCategories?.length === 0 ? (
<p></p>
) : (
<p></p>
)}
</div>
)}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
)}
<Dialog open={!!editingCategory} onOpenChange={(open) => !open && setEditingCategory(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<AddCategoryForm
defaultValues={{
title: editingCategory?.category.title || '',
icon: editingCategory?.category.icon || '',
description: editingCategory?.category.description || '',
enabled: editingCategory?.category.enabled ?? true
}}
onSubmit={editCategory}
onCancel={() => setEditingCategory(null)}
/>
</DialogContent>
</Dialog>
<Dialog open={!!deletingCategory} onOpenChange={(open) => !open && setDeletingCategory(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
&ldquo;{deletingCategory?.category.title}&rdquo;
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setDeletingCategory(null)}
>
</Button>
<Button
variant="destructive"
onClick={() => {
if (deletingCategory) {
deleteCategory(deletingCategory.category.id)
setDeletingCategory(null)
}
}}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,576 @@
'use client'
export const runtime = 'edge'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { Button } from "@/registry/new-york/ui/button"
import { useToast } from "@/registry/new-york/hooks/use-toast"
import { Icons } from "@/components/icons"
import { NavigationItem, NavigationSubItem } from '@/types/navigation'
import { Input } from "@/registry/new-york/ui/input"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogDescription,
DialogFooter,
} from "@/registry/new-york/ui/dialog"
import { AddItemForm } from '../../components/AddItemForm'
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd';
import { Skeleton } from "@/registry/new-york/ui/skeleton"
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/registry/new-york/ui/select"
import { Badge } from "@/registry/new-york/ui/badge"
interface EditingItem {
index: number
item: NavigationSubItem
}
function LoadingSkeleton() {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-8 w-[200px]" />
<Skeleton className="h-4 w-[300px]" />
</div>
<Skeleton className="h-10 w-[100px]" />
</div>
<div className="grid gap-2">
{[1, 2, 3].map((i) => (
<div
key={i}
className="flex items-center justify-between p-4 rounded-lg border"
>
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded-lg" />
<div>
<Skeleton className="h-4 w-[200px] mb-2" />
<Skeleton className="h-3 w-[150px]" />
</div>
</div>
<div className="flex items-center gap-1">
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-8 w-8 rounded-md" />
</div>
</div>
))}
</div>
</div>
)
}
export default function ItemsPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const { toast } = useToast()
const [navigation, setNavigation] = useState<NavigationItem | null>(null)
const [searchQuery, setSearchQuery] = useState("")
const [enabledFilter, setEnabledFilter] = useState<'all' | 'enabled' | 'disabled'>('all')
const [editingItem, setEditingItem] = useState<EditingItem | null>(null)
const [deletingItem, setDeletingItem] = useState<EditingItem | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
useEffect(() => {
if (!params?.id) {
router.push('/admin/navigation')
return
}
fetchNavigation()
}, [params?.id, router])
const fetchNavigation = async () => {
setIsLoading(true)
try {
if (!params?.id) {
throw new Error('Navigation ID not found')
}
const navigationId = params?.id
if (!navigationId) {
throw new Error('Navigation ID is missing')
}
const response = await fetch(`/api/navigation/${navigationId}`)
if (!response.ok) throw new Error('Failed to fetch')
const data = await response.json()
setNavigation(data)
} catch (error) {
toast({
title: "错误",
description: "获取数据失败",
variant: "destructive"
})
} finally {
setIsLoading(false)
}
}
const addItem = async (values: NavigationSubItem) => {
try {
if (!params?.id || !navigation) {
throw new Error('Navigation ID or data not found')
}
const navigationId = params?.id
if (!navigationId) {
throw new Error('Navigation ID is missing')
}
const response = await fetch(`/api/navigation/${navigationId}/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values)
})
if (!response.ok) throw new Error('Failed to save')
await fetchNavigation()
toast({
title: "成功",
description: "添加成功"
})
setIsAddDialogOpen(false)
} catch (error) {
toast({
title: "错误",
description: "保存失败",
variant: "destructive"
})
} finally {
setIsAddDialogOpen(false)
}
}
const updateItem = async (index: number, values: NavigationSubItem) => {
try {
if (!params?.id || !navigation) {
throw new Error('Navigation ID or data not found')
}
const navigationId = params?.id
if (!navigationId) {
throw new Error('Navigation ID is missing')
}
const items = [...(navigation.items || [])]
items[index] = values
const updatedNavigation: NavigationItem = {
id: navigation.id || navigationId,
title: navigation.title || '',
description: navigation.description || '',
items,
subCategories: navigation.subCategories || []
}
const response = await fetch(`/api/navigation/${navigationId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedNavigation)
})
if (!response.ok) throw new Error('Failed to save')
setNavigation(updatedNavigation)
toast({
title: "成功",
description: "保存成功"
})
setEditingItem(null)
} catch (error) {
toast({
title: "错误",
description: "保存失败",
variant: "destructive"
})
}
}
const deleteItem = async (index: number) => {
try {
if (!params?.id) {
throw new Error('Navigation ID not found')
}
const navigationId = params?.id
if (!navigationId) {
throw new Error('Navigation ID is missing')
}
const response = await fetch(`/api/navigation/${navigationId}/items`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ index })
})
if (!response.ok) throw new Error('Failed to delete')
await fetchNavigation()
toast({
title: "成功",
description: "删除成功"
})
setDeletingItem(null)
} catch (error) {
toast({
title: "错误",
description: "删除失败",
variant: "destructive"
})
}
}
const onDragEnd = async (result: DropResult) => {
if (!result.destination) return
const items = Array.from(navigation?.items || [])
const [reorderedItem] = items.splice(result.source.index, 1)
items.splice(result.destination.index, 0, reorderedItem)
try {
const navigationId = params?.id
if (!navigationId) {
throw new Error('Navigation ID is missing')
}
const response = await fetch(`/api/navigation/${navigationId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...navigation,
items
})
})
if (!response.ok) throw new Error('Failed to save order')
await fetchNavigation() // Refresh data
toast({
title: "成功",
description: "项目顺序已更新"
})
} catch (error) {
toast({
title: "错误",
description: "保存顺序失败",
variant: "destructive"
})
}
}
const moveToTop = async (index: number) => {
if (index > 0) {
const items = Array.from(navigation?.items || [])
const [reorderedItem] = items.splice(index, 1)
items.unshift(reorderedItem)
try {
const navigationId = params?.id
if (!navigationId) {
throw new Error('Navigation ID is missing')
}
const response = await fetch(`/api/navigation/${navigationId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...navigation,
items
})
})
if (!response.ok) throw new Error('Failed to save order')
await fetchNavigation() // Refresh data
toast({
title: "成功",
description: "项目顺序已更新"
})
} catch (error) {
toast({
title: "错误",
description: "保存顺序失败",
variant: "destructive"
})
}
}
}
const moveToBottom = async (index: number) => {
if (!navigation?.items) return
if (index < navigation.items.length - 1) {
const items = Array.from(navigation?.items || [])
const [reorderedItem] = items.splice(index, 1)
items.push(reorderedItem)
try {
const navigationId = params?.id
if (!navigationId) {
throw new Error('Navigation ID is missing')
}
const response = await fetch(`/api/navigation/${navigationId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...navigation,
items
})
})
if (!response.ok) throw new Error('Failed to save order')
await fetchNavigation() // Refresh data
toast({
title: "成功",
description: "项目顺序已更新"
})
} catch (error) {
toast({
title: "错误",
description: "保存顺序失败",
variant: "destructive"
})
}
}
}
const filteredItems = navigation?.items?.filter(item => {
const matchesSearch =
item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.href.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.description?.toLowerCase().includes(searchQuery.toLowerCase())
const matchesEnabled =
enabledFilter === "all" ? true :
enabledFilter === "enabled" ? item.enabled :
enabledFilter === "disabled" ? !item.enabled :
true
return matchesSearch && matchesEnabled
}) || []
if (isLoading) {
return <LoadingSkeleton />
}
if (!navigation) {
return (
<div className="flex items-center justify-center h-[50vh]">
<p className="text-muted-foreground"></p>
</div>
)
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => router.back()}
className="h-8 w-8"
title="返回"
>
<Icons.arrowLeft className="h-4 w-4" />
</Button>
<div>
<div className="text-sm text-muted-foreground mb-1">
{navigation?.title}
</div>
</div>
<div className="relative flex-1 max-w-sm">
<Icons.search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索站点..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
onClick={() => setSearchQuery("")}
className="absolute right-1 top-1 h-7 w-7 p-0"
>
<Icons.x className="h-4 w-4" />
</Button>
)}
</div>
<Select
value={enabledFilter}
onValueChange={(value: 'all' | 'enabled' | 'disabled') => setEnabledFilter(value)}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="按状态筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="enabled"></SelectItem>
<SelectItem value="disabled"></SelectItem>
</SelectContent>
</Select>
</div>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button>
<Icons.plus className="mr-2 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<AddItemForm
onSubmit={async (values) => {
await addItem(values)
setIsAddDialogOpen(false)
}}
onCancel={() => setIsAddDialogOpen(false)}
/>
</DialogContent>
</Dialog>
</div>
{navigation?.items && navigation.items.length > 0 ? (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="droppable">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="grid gap-2">
{filteredItems.map((item, index) => (
<Draggable key={item.id} draggableId={item.id} index={index}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className="flex items-center justify-between p-4 rounded-lg border bg-card text-card-foreground shadow-sm hover:border-primary/50 transition-colors"
>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10">
{item.icon ? (
<img src={item.icon} alt={item.title} className="w-4 h-4 object-contain" />
) : (
<Icons.link className="h-4 w-4 text-primary" />
)}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium leading-none">{item.title}</span>
{!item.enabled && (
<Badge variant="secondary" className="text-xs"></Badge>
)}
</div>
{item.description && (
<div className="text-xs text-muted-foreground mt-1">
{item.description}
</div>
)}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => window.open(item.href, '_blank')}
title="访问链接"
>
<Icons.globe className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setEditingItem({ index, item })}
title="编辑"
>
<Icons.pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setDeletingItem({ index, item })}
title="删除"
>
<Icons.trash className="h-4 w-4" />
</Button>
</div>
</div>
)}
</Draggable>
))}
{filteredItems.length === 0 && (
<div className="text-center py-10 text-muted-foreground">
{navigation?.items?.length === 0 ? (
<p></p>
) : (
<p></p>
)}
</div>
)}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
) : (
<div className="text-center py-10 text-muted-foreground">
</div>
)}
<Dialog open={!!editingItem} onOpenChange={(open) => !open && setEditingItem(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<AddItemForm
defaultValues={editingItem?.item}
onSubmit={(values) => {
if (editingItem) {
return updateItem(editingItem.index, values)
}
return Promise.resolve()
}}
onCancel={() => setEditingItem(null)}
/>
</DialogContent>
</Dialog>
<Dialog open={!!deletingItem} onOpenChange={(open) => !open && setDeletingItem(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
&ldquo;{deletingItem?.item.title}&rdquo;
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setDeletingItem(null)}
>
</Button>
<Button
variant="destructive"
onClick={() => {
if (deletingItem) {
deleteItem(deletingItem.index)
}
}}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,217 @@
'use client'
export const runtime = 'edge'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { Button } from "@/registry/new-york/ui/button"
import { useToast } from "@/registry/new-york/hooks/use-toast"
import { NavigationItem } from '@/types/navigation'
import { Icons } from '@/components/icons'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/registry/new-york/ui/table"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/registry/new-york/ui/dropdown-menu"
import { Skeleton } from "@/registry/new-york/ui/skeleton"
import {
MoreHorizontal,
Search,
Inbox,
FolderTree,
Edit,
Trash2
} from "lucide-react"
export default function NavigationPage() {
const params = useParams()
const router = useRouter()
const { toast } = useToast()
const [items, setItems] = useState<NavigationItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState("")
useEffect(() => {
if (!params?.id) {
router.push('/admin/navigation')
return
}
fetchItems()
}, [params?.id, router])
const fetchItems = async () => {
try {
setIsLoading(true)
const response = await fetch(`/api/navigation/${params!.id}/items`)
if (!response.ok) throw new Error('Failed to fetch')
const data = await response.json()
setItems(data)
} catch (error) {
toast({
title: "错误",
description: "加载数据失败",
variant: "destructive"
})
} finally {
setIsLoading(false)
}
}
const handleItemsManage = (itemId: string) => {
router.push(`/admin/navigation/${params!.id}/items/${itemId}`)
}
const handleCategoryManage = (itemId: string) => {
router.push(`/admin/navigation/${params!.id}/categories/${itemId}`)
}
const handleEdit = async (item: NavigationItem) => {
try {
const response = await fetch(`/api/navigation/${params!.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item)
})
if (!response.ok) throw new Error('Failed to update')
await fetchItems()
toast({
title: "成功",
description: "更新成功"
})
} catch (error) {
toast({
title: "错误",
description: "更新失败",
variant: "destructive"
})
}
}
const handleDelete = async (itemId: string) => {
if (!confirm('确定要删除这个导航吗?')) return
try {
const response = await fetch(`/api/navigation/${params!.id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: itemId })
})
if (!response.ok) throw new Error('Failed to delete')
await fetchItems()
toast({
title: "成功",
description: "删除成功"
})
} catch (error) {
toast({
title: "错误",
description: "删除失败",
variant: "destructive"
})
}
}
const filteredItems = items.filter(item =>
item.title.toLowerCase().includes(searchQuery.toLowerCase())
)
return (
<div className="space-y-6">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredItems.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8">
{searchQuery ? (
<div className="text-muted-foreground">
<Search className="mx-auto h-12 w-12 opacity-50" />
<p className="mt-2"></p>
</div>
) : (
<div className="text-muted-foreground">
<Inbox className="mx-auto h-12 w-12 opacity-50" />
<p className="mt-2"></p>
</div>
)}
</TableCell>
</TableRow>
) : (
filteredItems.map((item, index) => (
<TableRow key={item.id}>
<TableCell>{index + 1}</TableCell>
<TableCell>{item.title}</TableCell>
<TableCell>{item.subCategories?.length || 0}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 hover:bg-muted"
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only"></span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[160px]">
<DropdownMenuItem
onClick={() => handleItemsManage(item.id)}
>
<Icons.list className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleCategoryManage(item.id)}
>
<FolderTree className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleEdit(item)}
>
<Edit className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleDelete(item.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
)
}

View File

@@ -0,0 +1,84 @@
'use client'
export const runtime = 'edge'
import * as React from "react"
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast'
import { Icons } from '@/components/icons'
import { NavigationItem, NavigationSubItem } from '@/types/navigation'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
export default function SubCategoryItemsPage() {
const params = useParams()
const router = useRouter()
const { toast } = useToast()
const [isLoading, setIsLoading] = useState(false)
const [category, setCategory] = useState<NavigationItem | null>(null)
const [subCategory, setSubCategory] = useState<NavigationSubItem | null>(null)
const [editingId, setEditingId] = useState<number | null>(null)
useEffect(() => {
fetchData()
}, [])
const fetchData = async () => {
try {
if (!params?.id) {
throw new Error('Navigation ID not found')
}
const navigationId = Array.isArray(params.id) ? params.id[0] : params.id
const response = await fetch(`/api/navigation/${navigationId}`)
if (!response.ok) throw new Error('Failed to fetch')
const data = await response.json()
setCategory(data)
const sub = data.subCategories?.find((s: NavigationSubItem) => String(s.id) === params.subId)
if (sub) {
setSubCategory(sub)
} else {
throw new Error('Subcategory not found')
}
} catch (error) {
toast({
title: '错误',
description: '加载数据失败',
variant: 'destructive'
})
router.back()
}
}
// ... 添加其他必要的函数
if (!category || !subCategory) return null
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium">
{category.title} - {subCategory.title} -
</h3>
<p className="text-sm text-muted-foreground">
</p>
</div>
{/* ... 添加操作按钮 */}
</div>
{/* ... 添加表格和其他内容 */}
</div>
)
}

View File

@@ -0,0 +1,160 @@
'use client'
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
import { Button } from "@/registry/new-york/ui/button"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription } from "@/registry/new-york/ui/form"
import { Input } from "@/registry/new-york/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/registry/new-york/ui/select"
import { navigationIcons } from "@/lib/icons"
import { Switch } from "@/registry/new-york/ui/switch"
import { useToast } from "@/registry/new-york/hooks/use-toast"
const formSchema = z.object({
title: z.string().min(1, "标题不能为空"),
description: z.string().optional(),
icon: z.string().min(1, "请选择图标"),
enabled: z.boolean().default(true)
})
interface AddCategoryFormProps {
defaultValues?: {
title: string
description?: string
icon: string
enabled: boolean
}
onSubmit: (values: z.infer<typeof formSchema>) => Promise<void>
onCancel: () => void
}
export function AddCategoryForm({ defaultValues, onSubmit, onCancel }: AddCategoryFormProps) {
const { toast } = useToast()
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: defaultValues || {
title: "",
description: "",
icon: "",
enabled: true
}
})
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
try {
await onSubmit(values);
toast({
title: "保存成功",
});
setTimeout(onCancel, 0);
} catch (error) {
console.error('Failed to submit:', error);
toast({
title: "保存失败",
variant: "destructive",
});
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="输入分类标题" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="输入分类描述" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="icon"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="选择图标" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(navigationIcons).map(([key, Icon]) => (
<SelectItem key={key} value={key}>
<div className="flex items-center">
<Icon className="mr-2 h-4 w-4" />
{key}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
</FormLabel>
<FormDescription>
</FormDescription>
</div>
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={onCancel}
>
</Button>
<Button
type="submit"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? "保存中..." : "保存"}
</Button>
</div>
</form>
</Form>
)
}

View File

@@ -0,0 +1,364 @@
'use client'
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import { Button } from "@/registry/new-york/ui/button"
import { Input } from "@/registry/new-york/ui/input"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription,
} from "@/registry/new-york/ui/form"
import { NavigationSubItem } from "@/types/navigation"
import { Icons } from "@/components/icons"
import { Textarea } from "@/registry/new-york/ui/textarea"
import { Switch } from "@/registry/new-york/ui/switch"
import { useState, useEffect } from "react"
import { useToast } from "@/registry/new-york/hooks/use-toast"
const formSchema = z.object({
id: z.string().optional(),
title: z.string().min(2, { message: "网站标题至少需要2个字符" }),
href: z.string().url({ message: "请输入有效的网站链接" }),
icon: z.string().optional(),
description: z.string().optional(),
enabled: z.boolean().default(true),
})
interface AddItemFormProps {
onSubmit: (values: NavigationSubItem) => Promise<void>
onCancel: () => void
defaultValues?: NavigationSubItem
}
export function AddItemForm({ onSubmit, onCancel, defaultValues }: AddItemFormProps) {
const { toast } = useToast()
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: defaultValues || {
id: String(Date.now()),
title: "",
href: "",
icon: "",
description: "",
enabled: true,
}
})
const isSubmitting = form.formState.isSubmitting
const [isUploading, setIsUploading] = useState(false)
const [isFetchingMetadata, setIsFetchingMetadata] = useState(false)
// 监听 href 字段变化,自动获取网站信息
const hrefValue = form.watch("href")
useEffect(() => {
const timeoutId = setTimeout(() => {
if (hrefValue && isValidUrl(hrefValue) && !defaultValues) {
fetchWebsiteMetadata(hrefValue)
}
}, 1000) // 延迟1秒执行避免频繁请求
return () => clearTimeout(timeoutId)
}, [hrefValue, defaultValues])
const isValidUrl = (string: string): boolean => {
try {
new URL(string)
return true
} catch (_) {
return false
}
}
const fetchWebsiteMetadata = async (url: string) => {
if (isFetchingMetadata) return
setIsFetchingMetadata(true)
try {
const response = await fetch('/api/website-metadata', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ url }),
})
if (!response.ok) {
throw new Error('获取网站信息失败')
}
const metadata = await response.json()
// 只在字段为空时自动填充
if (!form.getValues('title')) {
form.setValue('title', metadata.title)
}
if (!form.getValues('description')) {
form.setValue('description', metadata.description)
}
if (!form.getValues('icon') && metadata.icon) {
form.setValue('icon', metadata.icon)
}
toast({
title: "成功",
description: "已自动获取网站信息"
})
} catch (error) {
console.error('Failed to fetch website metadata:', error)
toast({
title: "提示",
description: "自动获取网站信息失败,请手动填写",
variant: "destructive"
})
} finally {
setIsFetchingMetadata(false)
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(async (data) => {
try {
const values: NavigationSubItem = {
id: data.id || crypto.randomUUID(),
title: data.title,
href: data.href,
description: data.description,
icon: data.icon,
enabled: data.enabled
}
await onSubmit(values)
} catch (error) {
console.error('保存失败:', error)
}
})} className="space-y-4">
<FormField
control={form.control}
name="href"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<div className="flex items-center space-x-2">
<div className="relative flex-1">
<Input placeholder="输入网站链接,将自动获取网站信息" {...field} />
{isFetchingMetadata && (
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
<Icons.loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
<Button
type="button"
variant="outline"
size="sm"
disabled={!field.value || !isValidUrl(field.value) || isFetchingMetadata}
onClick={() => fetchWebsiteMetadata(field.value)}
>
{isFetchingMetadata ? (
<Icons.loader2 className="h-4 w-4 animate-spin" />
) : (
<Icons.refresh className="h-4 w-4" />
)}
</Button>
</div>
</FormControl>
<FormDescription>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="网站标题(可自动获取)" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="icon"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<div className="flex items-center space-x-2">
<div className="flex-1 relative">
<Input
placeholder="图标URL可自动获取"
{...field}
/>
{field.value && (
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
<img
src={field.value}
alt="图标预览"
className="w-4 h-4 object-contain"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
}}
/>
</div>
)}
</div>
<Button
type="button"
variant="outline"
className="relative"
disabled={isUploading}
onClick={() => {
const fileInput = document.getElementById('icon-upload');
fileInput?.click();
}}
>
{isUploading ? (
<>
<Icons.loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Icons.upload className="mr-2 h-4 w-4" />
</>
)}
<input
id="icon-upload"
type="file"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
try {
setIsUploading(true);
// 将文件转换为 base64
const base64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
const response = await fetch('/api/resource', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
image: base64 // 直接发送 base64 字符串
}),
});
if (!response.ok) {
throw new Error(`上传失败: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (data.imageUrl) {
field.onChange(`${data.imageUrl}`); // 使用返回的图片URL
} else {
throw new Error('未获取到上传后的图片URL');
}
} catch (error) {
console.error('上传失败:', error);
alert(error instanceof Error ? error.message : '上传失败,请重试');
} finally {
setIsUploading(false);
// 清空文件输入
const fileInput = document.getElementById('icon-upload') as HTMLInputElement;
if (fileInput) {
fileInput.value = '';
}
}
}
}}
className="hidden"
/>
</Button>
</div>
</FormControl>
<FormDescription>
URL或上传本地图片
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Textarea
placeholder="输入网站描述"
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
</FormLabel>
<FormDescription>
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex justify-end space-x-2">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && (
<Icons.loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{isSubmitting ? "保存中..." : "保存"}
</Button>
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
>
</Button>
</div>
</form>
</Form>
)
}

View File

@@ -0,0 +1,179 @@
'use client'
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import { Button } from "@/registry/new-york/ui/button"
import { Input } from "@/registry/new-york/ui/input"
import { Switch } from "@/registry/new-york/ui/switch"
import { Textarea } from "@/registry/new-york/ui/textarea"
import { IconSelector } from './IconSelector'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription,
} from "@/registry/new-york/ui/form"
import { useToast } from "@/registry/new-york/hooks/use-toast"
const formSchema = z.object({
title: z.string().min(2, { message: "标题至少需要2个字符" }),
icon: z.string().min(1, { message: "请选择图标" }),
description: z.string().optional(),
enabled: z.boolean().default(true)
})
interface AddNavigationFormProps {
onSubmit: (values: {
title: string;
icon: string;
description?: string;
enabled: boolean;
}) => void
defaultValues?: {
title: string
icon: string
description?: string
enabled: boolean
}
onCancel?: () => void
}
export function AddNavigationForm({
onSubmit,
defaultValues,
onCancel
}: AddNavigationFormProps) {
const { toast } = useToast()
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: defaultValues || {
title: "",
icon: "FolderKanban",
description: "",
enabled: true
}
})
const { isSubmitting } = form.formState
const onSubmitHandler = async (values: z.infer<typeof formSchema>) => {
try {
await onSubmit({
title: values.title,
icon: values.icon,
description: values.description,
enabled: values.enabled
})
toast({
title: defaultValues ? "更新成功" : "添加成功",
description: `导航项 "${values.title}" 已${defaultValues ? "更新" : "添加"}`,
})
} catch (error) {
toast({
title: "操作失败",
description: "请稍后重试",
variant: "destructive",
})
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmitHandler)} className="space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="输入导航标题" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="icon"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<IconSelector value={field.value} onChange={field.onChange} />
</FormControl>
<FormDescription>
Lucide
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Textarea
placeholder="输入分类描述(可选)"
className="resize-none"
rows={3}
{...field}
/>
</FormControl>
<FormDescription>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg p-4">
<div className="space-y-0.5">
<FormLabel className="text-base"></FormLabel>
<FormDescription>
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex gap-4">
<Button
type="submit"
className="flex-1"
disabled={isSubmitting}
>
{isSubmitting ? "提交中..." : defaultValues ? "更新" : "添加"}
</Button>
<Button
type="button"
variant="outline"
className="flex-1"
onClick={onCancel}
>
</Button>
</div>
</form>
</Form>
)
}

View File

@@ -0,0 +1,181 @@
'use client'
import { useState } from 'react'
import { Button } from "@/registry/new-york/ui/button"
import { Icons } from "@/components/icons"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/registry/new-york/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/registry/new-york/ui/popover"
import { cn } from "@/lib/utils"
import { Check } from "lucide-react"
// 按分类组织图标
const iconGroups = [
{
label: '常用',
icons: [
{ name: 'home', icon: Icons.home },
{ name: 'folder', icon: Icons.folder },
{ name: 'fileText', icon: Icons.fileText },
{ name: 'list', icon: Icons.list },
{ name: 'search', icon: Icons.search },
{ name: 'plus', icon: Icons.plus },
{ name: 'bookmark', icon: Icons.bookmark },
{ name: 'link', icon: Icons.link }
]
},
{
label: '导航',
icons: [
{ name: 'menu', icon: Icons.menu },
{ name: 'arrowLeft', icon: Icons.arrowLeft },
{ name: 'chevronLeft', icon: Icons.chevronLeft },
{ name: 'chevronRight', icon: Icons.chevronRight },
{ name: 'moreVertical', icon: Icons.moreVertical },
{ name: 'navigation', icon: Icons.navigation },
{ name: 'compass', icon: Icons.compass },
{ name: 'map', icon: Icons.map }
]
},
{
label: '状态',
icons: [
{ name: 'check', icon: Icons.check },
{ name: 'x', icon: Icons.x },
{ name: 'loader2', icon: Icons.loader2 },
{ name: 'plus', icon: Icons.plus }
]
},
{
label: '系统',
icons: [
{ name: 'layoutDashboard', icon: Icons.layoutDashboard },
{ name: 'database', icon: Icons.database },
{ name: 'command', icon: Icons.command },
{ name: 'monitor', icon: Icons.monitor }
]
},
{
label: '用户',
icons: [
{ name: 'user', icon: Icons.user },
{ name: 'logOut', icon: Icons.logOut },
{ name: 'github', icon: Icons.github },
{ name: 'mail', icon: Icons.mail }
]
},
{
label: '内容',
icons: [
{ name: 'bookOpen', icon: Icons.bookOpen },
{ name: 'bookmark', icon: Icons.bookmark },
{ name: 'library', icon: Icons.library },
{ name: 'newspaper', icon: Icons.newspaper },
{ name: 'mail', icon: Icons.mail },
{ name: 'messageSquare', icon: Icons.messageSquare },
{ name: 'calendar', icon: Icons.calendar }
]
}
]
interface IconPickerProps {
value: string
onChange: (value: string) => void
}
export function IconPicker({ value, onChange }: IconPickerProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState("")
// 查找当前选中的图标
const selectedIcon = iconGroups
.flatMap(group => group.icons)
.find(icon => icon.name === value)
const Icon = selectedIcon?.icon || Icons.folder
// 过滤图标
const filteredGroups = search
? [{
label: '搜索结果',
icons: iconGroups
.flatMap(group => group.icons)
.filter(icon =>
icon.name.toLowerCase().includes(search.toLowerCase())
)
}]
: iconGroups
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
<div className="flex items-center gap-2">
{Icon && <Icon className="h-4 w-4" />}
<span>{value || "选择图标"}</span>
</div>
<Icons.chevronRight className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[240px] p-0"
align="start"
side="bottom"
sideOffset={5}
>
<Command>
<CommandInput
placeholder="搜索图标..."
value={search}
onValueChange={setSearch}
/>
<div className="max-h-[300px] overflow-y-auto">
<CommandEmpty></CommandEmpty>
{filteredGroups.map((group, index) => (
<div key={group.label}>
{index > 0 && <CommandSeparator />}
<CommandGroup heading={group.label}>
{group.icons.map(icon => (
<CommandItem
key={icon.name}
value={icon.name}
onSelect={(currentValue) => {
onChange(currentValue)
setOpen(false)
setSearch("")
}}
className="flex items-center gap-2"
>
<div className="flex items-center gap-2 flex-1">
{icon.icon && <icon.icon className="h-4 w-4" />}
{icon.name}
</div>
{value === icon.name && (
<Check className="h-4 w-4" />
)}
</CommandItem>
))}
</CommandGroup>
</div>
))}
</div>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,82 @@
'use client'
import * as React from 'react'
import { Button } from "@/registry/new-york/ui/button"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/registry/new-york/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/registry/new-york/ui/popover"
import { cn } from "@/lib/utils"
import { Check, ChevronsUpDown } from "lucide-react"
import { navigationIcons, type IconType } from '@/lib/icons'
interface IconSelectorProps {
value?: string
onChange: (value: string) => void
}
export function IconSelector({ value, onChange }: IconSelectorProps) {
const [open, setOpen] = React.useState(false)
const [searchQuery, setSearchQuery] = React.useState("")
// 获取当前选中的图标组件
const SelectedIcon = value && navigationIcons[value as IconType]
? navigationIcons[value as IconType]
: null
// 过滤图标
const filteredIcons = Object.entries(navigationIcons)
.filter(([name]) =>
name.toLowerCase().includes(searchQuery.toLowerCase())
)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
<div className="flex items-center gap-2">
{SelectedIcon && <SelectedIcon className="h-4 w-4" />}
<span>{value || "选择图标"}</span>
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Command>
<CommandInput
placeholder="搜索图标..."
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandEmpty></CommandEmpty>
<div className="max-h-[200px] overflow-y-scroll">
<CommandGroup>
{filteredIcons.map(([name]) => (
<CommandItem
key={name}
value={name}
onSelect={(currentValue) => {
onChange(currentValue)
setOpen(false)
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === name ? "opacity-100" : "opacity-0"
)}
/>
{React.createElement(navigationIcons[name as IconType], { className: "mr-2 h-4 w-4" })}
{name}
</CommandItem>
))}
</CommandGroup>
</div>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,133 @@
import { useFormContext } from "react-hook-form"
import { FormControl, FormItem, FormLabel, FormMessage, FormDescription } from "@/registry/new-york/ui/form"
import { Button } from "@/registry/new-york/ui/button"
import { Input } from "@/registry/new-york/ui/input"
const GITHUB_API_URL = 'https://api.github.com/repos/YOUR_USERNAME/YOUR_REPO/contents'
const GITHUB_TOKEN = process.env.NEXT_PUBLIC_GITHUB_TOKEN
const ICONS_PATH = 'public/icons' // GitHub 仓库中存储图标的路径
interface IconUploaderProps {
onChange: (icon: string) => void;
value?: string;
}
export function IconUploader({ onChange, value }: IconUploaderProps) {
const { setValue, getValues } = useFormContext()
const fetchFavicon = async (url: string) => {
console.log('Fetching favicon for URL:', url);
if (!url) {
console.error('URL 不能为空')
return
}
try {
const urlObj = new URL(url)
const baseUrl = `${urlObj.protocol}//${urlObj.hostname}`
// 尝试获取网页内容
const response = await fetch(url)
const html = await response.text()
// 使用正则表达式查找 favicon 链接
const faviconMatch = html.match(/<link[^>]+rel=["'](?:icon|shortcut icon)["'][^>]+href=["']([^"']+)["']/i)
let faviconUrl = faviconMatch ? faviconMatch[1] : `${baseUrl}/favicon.ico`
// 处理相对路径
if (!faviconUrl.startsWith('http')) {
faviconUrl = new URL(faviconUrl, baseUrl).href
}
// 检查 favicon 是否存在
const faviconResponse = await fetch(faviconUrl, { method: 'HEAD' })
if (!faviconResponse.ok) {
throw new Error('未找到 favicon')
}
setValue('icon', faviconUrl)
} catch (error) {
console.error('获取网站图标失败:', error)
}
}
const uploadIconToGithub = async (file: File) => {
try {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = async () => {
const base64Content = (reader.result as string).split(',')[1]
const fileName = `icon-${Date.now()}-${file.name}`
const response = await fetch(`${GITHUB_API_URL}/${ICONS_PATH}/${fileName}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${GITHUB_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: `Upload icon: ${fileName}`,
content: base64Content,
}),
})
if (!response.ok) {
throw new Error('上传失败')
}
const data = await response.json()
const iconUrl = data.content.download_url
setValue('icon', iconUrl)
}
} catch (error) {
console.error('上传图标失败:', error)
}
}
return (
<FormItem>
<FormLabel></FormLabel>
<div className="flex space-x-2">
<FormControl className="flex-1">
<Input placeholder="输入图标URL" {...{ name: 'icon' }} />
</FormControl>
<Button
type="button"
variant="outline"
onClick={() => {
const href = getValues('href');
if (href) {
fetchFavicon(href);
} else {
console.error('请提供有效的 URL');
}
}}
>
</Button>
<div className="relative">
<Input
type="file"
className="absolute inset-0 opacity-0 cursor-pointer"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
uploadIconToGithub(file)
}
}}
/>
<Button
type="button"
variant="outline"
>
</Button>
</div>
</div>
<FormDescription>
URLBase64
</FormDescription>
<FormMessage />
</FormItem>
)
}

View File

@@ -0,0 +1,288 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from "@/registry/new-york/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/registry/new-york/ui/dialog"
import { useToast } from "@/registry/new-york/hooks/use-toast"
import { AddNavigationForm } from './AddNavigationForm'
import { Draggable } from "@hello-pangea/dnd"
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/registry/new-york/ui/tooltip"
import { NavigationItem } from '@/types/navigation'
import { navigationIcons, type IconType } from '@/lib/icons'
import {
Folder,
FolderOpen,
List,
Image,
Pencil,
Trash,
ChevronsUp,
ChevronsDown
} from 'lucide-react'
import { cn } from "@/lib/utils"
import { Badge } from "@/registry/new-york/ui/badge"
interface NavigationCardProps {
item: NavigationItem
index: number
onUpdate: () => void
onMoveToTop?: () => void
onMoveToBottom?: () => void
showMoveToTop?: boolean
showMoveToBottom?: boolean
}
export function NavigationCard({
item,
index,
onUpdate,
onMoveToTop,
onMoveToBottom,
showMoveToTop,
showMoveToBottom
}: NavigationCardProps) {
const router = useRouter()
const { toast } = useToast()
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const Icon = item.icon && navigationIcons[item.icon as IconType] ? navigationIcons[item.icon as IconType] : navigationIcons.Folder
const handleEdit = async (values: {
title: string;
description?: string;
icon: string;
enabled: boolean;
}) => {
try {
const response = await fetch(`/api/navigation/${item.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...item,
title: values.title,
description: values.description,
icon: values.icon,
enabled: values.enabled
})
})
if (!response.ok) throw new Error('Failed to save')
setIsEditDialogOpen(false)
onUpdate()
toast({
title: "成功",
description: "保存成功"
})
} catch (error) {
toast({
title: "错误",
description: "保存失败",
variant: "destructive"
})
}
}
const handleDelete = async () => {
try {
const response = await fetch(`/api/navigation/${item.id}`, {
method: 'DELETE'
})
if (!response.ok) throw new Error('Failed to delete')
setIsDeleteDialogOpen(false)
onUpdate()
toast({
title: "成功",
description: "删除成功"
})
} catch (error) {
toast({
title: "错误",
description: "删除失败",
variant: "destructive"
})
}
}
return (
<Draggable draggableId={item.id} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={`flex items-center justify-between p-4 rounded-lg border bg-card text-card-foreground shadow-sm hover:border-primary/50 transition-colors ${
snapshot.isDragging ? 'bg-gray-50' : ''
}`}
>
<div className="flex items-center space-x-4">
<Icon className="h-6 w-6 text-muted-foreground" />
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium">{item.title}</h3>
<Badge
variant={(item.enabled ?? true) ? "default" : "secondary"}
className={cn(
"text-xs",
(item.enabled ?? true)
? "bg-green-100 text-green-800 hover:bg-green-100"
: "bg-gray-100 text-gray-600 hover:bg-gray-100"
)}
>
{(item.enabled ?? true) ? "已启用" : "已禁用"}
</Badge>
</div>
<p className="text-xs text-muted-foreground">{item.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/admin/navigation/${item.id}/categories`)}
className="h-8 w-8"
>
<FolderOpen className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/admin/navigation/${item.id}/items`)}
className="h-8 w-8"
>
<List className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
{showMoveToTop && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onMoveToTop}
className="h-8 w-8"
>
<ChevronsUp className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
)}
{showMoveToBottom && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onMoveToBottom}
className="h-8 w-8"
>
<ChevronsDown className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setIsEditDialogOpen(true)}
className="h-8 w-8"
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setIsDeleteDialogOpen(true)}
className="h-8 w-8"
>
<Trash className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<AddNavigationForm
defaultValues={{
title: item.title,
description: item.description || '',
icon: item.icon || '',
enabled: item.enabled ?? true
}}
onSubmit={handleEdit}
onCancel={() => setIsEditDialogOpen(false)}
/>
</DialogContent>
</Dialog>
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setIsDeleteDialogOpen(false)}>
</Button>
<Button variant="destructive" onClick={handleDelete}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)}
</Draggable>
)
}

View File

@@ -0,0 +1,325 @@
'use client'
export const runtime = 'edge'
import { useState } from "react"
import { Button } from "@/registry/new-york/ui/button"
import { NavigationCard } from "./components/NavigationCard"
import { AddNavigationForm } from "./components/AddNavigationForm"
import { Input } from "@/registry/new-york/ui/input"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/registry/new-york/ui/dialog"
import { useToast } from "@/registry/new-york/hooks/use-toast"
import { Skeleton } from "@/registry/new-york/ui/skeleton"
import useSWR from 'swr'
import { NavigationItem } from "@/types/navigation"
import { DragDropContext, Droppable } from '@hello-pangea/dnd'
import { Plus, AlertTriangle, Inbox } from 'lucide-react'
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/registry/new-york/ui/select"
async function fetcher(url: string): Promise<NavigationItem[]> {
const res = await fetch(url)
if (!res.ok) throw new Error('Failed to fetch navigation items')
const data = await res.json()
return data.navigationItems || []
}
export default function NavigationPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [showEnabled, setShowEnabled] = useState<boolean | null>(null)
const { toast } = useToast()
const { data: items = [], error, isLoading, mutate } = useSWR<NavigationItem[]>(
'/api/navigation',
fetcher,
{
fallbackData: [],
revalidateOnFocus: false,
}
)
const handleAdd = async (values: {
title: string;
icon: string;
description?: string;
enabled?: boolean
}) => {
try {
// 生成唯一ID
const newItem: NavigationItem = {
id: Date.now().toString(),
title: values.title,
icon: values.icon,
description: values.description || '',
enabled: values.enabled ?? true,
items: [],
subCategories: []
}
const response = await fetch('/api/navigation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
navigationItems: [
...items, // 保留现有的导航项
newItem // 添加新的导航项
]
})
})
if (!response.ok) {
const errorText = await response.text()
console.error('添加失败:', errorText)
throw new Error('Failed to add')
}
setIsDialogOpen(false)
mutate()
toast({
title: "成功",
description: "添加成功"
})
} catch (error) {
console.error('添加导航项错误:', error)
toast({
title: "错误",
description: "添加失败:" + (error as Error).message,
variant: "destructive"
})
}
}
const handleMoveToTop = async (id: string) => {
try {
const response = await fetch(`/api/navigation/${id}/move-to-top`, {
method: 'POST'
})
if (!response.ok) throw new Error('Failed to move')
mutate()
toast({
title: "成功",
description: "移动成功"
})
} catch (error) {
toast({
title: "错误",
description: "移动失败",
variant: "destructive"
})
}
}
const handleMoveToBottom = async (id: string) => {
try {
const response = await fetch(`/api/navigation/${id}/move-to-bottom`, {
method: 'POST'
})
if (!response.ok) throw new Error('Failed to move')
mutate()
toast({
title: "成功",
description: "移动成功"
})
} catch (error) {
toast({
title: "错误",
description: "移动失败",
variant: "destructive"
})
}
}
const handleDragEnd = async (result: any) => {
if (!result.destination) return
const sourceIndex = result.source.index
const destinationIndex = result.destination.index
if (sourceIndex === destinationIndex) return
// 创建一个新的数组副本
const currentItems = Array.isArray(items) ? [...items] : []
// 记录原始顺序,用于可能的回滚
const originalItems = [...currentItems]
// 移动元素
const [movedItem] = currentItems.splice(sourceIndex, 1)
currentItems.splice(destinationIndex, 0, movedItem)
// 乐观更新:立即更新本地状态
await mutate(currentItems, {
revalidate: false // 阻止重新获取数据
})
try {
const response = await fetch('/api/navigation/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceIndex,
destinationIndex,
itemId: result.draggableId
})
})
if (!response.ok) {
// 如果服务器请求失败,回滚到原始状态
await mutate(originalItems, { revalidate: false })
throw new Error('排序失败')
}
// 成功后重新获取最新数据以确保一致性
await mutate()
toast({
title: "成功",
description: "排序已更新"
})
} catch (error) {
// 回滚到原始状态
await mutate(originalItems, { revalidate: false })
toast({
title: "错误",
description: "排序失败,已恢复原状",
variant: "destructive"
})
}
}
const filteredItems = items
.filter(item =>
item.title.toLowerCase().includes(searchQuery.toLowerCase()) &&
(showEnabled === null || item.enabled === showEnabled)
)
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-x-4">
<div className="flex items-center gap-4 flex-1">
<Input
placeholder="搜索分类..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="max-w-[300px]"
/>
<Select
value={showEnabled === null ? "all" : String(showEnabled)}
onValueChange={(value) => {
if (value === "all") {
setShowEnabled(null)
} else {
setShowEnabled(value === "true")
}
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="状态筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="true"></SelectItem>
<SelectItem value="false"></SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={() => setIsDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="space-y-4">
{error ? (
<div className="flex h-[450px] shrink-0 items-center justify-center rounded-md border border-dashed">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
<AlertTriangle className="h-10 w-10 text-muted-foreground" />
<h3 className="mt-4 text-lg font-semibold"></h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
</p>
<Button
variant="outline"
onClick={() => mutate()}
>
</Button>
</div>
</div>
) : isLoading ? (
Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="p-4 rounded-lg border">
<div className="flex items-center space-x-4">
<Skeleton className="h-6 w-6 rounded" />
<div className="space-y-2">
<Skeleton className="h-4 w-[200px]" />
</div>
</div>
</div>
))
) : filteredItems.length === 0 ? (
<div className="flex h-[450px] shrink-0 items-center justify-center rounded-md border border-dashed">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
<Inbox className="h-10 w-10 text-muted-foreground" />
<h3 className="mt-4 text-lg font-semibold"></h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
{searchQuery ? "没有找到匹配的分类。" : "还没有添加任何分类点击上方的添加按钮开<E992AE><E5BC80><EFBFBD>创建。"}
</p>
{searchQuery && (
<Button
variant="outline"
onClick={() => setSearchQuery("")}
>
</Button>
)}
</div>
</div>
) : (
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="navigation-list">
{(provided) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className="space-y-4"
>
{filteredItems.map((item, index) => (
<NavigationCard
key={item.id}
item={item}
index={index}
onUpdate={mutate}
showMoveToTop={index > 0}
showMoveToBottom={index < filteredItems.length - 1}
onMoveToTop={() => handleMoveToTop(item.id)}
onMoveToBottom={() => handleMoveToBottom(item.id)}
/>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
)}
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<AddNavigationForm
onSubmit={handleAdd}
onCancel={() => setIsDialogOpen(false)}
/>
</DialogContent>
</Dialog>
</div>
)
}