diff --git a/apps/server/src/modules/bookmarks/components/BookmarkItem.tsx b/apps/server/src/modules/bookmarks/components/BookmarkItem.tsx new file mode 100644 index 0000000..0a54e4d --- /dev/null +++ b/apps/server/src/modules/bookmarks/components/BookmarkItem.tsx @@ -0,0 +1,61 @@ +import * as icons from 'lucide-react' +import { GripVertical, Pencil, Trash2 } from 'lucide-react' + +const allIcons = icons as unknown as Record> + +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 ( +
+
+ +
+ +
+ +
+ + +

{bookmark.name}

+

{bookmark.url}

+
+ +
+ + +
+
+ ) +} diff --git a/apps/server/src/modules/bookmarks/components/CategorySection.tsx b/apps/server/src/modules/bookmarks/components/CategorySection.tsx new file mode 100644 index 0000000..7a220ab --- /dev/null +++ b/apps/server/src/modules/bookmarks/components/CategorySection.tsx @@ -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 ( + + ) +} + +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(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['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 ( +
+
+
+ + {editingName ? ( + 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 + /> + ) : ( +

{category.name}

+ )} +
+ +
+ + {showMenu && ( +
+ + +
+ )} +
+
+ +
+ + {items.map((bm, idx) => ( + handleEditBookmark(bm)} + onDelete={() => deleteBookmark.mutate({ id: bm.id })} + /> + ))} + + + {items.length === 0 && !showAddForm &&

暂无书签

} + + {editingBookmark && ( +
+
+ 编辑书签 + +
+ 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" + /> + 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" + /> +
+ + {showEditIconPicker && ( + setEditForm((p) => ({ ...p, icon }))} + onClose={() => setShowEditIconPicker(false)} + /> + )} +
+ +
+ )} + + {showAddForm ? ( +
+
+ 添加书签 + +
+ 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" + /> + 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" + /> +
+ + {showIconPicker && ( + setNewBookmark((p) => ({ ...p, icon }))} + onClose={() => setShowIconPicker(false)} + /> + )} +
+ +
+ ) : ( + + )} +
+
+ ) +} diff --git a/apps/server/src/modules/bookmarks/components/GreetingHeader.tsx b/apps/server/src/modules/bookmarks/components/GreetingHeader.tsx new file mode 100644 index 0000000..5a30962 --- /dev/null +++ b/apps/server/src/modules/bookmarks/components/GreetingHeader.tsx @@ -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 ( +
+

{getGreeting(now.getHours())}

+

{formatDate(now)}

+
+ ) +} diff --git a/apps/server/src/modules/bookmarks/components/IconPicker.tsx b/apps/server/src/modules/bookmarks/components/IconPicker.tsx new file mode 100644 index 0000000..451788c --- /dev/null +++ b/apps/server/src/modules/bookmarks/components/IconPicker.tsx @@ -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> + +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 ( +
+ 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 + /> +
+ {filtered.map((name) => { + const Icon = allIcons[name] + if (!Icon) return null + return ( + + ) + })} +
+ {filtered.length === 0 &&

未找到匹配图标

} +
+ ) +} diff --git a/apps/server/src/modules/bookmarks/components/SearchBar.tsx b/apps/server/src/modules/bookmarks/components/SearchBar.tsx new file mode 100644 index 0000000..f66d44f --- /dev/null +++ b/apps/server/src/modules/bookmarks/components/SearchBar.tsx @@ -0,0 +1,59 @@ +import { Search } from 'lucide-react' +import type { FormEvent } from 'react' +import { useState } from 'react' + +const SEARCH_ENGINES: Record = { + 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 ( +
+
+ + 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" + /> +
+
+ ) +}