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,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>
)
}