feat: added category crud options
f6294e21
10 file(s) · +905 −43
| 9 | 9 | "@evolu/react-web": "^1.0.1-preview.4", |
|
| 10 | 10 | "@radix-ui/react-avatar": "^1.1.10", |
|
| 11 | 11 | "@radix-ui/react-collapsible": "^1.1.12", |
|
| 12 | + | "@radix-ui/react-context-menu": "^2.2.16", |
|
| 12 | 13 | "@radix-ui/react-dialog": "^1.1.15", |
|
| 13 | 14 | "@radix-ui/react-dropdown-menu": "^2.1.16", |
|
| 14 | 15 | "@radix-ui/react-label": "^2.1.7", |
|
| 15 | 16 | "@radix-ui/react-popover": "^1.1.15", |
|
| 17 | + | "@radix-ui/react-select": "^2.2.6", |
|
| 16 | 18 | "@radix-ui/react-separator": "^1.1.7", |
|
| 17 | 19 | "@radix-ui/react-slot": "^1.2.3", |
|
| 18 | 20 | "@radix-ui/react-switch": "^1.2.6", |
|
| 181 | 183 | ||
| 182 | 184 | "@oxc-project/types": ["@oxc-project/types@0.93.0", "", {}, "sha512-yNtwmWZIBtJsMr5TEfoZFDxIWV6OdScOpza/f5YxbqUMJk+j6QX3Cf3jgZShGEFYWQJ5j9mJ6jM0tZHu2J9Yrg=="], |
|
| 183 | 185 | ||
| 186 | + | "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], |
|
| 187 | + | ||
| 184 | 188 | "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], |
|
| 185 | 189 | ||
| 186 | 190 | "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], |
|
| 194 | 198 | "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], |
|
| 195 | 199 | ||
| 196 | 200 | "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], |
|
| 201 | + | ||
| 202 | + | "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww=="], |
|
| 197 | 203 | ||
| 198 | 204 | "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], |
|
| 199 | 205 | ||
| 224 | 230 | "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], |
|
| 225 | 231 | ||
| 226 | 232 | "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], |
|
| 233 | + | ||
| 234 | + | "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="], |
|
| 227 | 235 | ||
| 228 | 236 | "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="], |
|
| 229 | 237 | ||
| 15 | 15 | "@evolu/react-web": "^1.0.1-preview.4", |
|
| 16 | 16 | "@radix-ui/react-avatar": "^1.1.10", |
|
| 17 | 17 | "@radix-ui/react-collapsible": "^1.1.12", |
|
| 18 | + | "@radix-ui/react-context-menu": "^2.2.16", |
|
| 18 | 19 | "@radix-ui/react-dialog": "^1.1.15", |
|
| 19 | 20 | "@radix-ui/react-dropdown-menu": "^2.1.16", |
|
| 20 | 21 | "@radix-ui/react-label": "^2.1.7", |
|
| 21 | 22 | "@radix-ui/react-popover": "^1.1.15", |
|
| 23 | + | "@radix-ui/react-select": "^2.2.6", |
|
| 22 | 24 | "@radix-ui/react-separator": "^1.1.7", |
|
| 23 | 25 | "@radix-ui/react-slot": "^1.2.3", |
|
| 24 | 26 | "@radix-ui/react-switch": "^1.2.6", |
| 5 | 5 | import { AddFeedDialog } from "@/components/add-feed-dialog"; |
|
| 6 | 6 | import { PostsList } from "@/components/posts-list"; |
|
| 7 | 7 | import { MobilePostsHeader } from "@/components/mobile-posts-header"; |
|
| 8 | + | import { CategoryEditDialog } from "@/components/category-edit-dialog"; |
|
| 9 | + | import { ChangeCategoryDialog } from "@/components/change-category-dialog"; |
|
| 8 | 10 | import { |
|
| 9 | 11 | Sidebar, |
|
| 10 | 12 | SidebarContent, |
|
| 50 | 52 | ...props |
|
| 51 | 53 | }: AppSidebarProps) { |
|
| 52 | 54 | const [dialogOpen, setDialogOpen] = React.useState(false); |
|
| 55 | + | const [categoryEditDialogOpen, setCategoryEditDialogOpen] = |
|
| 56 | + | React.useState(false); |
|
| 57 | + | const [changeCategoryDialogOpen, setChangeCategoryDialogOpen] = |
|
| 58 | + | React.useState(false); |
|
| 59 | + | const [categoryToEdit, setCategoryToEdit] = React.useState(""); |
|
| 53 | 60 | const [searchQuery, setSearchQuery] = React.useState(""); |
|
| 54 | 61 | const [mobileView, setMobileView] = React.useState<"feeds" | "posts">( |
|
| 55 | 62 | "feeds", |
|
| 285 | 292 | onFeedSelect(null); |
|
| 286 | 293 | }, [selectedFeedId, allFeeds, onFeedSelect]); |
|
| 287 | 294 | ||
| 295 | + | // Handle category edit from context menu |
|
| 296 | + | const handleCategoryEdit = React.useCallback((category: string) => { |
|
| 297 | + | setCategoryToEdit(category); |
|
| 298 | + | setCategoryEditDialogOpen(true); |
|
| 299 | + | }, []); |
|
| 300 | + | ||
| 301 | + | // Handle category delete from context menu |
|
| 302 | + | const handleCategoryDelete = React.useCallback( |
|
| 303 | + | (category: string) => { |
|
| 304 | + | // Find all feeds in this category |
|
| 305 | + | const feedsInCategory = allFeeds.filter((f) => f.category === category); |
|
| 306 | + | ||
| 307 | + | // Set category to null for all feeds in this category |
|
| 308 | + | feedsInCategory.forEach((feed) => { |
|
| 309 | + | evoluInstance.update("rssFeed", { |
|
| 310 | + | id: feed.id as any, |
|
| 311 | + | category: null, |
|
| 312 | + | }); |
|
| 313 | + | }); |
|
| 314 | + | ||
| 315 | + | toast.success( |
|
| 316 | + | `Removed category "${category}" from ${feedsInCategory.length} feed${feedsInCategory.length !== 1 ? "s" : ""}`, |
|
| 317 | + | ); |
|
| 318 | + | ||
| 319 | + | // Navigate back to all feeds |
|
| 320 | + | onFeedSelect(null); |
|
| 321 | + | }, |
|
| 322 | + | [allFeeds, onFeedSelect], |
|
| 323 | + | ); |
|
| 324 | + | ||
| 325 | + | // Handle category rename |
|
| 326 | + | const handleCategoryRename = React.useCallback( |
|
| 327 | + | (newName: string) => { |
|
| 328 | + | if (!categoryToEdit) return; |
|
| 329 | + | ||
| 330 | + | // Find all feeds in the old category |
|
| 331 | + | const feedsInCategory = allFeeds.filter( |
|
| 332 | + | (f) => f.category === categoryToEdit, |
|
| 333 | + | ); |
|
| 334 | + | ||
| 335 | + | // Update category for all feeds |
|
| 336 | + | feedsInCategory.forEach((feed) => { |
|
| 337 | + | evoluInstance.update("rssFeed", { |
|
| 338 | + | id: feed.id as any, |
|
| 339 | + | category: newName as any, |
|
| 340 | + | }); |
|
| 341 | + | }); |
|
| 342 | + | ||
| 343 | + | toast.success( |
|
| 344 | + | `Renamed category "${categoryToEdit}" to "${newName}" for ${feedsInCategory.length} feed${feedsInCategory.length !== 1 ? "s" : ""}`, |
|
| 345 | + | ); |
|
| 346 | + | }, |
|
| 347 | + | [categoryToEdit, allFeeds], |
|
| 348 | + | ); |
|
| 349 | + | ||
| 350 | + | // Handle change category for a specific feed |
|
| 351 | + | const handleChangeCategory = React.useCallback(() => { |
|
| 352 | + | const selectedFeed = allFeeds.find((f) => f.id === selectedFeedId); |
|
| 353 | + | if (!selectedFeed) return; |
|
| 354 | + | ||
| 355 | + | setCategoryToEdit(selectedFeed.category || ""); |
|
| 356 | + | setChangeCategoryDialogOpen(true); |
|
| 357 | + | }, [selectedFeedId, allFeeds]); |
|
| 358 | + | ||
| 359 | + | // Handle feed category change |
|
| 360 | + | const handleFeedCategoryChange = React.useCallback( |
|
| 361 | + | (newCategory: string | null) => { |
|
| 362 | + | if (!selectedFeedId) return; |
|
| 363 | + | ||
| 364 | + | const selectedFeed = allFeeds.find((f) => f.id === selectedFeedId); |
|
| 365 | + | if (!selectedFeed) return; |
|
| 366 | + | ||
| 367 | + | evoluInstance.update("rssFeed", { |
|
| 368 | + | id: selectedFeedId as any, |
|
| 369 | + | category: newCategory as any, |
|
| 370 | + | }); |
|
| 371 | + | ||
| 372 | + | const categoryText = newCategory ? `"${newCategory}"` : "Uncategorized"; |
|
| 373 | + | toast.success(`Moved feed to ${categoryText}`); |
|
| 374 | + | }, |
|
| 375 | + | [selectedFeedId, allFeeds], |
|
| 376 | + | ); |
|
| 377 | + | ||
| 288 | 378 | const refreshFeeds = React.useCallback(async () => { |
|
| 289 | 379 | if (allFeeds.length === 0) { |
|
| 290 | 380 | toast.error("No feeds to refresh"); |
|
| 373 | 463 | selectedFeedId === "unread" ? "Unread" : selectedFeed?.title || "All Posts"; |
|
| 374 | 464 | const selectedFeedCategory = selectedFeed?.category || null; |
|
| 375 | 465 | ||
| 466 | + | // Get list of existing categories for the change category dialog |
|
| 467 | + | const existingCategories = React.useMemo(() => { |
|
| 468 | + | const categories = new Set<string>(); |
|
| 469 | + | allFeeds.forEach((feed) => { |
|
| 470 | + | if (feed.category) { |
|
| 471 | + | categories.add(feed.category); |
|
| 472 | + | } |
|
| 473 | + | }); |
|
| 474 | + | return Array.from(categories).sort(); |
|
| 475 | + | }, [allFeeds]); |
|
| 476 | + | ||
| 376 | 477 | return ( |
|
| 377 | 478 | <> |
|
| 378 | 479 | <AddFeedDialog open={dialogOpen} onOpenChange={setDialogOpen} /> |
|
| 480 | + | <CategoryEditDialog |
|
| 481 | + | open={categoryEditDialogOpen} |
|
| 482 | + | onOpenChange={setCategoryEditDialogOpen} |
|
| 483 | + | currentCategory={categoryToEdit} |
|
| 484 | + | onRename={handleCategoryRename} |
|
| 485 | + | /> |
|
| 486 | + | <ChangeCategoryDialog |
|
| 487 | + | open={changeCategoryDialogOpen} |
|
| 488 | + | onOpenChange={setChangeCategoryDialogOpen} |
|
| 489 | + | currentCategory={categoryToEdit} |
|
| 490 | + | existingCategories={existingCategories} |
|
| 491 | + | onChangeCategory={handleFeedCategoryChange} |
|
| 492 | + | /> |
|
| 379 | 493 | ||
| 380 | 494 | <Sidebar collapsible="offcanvas" {...props}> |
|
| 381 | 495 | <SidebarHeader> |
|
| 412 | 526 | onMarkAllAsUnread={handleMarkAllAsUnread} |
|
| 413 | 527 | onDeleteFeed={handleDeleteFeed} |
|
| 414 | 528 | onDeleteCategory={handleDeleteCategory} |
|
| 529 | + | onChangeCategory={handleChangeCategory} |
|
| 415 | 530 | selectedFeedId={selectedFeedId} |
|
| 416 | 531 | selectedFeedCategory={selectedFeedCategory} |
|
| 417 | 532 | className="border-0" |
|
| 428 | 543 | feeds={allFeeds} |
|
| 429 | 544 | selectedFeedId={selectedFeedId} |
|
| 430 | 545 | onFeedSelect={handleFeedSelect} |
|
| 546 | + | onCategoryEdit={handleCategoryEdit} |
|
| 547 | + | onCategoryDelete={handleCategoryDelete} |
|
| 431 | 548 | /> |
|
| 432 | 549 | </> |
|
| 433 | 550 | )} |
|
| 450 | 567 | onMarkAllAsUnread={handleMarkAllAsUnread} |
|
| 451 | 568 | onDeleteFeed={handleDeleteFeed} |
|
| 452 | 569 | onDeleteCategory={handleDeleteCategory} |
|
| 570 | + | onChangeCategory={handleChangeCategory} |
|
| 453 | 571 | selectedFeedId={selectedFeedId} |
|
| 454 | 572 | selectedFeedCategory={selectedFeedCategory} |
|
| 455 | 573 | className={`bg-sidebar text-sidebar-foreground hidden md:flex ${hidden ? "w-0 min-w-0 border-0 overflow-hidden" : "w-[320px] overflow-y-auto"}`} |
|
| 1 | + | import * as React from "react"; |
|
| 2 | + | import { |
|
| 3 | + | Dialog, |
|
| 4 | + | DialogContent, |
|
| 5 | + | DialogDescription, |
|
| 6 | + | DialogFooter, |
|
| 7 | + | DialogHeader, |
|
| 8 | + | DialogTitle, |
|
| 9 | + | } from "@/components/ui/dialog"; |
|
| 10 | + | import { Button } from "@/components/ui/button"; |
|
| 11 | + | import { Input } from "@/components/ui/input"; |
|
| 12 | + | import { Label } from "@/components/ui/label"; |
|
| 13 | + | ||
| 14 | + | interface CategoryEditDialogProps { |
|
| 15 | + | open: boolean; |
|
| 16 | + | onOpenChange: (open: boolean) => void; |
|
| 17 | + | currentCategory: string; |
|
| 18 | + | onRename: (newName: string) => void; |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | export function CategoryEditDialog({ |
|
| 22 | + | open, |
|
| 23 | + | onOpenChange, |
|
| 24 | + | currentCategory, |
|
| 25 | + | onRename, |
|
| 26 | + | }: CategoryEditDialogProps) { |
|
| 27 | + | const [newName, setNewName] = React.useState(currentCategory); |
|
| 28 | + | ||
| 29 | + | React.useEffect(() => { |
|
| 30 | + | setNewName(currentCategory); |
|
| 31 | + | }, [currentCategory]); |
|
| 32 | + | ||
| 33 | + | const handleSubmit = (e: React.FormEvent) => { |
|
| 34 | + | e.preventDefault(); |
|
| 35 | + | if (newName.trim() && newName !== currentCategory) { |
|
| 36 | + | onRename(newName.trim()); |
|
| 37 | + | onOpenChange(false); |
|
| 38 | + | } |
|
| 39 | + | }; |
|
| 40 | + | ||
| 41 | + | return ( |
|
| 42 | + | <Dialog open={open} onOpenChange={onOpenChange}> |
|
| 43 | + | <DialogContent className="sm:max-w-[425px]"> |
|
| 44 | + | <DialogHeader> |
|
| 45 | + | <DialogTitle>Rename Category</DialogTitle> |
|
| 46 | + | <DialogDescription> |
|
| 47 | + | Rename "{currentCategory}" to a new name. This will update all feeds |
|
| 48 | + | in this category. |
|
| 49 | + | </DialogDescription> |
|
| 50 | + | </DialogHeader> |
|
| 51 | + | <form onSubmit={handleSubmit}> |
|
| 52 | + | <div className="grid gap-4 py-4"> |
|
| 53 | + | <div className="grid gap-2"> |
|
| 54 | + | <Label htmlFor="category-name">Category Name</Label> |
|
| 55 | + | <Input |
|
| 56 | + | id="category-name" |
|
| 57 | + | value={newName} |
|
| 58 | + | onChange={(e) => setNewName(e.target.value)} |
|
| 59 | + | placeholder="Enter category name" |
|
| 60 | + | maxLength={50} |
|
| 61 | + | autoFocus |
|
| 62 | + | /> |
|
| 63 | + | </div> |
|
| 64 | + | </div> |
|
| 65 | + | <DialogFooter> |
|
| 66 | + | <Button |
|
| 67 | + | type="button" |
|
| 68 | + | variant="outline" |
|
| 69 | + | onClick={() => onOpenChange(false)} |
|
| 70 | + | > |
|
| 71 | + | Cancel |
|
| 72 | + | </Button> |
|
| 73 | + | <Button type="submit" disabled={!newName.trim()}> |
|
| 74 | + | Rename |
|
| 75 | + | </Button> |
|
| 76 | + | </DialogFooter> |
|
| 77 | + | </form> |
|
| 78 | + | </DialogContent> |
|
| 79 | + | </Dialog> |
|
| 80 | + | ); |
|
| 81 | + | } |
| 1 | + | import * as React from "react"; |
|
| 2 | + | import { |
|
| 3 | + | Dialog, |
|
| 4 | + | DialogContent, |
|
| 5 | + | DialogDescription, |
|
| 6 | + | DialogFooter, |
|
| 7 | + | DialogHeader, |
|
| 8 | + | DialogTitle, |
|
| 9 | + | } from "@/components/ui/dialog"; |
|
| 10 | + | import { Button } from "@/components/ui/button"; |
|
| 11 | + | import { Input } from "@/components/ui/input"; |
|
| 12 | + | import { Label } from "@/components/ui/label"; |
|
| 13 | + | import { |
|
| 14 | + | Select, |
|
| 15 | + | SelectContent, |
|
| 16 | + | SelectItem, |
|
| 17 | + | SelectTrigger, |
|
| 18 | + | SelectValue, |
|
| 19 | + | } from "@/components/ui/select"; |
|
| 20 | + | ||
| 21 | + | interface ChangeCategoryDialogProps { |
|
| 22 | + | open: boolean; |
|
| 23 | + | onOpenChange: (open: boolean) => void; |
|
| 24 | + | currentCategory: string | null; |
|
| 25 | + | existingCategories: string[]; |
|
| 26 | + | onChangeCategory: (newCategory: string | null) => void; |
|
| 27 | + | } |
|
| 28 | + | ||
| 29 | + | export function ChangeCategoryDialog({ |
|
| 30 | + | open, |
|
| 31 | + | onOpenChange, |
|
| 32 | + | currentCategory, |
|
| 33 | + | existingCategories, |
|
| 34 | + | onChangeCategory, |
|
| 35 | + | }: ChangeCategoryDialogProps) { |
|
| 36 | + | const [mode, setMode] = React.useState<"existing" | "new">("existing"); |
|
| 37 | + | const [selectedCategory, setSelectedCategory] = React.useState<string>( |
|
| 38 | + | currentCategory || "", |
|
| 39 | + | ); |
|
| 40 | + | const [newCategory, setNewCategory] = React.useState(""); |
|
| 41 | + | ||
| 42 | + | React.useEffect(() => { |
|
| 43 | + | if (open) { |
|
| 44 | + | setMode("existing"); |
|
| 45 | + | setSelectedCategory(currentCategory || ""); |
|
| 46 | + | setNewCategory(""); |
|
| 47 | + | } |
|
| 48 | + | }, [open, currentCategory]); |
|
| 49 | + | ||
| 50 | + | const handleSubmit = (e: React.FormEvent) => { |
|
| 51 | + | e.preventDefault(); |
|
| 52 | + | ||
| 53 | + | if (mode === "new") { |
|
| 54 | + | if (newCategory.trim()) { |
|
| 55 | + | onChangeCategory(newCategory.trim()); |
|
| 56 | + | onOpenChange(false); |
|
| 57 | + | } |
|
| 58 | + | } else { |
|
| 59 | + | if (selectedCategory === "uncategorized") { |
|
| 60 | + | onChangeCategory(null); |
|
| 61 | + | } else if (selectedCategory) { |
|
| 62 | + | onChangeCategory(selectedCategory); |
|
| 63 | + | } |
|
| 64 | + | onOpenChange(false); |
|
| 65 | + | } |
|
| 66 | + | }; |
|
| 67 | + | ||
| 68 | + | // Filter out current category and "Uncategorized" from the list |
|
| 69 | + | const availableCategories = existingCategories.filter( |
|
| 70 | + | (cat) => cat !== "Uncategorized", |
|
| 71 | + | ); |
|
| 72 | + | ||
| 73 | + | return ( |
|
| 74 | + | <Dialog open={open} onOpenChange={onOpenChange}> |
|
| 75 | + | <DialogContent className="sm:max-w-[425px]"> |
|
| 76 | + | <DialogHeader> |
|
| 77 | + | <DialogTitle>Change Category</DialogTitle> |
|
| 78 | + | <DialogDescription> |
|
| 79 | + | Move this feed to a different category or create a new one. |
|
| 80 | + | </DialogDescription> |
|
| 81 | + | </DialogHeader> |
|
| 82 | + | <form onSubmit={handleSubmit}> |
|
| 83 | + | <div className="grid gap-4 py-4"> |
|
| 84 | + | <div className="grid gap-2"> |
|
| 85 | + | <Label>Choose an option</Label> |
|
| 86 | + | <div className="flex gap-2"> |
|
| 87 | + | <Button |
|
| 88 | + | type="button" |
|
| 89 | + | variant={mode === "existing" ? "default" : "outline"} |
|
| 90 | + | onClick={() => setMode("existing")} |
|
| 91 | + | className="flex-1" |
|
| 92 | + | > |
|
| 93 | + | Existing Category |
|
| 94 | + | </Button> |
|
| 95 | + | <Button |
|
| 96 | + | type="button" |
|
| 97 | + | variant={mode === "new" ? "default" : "outline"} |
|
| 98 | + | onClick={() => setMode("new")} |
|
| 99 | + | className="flex-1" |
|
| 100 | + | > |
|
| 101 | + | New Category |
|
| 102 | + | </Button> |
|
| 103 | + | </div> |
|
| 104 | + | </div> |
|
| 105 | + | ||
| 106 | + | {mode === "existing" ? ( |
|
| 107 | + | <div className="grid gap-2"> |
|
| 108 | + | <Label htmlFor="category-select">Select Category</Label> |
|
| 109 | + | <Select |
|
| 110 | + | value={selectedCategory} |
|
| 111 | + | onValueChange={setSelectedCategory} |
|
| 112 | + | > |
|
| 113 | + | <SelectTrigger id="category-select"> |
|
| 114 | + | <SelectValue placeholder="Select a category" /> |
|
| 115 | + | </SelectTrigger> |
|
| 116 | + | <SelectContent> |
|
| 117 | + | <SelectItem value="uncategorized">Uncategorized</SelectItem> |
|
| 118 | + | {availableCategories.map((cat) => ( |
|
| 119 | + | <SelectItem key={cat} value={cat}> |
|
| 120 | + | {cat} |
|
| 121 | + | </SelectItem> |
|
| 122 | + | ))} |
|
| 123 | + | </SelectContent> |
|
| 124 | + | </Select> |
|
| 125 | + | </div> |
|
| 126 | + | ) : ( |
|
| 127 | + | <div className="grid gap-2"> |
|
| 128 | + | <Label htmlFor="new-category">New Category Name</Label> |
|
| 129 | + | <Input |
|
| 130 | + | id="new-category" |
|
| 131 | + | value={newCategory} |
|
| 132 | + | onChange={(e) => setNewCategory(e.target.value)} |
|
| 133 | + | placeholder="Enter category name" |
|
| 134 | + | maxLength={50} |
|
| 135 | + | autoFocus |
|
| 136 | + | /> |
|
| 137 | + | </div> |
|
| 138 | + | )} |
|
| 139 | + | </div> |
|
| 140 | + | <DialogFooter> |
|
| 141 | + | <Button |
|
| 142 | + | type="button" |
|
| 143 | + | variant="outline" |
|
| 144 | + | onClick={() => onOpenChange(false)} |
|
| 145 | + | > |
|
| 146 | + | Cancel |
|
| 147 | + | </Button> |
|
| 148 | + | <Button |
|
| 149 | + | type="submit" |
|
| 150 | + | disabled={ |
|
| 151 | + | mode === "new" ? !newCategory.trim() : !selectedCategory |
|
| 152 | + | } |
|
| 153 | + | > |
|
| 154 | + | Change Category |
|
| 155 | + | </Button> |
|
| 156 | + | </DialogFooter> |
|
| 157 | + | </form> |
|
| 158 | + | </DialogContent> |
|
| 159 | + | </Dialog> |
|
| 160 | + | ); |
|
| 161 | + | } |
| 1 | - | import { ChevronRight } from "lucide-react"; |
|
| 1 | + | import { ChevronRight, Pencil, Trash2 } from "lucide-react"; |
|
| 2 | 2 | ||
| 3 | 3 | import { |
|
| 4 | 4 | Collapsible, |
|
| 15 | 15 | SidebarMenuSubButton, |
|
| 16 | 16 | SidebarMenuSubItem, |
|
| 17 | 17 | } from "@/components/ui/sidebar"; |
|
| 18 | + | import { |
|
| 19 | + | ContextMenu, |
|
| 20 | + | ContextMenuContent, |
|
| 21 | + | ContextMenuItem, |
|
| 22 | + | ContextMenuTrigger, |
|
| 23 | + | } from "@/components/ui/context-menu"; |
|
| 18 | 24 | ||
| 19 | 25 | interface Feed { |
|
| 20 | 26 | id: string; |
|
| 26 | 32 | feeds: readonly Feed[]; |
|
| 27 | 33 | selectedFeedId?: string | null; |
|
| 28 | 34 | onFeedSelect: (feedId: string | null) => void; |
|
| 35 | + | onCategoryEdit?: (category: string) => void; |
|
| 36 | + | onCategoryDelete?: (category: string) => void; |
|
| 29 | 37 | } |
|
| 30 | 38 | ||
| 31 | 39 | export function NavFeeds({ |
|
| 32 | 40 | feeds, |
|
| 33 | 41 | selectedFeedId, |
|
| 34 | 42 | onFeedSelect, |
|
| 43 | + | onCategoryEdit, |
|
| 44 | + | onCategoryDelete, |
|
| 35 | 45 | }: NavFeedsProps) { |
|
| 36 | 46 | // Group feeds by category |
|
| 37 | 47 | const feedsByCategory = feeds.reduce( |
|
| 80 | 90 | </SidebarMenuItem> |
|
| 81 | 91 | ||
| 82 | 92 | {/* Categories with feeds */} |
|
| 83 | - | {categories.map((category) => ( |
|
| 84 | - | <Collapsible |
|
| 85 | - | key={category} |
|
| 86 | - | asChild |
|
| 87 | - | defaultOpen={true} |
|
| 88 | - | className="group/collapsible" |
|
| 89 | - | > |
|
| 90 | - | <SidebarMenuItem> |
|
| 91 | - | <CollapsibleTrigger asChild> |
|
| 92 | - | <SidebarMenuButton tooltip={category}> |
|
| 93 | - | <span className="font-medium">{category}</span> |
|
| 94 | - | <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" /> |
|
| 95 | - | </SidebarMenuButton> |
|
| 96 | - | </CollapsibleTrigger> |
|
| 97 | - | <CollapsibleContent> |
|
| 98 | - | <SidebarMenuSub> |
|
| 99 | - | {feedsByCategory[category].map((feed) => ( |
|
| 100 | - | <SidebarMenuSubItem key={feed.id}> |
|
| 101 | - | <SidebarMenuSubButton |
|
| 102 | - | onClick={() => onFeedSelect(feed.id)} |
|
| 103 | - | isActive={selectedFeedId === feed.id} |
|
| 93 | + | {categories.map((category) => { |
|
| 94 | + | const isUncategorized = category === "Uncategorized"; |
|
| 95 | + | const categoryTrigger = ( |
|
| 96 | + | <CollapsibleTrigger asChild> |
|
| 97 | + | <SidebarMenuButton tooltip={category}> |
|
| 98 | + | <span className="font-medium">{category}</span> |
|
| 99 | + | <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" /> |
|
| 100 | + | </SidebarMenuButton> |
|
| 101 | + | </CollapsibleTrigger> |
|
| 102 | + | ); |
|
| 103 | + | ||
| 104 | + | return ( |
|
| 105 | + | <Collapsible |
|
| 106 | + | key={category} |
|
| 107 | + | asChild |
|
| 108 | + | defaultOpen={true} |
|
| 109 | + | className="group/collapsible" |
|
| 110 | + | > |
|
| 111 | + | <SidebarMenuItem> |
|
| 112 | + | {isUncategorized ? ( |
|
| 113 | + | categoryTrigger |
|
| 114 | + | ) : ( |
|
| 115 | + | <ContextMenu> |
|
| 116 | + | <ContextMenuTrigger asChild> |
|
| 117 | + | {categoryTrigger} |
|
| 118 | + | </ContextMenuTrigger> |
|
| 119 | + | <ContextMenuContent> |
|
| 120 | + | <ContextMenuItem |
|
| 121 | + | onClick={() => onCategoryEdit?.(category)} |
|
| 104 | 122 | > |
|
| 105 | - | <span>{feed.title || "Untitled"}</span> |
|
| 106 | - | </SidebarMenuSubButton> |
|
| 107 | - | </SidebarMenuSubItem> |
|
| 108 | - | ))} |
|
| 109 | - | </SidebarMenuSub> |
|
| 110 | - | </CollapsibleContent> |
|
| 111 | - | </SidebarMenuItem> |
|
| 112 | - | </Collapsible> |
|
| 113 | - | ))} |
|
| 123 | + | <Pencil className="h-4 w-4 mr-2" /> |
|
| 124 | + | Rename Category |
|
| 125 | + | </ContextMenuItem> |
|
| 126 | + | <ContextMenuItem |
|
| 127 | + | variant="destructive" |
|
| 128 | + | onClick={() => onCategoryDelete?.(category)} |
|
| 129 | + | > |
|
| 130 | + | <Trash2 className="h-4 w-4 mr-2" /> |
|
| 131 | + | Delete Category |
|
| 132 | + | </ContextMenuItem> |
|
| 133 | + | </ContextMenuContent> |
|
| 134 | + | </ContextMenu> |
|
| 135 | + | )} |
|
| 136 | + | <CollapsibleContent> |
|
| 137 | + | <SidebarMenuSub> |
|
| 138 | + | {feedsByCategory[category].map((feed) => ( |
|
| 139 | + | <SidebarMenuSubItem key={feed.id}> |
|
| 140 | + | <SidebarMenuSubButton |
|
| 141 | + | onClick={() => onFeedSelect(feed.id)} |
|
| 142 | + | isActive={selectedFeedId === feed.id} |
|
| 143 | + | > |
|
| 144 | + | <span>{feed.title || "Untitled"}</span> |
|
| 145 | + | </SidebarMenuSubButton> |
|
| 146 | + | </SidebarMenuSubItem> |
|
| 147 | + | ))} |
|
| 148 | + | </SidebarMenuSub> |
|
| 149 | + | </CollapsibleContent> |
|
| 150 | + | </SidebarMenuItem> |
|
| 151 | + | </Collapsible> |
|
| 152 | + | ); |
|
| 153 | + | })} |
|
| 114 | 154 | </SidebarMenu> |
|
| 115 | 155 | </SidebarGroup> |
|
| 116 | 156 | ); |
|
| 1 | - | import { MoreVertical, Check, X, Trash2, FolderX } from "lucide-react"; |
|
| 1 | + | import { |
|
| 2 | + | MoreVertical, |
|
| 3 | + | Check, |
|
| 4 | + | X, |
|
| 5 | + | Trash2, |
|
| 6 | + | FolderX, |
|
| 7 | + | FolderEdit, |
|
| 8 | + | } from "lucide-react"; |
|
| 2 | 9 | import { Button } from "@/components/ui/button"; |
|
| 3 | 10 | import { |
|
| 4 | 11 | DropdownMenu, |
|
| 31 | 38 | onMarkAllAsUnread: () => void; |
|
| 32 | 39 | onDeleteFeed?: () => void; |
|
| 33 | 40 | onDeleteCategory?: () => void; |
|
| 41 | + | onChangeCategory?: () => void; |
|
| 34 | 42 | selectedFeedId?: string | null; |
|
| 35 | 43 | selectedFeedCategory?: string | null; |
|
| 36 | 44 | className?: string; |
|
| 48 | 56 | onMarkAllAsUnread, |
|
| 49 | 57 | onDeleteFeed, |
|
| 50 | 58 | onDeleteCategory, |
|
| 59 | + | onChangeCategory, |
|
| 51 | 60 | selectedFeedId, |
|
| 52 | 61 | selectedFeedCategory, |
|
| 53 | 62 | className = "", |
|
| 78 | 87 | <X className="h-4 w-4 mr-2" /> |
|
| 79 | 88 | Mark all as unread |
|
| 80 | 89 | </DropdownMenuItem> |
|
| 81 | - | {selectedFeedId && onDeleteFeed && ( |
|
| 82 | - | <DropdownMenuItem |
|
| 83 | - | onClick={onDeleteFeed} |
|
| 84 | - | className="text-destructive focus:text-destructive" |
|
| 85 | - | > |
|
| 86 | - | <Trash2 className="h-4 w-4 mr-2" /> |
|
| 87 | - | Delete feed |
|
| 88 | - | </DropdownMenuItem> |
|
| 89 | - | )} |
|
| 90 | + | {selectedFeedId && |
|
| 91 | + | selectedFeedId !== "unread" && |
|
| 92 | + | onChangeCategory && ( |
|
| 93 | + | <DropdownMenuItem onClick={onChangeCategory}> |
|
| 94 | + | <FolderEdit className="h-4 w-4 mr-2" /> |
|
| 95 | + | Change category |
|
| 96 | + | </DropdownMenuItem> |
|
| 97 | + | )} |
|
| 98 | + | {selectedFeedId && |
|
| 99 | + | selectedFeedId !== "unread" && |
|
| 100 | + | onDeleteFeed && ( |
|
| 101 | + | <DropdownMenuItem |
|
| 102 | + | onClick={onDeleteFeed} |
|
| 103 | + | className="text-destructive focus:text-destructive" |
|
| 104 | + | > |
|
| 105 | + | <Trash2 className="h-4 w-4 mr-2" /> |
|
| 106 | + | Delete feed |
|
| 107 | + | </DropdownMenuItem> |
|
| 108 | + | )} |
|
| 90 | 109 | {selectedFeedCategory && onDeleteCategory && ( |
|
| 91 | 110 | <DropdownMenuItem |
|
| 92 | 111 | onClick={onDeleteCategory} |
|
| 1 | + | import * as React from "react" |
|
| 2 | + | import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" |
|
| 3 | + | import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" |
|
| 4 | + | ||
| 5 | + | import { cn } from "@/lib/utils" |
|
| 6 | + | ||
| 7 | + | function ContextMenu({ |
|
| 8 | + | ...props |
|
| 9 | + | }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) { |
|
| 10 | + | return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} /> |
|
| 11 | + | } |
|
| 12 | + | ||
| 13 | + | function ContextMenuTrigger({ |
|
| 14 | + | ...props |
|
| 15 | + | }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) { |
|
| 16 | + | return ( |
|
| 17 | + | <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} /> |
|
| 18 | + | ) |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | function ContextMenuGroup({ |
|
| 22 | + | ...props |
|
| 23 | + | }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) { |
|
| 24 | + | return ( |
|
| 25 | + | <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} /> |
|
| 26 | + | ) |
|
| 27 | + | } |
|
| 28 | + | ||
| 29 | + | function ContextMenuPortal({ |
|
| 30 | + | ...props |
|
| 31 | + | }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) { |
|
| 32 | + | return ( |
|
| 33 | + | <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} /> |
|
| 34 | + | ) |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | function ContextMenuSub({ |
|
| 38 | + | ...props |
|
| 39 | + | }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) { |
|
| 40 | + | return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} /> |
|
| 41 | + | } |
|
| 42 | + | ||
| 43 | + | function ContextMenuRadioGroup({ |
|
| 44 | + | ...props |
|
| 45 | + | }: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) { |
|
| 46 | + | return ( |
|
| 47 | + | <ContextMenuPrimitive.RadioGroup |
|
| 48 | + | data-slot="context-menu-radio-group" |
|
| 49 | + | {...props} |
|
| 50 | + | /> |
|
| 51 | + | ) |
|
| 52 | + | } |
|
| 53 | + | ||
| 54 | + | function ContextMenuSubTrigger({ |
|
| 55 | + | className, |
|
| 56 | + | inset, |
|
| 57 | + | children, |
|
| 58 | + | ...props |
|
| 59 | + | }: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & { |
|
| 60 | + | inset?: boolean |
|
| 61 | + | }) { |
|
| 62 | + | return ( |
|
| 63 | + | <ContextMenuPrimitive.SubTrigger |
|
| 64 | + | data-slot="context-menu-sub-trigger" |
|
| 65 | + | data-inset={inset} |
|
| 66 | + | className={cn( |
|
| 67 | + | "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", |
|
| 68 | + | className |
|
| 69 | + | )} |
|
| 70 | + | {...props} |
|
| 71 | + | > |
|
| 72 | + | {children} |
|
| 73 | + | <ChevronRightIcon className="ml-auto" /> |
|
| 74 | + | </ContextMenuPrimitive.SubTrigger> |
|
| 75 | + | ) |
|
| 76 | + | } |
|
| 77 | + | ||
| 78 | + | function ContextMenuSubContent({ |
|
| 79 | + | className, |
|
| 80 | + | ...props |
|
| 81 | + | }: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) { |
|
| 82 | + | return ( |
|
| 83 | + | <ContextMenuPrimitive.SubContent |
|
| 84 | + | data-slot="context-menu-sub-content" |
|
| 85 | + | className={cn( |
|
| 86 | + | "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg", |
|
| 87 | + | className |
|
| 88 | + | )} |
|
| 89 | + | {...props} |
|
| 90 | + | /> |
|
| 91 | + | ) |
|
| 92 | + | } |
|
| 93 | + | ||
| 94 | + | function ContextMenuContent({ |
|
| 95 | + | className, |
|
| 96 | + | ...props |
|
| 97 | + | }: React.ComponentProps<typeof ContextMenuPrimitive.Content>) { |
|
| 98 | + | return ( |
|
| 99 | + | <ContextMenuPrimitive.Portal> |
|
| 100 | + | <ContextMenuPrimitive.Content |
|
| 101 | + | data-slot="context-menu-content" |
|
| 102 | + | className={cn( |
|
| 103 | + | "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", |
|
| 104 | + | className |
|
| 105 | + | )} |
|
| 106 | + | {...props} |
|
| 107 | + | /> |
|
| 108 | + | </ContextMenuPrimitive.Portal> |
|
| 109 | + | ) |
|
| 110 | + | } |
|
| 111 | + | ||
| 112 | + | function ContextMenuItem({ |
|
| 113 | + | className, |
|
| 114 | + | inset, |
|
| 115 | + | variant = "default", |
|
| 116 | + | ...props |
|
| 117 | + | }: React.ComponentProps<typeof ContextMenuPrimitive.Item> & { |
|
| 118 | + | inset?: boolean |
|
| 119 | + | variant?: "default" | "destructive" |
|
| 120 | + | }) { |
|
| 121 | + | return ( |
|
| 122 | + | <ContextMenuPrimitive.Item |
|
| 123 | + | data-slot="context-menu-item" |
|
| 124 | + | data-inset={inset} |
|
| 125 | + | data-variant={variant} |
|
| 126 | + | className={cn( |
|
| 127 | + | "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", |
|
| 128 | + | className |
|
| 129 | + | )} |
|
| 130 | + | {...props} |
|
| 131 | + | /> |
|
| 132 | + | ) |
|
| 133 | + | } |
|
| 134 | + | ||
| 135 | + | function ContextMenuCheckboxItem({ |
|
| 136 | + | className, |
|
| 137 | + | children, |
|
| 138 | + | checked, |
|
| 139 | + | ...props |
|
| 140 | + | }: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) { |
|
| 141 | + | return ( |
|
| 142 | + | <ContextMenuPrimitive.CheckboxItem |
|
| 143 | + | data-slot="context-menu-checkbox-item" |
|
| 144 | + | className={cn( |
|
| 145 | + | "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", |
|
| 146 | + | className |
|
| 147 | + | )} |
|
| 148 | + | checked={checked} |
|
| 149 | + | {...props} |
|
| 150 | + | > |
|
| 151 | + | <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> |
|
| 152 | + | <ContextMenuPrimitive.ItemIndicator> |
|
| 153 | + | <CheckIcon className="size-4" /> |
|
| 154 | + | </ContextMenuPrimitive.ItemIndicator> |
|
| 155 | + | </span> |
|
| 156 | + | {children} |
|
| 157 | + | </ContextMenuPrimitive.CheckboxItem> |
|
| 158 | + | ) |
|
| 159 | + | } |
|
| 160 | + | ||
| 161 | + | function ContextMenuRadioItem({ |
|
| 162 | + | className, |
|
| 163 | + | children, |
|
| 164 | + | ...props |
|
| 165 | + | }: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) { |
|
| 166 | + | return ( |
|
| 167 | + | <ContextMenuPrimitive.RadioItem |
|
| 168 | + | data-slot="context-menu-radio-item" |
|
| 169 | + | className={cn( |
|
| 170 | + | "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", |
|
| 171 | + | className |
|
| 172 | + | )} |
|
| 173 | + | {...props} |
|
| 174 | + | > |
|
| 175 | + | <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> |
|
| 176 | + | <ContextMenuPrimitive.ItemIndicator> |
|
| 177 | + | <CircleIcon className="size-2 fill-current" /> |
|
| 178 | + | </ContextMenuPrimitive.ItemIndicator> |
|
| 179 | + | </span> |
|
| 180 | + | {children} |
|
| 181 | + | </ContextMenuPrimitive.RadioItem> |
|
| 182 | + | ) |
|
| 183 | + | } |
|
| 184 | + | ||
| 185 | + | function ContextMenuLabel({ |
|
| 186 | + | className, |
|
| 187 | + | inset, |
|
| 188 | + | ...props |
|
| 189 | + | }: React.ComponentProps<typeof ContextMenuPrimitive.Label> & { |
|
| 190 | + | inset?: boolean |
|
| 191 | + | }) { |
|
| 192 | + | return ( |
|
| 193 | + | <ContextMenuPrimitive.Label |
|
| 194 | + | data-slot="context-menu-label" |
|
| 195 | + | data-inset={inset} |
|
| 196 | + | className={cn( |
|
| 197 | + | "text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", |
|
| 198 | + | className |
|
| 199 | + | )} |
|
| 200 | + | {...props} |
|
| 201 | + | /> |
|
| 202 | + | ) |
|
| 203 | + | } |
|
| 204 | + | ||
| 205 | + | function ContextMenuSeparator({ |
|
| 206 | + | className, |
|
| 207 | + | ...props |
|
| 208 | + | }: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) { |
|
| 209 | + | return ( |
|
| 210 | + | <ContextMenuPrimitive.Separator |
|
| 211 | + | data-slot="context-menu-separator" |
|
| 212 | + | className={cn("bg-border -mx-1 my-1 h-px", className)} |
|
| 213 | + | {...props} |
|
| 214 | + | /> |
|
| 215 | + | ) |
|
| 216 | + | } |
|
| 217 | + | ||
| 218 | + | function ContextMenuShortcut({ |
|
| 219 | + | className, |
|
| 220 | + | ...props |
|
| 221 | + | }: React.ComponentProps<"span">) { |
|
| 222 | + | return ( |
|
| 223 | + | <span |
|
| 224 | + | data-slot="context-menu-shortcut" |
|
| 225 | + | className={cn( |
|
| 226 | + | "text-muted-foreground ml-auto text-xs tracking-widest", |
|
| 227 | + | className |
|
| 228 | + | )} |
|
| 229 | + | {...props} |
|
| 230 | + | /> |
|
| 231 | + | ) |
|
| 232 | + | } |
|
| 233 | + | ||
| 234 | + | export { |
|
| 235 | + | ContextMenu, |
|
| 236 | + | ContextMenuTrigger, |
|
| 237 | + | ContextMenuContent, |
|
| 238 | + | ContextMenuItem, |
|
| 239 | + | ContextMenuCheckboxItem, |
|
| 240 | + | ContextMenuRadioItem, |
|
| 241 | + | ContextMenuLabel, |
|
| 242 | + | ContextMenuSeparator, |
|
| 243 | + | ContextMenuShortcut, |
|
| 244 | + | ContextMenuGroup, |
|
| 245 | + | ContextMenuPortal, |
|
| 246 | + | ContextMenuSub, |
|
| 247 | + | ContextMenuSubContent, |
|
| 248 | + | ContextMenuSubTrigger, |
|
| 249 | + | ContextMenuRadioGroup, |
|
| 250 | + | } |
| 1 | + | import * as React from "react" |
|
| 2 | + | import * as SelectPrimitive from "@radix-ui/react-select" |
|
| 3 | + | import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" |
|
| 4 | + | ||
| 5 | + | import { cn } from "@/lib/utils" |
|
| 6 | + | ||
| 7 | + | function Select({ |
|
| 8 | + | ...props |
|
| 9 | + | }: React.ComponentProps<typeof SelectPrimitive.Root>) { |
|
| 10 | + | return <SelectPrimitive.Root data-slot="select" {...props} /> |
|
| 11 | + | } |
|
| 12 | + | ||
| 13 | + | function SelectGroup({ |
|
| 14 | + | ...props |
|
| 15 | + | }: React.ComponentProps<typeof SelectPrimitive.Group>) { |
|
| 16 | + | return <SelectPrimitive.Group data-slot="select-group" {...props} /> |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | function SelectValue({ |
|
| 20 | + | ...props |
|
| 21 | + | }: React.ComponentProps<typeof SelectPrimitive.Value>) { |
|
| 22 | + | return <SelectPrimitive.Value data-slot="select-value" {...props} /> |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | function SelectTrigger({ |
|
| 26 | + | className, |
|
| 27 | + | size = "default", |
|
| 28 | + | children, |
|
| 29 | + | ...props |
|
| 30 | + | }: React.ComponentProps<typeof SelectPrimitive.Trigger> & { |
|
| 31 | + | size?: "sm" | "default" |
|
| 32 | + | }) { |
|
| 33 | + | return ( |
|
| 34 | + | <SelectPrimitive.Trigger |
|
| 35 | + | data-slot="select-trigger" |
|
| 36 | + | data-size={size} |
|
| 37 | + | className={cn( |
|
| 38 | + | "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", |
|
| 39 | + | className |
|
| 40 | + | )} |
|
| 41 | + | {...props} |
|
| 42 | + | > |
|
| 43 | + | {children} |
|
| 44 | + | <SelectPrimitive.Icon asChild> |
|
| 45 | + | <ChevronDownIcon className="size-4 opacity-50" /> |
|
| 46 | + | </SelectPrimitive.Icon> |
|
| 47 | + | </SelectPrimitive.Trigger> |
|
| 48 | + | ) |
|
| 49 | + | } |
|
| 50 | + | ||
| 51 | + | function SelectContent({ |
|
| 52 | + | className, |
|
| 53 | + | children, |
|
| 54 | + | position = "popper", |
|
| 55 | + | align = "center", |
|
| 56 | + | ...props |
|
| 57 | + | }: React.ComponentProps<typeof SelectPrimitive.Content>) { |
|
| 58 | + | return ( |
|
| 59 | + | <SelectPrimitive.Portal> |
|
| 60 | + | <SelectPrimitive.Content |
|
| 61 | + | data-slot="select-content" |
|
| 62 | + | className={cn( |
|
| 63 | + | "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md", |
|
| 64 | + | position === "popper" && |
|
| 65 | + | "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", |
|
| 66 | + | className |
|
| 67 | + | )} |
|
| 68 | + | position={position} |
|
| 69 | + | align={align} |
|
| 70 | + | {...props} |
|
| 71 | + | > |
|
| 72 | + | <SelectScrollUpButton /> |
|
| 73 | + | <SelectPrimitive.Viewport |
|
| 74 | + | className={cn( |
|
| 75 | + | "p-1", |
|
| 76 | + | position === "popper" && |
|
| 77 | + | "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1" |
|
| 78 | + | )} |
|
| 79 | + | > |
|
| 80 | + | {children} |
|
| 81 | + | </SelectPrimitive.Viewport> |
|
| 82 | + | <SelectScrollDownButton /> |
|
| 83 | + | </SelectPrimitive.Content> |
|
| 84 | + | </SelectPrimitive.Portal> |
|
| 85 | + | ) |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | function SelectLabel({ |
|
| 89 | + | className, |
|
| 90 | + | ...props |
|
| 91 | + | }: React.ComponentProps<typeof SelectPrimitive.Label>) { |
|
| 92 | + | return ( |
|
| 93 | + | <SelectPrimitive.Label |
|
| 94 | + | data-slot="select-label" |
|
| 95 | + | className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} |
|
| 96 | + | {...props} |
|
| 97 | + | /> |
|
| 98 | + | ) |
|
| 99 | + | } |
|
| 100 | + | ||
| 101 | + | function SelectItem({ |
|
| 102 | + | className, |
|
| 103 | + | children, |
|
| 104 | + | ...props |
|
| 105 | + | }: React.ComponentProps<typeof SelectPrimitive.Item>) { |
|
| 106 | + | return ( |
|
| 107 | + | <SelectPrimitive.Item |
|
| 108 | + | data-slot="select-item" |
|
| 109 | + | className={cn( |
|
| 110 | + | "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", |
|
| 111 | + | className |
|
| 112 | + | )} |
|
| 113 | + | {...props} |
|
| 114 | + | > |
|
| 115 | + | <span className="absolute right-2 flex size-3.5 items-center justify-center"> |
|
| 116 | + | <SelectPrimitive.ItemIndicator> |
|
| 117 | + | <CheckIcon className="size-4" /> |
|
| 118 | + | </SelectPrimitive.ItemIndicator> |
|
| 119 | + | </span> |
|
| 120 | + | <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> |
|
| 121 | + | </SelectPrimitive.Item> |
|
| 122 | + | ) |
|
| 123 | + | } |
|
| 124 | + | ||
| 125 | + | function SelectSeparator({ |
|
| 126 | + | className, |
|
| 127 | + | ...props |
|
| 128 | + | }: React.ComponentProps<typeof SelectPrimitive.Separator>) { |
|
| 129 | + | return ( |
|
| 130 | + | <SelectPrimitive.Separator |
|
| 131 | + | data-slot="select-separator" |
|
| 132 | + | className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} |
|
| 133 | + | {...props} |
|
| 134 | + | /> |
|
| 135 | + | ) |
|
| 136 | + | } |
|
| 137 | + | ||
| 138 | + | function SelectScrollUpButton({ |
|
| 139 | + | className, |
|
| 140 | + | ...props |
|
| 141 | + | }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) { |
|
| 142 | + | return ( |
|
| 143 | + | <SelectPrimitive.ScrollUpButton |
|
| 144 | + | data-slot="select-scroll-up-button" |
|
| 145 | + | className={cn( |
|
| 146 | + | "flex cursor-default items-center justify-center py-1", |
|
| 147 | + | className |
|
| 148 | + | )} |
|
| 149 | + | {...props} |
|
| 150 | + | > |
|
| 151 | + | <ChevronUpIcon className="size-4" /> |
|
| 152 | + | </SelectPrimitive.ScrollUpButton> |
|
| 153 | + | ) |
|
| 154 | + | } |
|
| 155 | + | ||
| 156 | + | function SelectScrollDownButton({ |
|
| 157 | + | className, |
|
| 158 | + | ...props |
|
| 159 | + | }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) { |
|
| 160 | + | return ( |
|
| 161 | + | <SelectPrimitive.ScrollDownButton |
|
| 162 | + | data-slot="select-scroll-down-button" |
|
| 163 | + | className={cn( |
|
| 164 | + | "flex cursor-default items-center justify-center py-1", |
|
| 165 | + | className |
|
| 166 | + | )} |
|
| 167 | + | {...props} |
|
| 168 | + | > |
|
| 169 | + | <ChevronDownIcon className="size-4" /> |
|
| 170 | + | </SelectPrimitive.ScrollDownButton> |
|
| 171 | + | ) |
|
| 172 | + | } |
|
| 173 | + | ||
| 174 | + | export { |
|
| 175 | + | Select, |
|
| 176 | + | SelectContent, |
|
| 177 | + | SelectGroup, |
|
| 178 | + | SelectItem, |
|
| 179 | + | SelectLabel, |
|
| 180 | + | SelectScrollDownButton, |
|
| 181 | + | SelectScrollUpButton, |
|
| 182 | + | SelectSeparator, |
|
| 183 | + | SelectTrigger, |
|
| 184 | + | SelectValue, |
|
| 185 | + | } |
| 150 | 150 | url: string, |
|
| 151 | 151 | ): Promise<string | null> { |
|
| 152 | 152 | try { |
|
| 153 | - | const urlObj = new URL(url); |
|
| 154 | - | ||
| 155 | 153 | // Direct channel ID format |
|
| 156 | 154 | if (url.includes("/channel/")) { |
|
| 157 | 155 | const match = url.match(/\/channel\/([^/?]+)/); |