feat: 添加书签模块 UI 组件
This commit is contained in:
@@ -0,0 +1,61 @@
|
|||||||
|
import * as icons from 'lucide-react'
|
||||||
|
import { GripVertical, Pencil, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
|
const allIcons = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>
|
||||||
|
|
||||||
|
interface BookmarkItemProps {
|
||||||
|
bookmark: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
icon: string | null
|
||||||
|
}
|
||||||
|
onEdit: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
handleRef?: (el: Element | null) => void
|
||||||
|
sortableRef?: (el: Element | null) => void
|
||||||
|
isDragging?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BookmarkItem = ({ bookmark, onEdit, onDelete, handleRef, sortableRef, isDragging }: BookmarkItemProps) => {
|
||||||
|
const Icon = (bookmark.icon && allIcons[bookmark.icon]) || icons.Globe
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={sortableRef}
|
||||||
|
className={`group flex items-center gap-3 rounded-lg px-3 py-2 transition-all hover:bg-slate-50 ${
|
||||||
|
isDragging ? 'opacity-50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div ref={handleRef} className="cursor-grab text-slate-300 hover:text-slate-500 active:cursor-grabbing">
|
||||||
|
<GripVertical className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-slate-100">
|
||||||
|
<Icon className="h-4 w-4 text-slate-600" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href={bookmark.url} target="_blank" rel="noopener noreferrer" className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-slate-700 truncate">{bookmark.name}</p>
|
||||||
|
<p className="text-xs text-slate-400 truncate">{bookmark.url}</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEdit}
|
||||||
|
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import { DragDropProvider } from '@dnd-kit/react'
|
||||||
|
import { useSortable } from '@dnd-kit/react/sortable'
|
||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { FolderOpen, MoreHorizontal, Pencil, Plus, Trash2, X } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { orpc } from '@/client/orpc'
|
||||||
|
import { BookmarkItem } from './BookmarkItem'
|
||||||
|
import { IconPicker } from './IconPicker'
|
||||||
|
|
||||||
|
interface Bookmark {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
icon: string | null
|
||||||
|
orderId: number
|
||||||
|
categoryId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Category {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
orderId: number
|
||||||
|
bookmarks: Bookmark[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategorySectionProps {
|
||||||
|
category: Category
|
||||||
|
}
|
||||||
|
|
||||||
|
const SortableBookmark = ({
|
||||||
|
bookmark,
|
||||||
|
index,
|
||||||
|
groupId,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
bookmark: Bookmark
|
||||||
|
index: number
|
||||||
|
groupId: string
|
||||||
|
onEdit: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
}) => {
|
||||||
|
const { ref, handleRef, isDragging } = useSortable({
|
||||||
|
id: bookmark.id,
|
||||||
|
index,
|
||||||
|
group: groupId,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BookmarkItem
|
||||||
|
bookmark={bookmark}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
sortableRef={ref}
|
||||||
|
handleRef={handleRef}
|
||||||
|
isDragging={isDragging}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CategorySection = ({ category }: CategorySectionProps) => {
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
|
const [editingName, setEditingName] = useState(false)
|
||||||
|
const [categoryName, setCategoryName] = useState(category.name)
|
||||||
|
const [newBookmark, setNewBookmark] = useState({ name: '', url: '', icon: '' })
|
||||||
|
const [showIconPicker, setShowIconPicker] = useState(false)
|
||||||
|
const [showMenu, setShowMenu] = useState(false)
|
||||||
|
const [editingBookmark, setEditingBookmark] = useState<Bookmark | null>(null)
|
||||||
|
const [editForm, setEditForm] = useState({ name: '', url: '', icon: '' })
|
||||||
|
const [showEditIconPicker, setShowEditIconPicker] = useState(false)
|
||||||
|
const [items, setItems] = useState(category.bookmarks)
|
||||||
|
|
||||||
|
const updateCategory = useMutation(orpc.bookmarks.category.update.mutationOptions())
|
||||||
|
const deleteCategory = useMutation(orpc.bookmarks.category.remove.mutationOptions())
|
||||||
|
const createBookmark = useMutation(orpc.bookmarks.bookmark.create.mutationOptions())
|
||||||
|
const updateBookmark = useMutation(orpc.bookmarks.bookmark.update.mutationOptions())
|
||||||
|
const deleteBookmark = useMutation(orpc.bookmarks.bookmark.remove.mutationOptions())
|
||||||
|
const reorderBookmarks = useMutation(orpc.bookmarks.bookmark.reorder.mutationOptions())
|
||||||
|
|
||||||
|
if (items !== category.bookmarks && !reorderBookmarks.isPending) {
|
||||||
|
setItems(category.bookmarks)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveCategoryName = () => {
|
||||||
|
if (categoryName.trim() && categoryName !== category.name) {
|
||||||
|
updateCategory.mutate({ id: category.id, data: { name: categoryName.trim() } })
|
||||||
|
}
|
||||||
|
setEditingName(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddBookmark = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (newBookmark.name.trim() && newBookmark.url.trim()) {
|
||||||
|
createBookmark.mutate({
|
||||||
|
name: newBookmark.name.trim(),
|
||||||
|
url: newBookmark.url.trim(),
|
||||||
|
icon: newBookmark.icon || null,
|
||||||
|
categoryId: category.id,
|
||||||
|
orderId: category.bookmarks.length,
|
||||||
|
})
|
||||||
|
setNewBookmark({ name: '', url: '', icon: '' })
|
||||||
|
setShowAddForm(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditBookmark = (bm: Bookmark) => {
|
||||||
|
setEditingBookmark(bm)
|
||||||
|
setEditForm({ name: bm.name, url: bm.url, icon: bm.icon ?? '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveEditBookmark = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (editingBookmark && editForm.name.trim() && editForm.url.trim()) {
|
||||||
|
updateBookmark.mutate({
|
||||||
|
id: editingBookmark.id,
|
||||||
|
data: {
|
||||||
|
name: editForm.name.trim(),
|
||||||
|
url: editForm.url.trim(),
|
||||||
|
icon: editForm.icon || null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
setEditingBookmark(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragEnd: NonNullable<React.ComponentProps<typeof DragDropProvider>['onDragEnd']> = (event) => {
|
||||||
|
if (event.canceled) return
|
||||||
|
const sourceId = event.operation.source?.id
|
||||||
|
const targetId = event.operation.target?.id
|
||||||
|
if (!sourceId || !targetId || sourceId === targetId) return
|
||||||
|
|
||||||
|
const oldIndex = items.findIndex((b) => b.id === sourceId)
|
||||||
|
const newIndex = items.findIndex((b) => b.id === targetId)
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return
|
||||||
|
|
||||||
|
const reordered = [...items]
|
||||||
|
const [moved] = reordered.splice(oldIndex, 1)
|
||||||
|
if (!moved) return
|
||||||
|
reordered.splice(newIndex, 0, moved)
|
||||||
|
setItems(reordered)
|
||||||
|
|
||||||
|
reorderBookmarks.mutate(reordered.map((b, i) => ({ id: b.id, orderId: i })))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl bg-white ring-1 ring-slate-100 shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FolderOpen className="h-4 w-4 text-indigo-500" />
|
||||||
|
{editingName ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={categoryName}
|
||||||
|
onChange={(e) => setCategoryName(e.target.value)}
|
||||||
|
onBlur={handleSaveCategoryName}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSaveCategoryName()}
|
||||||
|
className="px-2 py-0.5 text-sm font-semibold text-slate-900 bg-slate-50 rounded-md ring-1 ring-indigo-300 outline-none"
|
||||||
|
// biome-ignore lint/a11y/noAutofocus: inline edit needs immediate focus
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900">{category.name}</h3>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowMenu(!showMenu)}
|
||||||
|
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{showMenu && (
|
||||||
|
<div className="absolute right-0 z-40 mt-1 w-32 rounded-lg bg-white py-1 shadow-lg ring-1 ring-slate-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingName(true)
|
||||||
|
setShowMenu(false)
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" /> 重命名
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
deleteCategory.mutate({ id: category.id })
|
||||||
|
setShowMenu(false)
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" /> 删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-2">
|
||||||
|
<DragDropProvider onDragEnd={handleDragEnd}>
|
||||||
|
{items.map((bm, idx) => (
|
||||||
|
<SortableBookmark
|
||||||
|
key={bm.id}
|
||||||
|
bookmark={bm}
|
||||||
|
index={idx}
|
||||||
|
groupId={category.id}
|
||||||
|
onEdit={() => handleEditBookmark(bm)}
|
||||||
|
onDelete={() => deleteBookmark.mutate({ id: bm.id })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</DragDropProvider>
|
||||||
|
|
||||||
|
{items.length === 0 && !showAddForm && <p className="py-4 text-center text-sm text-slate-400">暂无书签</p>}
|
||||||
|
|
||||||
|
{editingBookmark && (
|
||||||
|
<form onSubmit={handleSaveEditBookmark} className="mx-2 mt-2 space-y-2 rounded-lg bg-slate-50 p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-slate-500">编辑书签</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingBookmark(null)}
|
||||||
|
className="text-slate-400 hover:text-slate-600"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editForm.name}
|
||||||
|
onChange={(e) => setEditForm((p) => ({ ...p, name: e.target.value }))}
|
||||||
|
placeholder="名称"
|
||||||
|
className="w-full rounded-lg bg-white px-3 py-2 text-sm ring-1 ring-slate-200 outline-none focus:ring-2 focus:ring-indigo-500/50"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={editForm.url}
|
||||||
|
onChange={(e) => setEditForm((p) => ({ ...p, url: e.target.value }))}
|
||||||
|
placeholder="URL"
|
||||||
|
className="w-full rounded-lg bg-white px-3 py-2 text-sm ring-1 ring-slate-200 outline-none focus:ring-2 focus:ring-indigo-500/50"
|
||||||
|
/>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowEditIconPicker(!showEditIconPicker)}
|
||||||
|
className="rounded-lg bg-white px-3 py-2 text-sm ring-1 ring-slate-200 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
{editForm.icon || '选择图标'}
|
||||||
|
</button>
|
||||||
|
{showEditIconPicker && (
|
||||||
|
<IconPicker
|
||||||
|
value={editForm.icon}
|
||||||
|
onChange={(icon) => setEditForm((p) => ({ ...p, icon }))}
|
||||||
|
onClose={() => setShowEditIconPicker(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full rounded-lg bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-700 transition-colors"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAddForm ? (
|
||||||
|
<form onSubmit={handleAddBookmark} className="mx-2 mt-2 space-y-2 rounded-lg bg-slate-50 p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-slate-500">添加书签</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAddForm(false)}
|
||||||
|
className="text-slate-400 hover:text-slate-600"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newBookmark.name}
|
||||||
|
onChange={(e) => setNewBookmark((p) => ({ ...p, name: e.target.value }))}
|
||||||
|
placeholder="名称"
|
||||||
|
required
|
||||||
|
className="w-full rounded-lg bg-white px-3 py-2 text-sm ring-1 ring-slate-200 outline-none focus:ring-2 focus:ring-indigo-500/50"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={newBookmark.url}
|
||||||
|
onChange={(e) => setNewBookmark((p) => ({ ...p, url: e.target.value }))}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
required
|
||||||
|
className="w-full rounded-lg bg-white px-3 py-2 text-sm ring-1 ring-slate-200 outline-none focus:ring-2 focus:ring-indigo-500/50"
|
||||||
|
/>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowIconPicker(!showIconPicker)}
|
||||||
|
className="rounded-lg bg-white px-3 py-2 text-sm ring-1 ring-slate-200 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
{newBookmark.icon || '选择图标'}
|
||||||
|
</button>
|
||||||
|
{showIconPicker && (
|
||||||
|
<IconPicker
|
||||||
|
value={newBookmark.icon}
|
||||||
|
onChange={(icon) => setNewBookmark((p) => ({ ...p, icon }))}
|
||||||
|
onClose={() => setShowIconPicker(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createBookmark.isPending}
|
||||||
|
className="w-full rounded-lg bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{createBookmark.isPending ? '添加中...' : '添加'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAddForm(true)}
|
||||||
|
className="mx-2 mt-1 flex w-[calc(100%-1rem)] items-center justify-center gap-1 rounded-lg py-2 text-sm text-slate-400 hover:bg-slate-50 hover:text-slate-600 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" /> 添加书签
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
const getGreeting = (hour: number): string => {
|
||||||
|
if (hour >= 5 && hour < 12) return '早上好'
|
||||||
|
if (hour >= 12 && hour < 14) return '中午好'
|
||||||
|
if (hour >= 14 && hour < 18) return '下午好'
|
||||||
|
return '晚上好'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (date: Date): string => {
|
||||||
|
const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = date.getMonth() + 1
|
||||||
|
const day = date.getDate()
|
||||||
|
const weekday = weekdays[date.getDay()]
|
||||||
|
return `${year}年${month}月${day}日 ${weekday}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GreetingHeader = () => {
|
||||||
|
const [now, setNow] = useState(() => new Date())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => setNow(new Date()), 60_000)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 tracking-tight">{getGreeting(now.getHours())}</h2>
|
||||||
|
<p className="text-slate-500">{formatDate(now)}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import * as icons from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
const ICON_NAMES = [
|
||||||
|
'Globe',
|
||||||
|
'Home',
|
||||||
|
'Code',
|
||||||
|
'Database',
|
||||||
|
'Mail',
|
||||||
|
'MessageSquare',
|
||||||
|
'Music',
|
||||||
|
'Video',
|
||||||
|
'Image',
|
||||||
|
'FileText',
|
||||||
|
'Folder',
|
||||||
|
'Star',
|
||||||
|
'Heart',
|
||||||
|
'Bookmark',
|
||||||
|
'Search',
|
||||||
|
'Settings',
|
||||||
|
'User',
|
||||||
|
'Shield',
|
||||||
|
'Key',
|
||||||
|
'Terminal',
|
||||||
|
'Github',
|
||||||
|
'Chrome',
|
||||||
|
'Cpu',
|
||||||
|
'Server',
|
||||||
|
'Cloud',
|
||||||
|
'Wifi',
|
||||||
|
'Zap',
|
||||||
|
'Coffee',
|
||||||
|
'BookOpen',
|
||||||
|
'Briefcase',
|
||||||
|
'Calendar',
|
||||||
|
'Clock',
|
||||||
|
'Download',
|
||||||
|
'Edit',
|
||||||
|
'ExternalLink',
|
||||||
|
'Eye',
|
||||||
|
'Film',
|
||||||
|
'Gift',
|
||||||
|
'Headphones',
|
||||||
|
'Layout',
|
||||||
|
'Link',
|
||||||
|
'Map',
|
||||||
|
'Monitor',
|
||||||
|
'Package',
|
||||||
|
'Phone',
|
||||||
|
'ShoppingCart',
|
||||||
|
'Smartphone',
|
||||||
|
'Tv',
|
||||||
|
'Upload',
|
||||||
|
'Box',
|
||||||
|
'Compass',
|
||||||
|
'Rss',
|
||||||
|
'Camera',
|
||||||
|
'Printer',
|
||||||
|
'Layers',
|
||||||
|
'Activity',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const allIcons = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>
|
||||||
|
|
||||||
|
interface IconPickerProps {
|
||||||
|
value?: string | null
|
||||||
|
onChange: (iconName: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IconPicker = ({ value, onChange, onClose }: IconPickerProps) => {
|
||||||
|
const [filter, setFilter] = useState('')
|
||||||
|
|
||||||
|
const filtered = ICON_NAMES.filter((name) => name.toLowerCase().includes(filter.toLowerCase()))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute z-50 mt-1 w-72 rounded-xl bg-white p-3 shadow-lg ring-1 ring-slate-200">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
placeholder="搜索图标..."
|
||||||
|
className="mb-2 w-full rounded-lg bg-slate-50 px-3 py-2 text-sm ring-1 ring-slate-200 outline-none focus:ring-2 focus:ring-indigo-500/50"
|
||||||
|
// biome-ignore lint/a11y/noAutofocus: icon picker search needs immediate focus
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="grid max-h-48 grid-cols-8 gap-1 overflow-y-auto">
|
||||||
|
{filtered.map((name) => {
|
||||||
|
const Icon = allIcons[name]
|
||||||
|
if (!Icon) return null
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(name)
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-colors ${
|
||||||
|
value === name ? 'bg-indigo-100 text-indigo-600' : 'hover:bg-slate-100 text-slate-600'
|
||||||
|
}`}
|
||||||
|
title={name}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{filtered.length === 0 && <p className="py-4 text-center text-sm text-slate-400">未找到匹配图标</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Search } from 'lucide-react'
|
||||||
|
import type { FormEvent } from 'react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
const SEARCH_ENGINES: Record<string, string> = {
|
||||||
|
g: 'https://google.com/search?q=',
|
||||||
|
d: 'https://duckduckgo.com/?q=',
|
||||||
|
b: 'https://bing.com/search?q=',
|
||||||
|
gh: 'https://github.com/search?q=',
|
||||||
|
yt: 'https://youtube.com/results?search_query=',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_ENGINE = 'https://google.com/search?q='
|
||||||
|
|
||||||
|
const parseSearchQuery = (raw: string): string => {
|
||||||
|
const trimmed = raw.trim()
|
||||||
|
if (!trimmed) return ''
|
||||||
|
|
||||||
|
if (/^https?:\/\//i.test(trimmed) || /^[^\s]+\.[^\s]+$/.test(trimmed)) {
|
||||||
|
return trimmed.startsWith('http') ? trimmed : `https://${trimmed}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = trimmed.match(/^\/(\w+)\s+(.+)$/)
|
||||||
|
if (match) {
|
||||||
|
const prefix = match[1]?.toLowerCase() ?? ''
|
||||||
|
const query = match[2] ?? ''
|
||||||
|
const engine = SEARCH_ENGINES[prefix]
|
||||||
|
if (engine) return `${engine}${encodeURIComponent(query)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${DEFAULT_ENGINE}${encodeURIComponent(trimmed)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchBar = () => {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const url = parseSearchQuery(query)
|
||||||
|
if (url) {
|
||||||
|
window.location.href = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="w-full">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="搜索或输入 URL · /g Google · /gh GitHub · /yt YouTube"
|
||||||
|
className="w-full rounded-2xl bg-white py-4 pl-12 pr-6 shadow-[0_8px_30px_rgb(0,0,0,0.04)] ring-1 ring-slate-100 outline-none transition-all placeholder:text-slate-400 text-lg text-slate-700 focus:ring-2 focus:ring-indigo-500/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user