feat: added category crud options f6294e21
Steve · 2025-11-06 21:57 10 file(s) · +905 −43
bun.lock +8 −0
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
package.json +2 −0
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",
src/components/app-sidebar.tsx +118 −0
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"}`}
src/components/category-edit-dialog.tsx (added) +81 −0
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 +
}
src/components/change-category-dialog.tsx (added) +161 −0
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 +
}
src/components/nav-feeds.tsx +71 −31
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
	);
src/components/posts-list.tsx +29 −10
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}
src/components/ui/context-menu.tsx (added) +250 −0
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 +
}
src/components/ui/select.tsx (added) +185 −0
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 +
}
src/lib/feed-operations.ts +0 −2
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\/([^/?]+)/);