mirror of
https://github.com/0x2E/fusion.git
synced 2026-05-19 18:30:35 +00:00
new design
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(cat:*)",
|
||||
"Bash(npx tsc:*)"
|
||||
"Bash(npx tsc:*)",
|
||||
"WebFetch(domain:registry.npmjs.org)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,5 +30,5 @@
|
||||
|
||||
## Frontend Development
|
||||
|
||||
- After editing TypeScript/TSX code, run type checking: `npx tsc --noEmit`.
|
||||
- Verify TypeScript/TSX compilation: `npx tsc -b --noEmit`.
|
||||
- DO NOT modify shadcn component source files directly.
|
||||
|
||||
@@ -26,30 +26,3 @@ pnpm build
|
||||
# Preview production build
|
||||
pnpm preview
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui components
|
||||
│ ├── layout/ # Layout components
|
||||
│ ├── feed/ # Feed-related components
|
||||
│ ├── item/ # Item-related components
|
||||
│ └── settings/ # Settings components
|
||||
├── routes/ # TanStack Router routes
|
||||
│ ├── __root.tsx # Root layout
|
||||
│ └── index.tsx # Main view
|
||||
├── lib/
|
||||
│ ├── api/ # API client
|
||||
│ └── utils.ts # Utility functions
|
||||
├── store/
|
||||
│ └── app.ts # Zustand store
|
||||
└── main.tsx # Application entry point
|
||||
```
|
||||
|
||||
## Development Status
|
||||
|
||||
Phase 2.1 (Project Initialization) - ✅ Complete
|
||||
|
||||
Next steps: Implement base infrastructure (Phase 2.2)
|
||||
|
||||
+18
-18
@@ -26,38 +26,38 @@
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tanstack/react-router": "^1.143.11",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-router": "^1.157.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dompurify": "^3.3.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"shadcn": "^3.6.2",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"shadcn": "^3.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zustand": "^5.0.9"
|
||||
"zustand": "^5.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tanstack/router-devtools": "^1.143.11",
|
||||
"@tanstack/router-vite-plugin": "^1.143.11",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@tanstack/router-devtools": "^1.157.5",
|
||||
"@tanstack/router-vite-plugin": "^1.157.5",
|
||||
"@types/node": "^24.10.9",
|
||||
"@types/react": "^19.2.9",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
"typescript-eslint": "^8.53.1",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1133
-1112
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -1,184 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useDataStore } from "@/store/data";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { Check, Circle, ExternalLink, Star } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function ArticleDrawer() {
|
||||
const navigate = useNavigate();
|
||||
const search = useSearch({ from: "/" });
|
||||
const {
|
||||
items,
|
||||
feeds,
|
||||
bookmarks,
|
||||
markItemsRead,
|
||||
markItemsUnread,
|
||||
createBookmark,
|
||||
deleteBookmark,
|
||||
} = useDataStore();
|
||||
const [isCreatingBookmark, setIsCreatingBookmark] = useState(false);
|
||||
|
||||
const itemId = search.item;
|
||||
const open = !!itemId;
|
||||
const article = items.find((item) => item.id === itemId);
|
||||
const feed = article
|
||||
? feeds.find((f) => f.id === article.feed_id)
|
||||
: undefined;
|
||||
const bookmark = article
|
||||
? bookmarks.find((b) => b.item_id === article.id)
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (article && article.unread) {
|
||||
markItemsRead([article.id]);
|
||||
}
|
||||
}, [article, markItemsRead]);
|
||||
|
||||
const handleClose = () => {
|
||||
navigate({
|
||||
to: "/",
|
||||
search: {
|
||||
feed: search.feed,
|
||||
group: search.group,
|
||||
filter: search.filter,
|
||||
search: search.search,
|
||||
settings: search.settings,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleRead = async () => {
|
||||
if (!article) return;
|
||||
if (article.unread) {
|
||||
await markItemsRead([article.id]);
|
||||
} else {
|
||||
await markItemsUnread([article.id]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleBookmark = async () => {
|
||||
if (!article) return;
|
||||
setIsCreatingBookmark(true);
|
||||
try {
|
||||
if (bookmark) {
|
||||
await deleteBookmark(bookmark.id);
|
||||
} else {
|
||||
await createBookmark({
|
||||
item_id: article.id,
|
||||
link: article.link,
|
||||
title: article.title,
|
||||
content: article.content,
|
||||
pub_date: article.pub_date,
|
||||
feed_name: feed?.name || "Unknown",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsCreatingBookmark(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenLink = () => {
|
||||
if (article) {
|
||||
window.open(article.link, "_blank");
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
if (!itemId) return null;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={handleClose}>
|
||||
<SheetContent className="w-full sm:max-w-3xl p-0 flex flex-col">
|
||||
{!article ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="space-y-4 w-full max-w-2xl px-8">
|
||||
<Skeleton className="h-8 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SheetHeader className="border-b border-border px-8 py-6 space-y-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
title={!article.unread ? "Mark as Unread" : "Mark as Read"}
|
||||
onClick={handleToggleRead}
|
||||
>
|
||||
{!article.unread ? (
|
||||
<Circle className="w-4 h-4" />
|
||||
) : (
|
||||
<Check className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
title={bookmark ? "Remove Bookmark" : "Add Bookmark"}
|
||||
onClick={handleToggleBookmark}
|
||||
disabled={isCreatingBookmark}
|
||||
>
|
||||
<Star
|
||||
className={`w-4 h-4 ${
|
||||
bookmark && "fill-yellow-500 text-yellow-500"
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
title="Open Original Link"
|
||||
onClick={handleOpenLink}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<SheetTitle className="text-3xl font-bold text-balance leading-tight tracking-tight">
|
||||
{article.title}
|
||||
</SheetTitle>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{feed?.name || "Unknown"}</span>
|
||||
<span>•</span>
|
||||
<span>{formatDate(article.pub_date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 relative">
|
||||
<ScrollArea className="h-full">
|
||||
<article className="max-w-3xl mx-auto px-16 py-12">
|
||||
<div
|
||||
className="notion-content space-y-4 text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: article.content }}
|
||||
/>
|
||||
</article>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,393 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { Item } from "@/lib/api/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDataStore } from "@/store/data";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
CheckCheck,
|
||||
Circle,
|
||||
ExternalLink,
|
||||
Menu,
|
||||
MoreHorizontal,
|
||||
Pause,
|
||||
Settings,
|
||||
Star,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface ArticleListProps {
|
||||
onMenuOpen?: () => void;
|
||||
}
|
||||
|
||||
export function ArticleList({ onMenuOpen }: ArticleListProps) {
|
||||
const navigate = useNavigate();
|
||||
const search = useSearch({ from: "/" });
|
||||
const {
|
||||
items,
|
||||
feeds,
|
||||
groups,
|
||||
itemsLoading,
|
||||
fetchItems,
|
||||
markItemsRead,
|
||||
markItemsUnread,
|
||||
} = useDataStore();
|
||||
|
||||
const filter = search.filter || "all";
|
||||
|
||||
useEffect(() => {
|
||||
const params = {
|
||||
feed_id: search.feed,
|
||||
group_id: search.group,
|
||||
unread: filter === "unread" ? true : undefined,
|
||||
limit: 100,
|
||||
};
|
||||
fetchItems(params);
|
||||
}, [search.feed, search.group, filter, fetchItems]);
|
||||
|
||||
const handleFilterChange = (newFilter: "all" | "unread" | "starred") => {
|
||||
navigate({
|
||||
to: "/",
|
||||
search: {
|
||||
feed: search.feed,
|
||||
group: search.group,
|
||||
filter: newFilter,
|
||||
search: search.search,
|
||||
settings: search.settings,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectArticle = (itemId: number) => {
|
||||
navigate({
|
||||
to: "/",
|
||||
search: {
|
||||
feed: search.feed,
|
||||
group: search.group,
|
||||
filter: search.filter,
|
||||
item: itemId,
|
||||
search: search.search,
|
||||
settings: search.settings,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleRead = async (item: Item, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (item.unread) {
|
||||
await markItemsRead([item.id]);
|
||||
} else {
|
||||
await markItemsUnread([item.id]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
const unreadIds = items
|
||||
.filter((item) => item.unread)
|
||||
.map((item) => item.id);
|
||||
if (unreadIds.length > 0) {
|
||||
await markItemsRead(unreadIds);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenLink = (link: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
window.open(link, "_blank");
|
||||
};
|
||||
|
||||
const getFeedName = (feedId: number) => {
|
||||
return feeds.find((f) => f.id === feedId)?.name || "Unknown";
|
||||
};
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleDateString();
|
||||
};
|
||||
|
||||
const getCurrentViewName = () => {
|
||||
if (search.feed) {
|
||||
return feeds.find((f) => f.id === search.feed)?.name || "Feed";
|
||||
}
|
||||
if (search.group) {
|
||||
return groups.find((g) => g.id === search.group)?.name || "Group";
|
||||
}
|
||||
return "All Articles";
|
||||
};
|
||||
|
||||
const feedType = search.feed ? "feed" : search.group ? "group" : "all";
|
||||
|
||||
const filteredItems = filter === "starred" ? [] : items;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="border-b border-border px-6 py-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{onMenuOpen && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="md:hidden -ml-2"
|
||||
onClick={onMenuOpen}
|
||||
title="Open menu"
|
||||
>
|
||||
<Menu className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{getCurrentViewName()}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Mark all as read"
|
||||
onClick={handleMarkAllRead}
|
||||
>
|
||||
<CheckCheck className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" title="Sort">
|
||||
<ArrowUpDown className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>Newest first</DropdownMenuItem>
|
||||
<DropdownMenuItem>Oldest first</DropdownMenuItem>
|
||||
<DropdownMenuItem>By feed name</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{feedType === "group" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Pause group"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Pause className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{feedType === "feed" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Pause feed"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Pause className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" title="More options">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete Feed
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={filter === "all" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => handleFilterChange("all")}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === "unread" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => handleFilterChange("unread")}
|
||||
>
|
||||
Unread
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === "starred" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => handleFilterChange("starred")}
|
||||
>
|
||||
Starred
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
{itemsLoading ? (
|
||||
<div className="space-y-2 p-4">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>No articles</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
There are no articles to display.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{filteredItems.map((item) => {
|
||||
const isBookmarked = false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="group/article relative cursor-pointer transition-colors hover:bg-muted/30"
|
||||
onClick={() => handleSelectArticle(item.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
const button = e.currentTarget.querySelector(
|
||||
"[data-context-trigger]"
|
||||
) as HTMLElement;
|
||||
button?.click();
|
||||
}}
|
||||
>
|
||||
<div className="px-6 py-3 flex flex-col md:grid md:grid-cols-[20px_auto_130px_100px] md:gap-4 md:items-center">
|
||||
<div className="flex items-start gap-3 md:contents">
|
||||
<div className="flex items-center justify-start w-5 shrink-0 pt-0.5 md:pt-0">
|
||||
{item.unread && isBookmarked && (
|
||||
<div className="relative w-3.5 h-3.5">
|
||||
<Circle className="absolute w-2 h-2 text-primary fill-primary top-0 left-0" />
|
||||
<Star className="absolute w-2.5 h-2.5 fill-yellow-500 text-yellow-500 bottom-0 right-0" />
|
||||
</div>
|
||||
)}
|
||||
{item.unread && !isBookmarked && (
|
||||
<Circle className="w-2 h-2 text-primary fill-primary" />
|
||||
)}
|
||||
{!item.unread && isBookmarked && (
|
||||
<Star className="w-3 h-3 fill-yellow-500 text-yellow-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 md:pr-4">
|
||||
<h3
|
||||
className={cn(
|
||||
"font-medium text-sm leading-snug mb-1 line-clamp-2 md:line-clamp-1",
|
||||
!item.unread
|
||||
? "text-muted-foreground"
|
||||
: "text-foreground"
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="hidden md:block text-xs text-muted-foreground truncate line-clamp-1">
|
||||
{item.content
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.substring(0, 150)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span className="hidden md:block text-xs text-muted-foreground truncate text-right">
|
||||
{getFeedName(item.feed_id)}
|
||||
</span>
|
||||
<span className="hidden md:block text-xs text-muted-foreground text-right">
|
||||
{formatDate(item.pub_date)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-1 ml-8 text-xs text-muted-foreground md:hidden">
|
||||
<span className="truncate">
|
||||
{getFeedName(item.feed_id)}
|
||||
</span>
|
||||
<span className="text-muted-foreground/60">•</span>
|
||||
<span className="shrink-0">
|
||||
{formatDate(item.pub_date)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-0 top-0 bottom-0 opacity-0 group-hover/article:opacity-100 transition-opacity flex items-stretch">
|
||||
<div className="flex items-center gap-1 bg-background/95 backdrop-blur-sm px-2 h-full">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button data-context-trigger className="hidden" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => handleToggleRead(item, e)}
|
||||
>
|
||||
<Check className="w-3.5 h-3.5 mr-2" />
|
||||
{!item.unread ? "Mark as Unread" : "Mark as Read"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => handleOpenLink(item.link, e)}
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5 mr-2" />
|
||||
Open Link
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 hover:bg-muted"
|
||||
title={
|
||||
!item.unread ? "Mark as Unread" : "Mark as Read"
|
||||
}
|
||||
onClick={(e) => handleToggleRead(item, e)}
|
||||
>
|
||||
{!item.unread ? (
|
||||
<Circle className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 hover:bg-muted"
|
||||
title="Open Link"
|
||||
onClick={(e) => handleOpenLink(item.link, e)}
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
BookmarkPlus,
|
||||
BookmarkCheck,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
X,
|
||||
Circle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useUIStore, useDataStore } from "@/store";
|
||||
import { useArticles } from "@/hooks/use-articles";
|
||||
import { useStarred } from "@/hooks/use-starred";
|
||||
import { useArticleNavigation } from "@/hooks/use-keyboard";
|
||||
import { formatDate, sanitizeHTML } from "@/lib/utils";
|
||||
|
||||
export function ArticleDrawer() {
|
||||
const { selectedArticleId, setSelectedArticle } = useUIStore();
|
||||
const { getItemById, getFeedById } = useDataStore();
|
||||
const { articles, markAsRead, markAsUnread } = useArticles();
|
||||
const { toggleStar, isStarred } = useStarred();
|
||||
|
||||
const articleIds = articles.map((a) => a.id);
|
||||
const { goToNext, goToPrevious, hasNext, hasPrevious } =
|
||||
useArticleNavigation(articleIds);
|
||||
|
||||
const article = selectedArticleId ? getItemById(selectedArticleId) : null;
|
||||
const feed = article ? getFeedById(article.feed_id) : null;
|
||||
const starred = article ? isStarred(article.id) : false;
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
setSelectedArticle(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleRead = async () => {
|
||||
if (!article) return;
|
||||
if (article.unread) {
|
||||
await markAsRead(article.id);
|
||||
} else {
|
||||
await markAsUnread(article.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStar = async () => {
|
||||
if (!article) return;
|
||||
await toggleStar(article);
|
||||
};
|
||||
|
||||
const handleOpenOriginal = () => {
|
||||
if (!article) return;
|
||||
window.open(article.link, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={selectedArticleId !== null} onOpenChange={handleOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-full max-w-[720px] p-0 sm:max-w-[720px]"
|
||||
>
|
||||
{article && (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<SheetHeader className="flex flex-row items-center justify-between border-b px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleToggleRead}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
{article.unread ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{article.unread ? "Mark as read" : "Mark as unread"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleToggleStar}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
{starred ? (
|
||||
<BookmarkCheck className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<BookmarkPlus className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{starred ? "Remove star" : "Star article"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleOpenOriginal}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Open original</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<SheetTitle className="sr-only">{article.title}</SheetTitle>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSelectedArticle(null)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</SheetHeader>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<article className="p-6">
|
||||
<h1 className="text-2xl font-bold leading-tight">
|
||||
{article.title}
|
||||
</h1>
|
||||
<div className="mt-3 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{feed?.name ?? "Unknown"}</span>
|
||||
<span>·</span>
|
||||
<span>{formatDate(article.pub_date)}</span>
|
||||
</div>
|
||||
|
||||
<Separator className="my-6" />
|
||||
|
||||
<div
|
||||
className="prose prose-sm max-w-none dark:prose-invert"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHTML(article.content),
|
||||
}}
|
||||
/>
|
||||
</article>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer - Navigation */}
|
||||
<div className="flex items-center justify-between border-t px-4 py-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={goToPrevious}
|
||||
disabled={!hasPrevious()}
|
||||
className="gap-1"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={goToNext}
|
||||
disabled={!hasNext()}
|
||||
className="gap-1"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { cn, formatDate, extractSummary } from "@/lib/utils";
|
||||
import { useUIStore, useDataStore } from "@/store";
|
||||
import type { Item } from "@/lib/api";
|
||||
|
||||
interface ArticleItemProps {
|
||||
article: Item;
|
||||
}
|
||||
|
||||
export function ArticleItem({ article }: ArticleItemProps) {
|
||||
const { selectedArticleId, setSelectedArticle } = useUIStore();
|
||||
const { getFeedById } = useDataStore();
|
||||
const isSelected = selectedArticleId === article.id;
|
||||
const feed = getFeedById(article.feed_id);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setSelectedArticle(article.id)}
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-1 rounded-md border p-3 text-left transition-colors",
|
||||
isSelected
|
||||
? "border-primary bg-accent"
|
||||
: "border-transparent hover:bg-accent/50",
|
||||
article.unread && "border-l-2 border-l-primary"
|
||||
)}
|
||||
>
|
||||
<h3
|
||||
className={cn(
|
||||
"line-clamp-2 text-sm",
|
||||
article.unread ? "font-medium" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{article.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="truncate">{feed?.name ?? "Unknown"}</span>
|
||||
<span>·</span>
|
||||
<span className="shrink-0">{formatDate(article.pub_date)}</span>
|
||||
</div>
|
||||
<p className="line-clamp-2 text-xs text-muted-foreground">
|
||||
{extractSummary(article.content, 100)}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { CheckCheck } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ArticleItem } from "./article-item";
|
||||
import { useArticles } from "@/hooks/use-articles";
|
||||
import { useArticleNavigation } from "@/hooks/use-keyboard";
|
||||
import { useUIStore, useDataStore, type ArticleFilter } from "@/store";
|
||||
|
||||
export function ArticleList() {
|
||||
const { articles, isLoading, markAllAsRead } = useArticles();
|
||||
const { articleFilter, setArticleFilter, selectedFeedId, selectedGroupId } = useUIStore();
|
||||
const { getFeedById, getGroupById } = useDataStore();
|
||||
|
||||
// Setup keyboard navigation
|
||||
const articleIds = articles.map((a) => a.id);
|
||||
useArticleNavigation(articleIds);
|
||||
|
||||
// Determine title based on selection
|
||||
let title = "All Articles";
|
||||
if (selectedFeedId) {
|
||||
const feed = getFeedById(selectedFeedId);
|
||||
title = feed?.name ?? "Feed";
|
||||
} else if (selectedGroupId) {
|
||||
const group = getGroupById(selectedGroupId);
|
||||
title = group?.name ?? "Group";
|
||||
}
|
||||
|
||||
const unreadCount = articles.filter((a) => a.unread).length;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={markAllAsRead}
|
||||
disabled={unreadCount === 0}
|
||||
className="gap-1.5 text-xs"
|
||||
>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
Mark all as read
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="border-b px-4 py-2">
|
||||
<Tabs
|
||||
value={articleFilter}
|
||||
onValueChange={(v) => setArticleFilter(v as ArticleFilter)}
|
||||
>
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="all" className="text-xs">
|
||||
All
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="unread" className="text-xs">
|
||||
Unread
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="starred" className="text-xs">
|
||||
Starred
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Article list */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-1 p-2">
|
||||
{isLoading && articles.length === 0 ? (
|
||||
<div className="space-y-2 p-2">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="h-24 animate-pulse rounded-md bg-accent" />
|
||||
))}
|
||||
</div>
|
||||
) : articles.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p className="text-sm text-muted-foreground">No articles found</p>
|
||||
</div>
|
||||
) : (
|
||||
articles.map((article) => (
|
||||
<ArticleItem key={article.id} article={article} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { feedAPI } from "@/lib/api";
|
||||
import type { Feed } from "@/lib/api/types";
|
||||
import { useDataStore } from "@/store/data";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface FeedDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
feed?: Feed;
|
||||
defaultGroupId?: number;
|
||||
}
|
||||
|
||||
export function FeedDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
feed,
|
||||
defaultGroupId,
|
||||
}: FeedDialogProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
groupId: defaultGroupId || 0,
|
||||
name: "",
|
||||
url: "",
|
||||
siteUrl: "",
|
||||
proxy: "",
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const { groups, createFeed, updateFeed } = useDataStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (feed) {
|
||||
setFormData({
|
||||
groupId: feed.group_id,
|
||||
name: feed.name,
|
||||
url: feed.link,
|
||||
siteUrl: feed.site_url || "",
|
||||
proxy: feed.proxy || "",
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
groupId: defaultGroupId || groups[0]?.id || 0,
|
||||
name: "",
|
||||
url: "",
|
||||
siteUrl: "",
|
||||
proxy: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [open, feed, defaultGroupId, groups]);
|
||||
|
||||
const handleValidate = async () => {
|
||||
if (!formData.url.trim()) {
|
||||
toast.error("Please enter a feed URL");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsValidating(true);
|
||||
try {
|
||||
const response = await feedAPI.validate({ url: formData.url });
|
||||
if (response.data?.valid) {
|
||||
toast.success("Feed URL is valid");
|
||||
} else {
|
||||
toast.error("Feed URL is invalid");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to validate feed"
|
||||
);
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const trimmedUrl = formData.url.trim();
|
||||
if (!trimmedUrl || !formData.groupId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (feed) {
|
||||
await updateFeed(feed.id, {
|
||||
group_id: formData.groupId,
|
||||
name: formData.name.trim() || undefined,
|
||||
site_url: formData.siteUrl.trim() || undefined,
|
||||
proxy: formData.proxy.trim() || undefined,
|
||||
});
|
||||
} else {
|
||||
await createFeed({
|
||||
group_id: formData.groupId,
|
||||
name: formData.name.trim(),
|
||||
link: trimmedUrl,
|
||||
site_url: formData.siteUrl.trim() || undefined,
|
||||
proxy: formData.proxy.trim() || undefined,
|
||||
});
|
||||
}
|
||||
onOpenChange(false);
|
||||
setFormData({
|
||||
groupId: 0,
|
||||
name: "",
|
||||
url: "",
|
||||
siteUrl: "",
|
||||
proxy: "",
|
||||
});
|
||||
} catch (error) {
|
||||
// Error is already handled in store with toast
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{feed ? "Edit Feed" : "Add Feed"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="feed-url">
|
||||
Feed URL <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="feed-url"
|
||||
placeholder="https://example.com/feed.xml"
|
||||
value={formData.url}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, url: e.target.value })
|
||||
}
|
||||
autoFocus
|
||||
required
|
||||
disabled={!!feed}
|
||||
className="flex-1"
|
||||
/>
|
||||
{!feed && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleValidate}
|
||||
disabled={isValidating || !formData.url.trim()}
|
||||
>
|
||||
{isValidating ? "Checking..." : "Validate"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="feed-name">Feed Name</Label>
|
||||
<Input
|
||||
id="feed-name"
|
||||
placeholder="Leave empty to use feed title"
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="feed-group">
|
||||
Group <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.groupId.toString()}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, groupId: parseInt(value) })
|
||||
}
|
||||
required
|
||||
>
|
||||
<SelectTrigger id="feed-group">
|
||||
<SelectValue placeholder="Select a group" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groups.map((group) => (
|
||||
<SelectItem key={group.id} value={group.id.toString()}>
|
||||
{group.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="feed-site-url">Site URL</Label>
|
||||
<Input
|
||||
id="feed-site-url"
|
||||
placeholder="https://example.com"
|
||||
value={formData.siteUrl}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, siteUrl: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="feed-proxy">Proxy</Label>
|
||||
<Input
|
||||
id="feed-proxy"
|
||||
placeholder="http://proxy.example.com:8080"
|
||||
value={formData.proxy}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, proxy: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isSubmitting || !formData.url.trim() || !formData.groupId
|
||||
}
|
||||
>
|
||||
{isSubmitting ? "Saving..." : feed ? "Save" : "Add"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FeedItem } from "./feed-item";
|
||||
import type { Feed } from "@/lib/api";
|
||||
|
||||
// Generate consistent color from feed name
|
||||
function getColorFromName(name: string): string {
|
||||
const colors = [
|
||||
"#EB5757", "#F2994A", "#F2C94C", "#27AE60",
|
||||
"#2D9CDB", "#9B51E0", "#BB6BD9", "#56CCF2",
|
||||
];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
|
||||
interface FeedGroupProps {
|
||||
name: string;
|
||||
feeds: Feed[];
|
||||
unreadCount: number;
|
||||
getUnreadCount: (feedId: number) => number;
|
||||
}
|
||||
|
||||
export function FeedGroup({ name, feeds, unreadCount, getUnreadCount }: FeedGroupProps) {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-1 rounded-md px-2 py-1.5 text-sm font-medium hover:bg-accent/50">
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
isOpen && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
<span className="flex-1 truncate text-left">{name}</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground">{unreadCount}</span>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="ml-3 mt-0.5 space-y-0.5 border-l pl-2">
|
||||
{feeds.map((feed) => (
|
||||
<FeedItem
|
||||
key={feed.id}
|
||||
id={feed.id}
|
||||
name={feed.name}
|
||||
unreadCount={getUnreadCount(feed.id)}
|
||||
color={getColorFromName(feed.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useUIStore } from "@/store";
|
||||
|
||||
interface FeedItemProps {
|
||||
id: number;
|
||||
name: string;
|
||||
unreadCount: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function FeedItem({ id, name, unreadCount, color = "#EB5757" }: FeedItemProps) {
|
||||
const { selectedFeedId, setSelectedFeed } = useUIStore();
|
||||
const isSelected = selectedFeedId === id;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setSelectedFeed(id)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
|
||||
isSelected
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="h-4 w-4 shrink-0 rounded"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="flex-1 truncate">{name}</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground">{unreadCount}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Inbox } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { FeedGroup } from "./feed-group";
|
||||
import { useFeeds } from "@/hooks/use-feeds";
|
||||
import { useUIStore } from "@/store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function FeedList() {
|
||||
const { groups, feeds, isLoading, getFeedsByGroup, getUnreadCount, getGroupUnreadCount, getTotalUnreadCount } =
|
||||
useFeeds();
|
||||
const { selectedFeedId, selectedGroupId, selectAll } = useUIStore();
|
||||
|
||||
const isAllSelected = selectedFeedId === null && selectedGroupId === null;
|
||||
const totalUnread = getTotalUnreadCount();
|
||||
|
||||
if (isLoading && groups.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 p-4">
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-8 animate-pulse rounded-md bg-accent" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
{/* All feeds */}
|
||||
<button
|
||||
onClick={selectAll}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
|
||||
isAllSelected
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<Inbox className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1">All</span>
|
||||
{totalUnread > 0 && (
|
||||
<span className="text-xs text-muted-foreground">{totalUnread}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Feed groups */}
|
||||
<div className="mt-2 space-y-1">
|
||||
{groups.map((group) => {
|
||||
const groupFeeds = getFeedsByGroup(group.id);
|
||||
if (groupFeeds.length === 0) return null;
|
||||
|
||||
return (
|
||||
<FeedGroup
|
||||
key={group.id}
|
||||
name={group.name}
|
||||
feeds={groupFeeds}
|
||||
unreadCount={getGroupUnreadCount(group.id)}
|
||||
getUnreadCount={getUnreadCount}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Ungrouped feeds (group_id = 0) */}
|
||||
{feeds
|
||||
.filter((f) => f.group_id === 0)
|
||||
.map((feed) => (
|
||||
<div key={feed.id} className="pl-5">
|
||||
<button
|
||||
onClick={() => useUIStore.getState().setSelectedFeed(feed.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
|
||||
selectedFeedId === feed.id
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="h-4 w-4 shrink-0 rounded"
|
||||
style={{ backgroundColor: "#EB5757" }}
|
||||
/>
|
||||
<span className="flex-1 truncate">{feed.name}</span>
|
||||
{getUnreadCount(feed.id) > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{getUnreadCount(feed.id)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { Group } from "@/lib/api/types";
|
||||
import { useDataStore } from "@/store/data";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface GroupDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
group?: Group;
|
||||
}
|
||||
|
||||
export function GroupDialog({ open, onOpenChange, group }: GroupDialogProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createGroup, updateGroup } = useDataStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(group?.name || "");
|
||||
}
|
||||
}, [open, group]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (group) {
|
||||
await updateGroup(group.id, { name: trimmedName });
|
||||
} else {
|
||||
await createGroup({ name: trimmedName });
|
||||
}
|
||||
onOpenChange(false);
|
||||
setName("");
|
||||
} catch (error) {
|
||||
// Error is already handled in store with toast
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{group ? "Edit Group" : "Create Group"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="group-name">Name</Label>
|
||||
<Input
|
||||
id="group-name"
|
||||
placeholder="Enter group name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || !name.trim()}>
|
||||
{isSubmitting ? "Saving..." : group ? "Save" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Menu } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Sidebar } from "./sidebar";
|
||||
import { ArticleDrawer } from "@/components/article/article-drawer";
|
||||
import { SearchDialog } from "@/components/search/search-dialog";
|
||||
import { SettingsDialog } from "@/components/settings/settings-dialog";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AppLayout({ children }: AppLayoutProps) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Register global keyboard shortcuts
|
||||
useKeyboardShortcuts();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden">
|
||||
{/* Desktop sidebar */}
|
||||
{!isMobile && <Sidebar />}
|
||||
|
||||
{/* Mobile sidebar */}
|
||||
{isMobile && (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="fixed left-2 top-2 z-40 md:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-[260px] p-0">
|
||||
<SheetTitle className="sr-only">Navigation</SheetTitle>
|
||||
<Sidebar />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-hidden">{children}</main>
|
||||
|
||||
{/* Modals and drawers */}
|
||||
<ArticleDrawer />
|
||||
<SearchDialog />
|
||||
<SettingsDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Search, Settings } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { FeedList } from "@/components/feed/feed-list";
|
||||
import { useUIStore } from "@/store";
|
||||
|
||||
export function Sidebar() {
|
||||
const { setSearchOpen, setSettingsOpen } = useUIStore();
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-[260px] shrink-0 flex-col border-r bg-sidebar">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-4 py-3">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-primary">
|
||||
<span className="text-sm font-bold text-primary-foreground">F</span>
|
||||
</div>
|
||||
<span className="text-base font-semibold">Fusion</span>
|
||||
</div>
|
||||
|
||||
{/* Search button */}
|
||||
<div className="px-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2 text-muted-foreground"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
<span className="flex-1 text-left text-sm">Search</span>
|
||||
<kbd className="pointer-events-none hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex">
|
||||
<span className="text-xs">⌘</span>K
|
||||
</kbd>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
{/* Feed list */}
|
||||
<FeedList />
|
||||
|
||||
{/* Footer */}
|
||||
<Separator />
|
||||
<div className="p-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2 text-muted-foreground"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span className="text-sm">Settings</span>
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Sidebar } from "@/components/sidebar";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
|
||||
interface MobileSidebarProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSearchOpen: () => void;
|
||||
onSettingsOpen: () => void;
|
||||
}
|
||||
|
||||
export function MobileSidebar({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSearchOpen,
|
||||
onSettingsOpen,
|
||||
}: MobileSidebarProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="left" className="p-0 w-64 h-full">
|
||||
<Sidebar onSearchOpen={onSearchOpen} onSettingsOpen={onSettingsOpen} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { useDataStore } from "@/store/data";
|
||||
import { useUIStore } from "@/store/ui";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import { ArticleDrawer } from "./article-drawer";
|
||||
import { ArticleList } from "./article-list";
|
||||
import { MobileSidebar } from "./mobile-sidebar";
|
||||
import { SearchDialog } from "./search-dialog";
|
||||
import { SettingsDialog } from "./settings-dialog";
|
||||
import { Sidebar } from "./sidebar";
|
||||
|
||||
export function RSSReaderApp() {
|
||||
const navigate = useNavigate();
|
||||
const search = useSearch({ from: "/" });
|
||||
const { fetchGroups, fetchFeeds } = useDataStore();
|
||||
const { sidebarOpen, setSidebarOpen } = useUIStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchGroups();
|
||||
fetchFeeds();
|
||||
}, [fetchGroups, fetchFeeds]);
|
||||
|
||||
const handleUpdateSearch = (updates: Partial<typeof search>) => {
|
||||
navigate({
|
||||
to: "/",
|
||||
search: { ...search, ...updates },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{/* Desktop Sidebar */}
|
||||
<aside className="hidden md:block">
|
||||
<Sidebar
|
||||
onSearchOpen={() => handleUpdateSearch({ search: true })}
|
||||
onSettingsOpen={() => handleUpdateSearch({ settings: true })}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Mobile Sidebar */}
|
||||
<MobileSidebar
|
||||
open={sidebarOpen}
|
||||
onOpenChange={setSidebarOpen}
|
||||
onSearchOpen={() => {
|
||||
handleUpdateSearch({ search: true });
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
onSettingsOpen={() => {
|
||||
handleUpdateSearch({ settings: true });
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex flex-col h-full overflow-hidden">
|
||||
<ArticleList onMenuOpen={() => setSidebarOpen(true)} />
|
||||
</main>
|
||||
|
||||
{/* Article Drawer */}
|
||||
<ArticleDrawer />
|
||||
|
||||
{/* Dialogs */}
|
||||
<SearchDialog
|
||||
open={search.search ?? false}
|
||||
onOpenChange={(open) => handleUpdateSearch({ search: open })}
|
||||
/>
|
||||
<SettingsDialog
|
||||
open={search.settings ?? false}
|
||||
onOpenChange={(open) => handleUpdateSearch({ settings: open })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useDataStore } from "@/store/data";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { FileText, Rss, Search } from "lucide-react";
|
||||
import { useDeferredValue, useMemo, useState } from "react";
|
||||
|
||||
interface SearchDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
|
||||
const navigate = useNavigate();
|
||||
const { feeds, items } = useDataStore();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchType, setSearchType] = useState<"all" | "feeds" | "articles">(
|
||||
"all"
|
||||
);
|
||||
const deferredQuery = useDeferredValue(searchQuery);
|
||||
|
||||
const searchResults = useMemo(() => {
|
||||
const query = deferredQuery.toLowerCase().trim();
|
||||
if (!query) {
|
||||
return { feeds: [], articles: [] };
|
||||
}
|
||||
|
||||
const matchedFeeds = feeds.filter((feed) => {
|
||||
return (
|
||||
feed.name.toLowerCase().includes(query) ||
|
||||
feed.link.toLowerCase().includes(query) ||
|
||||
feed.site_url?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const matchedArticles = items.filter((item) => {
|
||||
return (
|
||||
item.title.toLowerCase().includes(query) ||
|
||||
item.content.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
feeds: matchedFeeds.slice(0, 20),
|
||||
articles: matchedArticles.slice(0, 20),
|
||||
};
|
||||
}, [deferredQuery, feeds, items]);
|
||||
|
||||
const handleSelectFeed = (feedId: number) => {
|
||||
navigate({ to: "/", search: { feed: feedId, filter: "all" as const } });
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleSelectArticle = (itemId: number) => {
|
||||
navigate({ to: "/", search: { item: itemId } });
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const displayResults = useMemo(() => {
|
||||
if (searchType === "feeds") {
|
||||
return { feeds: searchResults.feeds, articles: [] };
|
||||
} else if (searchType === "articles") {
|
||||
return { feeds: [], articles: searchResults.articles };
|
||||
}
|
||||
return searchResults;
|
||||
}, [searchType, searchResults]);
|
||||
|
||||
const totalResults =
|
||||
displayResults.feeds.length + displayResults.articles.length;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl p-0 gap-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-4">
|
||||
<DialogTitle>Search</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pb-4 space-y-3">
|
||||
{/* Search Input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search feeds and articles..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Search Type Filters */}
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={searchType === "all" ? "secondary" : "ghost"}
|
||||
onClick={() => setSearchType("all")}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={searchType === "feeds" ? "secondary" : "ghost"}
|
||||
onClick={() => setSearchType("feeds")}
|
||||
>
|
||||
<Rss className="w-3 h-3 mr-1" />
|
||||
Feeds
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={searchType === "articles" ? "secondary" : "ghost"}
|
||||
onClick={() => setSearchType("articles")}
|
||||
>
|
||||
<FileText className="w-3 h-3 mr-1" />
|
||||
Articles
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Results */}
|
||||
<ScrollArea className="max-h-96 border-t border-border">
|
||||
<div className="p-4">
|
||||
{!deferredQuery ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Search className="w-12 h-12 mx-auto mb-3 opacity-20" />
|
||||
<p>Start typing to search...</p>
|
||||
</div>
|
||||
) : totalResults === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Search className="w-12 h-12 mx-auto mb-3 opacity-20" />
|
||||
<p>No results found for "{deferredQuery}"</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Feeds Results */}
|
||||
{displayResults.feeds.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="px-2 py-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Feeds ({displayResults.feeds.length})
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{displayResults.feeds.map((feed) => (
|
||||
<button
|
||||
key={feed.id}
|
||||
onClick={() => handleSelectFeed(feed.id)}
|
||||
className="w-full text-left px-3 py-2 rounded-md hover:bg-accent transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Rss className="w-4 h-4 mt-0.5 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">
|
||||
{feed.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{feed.link}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Articles Results */}
|
||||
{displayResults.articles.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="px-2 py-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Articles ({displayResults.articles.length})
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{displayResults.articles.map((article) => (
|
||||
<button
|
||||
key={article.id}
|
||||
onClick={() => handleSelectArticle(article.id)}
|
||||
className="w-full text-left px-3 py-2 rounded-md hover:bg-accent transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<FileText className="w-4 h-4 mt-0.5 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm line-clamp-2">
|
||||
{article.title}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground line-clamp-1 mt-0.5">
|
||||
{new Date(
|
||||
article.pub_date * 1000
|
||||
).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { FileText, Rss, Search } from "lucide-react";
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import { useUIStore, useDataStore } from "@/store";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
export function SearchDialog() {
|
||||
const { isSearchOpen, setSearchOpen, setSelectedFeed, setSelectedArticle } = useUIStore();
|
||||
const { feeds, items, getFeedById } = useDataStore();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
// Reset query when dialog closes
|
||||
useEffect(() => {
|
||||
if (!isSearchOpen) {
|
||||
setQuery("");
|
||||
}
|
||||
}, [isSearchOpen]);
|
||||
|
||||
// Filter feeds by query
|
||||
const filteredFeeds = query
|
||||
? feeds.filter((feed) =>
|
||||
feed.name.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
: [];
|
||||
|
||||
// Filter articles by query
|
||||
const filteredArticles = query
|
||||
? items
|
||||
.filter((item) =>
|
||||
item.title.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
.slice(0, 10)
|
||||
: [];
|
||||
|
||||
const handleSelectFeed = (feedId: number) => {
|
||||
setSelectedFeed(feedId);
|
||||
setSearchOpen(false);
|
||||
};
|
||||
|
||||
const handleSelectArticle = (articleId: number) => {
|
||||
setSelectedArticle(articleId);
|
||||
setSearchOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<CommandDialog open={isSearchOpen} onOpenChange={setSearchOpen}>
|
||||
<CommandInput
|
||||
placeholder="Search feeds and articles..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
|
||||
{filteredFeeds.length > 0 && (
|
||||
<CommandGroup heading="Feeds">
|
||||
{filteredFeeds.map((feed) => (
|
||||
<CommandItem
|
||||
key={`feed-${feed.id}`}
|
||||
value={`feed-${feed.name}`}
|
||||
onSelect={() => handleSelectFeed(feed.id)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Rss className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{feed.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{filteredFeeds.length > 0 && filteredArticles.length > 0 && (
|
||||
<CommandSeparator />
|
||||
)}
|
||||
|
||||
{filteredArticles.length > 0 && (
|
||||
<CommandGroup heading="Articles">
|
||||
{filteredArticles.map((article) => {
|
||||
const feed = getFeedById(article.feed_id);
|
||||
return (
|
||||
<CommandItem
|
||||
key={`article-${article.id}`}
|
||||
value={`article-${article.title}`}
|
||||
onSelect={() => handleSelectArticle(article.id)}
|
||||
className="flex-col items-start gap-1"
|
||||
>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">{article.title}</span>
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-2 pl-6 text-xs text-muted-foreground">
|
||||
<span>{feed?.name ?? "Unknown"}</span>
|
||||
<span>·</span>
|
||||
<span>{formatDate(article.pub_date)}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{!query && (
|
||||
<CommandGroup heading="Quick Actions">
|
||||
<CommandItem className="gap-2">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<span>Type to search feeds and articles</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { LogOut, Monitor, Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
interface SettingsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { logout } = useAuthStore();
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
// Error already handled in store
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-6">
|
||||
{/* Theme Setting */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">Appearance</h3>
|
||||
<RadioGroup value={theme} onValueChange={setTheme}>
|
||||
<div className="flex items-center space-x-2 py-2">
|
||||
<RadioGroupItem value="light" id="theme-light" />
|
||||
<Label
|
||||
htmlFor="theme-light"
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Sun className="w-4 h-4" />
|
||||
<span>Light</span>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 py-2">
|
||||
<RadioGroupItem value="dark" id="theme-dark" />
|
||||
<Label
|
||||
htmlFor="theme-dark"
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Moon className="w-4 h-4" />
|
||||
<span>Dark</span>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 py-2">
|
||||
<RadioGroupItem value="system" id="theme-system" />
|
||||
<Label
|
||||
htmlFor="theme-system"
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Monitor className="w-4 h-4" />
|
||||
<span>System</span>
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Account Section */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">Account</h3>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full justify-start"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useTheme } from "next-themes";
|
||||
import { Monitor, Moon, Sun } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useUIStore } from "@/store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function SettingsDialog() {
|
||||
const { isSettingsOpen, setSettingsOpen } = useUIStore();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<Dialog open={isSettingsOpen} onOpenChange={setSettingsOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Appearance section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Appearance</h3>
|
||||
|
||||
{/* Language */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm">Language</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select your preferred language
|
||||
</p>
|
||||
</div>
|
||||
<Select defaultValue="en">
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
<SelectItem value="zh">中文</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Theme */}
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-sm">Theme</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select your preferred theme
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
onClick={() => setTheme("light")}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-2 rounded-lg border p-3 transition-colors",
|
||||
theme === "light"
|
||||
? "border-primary bg-accent"
|
||||
: "border-border hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<Sun className="h-5 w-5" />
|
||||
<span className="text-xs">Light</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme("dark")}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-2 rounded-lg border p-3 transition-colors",
|
||||
theme === "dark"
|
||||
? "border-primary bg-accent"
|
||||
: "border-border hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<Moon className="h-5 w-5" />
|
||||
<span className="text-xs">Dark</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme("system")}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-2 rounded-lg border p-3 transition-colors",
|
||||
theme === "system"
|
||||
? "border-primary bg-accent"
|
||||
: "border-border hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<Monitor className="h-5 w-5" />
|
||||
<span className="text-xs">System</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* About section */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">About</h3>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary">
|
||||
<span className="text-sm font-bold text-primary-foreground">
|
||||
F
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Fusion</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A modern RSS reader
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
import { FeedDialog } from "@/components/feed-dialog";
|
||||
import { GroupDialog } from "@/components/group-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { Feed, Group } from "@/lib/api/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDataStore } from "@/store/data";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
Rows3,
|
||||
Rss,
|
||||
Search,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface SidebarProps {
|
||||
onSearchOpen: () => void;
|
||||
onSettingsOpen: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ onSearchOpen, onSettingsOpen }: SidebarProps) {
|
||||
const navigate = useNavigate();
|
||||
const search = useSearch({ from: "/" });
|
||||
const {
|
||||
groups,
|
||||
feeds,
|
||||
groupsLoading,
|
||||
feedsLoading,
|
||||
deleteGroup,
|
||||
deleteFeed,
|
||||
} = useDataStore();
|
||||
const [openGroups, setOpenGroups] = useState<Record<number, boolean>>({});
|
||||
const [groupDialogOpen, setGroupDialogOpen] = useState(false);
|
||||
const [feedDialogOpen, setFeedDialogOpen] = useState(false);
|
||||
const [editingGroup, setEditingGroup] = useState<Group | undefined>();
|
||||
const [editingFeed, setEditingFeed] = useState<Feed | undefined>();
|
||||
const [feedDialogGroupId, setFeedDialogGroupId] = useState<
|
||||
number | undefined
|
||||
>();
|
||||
|
||||
const toggleGroup = (groupId: number) => {
|
||||
setOpenGroups((prev) => ({ ...prev, [groupId]: !prev[groupId] }));
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
navigate({ to: "/", search: { filter: search.filter || "all" } });
|
||||
};
|
||||
|
||||
const handleSelectFeed = (feedId: number) => {
|
||||
navigate({
|
||||
to: "/",
|
||||
search: { feed: feedId, filter: search.filter || "all" },
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteGroup = async (groupId: number) => {
|
||||
if (confirm("Are you sure you want to delete this group?")) {
|
||||
await deleteGroup(groupId);
|
||||
if (search.group === groupId) {
|
||||
navigate({ to: "/", search: { filter: search.filter || "all" } });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFeed = async (feedId: number) => {
|
||||
if (confirm("Are you sure you want to unsubscribe from this feed?")) {
|
||||
await deleteFeed(feedId);
|
||||
if (search.feed === feedId) {
|
||||
navigate({ to: "/", search: { filter: search.filter || "all" } });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateGroup = () => {
|
||||
setEditingGroup(undefined);
|
||||
setGroupDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditGroup = (group: Group) => {
|
||||
setEditingGroup(group);
|
||||
setGroupDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateFeed = (groupId?: number) => {
|
||||
setEditingFeed(undefined);
|
||||
setFeedDialogGroupId(groupId);
|
||||
setFeedDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditFeed = (feed: Feed) => {
|
||||
setEditingFeed(feed);
|
||||
setFeedDialogGroupId(undefined);
|
||||
setFeedDialogOpen(true);
|
||||
};
|
||||
|
||||
const getUnreadCount = (feedId: number) => {
|
||||
return feeds.find((f) => f.id === feedId)?.failures || 0;
|
||||
};
|
||||
|
||||
const groupedFeeds = groups.map((group) => ({
|
||||
...group,
|
||||
feeds: feeds.filter((f) => f.group_id === group.id),
|
||||
}));
|
||||
|
||||
return (
|
||||
<aside className="w-64 h-full border-r border-border bg-sidebar flex flex-col">
|
||||
{/* Logo and Search */}
|
||||
<div className="p-4 space-y-3 border-b border-sidebar-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
|
||||
<Rss className="w-4 h-4 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="font-semibold text-sidebar-foreground">
|
||||
RSS Reader
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-muted-foreground hover:text-foreground"
|
||||
onClick={onSearchOpen}
|
||||
>
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Feed List */}
|
||||
<ScrollArea className="flex-1 p-2">
|
||||
<div className="space-y-0.5">
|
||||
<div className="px-2 py-1 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Feeds
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 p-0"
|
||||
title="Create Group"
|
||||
onClick={handleCreateGroup}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{groupsLoading || feedsLoading ? (
|
||||
<div className="space-y-2 p-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* All Feeds */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full justify-start h-8 px-2 text-sm",
|
||||
!search.feed &&
|
||||
!search.group &&
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
)}
|
||||
onClick={handleSelectAll}
|
||||
>
|
||||
<Rows3 className="w-3.5 h-3.5 mr-2" />
|
||||
<span>All</span>
|
||||
</Button>
|
||||
|
||||
{/* Groups */}
|
||||
{groupedFeeds.map((group) => (
|
||||
<Collapsible
|
||||
key={group.id}
|
||||
open={openGroups[group.id]}
|
||||
onOpenChange={() => toggleGroup(group.id)}
|
||||
>
|
||||
<div className="group/group relative">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start h-8 px-2 pr-16 text-sm"
|
||||
>
|
||||
{openGroups[group.id] ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 mr-1.5" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 mr-1.5" />
|
||||
)}
|
||||
<span>{group.name}</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<div className="absolute right-0.5 top-0.5 opacity-0 group-hover/group:opacity-100 transition-opacity flex gap-0.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
title="Add Feed"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCreateFeed(group.id);
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="w-3 h-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleCreateFeed(group.id)}
|
||||
>
|
||||
Add Feed
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleEditGroup(group)}
|
||||
>
|
||||
Rename Group
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDeleteGroup(group.id)}
|
||||
>
|
||||
Delete Group
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent className="ml-2 space-y-0.5 mt-0.5">
|
||||
{group.feeds.map((feed) => (
|
||||
<div
|
||||
key={feed.id}
|
||||
className="group/feed relative"
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full justify-start h-8 pl-6 pr-2 relative",
|
||||
search.feed === feed.id &&
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
)}
|
||||
onClick={() => handleSelectFeed(feed.id)}
|
||||
>
|
||||
<span className="flex-1 text-left truncate text-sm">
|
||||
{feed.name}
|
||||
</span>
|
||||
{getUnreadCount(feed.id) > 0 && (
|
||||
<span className="absolute right-2 text-xs text-muted-foreground">
|
||||
{getUnreadCount(feed.id)}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 top-0 h-full flex items-center pr-1 opacity-0 group-hover/feed:opacity-100 transition-opacity",
|
||||
search.feed === feed.id
|
||||
? "bg-sidebar-accent"
|
||||
: "bg-sidebar hover:bg-sidebar"
|
||||
)}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 hover:bg-sidebar-accent"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="w-3 h-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>Mark as Read</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleEditFeed(feed)}
|
||||
>
|
||||
Edit Feed
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDeleteFeed(feed.id)}
|
||||
>
|
||||
Unsubscribe
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Settings and Version */}
|
||||
<div className="p-2 border-t border-sidebar-border space-y-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start h-8 px-2"
|
||||
onClick={onSettingsOpen}
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Settings
|
||||
</Button>
|
||||
<div className="px-2 py-1 text-xs text-muted-foreground">v1.0.0</div>
|
||||
</div>
|
||||
|
||||
<GroupDialog
|
||||
open={groupDialogOpen}
|
||||
onOpenChange={setGroupDialogOpen}
|
||||
group={editingGroup}
|
||||
/>
|
||||
|
||||
<FeedDialog
|
||||
open={feedDialogOpen}
|
||||
onOpenChange={setFeedDialogOpen}
|
||||
feed={editingFeed}
|
||||
defaultGroupId={feedDialogGroupId}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -7,13 +7,17 @@ import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -42,7 +46,7 @@ function AvatarFallback({
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
"bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -50,4 +54,56 @@ function AvatarFallback({
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full ring-2 select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarBadge,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -1,109 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
@@ -22,9 +22,11 @@ const buttonVariants = cva(
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_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=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
@@ -88,7 +89,14 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
@@ -97,7 +105,14 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"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-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"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 gap-2 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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"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-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty"
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
className={cn(
|
||||
"flex max-w-sm flex-col items-center gap-2 text-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyMediaVariants = cva(
|
||||
"flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function EmptyMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
data-variant={variant}
|
||||
className={cn(emptyMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
className={cn("text-lg font-medium tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-description"
|
||||
className={cn(
|
||||
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-content"
|
||||
className={cn(
|
||||
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
EmptyMedia,
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { AlertCircleIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ErrorStateProps {
|
||||
title?: string;
|
||||
message: string;
|
||||
onRetry?: () => void;
|
||||
retrying?: boolean;
|
||||
variant?: 'inline' | 'centered';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ErrorState({
|
||||
title = 'Failed to load',
|
||||
message,
|
||||
onRetry,
|
||||
retrying = false,
|
||||
variant = 'centered',
|
||||
className,
|
||||
}: ErrorStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-3',
|
||||
variant === 'centered' ? 'py-12 px-4' : 'py-6 px-3',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="rounded-md bg-destructive/10 p-4 text-center">
|
||||
<div className="flex items-center justify-center gap-2 mb-1">
|
||||
<AlertCircleIcon className="size-4 text-destructive" />
|
||||
<p className="text-sm font-medium text-destructive">{title}</p>
|
||||
</div>
|
||||
<p className="text-xs text-destructive/80 mt-1">{message}</p>
|
||||
</div>
|
||||
{onRetry && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRetry}
|
||||
disabled={retrying}
|
||||
>
|
||||
{retrying ? (
|
||||
<>
|
||||
<div className="size-3 mr-2 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
Retrying...
|
||||
</>
|
||||
) : (
|
||||
'Retry'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -1,43 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn("grid gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"border-input text-primary 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 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
@@ -48,9 +48,11 @@ function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
@@ -72,10 +74,12 @@ function SheetContent({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
|
||||
@@ -1,724 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer text-sidebar-foreground hidden md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("size-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"bg-background relative flex w-full flex-1 flex-col",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!tooltip) {
|
||||
return button
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -1,29 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -1,32 +1,54 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -40,7 +62,10 @@ function TabsTrigger({
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
|
||||
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -61,4 +86,4 @@ function TabsContent({
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useCallback, useMemo } from "react";
|
||||
import { itemAPI, type ListItemsParams } from "@/lib/api";
|
||||
import { useDataStore, useUIStore } from "@/store";
|
||||
|
||||
export function useArticles() {
|
||||
const {
|
||||
items,
|
||||
isLoadingItems,
|
||||
itemsError,
|
||||
setItems,
|
||||
setLoadingItems,
|
||||
setItemsError,
|
||||
markItemRead,
|
||||
markItemUnread,
|
||||
markItemsRead,
|
||||
getFeedById,
|
||||
isItemStarred,
|
||||
} = useDataStore();
|
||||
|
||||
const { selectedFeedId, selectedGroupId, articleFilter } = useUIStore();
|
||||
|
||||
const fetchArticles = useCallback(
|
||||
async (params?: ListItemsParams) => {
|
||||
setLoadingItems(true);
|
||||
setItemsError(null);
|
||||
try {
|
||||
const response = await itemAPI.list({
|
||||
limit: 100,
|
||||
order_by: "pub_date:desc",
|
||||
...params,
|
||||
});
|
||||
setItems(response.data);
|
||||
} catch (error) {
|
||||
setItemsError(error instanceof Error ? error.message : "Failed to load articles");
|
||||
} finally {
|
||||
setLoadingItems(false);
|
||||
}
|
||||
},
|
||||
[setItems, setLoadingItems, setItemsError]
|
||||
);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const params: ListItemsParams = {};
|
||||
if (selectedFeedId) params.feed_id = selectedFeedId;
|
||||
if (selectedGroupId) params.group_id = selectedGroupId;
|
||||
if (articleFilter === "unread") params.unread = true;
|
||||
await fetchArticles(params);
|
||||
}, [fetchArticles, selectedFeedId, selectedGroupId, articleFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
const params: ListItemsParams = {};
|
||||
if (selectedFeedId) params.feed_id = selectedFeedId;
|
||||
if (selectedGroupId) params.group_id = selectedGroupId;
|
||||
if (articleFilter === "unread") params.unread = true;
|
||||
fetchArticles(params);
|
||||
}, [selectedFeedId, selectedGroupId, articleFilter, fetchArticles]);
|
||||
|
||||
const filteredArticles = useMemo(() => {
|
||||
let result = items;
|
||||
|
||||
if (articleFilter === "starred") {
|
||||
result = result.filter((item) => isItemStarred(item.id));
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [items, articleFilter, isItemStarred]);
|
||||
|
||||
const markAsRead = useCallback(
|
||||
async (itemId: number) => {
|
||||
try {
|
||||
await itemAPI.markRead({ ids: [itemId] });
|
||||
markItemRead(itemId);
|
||||
} catch (error) {
|
||||
console.error("Failed to mark as read:", error);
|
||||
}
|
||||
},
|
||||
[markItemRead]
|
||||
);
|
||||
|
||||
const markAsUnread = useCallback(
|
||||
async (itemId: number) => {
|
||||
try {
|
||||
await itemAPI.markUnread({ ids: [itemId] });
|
||||
markItemUnread(itemId);
|
||||
} catch (error) {
|
||||
console.error("Failed to mark as unread:", error);
|
||||
}
|
||||
},
|
||||
[markItemUnread]
|
||||
);
|
||||
|
||||
const markAllAsRead = useCallback(async () => {
|
||||
const unreadIds = filteredArticles.filter((a) => a.unread).map((a) => a.id);
|
||||
if (unreadIds.length === 0) return;
|
||||
|
||||
try {
|
||||
await itemAPI.markRead({ ids: unreadIds });
|
||||
markItemsRead(unreadIds);
|
||||
} catch (error) {
|
||||
console.error("Failed to mark all as read:", error);
|
||||
}
|
||||
}, [filteredArticles, markItemsRead]);
|
||||
|
||||
const getArticleWithMeta = useCallback(
|
||||
(itemId: number) => {
|
||||
const item = items.find((i) => i.id === itemId);
|
||||
if (!item) return null;
|
||||
|
||||
const feed = getFeedById(item.feed_id);
|
||||
return {
|
||||
...item,
|
||||
feedName: feed?.name ?? "Unknown",
|
||||
isStarred: isItemStarred(item.id),
|
||||
};
|
||||
},
|
||||
[items, getFeedById, isItemStarred]
|
||||
);
|
||||
|
||||
return {
|
||||
articles: filteredArticles,
|
||||
isLoading: isLoadingItems,
|
||||
error: itemsError,
|
||||
refresh,
|
||||
markAsRead,
|
||||
markAsUnread,
|
||||
markAllAsRead,
|
||||
getArticleWithMeta,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useCallback, useRef } from "react";
|
||||
import { groupAPI, feedAPI } from "@/lib/api";
|
||||
import { useDataStore } from "@/store";
|
||||
|
||||
export function useFeeds() {
|
||||
const didInitRef = useRef(false);
|
||||
const {
|
||||
groups,
|
||||
feeds,
|
||||
isLoadingGroups,
|
||||
isLoadingFeeds,
|
||||
groupsError,
|
||||
feedsError,
|
||||
setGroups,
|
||||
setFeeds,
|
||||
setLoadingGroups,
|
||||
setLoadingFeeds,
|
||||
setGroupsError,
|
||||
setFeedsError,
|
||||
getFeedsByGroup,
|
||||
} = useDataStore();
|
||||
|
||||
const fetchGroups = useCallback(async () => {
|
||||
setLoadingGroups(true);
|
||||
setGroupsError(null);
|
||||
try {
|
||||
const response = await groupAPI.list();
|
||||
setGroups(response.data);
|
||||
} catch (error) {
|
||||
setGroupsError(error instanceof Error ? error.message : "Failed to load groups");
|
||||
} finally {
|
||||
setLoadingGroups(false);
|
||||
}
|
||||
}, [setGroups, setLoadingGroups, setGroupsError]);
|
||||
|
||||
const fetchFeeds = useCallback(async () => {
|
||||
setLoadingFeeds(true);
|
||||
setFeedsError(null);
|
||||
try {
|
||||
const response = await feedAPI.list();
|
||||
setFeeds(response.data);
|
||||
} catch (error) {
|
||||
setFeedsError(error instanceof Error ? error.message : "Failed to load feeds");
|
||||
} finally {
|
||||
setLoadingFeeds(false);
|
||||
}
|
||||
}, [setFeeds, setLoadingFeeds, setFeedsError]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
await Promise.all([fetchGroups(), fetchFeeds()]);
|
||||
}, [fetchGroups, fetchFeeds]);
|
||||
|
||||
useEffect(() => {
|
||||
// Prevent infinite re-fetch loops on failed requests (or valid empty responses).
|
||||
// Only perform the initial load attempt once per mount.
|
||||
if (didInitRef.current) return;
|
||||
didInitRef.current = true;
|
||||
|
||||
if (groups.length === 0) {
|
||||
fetchGroups();
|
||||
}
|
||||
if (feeds.length === 0) {
|
||||
fetchFeeds();
|
||||
}
|
||||
}, [fetchGroups, fetchFeeds, groups.length, feeds.length]);
|
||||
|
||||
const getUnreadCount = useCallback(
|
||||
(feedId: number) => {
|
||||
const { items } = useDataStore.getState();
|
||||
return items.filter((item) => item.feed_id === feedId && item.unread).length;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const getGroupUnreadCount = useCallback(
|
||||
(groupId: number) => {
|
||||
const feedIds = getFeedsByGroup(groupId).map((f) => f.id);
|
||||
const { items } = useDataStore.getState();
|
||||
return items.filter((item) => feedIds.includes(item.feed_id) && item.unread).length;
|
||||
},
|
||||
[getFeedsByGroup]
|
||||
);
|
||||
|
||||
const getTotalUnreadCount = useCallback(() => {
|
||||
const { items } = useDataStore.getState();
|
||||
return items.filter((item) => item.unread).length;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
groups,
|
||||
feeds,
|
||||
isLoading: isLoadingGroups || isLoadingFeeds,
|
||||
error: groupsError || feedsError,
|
||||
refresh,
|
||||
getFeedsByGroup,
|
||||
getUnreadCount,
|
||||
getGroupUnreadCount,
|
||||
getTotalUnreadCount,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useEffect } from "react";
|
||||
import { useUIStore } from "@/store";
|
||||
|
||||
export function useKeyboardShortcuts() {
|
||||
const { setSearchOpen, setSettingsOpen, isSearchOpen, isSettingsOpen, selectedArticleId, setSelectedArticle } =
|
||||
useUIStore();
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
// ⌘K or Ctrl+K: Open search
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
|
||||
event.preventDefault();
|
||||
setSearchOpen(!isSearchOpen);
|
||||
return;
|
||||
}
|
||||
|
||||
// ESC: Close modals/drawer
|
||||
if (event.key === "Escape") {
|
||||
if (isSearchOpen) {
|
||||
setSearchOpen(false);
|
||||
return;
|
||||
}
|
||||
if (isSettingsOpen) {
|
||||
setSettingsOpen(false);
|
||||
return;
|
||||
}
|
||||
if (selectedArticleId !== null) {
|
||||
setSelectedArticle(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isSearchOpen, isSettingsOpen, selectedArticleId, setSearchOpen, setSettingsOpen, setSelectedArticle]);
|
||||
}
|
||||
|
||||
export function useArticleNavigation(articleIds: number[]) {
|
||||
const { selectedArticleId, setSelectedArticle } = useUIStore();
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
// Don't handle if we're in an input or if no article is selected
|
||||
if (
|
||||
event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = selectedArticleId
|
||||
? articleIds.indexOf(selectedArticleId)
|
||||
: -1;
|
||||
|
||||
// J or ArrowDown: Next article
|
||||
if (event.key === "j" || event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
if (currentIndex < articleIds.length - 1) {
|
||||
setSelectedArticle(articleIds[currentIndex + 1]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// K or ArrowUp: Previous article
|
||||
if (event.key === "k" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
if (currentIndex > 0) {
|
||||
setSelectedArticle(articleIds[currentIndex - 1]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter: Open selected article
|
||||
if (event.key === "Enter" && currentIndex >= 0) {
|
||||
event.preventDefault();
|
||||
// Article is already selected, just keeping selection
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [articleIds, selectedArticleId, setSelectedArticle]);
|
||||
|
||||
const goToNext = () => {
|
||||
const currentIndex = selectedArticleId
|
||||
? articleIds.indexOf(selectedArticleId)
|
||||
: -1;
|
||||
if (currentIndex < articleIds.length - 1) {
|
||||
setSelectedArticle(articleIds[currentIndex + 1]);
|
||||
}
|
||||
};
|
||||
|
||||
const goToPrevious = () => {
|
||||
const currentIndex = selectedArticleId
|
||||
? articleIds.indexOf(selectedArticleId)
|
||||
: -1;
|
||||
if (currentIndex > 0) {
|
||||
setSelectedArticle(articleIds[currentIndex - 1]);
|
||||
}
|
||||
};
|
||||
|
||||
const hasNext = () => {
|
||||
const currentIndex = selectedArticleId
|
||||
? articleIds.indexOf(selectedArticleId)
|
||||
: -1;
|
||||
return currentIndex < articleIds.length - 1;
|
||||
};
|
||||
|
||||
const hasPrevious = () => {
|
||||
const currentIndex = selectedArticleId
|
||||
? articleIds.indexOf(selectedArticleId)
|
||||
: -1;
|
||||
return currentIndex > 0;
|
||||
};
|
||||
|
||||
return { goToNext, goToPrevious, hasNext, hasPrevious };
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useCallback, useRef } from "react";
|
||||
import { bookmarkAPI, type Item } from "@/lib/api";
|
||||
import { useDataStore } from "@/store";
|
||||
|
||||
export function useStarred() {
|
||||
const didInitRef = useRef(false);
|
||||
const {
|
||||
bookmarks,
|
||||
isLoadingBookmarks,
|
||||
bookmarksError,
|
||||
setBookmarks,
|
||||
setLoadingBookmarks,
|
||||
setBookmarksError,
|
||||
addBookmark,
|
||||
removeBookmark,
|
||||
isItemStarred,
|
||||
getBookmarkByItemId,
|
||||
getFeedById,
|
||||
} = useDataStore();
|
||||
|
||||
const fetchBookmarks = useCallback(async () => {
|
||||
setLoadingBookmarks(true);
|
||||
setBookmarksError(null);
|
||||
try {
|
||||
const response = await bookmarkAPI.list(100, 0);
|
||||
setBookmarks(response.data);
|
||||
} catch (error) {
|
||||
setBookmarksError(error instanceof Error ? error.message : "Failed to load starred");
|
||||
} finally {
|
||||
setLoadingBookmarks(false);
|
||||
}
|
||||
}, [setBookmarks, setLoadingBookmarks, setBookmarksError]);
|
||||
|
||||
useEffect(() => {
|
||||
// Prevent infinite re-fetch loops on failed requests (or valid empty responses).
|
||||
// Only perform the initial load attempt once per mount.
|
||||
if (didInitRef.current) return;
|
||||
didInitRef.current = true;
|
||||
|
||||
if (bookmarks.length === 0) {
|
||||
fetchBookmarks();
|
||||
}
|
||||
}, [bookmarks.length, fetchBookmarks]);
|
||||
|
||||
const starArticle = useCallback(
|
||||
async (item: Item) => {
|
||||
const feed = getFeedById(item.feed_id);
|
||||
try {
|
||||
const response = await bookmarkAPI.create({
|
||||
item_id: item.id,
|
||||
link: item.link,
|
||||
title: item.title,
|
||||
content: item.content,
|
||||
pub_date: item.pub_date,
|
||||
feed_name: feed?.name ?? "Unknown",
|
||||
});
|
||||
if (response.data) {
|
||||
addBookmark(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to star article:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[getFeedById, addBookmark]
|
||||
);
|
||||
|
||||
const unstarArticle = useCallback(
|
||||
async (itemId: number) => {
|
||||
const bookmark = getBookmarkByItemId(itemId);
|
||||
if (!bookmark) return;
|
||||
|
||||
try {
|
||||
await bookmarkAPI.delete(bookmark.id);
|
||||
removeBookmark(bookmark.id);
|
||||
} catch (error) {
|
||||
console.error("Failed to unstar article:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[getBookmarkByItemId, removeBookmark]
|
||||
);
|
||||
|
||||
const toggleStar = useCallback(
|
||||
async (item: Item) => {
|
||||
if (isItemStarred(item.id)) {
|
||||
await unstarArticle(item.id);
|
||||
} else {
|
||||
await starArticle(item);
|
||||
}
|
||||
},
|
||||
[isItemStarred, starArticle, unstarArticle]
|
||||
);
|
||||
|
||||
return {
|
||||
bookmarks,
|
||||
isLoading: isLoadingBookmarks,
|
||||
error: bookmarksError,
|
||||
refresh: fetchBookmarks,
|
||||
starArticle,
|
||||
unstarArticle,
|
||||
toggleStar,
|
||||
isStarred: isItemStarred,
|
||||
};
|
||||
}
|
||||
@@ -1,31 +1,9 @@
|
||||
import { createRootRoute, Outlet, redirect } from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
|
||||
import { useAuthStore } from "@/store";
|
||||
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
beforeLoad: async ({ location }) => {
|
||||
// Allow access to login page
|
||||
if (location.pathname === "/login") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check authentication for all other routes
|
||||
await useAuthStore.getState().checkAuth();
|
||||
const { isAuthenticated } = useAuthStore.getState();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
throw redirect({
|
||||
to: "/login",
|
||||
search: {
|
||||
redirect: location.pathname,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
component: () => (
|
||||
<>
|
||||
<Outlet />
|
||||
<TanStackRouterDevtools />
|
||||
</>
|
||||
),
|
||||
component: RootLayout,
|
||||
});
|
||||
|
||||
function RootLayout() {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,15 @@
|
||||
import { RSSReaderApp } from "@/components/rss-reader-app";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
interface SearchParams {
|
||||
feed?: number;
|
||||
group?: number;
|
||||
filter?: "all" | "unread" | "starred";
|
||||
item?: number;
|
||||
search?: boolean;
|
||||
settings?: boolean;
|
||||
}
|
||||
import { AppLayout } from "@/components/layout/app-layout";
|
||||
import { ArticleList } from "@/components/article/article-list";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: RSSReaderApp,
|
||||
validateSearch: (search: Record<string, unknown>): SearchParams => {
|
||||
return {
|
||||
feed: typeof search.feed === "number" ? search.feed : undefined,
|
||||
group: typeof search.group === "number" ? search.group : undefined,
|
||||
filter:
|
||||
search.filter === "unread" || search.filter === "starred"
|
||||
? search.filter
|
||||
: "all",
|
||||
item: typeof search.item === "number" ? search.item : undefined,
|
||||
search: search.search === true,
|
||||
settings: search.settings === true,
|
||||
};
|
||||
},
|
||||
component: HomePage,
|
||||
});
|
||||
|
||||
function HomePage() {
|
||||
return (
|
||||
<AppLayout>
|
||||
<ArticleList />
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useAuthStore } from "@/store";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { sessionAPI } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/login")({
|
||||
component: LoginPage,
|
||||
@@ -11,62 +11,55 @@ export const Route = createFileRoute("/login")({
|
||||
|
||||
function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { login, isAuthenticated, isLoading } = useAuthStore();
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
// Get redirect URL from query params
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const redirectTo = searchParams.get("redirect") || "/";
|
||||
|
||||
// Auto redirect if already authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
navigate({ to: redirectTo });
|
||||
}
|
||||
}, [isAuthenticated, navigate, redirectTo]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!password) return;
|
||||
|
||||
if (!password.trim()) {
|
||||
toast.error("Please enter a password");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await login(password);
|
||||
navigate({ to: redirectTo });
|
||||
await sessionAPI.login({ password });
|
||||
toast.success("Welcome back!");
|
||||
navigate({ to: "/" });
|
||||
} catch (error) {
|
||||
// Error is already handled by toast in the store
|
||||
toast.error("Invalid password");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="text-3xl font-bold">Fusion</h1>
|
||||
<p className="text-muted-foreground">
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="w-full max-w-sm space-y-6 p-4">
|
||||
{/* Logo */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary">
|
||||
<span className="text-xl font-bold text-primary-foreground">F</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">Fusion</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter your password to continue
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading || !password}
|
||||
>
|
||||
{isLoading ? "Logging in..." : "Login"}
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
/>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { toast } from "sonner";
|
||||
import { create } from "zustand";
|
||||
import { sessionAPI } from "../lib/api";
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
checkAuth: () => Promise<void>;
|
||||
setUnauthenticated: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
|
||||
login: async (password: string) => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
await sessionAPI.login({ password });
|
||||
set({ isAuthenticated: true, isLoading: false });
|
||||
toast.success("Login successful");
|
||||
} catch (error) {
|
||||
set({ isLoading: false });
|
||||
toast.error(error instanceof Error ? error.message : "Login failed");
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
await sessionAPI.logout();
|
||||
set({ isAuthenticated: false, isLoading: false });
|
||||
toast.success("Logged out successfully");
|
||||
} catch (error) {
|
||||
set({ isLoading: false });
|
||||
toast.error(error instanceof Error ? error.message : "Logout failed");
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
// Try to fetch groups to verify auth - if 401, we're not authenticated
|
||||
const response = await fetch("/api/groups", { credentials: "include" });
|
||||
set({
|
||||
isAuthenticated: response.ok,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
set({ isAuthenticated: false, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setUnauthenticated: () => {
|
||||
set({ isAuthenticated: false });
|
||||
},
|
||||
}));
|
||||
+89
-366
@@ -1,18 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import { groupAPI, feedAPI, itemAPI, bookmarkAPI } from "../lib/api";
|
||||
import type {
|
||||
Group,
|
||||
Feed,
|
||||
Item,
|
||||
Bookmark,
|
||||
CreateGroupRequest,
|
||||
UpdateGroupRequest,
|
||||
CreateFeedRequest,
|
||||
UpdateFeedRequest,
|
||||
ListItemsParams,
|
||||
CreateBookmarkRequest,
|
||||
} from "../lib/api";
|
||||
import { toast } from "sonner";
|
||||
import type { Group, Feed, Item, Bookmark } from "@/lib/api";
|
||||
|
||||
interface DataState {
|
||||
// Data
|
||||
@@ -20,389 +7,125 @@ interface DataState {
|
||||
feeds: Feed[];
|
||||
items: Item[];
|
||||
bookmarks: Bookmark[];
|
||||
itemsTotal: number;
|
||||
bookmarksTotal: number;
|
||||
|
||||
// Loading states
|
||||
groupsLoading: boolean;
|
||||
feedsLoading: boolean;
|
||||
itemsLoading: boolean;
|
||||
bookmarksLoading: boolean;
|
||||
isLoadingGroups: boolean;
|
||||
isLoadingFeeds: boolean;
|
||||
isLoadingItems: boolean;
|
||||
isLoadingBookmarks: boolean;
|
||||
|
||||
// Fetch methods
|
||||
fetchGroups: () => Promise<void>;
|
||||
fetchFeeds: () => Promise<void>;
|
||||
fetchItems: (params?: ListItemsParams) => Promise<void>;
|
||||
fetchBookmarks: (limit?: number, offset?: number) => Promise<void>;
|
||||
// Error states
|
||||
groupsError: string | null;
|
||||
feedsError: string | null;
|
||||
itemsError: string | null;
|
||||
bookmarksError: string | null;
|
||||
|
||||
// Group CRUD
|
||||
createGroup: (data: CreateGroupRequest) => Promise<Group>;
|
||||
updateGroup: (id: number, data: UpdateGroupRequest) => Promise<void>;
|
||||
deleteGroup: (id: number) => Promise<void>;
|
||||
// Actions
|
||||
setGroups: (groups: Group[]) => void;
|
||||
setFeeds: (feeds: Feed[]) => void;
|
||||
setItems: (items: Item[]) => void;
|
||||
setBookmarks: (bookmarks: Bookmark[]) => void;
|
||||
|
||||
// Feed CRUD
|
||||
createFeed: (data: CreateFeedRequest) => Promise<Feed>;
|
||||
updateFeed: (id: number, data: UpdateFeedRequest) => Promise<void>;
|
||||
deleteFeed: (id: number) => Promise<void>;
|
||||
refreshFeeds: () => Promise<void>;
|
||||
setLoadingGroups: (loading: boolean) => void;
|
||||
setLoadingFeeds: (loading: boolean) => void;
|
||||
setLoadingItems: (loading: boolean) => void;
|
||||
setLoadingBookmarks: (loading: boolean) => void;
|
||||
|
||||
// Item operations
|
||||
markItemsRead: (ids: number[]) => Promise<void>;
|
||||
markItemsUnread: (ids: number[]) => Promise<void>;
|
||||
deleteItem: (id: number) => Promise<void>;
|
||||
setGroupsError: (error: string | null) => void;
|
||||
setFeedsError: (error: string | null) => void;
|
||||
setItemsError: (error: string | null) => void;
|
||||
setBookmarksError: (error: string | null) => void;
|
||||
|
||||
// Bookmark operations
|
||||
createBookmark: (data: CreateBookmarkRequest) => Promise<Bookmark>;
|
||||
deleteBookmark: (id: number) => Promise<void>;
|
||||
// Item mutations
|
||||
markItemRead: (itemId: number) => void;
|
||||
markItemUnread: (itemId: number) => void;
|
||||
markItemsRead: (itemIds: number[]) => void;
|
||||
|
||||
// Reset all data
|
||||
reset: () => void;
|
||||
// Bookmark mutations
|
||||
addBookmark: (bookmark: Bookmark) => void;
|
||||
removeBookmark: (bookmarkId: number) => void;
|
||||
|
||||
// Helpers
|
||||
getFeedById: (feedId: number) => Feed | undefined;
|
||||
getGroupById: (groupId: number) => Group | undefined;
|
||||
getFeedsByGroup: (groupId: number) => Feed[];
|
||||
getItemById: (itemId: number) => Item | undefined;
|
||||
isItemStarred: (itemId: number) => boolean;
|
||||
getBookmarkByItemId: (itemId: number) => Bookmark | undefined;
|
||||
}
|
||||
|
||||
export const useDataStore = create<DataState>((set, get) => ({
|
||||
// Initial state
|
||||
groups: [],
|
||||
feeds: [],
|
||||
items: [],
|
||||
bookmarks: [],
|
||||
itemsTotal: 0,
|
||||
bookmarksTotal: 0,
|
||||
groupsLoading: false,
|
||||
feedsLoading: false,
|
||||
itemsLoading: false,
|
||||
bookmarksLoading: false,
|
||||
|
||||
// Fetch methods
|
||||
fetchGroups: async () => {
|
||||
set({ groupsLoading: true });
|
||||
try {
|
||||
const response = await groupAPI.list();
|
||||
set({ groups: response.data, groupsLoading: false });
|
||||
} catch (error) {
|
||||
set({ groupsLoading: false });
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to fetch groups"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
isLoadingGroups: false,
|
||||
isLoadingFeeds: false,
|
||||
isLoadingItems: false,
|
||||
isLoadingBookmarks: false,
|
||||
|
||||
fetchFeeds: async () => {
|
||||
set({ feedsLoading: true });
|
||||
try {
|
||||
const response = await feedAPI.list();
|
||||
set({ feeds: response.data, feedsLoading: false });
|
||||
} catch (error) {
|
||||
set({ feedsLoading: false });
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to fetch feeds"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
groupsError: null,
|
||||
feedsError: null,
|
||||
itemsError: null,
|
||||
bookmarksError: null,
|
||||
|
||||
fetchItems: async (params?: ListItemsParams) => {
|
||||
set({ itemsLoading: true });
|
||||
try {
|
||||
const response = await itemAPI.list(params);
|
||||
set({
|
||||
items: response.data,
|
||||
itemsTotal: response.total,
|
||||
itemsLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
set({ itemsLoading: false });
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to fetch items"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
setGroups: (groups) => set({ groups }),
|
||||
setFeeds: (feeds) => set({ feeds }),
|
||||
setItems: (items) => set({ items }),
|
||||
setBookmarks: (bookmarks) => set({ bookmarks }),
|
||||
|
||||
fetchBookmarks: async (limit = 50, offset = 0) => {
|
||||
set({ bookmarksLoading: true });
|
||||
try {
|
||||
const response = await bookmarkAPI.list(limit, offset);
|
||||
set({
|
||||
bookmarks: response.data,
|
||||
bookmarksTotal: response.total,
|
||||
bookmarksLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
set({ bookmarksLoading: false });
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to fetch bookmarks"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
setLoadingGroups: (loading) => set({ isLoadingGroups: loading }),
|
||||
setLoadingFeeds: (loading) => set({ isLoadingFeeds: loading }),
|
||||
setLoadingItems: (loading) => set({ isLoadingItems: loading }),
|
||||
setLoadingBookmarks: (loading) => set({ isLoadingBookmarks: loading }),
|
||||
|
||||
// Group CRUD
|
||||
createGroup: async (data: CreateGroupRequest) => {
|
||||
try {
|
||||
const response = await groupAPI.create(data);
|
||||
if (!response.data) {
|
||||
throw new Error("No data returned from create group");
|
||||
}
|
||||
// Optimistic update
|
||||
set((state) => ({
|
||||
groups: [...state.groups, response.data!],
|
||||
}));
|
||||
toast.success("Group created successfully");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to create group"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
setGroupsError: (error) => set({ groupsError: error }),
|
||||
setFeedsError: (error) => set({ feedsError: error }),
|
||||
setItemsError: (error) => set({ itemsError: error }),
|
||||
setBookmarksError: (error) => set({ bookmarksError: error }),
|
||||
|
||||
updateGroup: async (id: number, data: UpdateGroupRequest) => {
|
||||
const { groups } = get();
|
||||
const originalGroup = groups.find((g) => g.id === id);
|
||||
|
||||
// Optimistic update
|
||||
set({
|
||||
groups: groups.map((g) => (g.id === id ? { ...g, ...data } : g)),
|
||||
});
|
||||
|
||||
try {
|
||||
await groupAPI.update(id, data);
|
||||
toast.success("Group updated successfully");
|
||||
} catch (error) {
|
||||
// Rollback on error
|
||||
if (originalGroup) {
|
||||
set({
|
||||
groups: groups.map((g) => (g.id === id ? originalGroup : g)),
|
||||
});
|
||||
}
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to update group"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteGroup: async (id: number) => {
|
||||
const { groups } = get();
|
||||
const originalGroups = [...groups];
|
||||
|
||||
// Optimistic update
|
||||
set({ groups: groups.filter((g) => g.id !== id) });
|
||||
|
||||
try {
|
||||
await groupAPI.delete(id);
|
||||
toast.success("Group deleted successfully");
|
||||
} catch (error) {
|
||||
// Rollback on error
|
||||
set({ groups: originalGroups });
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to delete group"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Feed CRUD
|
||||
createFeed: async (data: CreateFeedRequest) => {
|
||||
try {
|
||||
const response = await feedAPI.create(data);
|
||||
if (!response.data) {
|
||||
throw new Error("No data returned from create feed");
|
||||
}
|
||||
// Optimistic update
|
||||
set((state) => ({
|
||||
feeds: [...state.feeds, response.data!],
|
||||
}));
|
||||
toast.success("Feed created successfully");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to create feed"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
updateFeed: async (id: number, data: UpdateFeedRequest) => {
|
||||
const { feeds } = get();
|
||||
const originalFeed = feeds.find((f) => f.id === id);
|
||||
|
||||
// Optimistic update
|
||||
set({
|
||||
feeds: feeds.map((f) => (f.id === id ? { ...f, ...data } : f)),
|
||||
});
|
||||
|
||||
try {
|
||||
await feedAPI.update(id, data);
|
||||
toast.success("Feed updated successfully");
|
||||
} catch (error) {
|
||||
// Rollback on error
|
||||
if (originalFeed) {
|
||||
set({
|
||||
feeds: feeds.map((f) => (f.id === id ? originalFeed : f)),
|
||||
});
|
||||
}
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to update feed"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteFeed: async (id: number) => {
|
||||
const { feeds } = get();
|
||||
const originalFeeds = [...feeds];
|
||||
|
||||
// Optimistic update
|
||||
set({ feeds: feeds.filter((f) => f.id !== id) });
|
||||
|
||||
try {
|
||||
await feedAPI.delete(id);
|
||||
toast.success("Feed deleted successfully");
|
||||
} catch (error) {
|
||||
// Rollback on error
|
||||
set({ feeds: originalFeeds });
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to delete feed"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
refreshFeeds: async () => {
|
||||
try {
|
||||
await feedAPI.refresh();
|
||||
toast.success("Feeds refresh triggered");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to refresh feeds"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Item operations
|
||||
markItemsRead: async (ids: number[]) => {
|
||||
const { items } = get();
|
||||
const originalItems = [...items];
|
||||
|
||||
// Optimistic update
|
||||
set({
|
||||
items: items.map((item) =>
|
||||
ids.includes(item.id) ? { ...item, unread: false } : item
|
||||
markItemRead: (itemId) =>
|
||||
set((state) => ({
|
||||
items: state.items.map((item) =>
|
||||
item.id === itemId ? { ...item, unread: false } : item
|
||||
),
|
||||
});
|
||||
})),
|
||||
|
||||
try {
|
||||
await itemAPI.markRead({ ids });
|
||||
toast.success(`Marked ${ids.length} item(s) as read`);
|
||||
} catch (error) {
|
||||
// Rollback on error
|
||||
set({ items: originalItems });
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to mark items as read"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
markItemsUnread: async (ids: number[]) => {
|
||||
const { items } = get();
|
||||
const originalItems = [...items];
|
||||
|
||||
// Optimistic update
|
||||
set({
|
||||
items: items.map((item) =>
|
||||
ids.includes(item.id) ? { ...item, unread: true } : item
|
||||
markItemUnread: (itemId) =>
|
||||
set((state) => ({
|
||||
items: state.items.map((item) =>
|
||||
item.id === itemId ? { ...item, unread: true } : item
|
||||
),
|
||||
});
|
||||
})),
|
||||
|
||||
try {
|
||||
await itemAPI.markUnread({ ids });
|
||||
toast.success(`Marked ${ids.length} item(s) as unread`);
|
||||
} catch (error) {
|
||||
// Rollback on error
|
||||
set({ items: originalItems });
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to mark items as unread"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
markItemsRead: (itemIds) =>
|
||||
set((state) => ({
|
||||
items: state.items.map((item) =>
|
||||
itemIds.includes(item.id) ? { ...item, unread: false } : item
|
||||
),
|
||||
})),
|
||||
|
||||
deleteItem: async (id: number) => {
|
||||
const { items } = get();
|
||||
const originalItems = [...items];
|
||||
addBookmark: (bookmark) =>
|
||||
set((state) => ({ bookmarks: [bookmark, ...state.bookmarks] })),
|
||||
|
||||
// Optimistic update
|
||||
set({ items: items.filter((item) => item.id !== id) });
|
||||
removeBookmark: (bookmarkId) =>
|
||||
set((state) => ({
|
||||
bookmarks: state.bookmarks.filter((b) => b.id !== bookmarkId),
|
||||
})),
|
||||
|
||||
try {
|
||||
await itemAPI.delete(id);
|
||||
toast.success("Item deleted successfully");
|
||||
} catch (error) {
|
||||
// Rollback on error
|
||||
set({ items: originalItems });
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to delete item"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
getFeedById: (feedId) => get().feeds.find((f) => f.id === feedId),
|
||||
|
||||
// Bookmark operations
|
||||
createBookmark: async (data: CreateBookmarkRequest) => {
|
||||
try {
|
||||
const response = await bookmarkAPI.create(data);
|
||||
if (!response.data) {
|
||||
throw new Error("No data returned from create bookmark");
|
||||
}
|
||||
// Optimistic update
|
||||
set((state) => ({
|
||||
bookmarks: [response.data!, ...state.bookmarks],
|
||||
}));
|
||||
toast.success("Bookmark created successfully");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to create bookmark"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
getGroupById: (groupId) => get().groups.find((g) => g.id === groupId),
|
||||
|
||||
deleteBookmark: async (id: number) => {
|
||||
const { bookmarks } = get();
|
||||
const originalBookmarks = [...bookmarks];
|
||||
getFeedsByGroup: (groupId) =>
|
||||
get().feeds.filter((f) => f.group_id === groupId),
|
||||
|
||||
// Optimistic update
|
||||
set({ bookmarks: bookmarks.filter((b) => b.id !== id) });
|
||||
getItemById: (itemId) => get().items.find((i) => i.id === itemId),
|
||||
|
||||
try {
|
||||
await bookmarkAPI.delete(id);
|
||||
toast.success("Bookmark deleted successfully");
|
||||
} catch (error) {
|
||||
// Rollback on error
|
||||
set({ bookmarks: originalBookmarks });
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to delete bookmark"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
isItemStarred: (itemId) =>
|
||||
get().bookmarks.some((b) => b.item_id === itemId),
|
||||
|
||||
reset: () => {
|
||||
set({
|
||||
groups: [],
|
||||
feeds: [],
|
||||
items: [],
|
||||
bookmarks: [],
|
||||
itemsTotal: 0,
|
||||
bookmarksTotal: 0,
|
||||
});
|
||||
},
|
||||
getBookmarkByItemId: (itemId) =>
|
||||
get().bookmarks.find((b) => b.item_id === itemId),
|
||||
}));
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { setUnauthorizedCallback } from "../lib/api";
|
||||
import { useAuthStore } from "./auth";
|
||||
import { setUnauthorizedCallback } from "@/lib/api";
|
||||
|
||||
// Initialize 401 handler
|
||||
setUnauthorizedCallback(() => {
|
||||
useAuthStore.getState().setUnauthenticated();
|
||||
});
|
||||
|
||||
export { useAuthStore } from "./auth";
|
||||
export { useUIStore, type ArticleFilter } from "./ui";
|
||||
export { useDataStore } from "./data";
|
||||
export { useUIStore } from "./ui";
|
||||
|
||||
// Setup 401 handler - redirect to login on unauthorized
|
||||
setUnauthorizedCallback(() => {
|
||||
window.location.href = "/login";
|
||||
});
|
||||
|
||||
+45
-11
@@ -1,19 +1,53 @@
|
||||
import { create } from 'zustand';
|
||||
import { create } from "zustand";
|
||||
|
||||
export type ArticleFilter = "all" | "unread" | "starred";
|
||||
|
||||
interface UIState {
|
||||
sidebarOpen: boolean;
|
||||
toggleSidebar: () => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
// Navigation
|
||||
selectedGroupId: number | null;
|
||||
selectedFeedId: number | null;
|
||||
|
||||
// Article drawer
|
||||
selectedArticleId: number | null;
|
||||
|
||||
// Filters
|
||||
articleFilter: ArticleFilter;
|
||||
|
||||
// Modals
|
||||
isSearchOpen: boolean;
|
||||
isSettingsOpen: boolean;
|
||||
|
||||
// Actions
|
||||
setSelectedGroup: (groupId: number | null) => void;
|
||||
setSelectedFeed: (feedId: number | null) => void;
|
||||
setSelectedArticle: (articleId: number | null) => void;
|
||||
setArticleFilter: (filter: ArticleFilter) => void;
|
||||
setSearchOpen: (open: boolean) => void;
|
||||
setSettingsOpen: (open: boolean) => void;
|
||||
selectAll: () => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>((set) => ({
|
||||
sidebarOpen: false,
|
||||
selectedGroupId: null,
|
||||
selectedFeedId: null,
|
||||
selectedArticleId: null,
|
||||
articleFilter: "all",
|
||||
isSearchOpen: false,
|
||||
isSettingsOpen: false,
|
||||
|
||||
toggleSidebar: () => {
|
||||
set((state) => ({ sidebarOpen: !state.sidebarOpen }));
|
||||
},
|
||||
setSelectedGroup: (groupId) =>
|
||||
set({ selectedGroupId: groupId, selectedFeedId: null }),
|
||||
|
||||
setSidebarOpen: (open: boolean) => {
|
||||
set({ sidebarOpen: open });
|
||||
},
|
||||
setSelectedFeed: (feedId) =>
|
||||
set({ selectedFeedId: feedId, selectedGroupId: null }),
|
||||
|
||||
setSelectedArticle: (articleId) => set({ selectedArticleId: articleId }),
|
||||
|
||||
setArticleFilter: (filter) => set({ articleFilter: filter }),
|
||||
|
||||
setSearchOpen: (open) => set({ isSearchOpen: open }),
|
||||
|
||||
setSettingsOpen: (open) => set({ isSettingsOpen: open }),
|
||||
|
||||
selectAll: () => set({ selectedGroupId: null, selectedFeedId: null }),
|
||||
}));
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import path from "path"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { TanStackRouterVite } from "@tanstack/router-vite-plugin"
|
||||
import { defineConfig } from "vite"
|
||||
import path from "path";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { tanstackRouter } from "@tanstack/router-vite-plugin";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), TanStackRouterVite(), tailwindcss()],
|
||||
plugins: [react(), tanstackRouter(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
@@ -20,4 +20,4 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user