chore: added home page 6e6a91c4
Steve · 2025-11-04 22:21 9 file(s) · +290 −207
TODO.md +1 −1
15 15
- [x] Marking Read and Unread
16 16
- [x] Change Reset to "Log out" or something along those lines
17 17
- [x] Add About to settings menu?
18 -
- [ ] Add Home screen with start button + acknowledgements
18 +
- [x] Add Home screen with start button + acknowledgements
19 19
- [ ] Import + Export OPML in settings
src/App.tsx +132 −1
1 1
import Dashboard from "./components/dashboard";
2 +
import { useQuery } from "@evolu/react";
3 +
import { allFeedsQuery, useEvolu } from "@/lib/evolu";
4 +
import { Button } from "@/components/ui/button";
5 +
import { Input } from "@/components/ui/input";
6 +
import * as React from "react";
7 +
import { toast } from "sonner";
8 +
import {
9 +
	fetchFeedWithFallback,
10 +
	parseFeedXml,
11 +
	discoverFeed,
12 +
	looksLikeFeedUrl,
13 +
	extractPostLink,
14 +
	extractPostAuthor,
15 +
	extractPostContent,
16 +
	extractPostDate,
17 +
} from "@/lib/feed-operations";
2 18
3 19
function App() {
20 +
	const allFeeds = useQuery(allFeedsQuery);
21 +
	const hasFeeds = allFeeds.length > 0;
22 +
	const [urlInput, setUrlInput] = React.useState("");
23 +
	const [isAddingFeed, setIsAddingFeed] = React.useState(false);
24 +
	const [errorMessage, setErrorMessage] = React.useState("");
25 +
26 +
	const evolu = useEvolu();
27 +
28 +
	async function addFeed() {
29 +
		if (!urlInput.trim()) {
30 +
			setErrorMessage("Please enter a URL");
31 +
			return;
32 +
		}
33 +
34 +
		setIsAddingFeed(true);
35 +
		setErrorMessage("");
36 +
37 +
		try {
38 +
			let feedUrl = urlInput;
39 +
			let xmlData: string | null = null;
40 +
41 +
			if (!looksLikeFeedUrl(urlInput)) {
42 +
				const discovered = await discoverFeed(urlInput);
43 +
44 +
				if (!discovered) {
45 +
					setErrorMessage(
46 +
						"Could not find an RSS feed at this URL. Please enter a direct feed URL.",
47 +
					);
48 +
					setIsAddingFeed(false);
49 +
					return;
50 +
				}
51 +
52 +
				feedUrl = discovered.feedUrl;
53 +
				xmlData = discovered.xmlData;
54 +
			} else {
55 +
				xmlData = await fetchFeedWithFallback(feedUrl);
56 +
			}
57 +
58 +
			const { feedData, posts, isAtom } = parseFeedXml(xmlData);
59 +
60 +
			const result = evolu.insert("rssFeed", {
61 +
				feedUrl: feedUrl,
62 +
				title: feedData.title,
63 +
				description: feedData.description || feedData.subtitle || "",
64 +
				category: "Uncategorized",
65 +
				dateUpdated: new Date().toISOString(),
66 +
			});
67 +
68 +
			if (!result.ok) {
69 +
				throw new Error("Failed to insert feed");
70 +
			}
71 +
72 +
			for (const post of posts) {
73 +
				evolu.insert("rssPost", {
74 +
					title: post.title,
75 +
					author: extractPostAuthor(post, isAtom, feedData.title),
76 +
					publishedDate: extractPostDate(post),
77 +
					link: extractPostLink(post, isAtom),
78 +
					feedId: result.value.id,
79 +
					content: extractPostContent(post),
80 +
				});
81 +
			}
82 +
83 +
			toast.success(
84 +
				`Successfully added "${feedData.title}" with ${posts.length} post${posts.length !== 1 ? "s" : ""}`,
85 +
			);
86 +
87 +
			setUrlInput("");
88 +
			setErrorMessage("");
89 +
		} catch (error) {
90 +
			console.error("Error adding feed:", error);
91 +
			setErrorMessage(
92 +
				error instanceof Error
93 +
					? error.message
94 +
					: "Failed to add feed. Please check the URL and try again.",
95 +
			);
96 +
		} finally {
97 +
			setIsAddingFeed(false);
98 +
		}
99 +
	}
100 +
4 101
	return (
5 102
		<main className="min-h-screen w-full items-center justify-center flex-col flex gap-2">
6 -
			<Dashboard />
103 +
			{hasFeeds ? (
104 +
				<Dashboard />
105 +
			) : (
106 +
				<div className="flex flex-col items-start justify-center gap-6 max-w-md w-full px-4">
107 +
					<div className="flex flex-col gap-2">
108 +
						<h1 className="text-4xl font-bold">Alcove</h1>
109 +
						<h4 className="sm:text-sm text-xs">
110 +
							A privacy focused RSS reader for the open web
111 +
						</h4>
112 +
					</div>
113 +
					<div className="flex flex-col gap-3 w-full">
114 +
						<div className="flex flex-row gap-3 w-full">
115 +
							<Input
116 +
								value={urlInput}
117 +
								onChange={(e) => setUrlInput(e.target.value)}
118 +
								placeholder="https://example.com"
119 +
								disabled={isAddingFeed}
120 +
								onKeyDown={(e) => {
121 +
									if (e.key === "Enter") {
122 +
										addFeed();
123 +
									}
124 +
								}}
125 +
							/>
126 +
							<Button onClick={addFeed} disabled={isAddingFeed} size="lg">
127 +
								{isAddingFeed ? "Adding..." : "Add Feed"}
128 +
							</Button>
129 +
						</div>
130 +
						{errorMessage && (
131 +
							<div className="text-sm text-center text-destructive">
132 +
								{errorMessage}
133 +
							</div>
134 +
						)}
135 +
					</div>
136 +
				</div>
137 +
			)}
7 138
		</main>
8 139
	);
9 140
}
src/components/app-sidebar.tsx +1 −16
1 -
"use client";
2 -
3 1
import * as React from "react";
4 2
import { NavUser } from "@/components/nav-user";
5 3
import { NavFeeds } from "@/components/nav-feeds";
24 22
	allReadStatusesQuery,
25 23
	allReadStatusesWithUnreadQuery,
26 24
	useEvolu,
27 -
	reset,
28 25
	evolu as evoluInstance,
29 26
} from "@/lib/evolu";
30 27
import * as Evolu from "@evolu/common";
37 34
	extractPostContent,
38 35
	extractPostDate,
39 36
} from "@/lib/feed-operations";
40 -
41 -
// This is sample data
42 -
const data = {
43 -
	user: {
44 -
		name: "shadcn",
45 -
		email: "m@example.com",
46 -
		avatar: "/avatars/shadcn.jpg",
47 -
	},
48 -
};
49 37
50 38
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
51 39
	selectedFeedId?: string | null;
419 407
							<FeedActions
420 408
								onAddFeed={() => setDialogOpen(true)}
421 409
								onRefresh={refreshFeeds}
422 -
								onReset={reset}
423 410
							/>
424 411
							<NavFeeds
425 412
								feeds={allFeeds}
430 417
					)}
431 418
				</SidebarContent>
432 419
				<SidebarFooter>
433 -
					{!isMobile || mobileView === "feeds" ? (
434 -
						<NavUser user={data.user} />
435 -
					) : null}
420 +
					{!isMobile || mobileView === "feeds" ? <NavUser /> : null}
436 421
				</SidebarFooter>
437 422
			</Sidebar>
438 423
src/components/dashboard.tsx +0 −2
1 -
"use client";
2 -
3 1
import * as React from "react";
4 2
import { AppSidebar } from "@/components/app-sidebar";
5 3
import {
src/components/feed-actions.tsx +1 −12
11 11
interface FeedActionsProps {
12 12
	onAddFeed: () => void;
13 13
	onRefresh: () => void;
14 -
	onReset: () => void;
15 14
}
16 15
17 -
export function FeedActions({
18 -
	onAddFeed,
19 -
	onRefresh,
20 -
	onReset,
21 -
}: FeedActionsProps) {
16 +
export function FeedActions({ onAddFeed, onRefresh }: FeedActionsProps) {
22 17
	return (
23 18
		<SidebarGroup>
24 19
			<SidebarGroupLabel>Actions</SidebarGroupLabel>
28 23
						<SidebarMenuButton onClick={onAddFeed}>
29 24
							<Plus className="size-4" />
30 25
							<span>Add Feed</span>
31 -
						</SidebarMenuButton>
32 -
					</SidebarMenuItem>
33 -
					<SidebarMenuItem>
34 -
						<SidebarMenuButton onClick={onReset}>
35 -
							<RotateCw className="size-4" />
36 -
							<span>Reset</span>
37 26
						</SidebarMenuButton>
38 27
					</SidebarMenuItem>
39 28
					<SidebarMenuItem>
src/components/nav-favorites.tsx +82 −84
1 -
"use client"
2 -
3 1
import {
4 -
  ArrowUpRight,
5 -
  Link,
6 -
  MoreHorizontal,
7 -
  StarOff,
8 -
  Trash2,
9 -
} from "lucide-react"
2 +
	ArrowUpRight,
3 +
	Link,
4 +
	MoreHorizontal,
5 +
	StarOff,
6 +
	Trash2,
7 +
} from "lucide-react";
10 8
11 9
import {
12 -
  DropdownMenu,
13 -
  DropdownMenuContent,
14 -
  DropdownMenuItem,
15 -
  DropdownMenuSeparator,
16 -
  DropdownMenuTrigger,
17 -
} from "@/components/ui/dropdown-menu"
10 +
	DropdownMenu,
11 +
	DropdownMenuContent,
12 +
	DropdownMenuItem,
13 +
	DropdownMenuSeparator,
14 +
	DropdownMenuTrigger,
15 +
} from "@/components/ui/dropdown-menu";
18 16
import {
19 -
  SidebarGroup,
20 -
  SidebarGroupLabel,
21 -
  SidebarMenu,
22 -
  SidebarMenuAction,
23 -
  SidebarMenuButton,
24 -
  SidebarMenuItem,
25 -
  useSidebar,
26 -
} from "@/components/ui/sidebar"
17 +
	SidebarGroup,
18 +
	SidebarGroupLabel,
19 +
	SidebarMenu,
20 +
	SidebarMenuAction,
21 +
	SidebarMenuButton,
22 +
	SidebarMenuItem,
23 +
	useSidebar,
24 +
} from "@/components/ui/sidebar";
27 25
28 26
export function NavFavorites({
29 -
  favorites,
27 +
	favorites,
30 28
}: {
31 -
  favorites: {
32 -
    name: string
33 -
    url: string
34 -
    emoji: string
35 -
  }[]
29 +
	favorites: {
30 +
		name: string;
31 +
		url: string;
32 +
		emoji: string;
33 +
	}[];
36 34
}) {
37 -
  const { isMobile } = useSidebar()
35 +
	const { isMobile } = useSidebar();
38 36
39 -
  return (
40 -
    <SidebarGroup className="group-data-[collapsible=icon]:hidden">
41 -
      <SidebarGroupLabel>Favorites</SidebarGroupLabel>
42 -
      <SidebarMenu>
43 -
        {favorites.map((item) => (
44 -
          <SidebarMenuItem key={item.name}>
45 -
            <SidebarMenuButton asChild>
46 -
              <a href={item.url} title={item.name}>
47 -
                <span>{item.emoji}</span>
48 -
                <span>{item.name}</span>
49 -
              </a>
50 -
            </SidebarMenuButton>
51 -
            <DropdownMenu>
52 -
              <DropdownMenuTrigger asChild>
53 -
                <SidebarMenuAction showOnHover>
54 -
                  <MoreHorizontal />
55 -
                  <span className="sr-only">More</span>
56 -
                </SidebarMenuAction>
57 -
              </DropdownMenuTrigger>
58 -
              <DropdownMenuContent
59 -
                className="w-56 rounded-lg"
60 -
                side={isMobile ? "bottom" : "right"}
61 -
                align={isMobile ? "end" : "start"}
62 -
              >
63 -
                <DropdownMenuItem>
64 -
                  <StarOff className="text-muted-foreground" />
65 -
                  <span>Remove from Favorites</span>
66 -
                </DropdownMenuItem>
67 -
                <DropdownMenuSeparator />
68 -
                <DropdownMenuItem>
69 -
                  <Link className="text-muted-foreground" />
70 -
                  <span>Copy Link</span>
71 -
                </DropdownMenuItem>
72 -
                <DropdownMenuItem>
73 -
                  <ArrowUpRight className="text-muted-foreground" />
74 -
                  <span>Open in New Tab</span>
75 -
                </DropdownMenuItem>
76 -
                <DropdownMenuSeparator />
77 -
                <DropdownMenuItem>
78 -
                  <Trash2 className="text-muted-foreground" />
79 -
                  <span>Delete</span>
80 -
                </DropdownMenuItem>
81 -
              </DropdownMenuContent>
82 -
            </DropdownMenu>
83 -
          </SidebarMenuItem>
84 -
        ))}
85 -
        <SidebarMenuItem>
86 -
          <SidebarMenuButton className="text-sidebar-foreground/70">
87 -
            <MoreHorizontal />
88 -
            <span>More</span>
89 -
          </SidebarMenuButton>
90 -
        </SidebarMenuItem>
91 -
      </SidebarMenu>
92 -
    </SidebarGroup>
93 -
  )
37 +
	return (
38 +
		<SidebarGroup className="group-data-[collapsible=icon]:hidden">
39 +
			<SidebarGroupLabel>Favorites</SidebarGroupLabel>
40 +
			<SidebarMenu>
41 +
				{favorites.map((item) => (
42 +
					<SidebarMenuItem key={item.name}>
43 +
						<SidebarMenuButton asChild>
44 +
							<a href={item.url} title={item.name}>
45 +
								<span>{item.emoji}</span>
46 +
								<span>{item.name}</span>
47 +
							</a>
48 +
						</SidebarMenuButton>
49 +
						<DropdownMenu>
50 +
							<DropdownMenuTrigger asChild>
51 +
								<SidebarMenuAction showOnHover>
52 +
									<MoreHorizontal />
53 +
									<span className="sr-only">More</span>
54 +
								</SidebarMenuAction>
55 +
							</DropdownMenuTrigger>
56 +
							<DropdownMenuContent
57 +
								className="w-56 rounded-lg"
58 +
								side={isMobile ? "bottom" : "right"}
59 +
								align={isMobile ? "end" : "start"}
60 +
							>
61 +
								<DropdownMenuItem>
62 +
									<StarOff className="text-muted-foreground" />
63 +
									<span>Remove from Favorites</span>
64 +
								</DropdownMenuItem>
65 +
								<DropdownMenuSeparator />
66 +
								<DropdownMenuItem>
67 +
									<Link className="text-muted-foreground" />
68 +
									<span>Copy Link</span>
69 +
								</DropdownMenuItem>
70 +
								<DropdownMenuItem>
71 +
									<ArrowUpRight className="text-muted-foreground" />
72 +
									<span>Open in New Tab</span>
73 +
								</DropdownMenuItem>
74 +
								<DropdownMenuSeparator />
75 +
								<DropdownMenuItem>
76 +
									<Trash2 className="text-muted-foreground" />
77 +
									<span>Delete</span>
78 +
								</DropdownMenuItem>
79 +
							</DropdownMenuContent>
80 +
						</DropdownMenu>
81 +
					</SidebarMenuItem>
82 +
				))}
83 +
				<SidebarMenuItem>
84 +
					<SidebarMenuButton className="text-sidebar-foreground/70">
85 +
						<MoreHorizontal />
86 +
						<span>More</span>
87 +
					</SidebarMenuButton>
88 +
				</SidebarMenuItem>
89 +
			</SidebarMenu>
90 +
		</SidebarGroup>
91 +
	);
94 92
}
src/components/nav-feeds.tsx +1 −3
1 -
"use client";
2 -
3 -
import { ChevronRight, Rss } from "lucide-react";
1 +
import { ChevronRight } from "lucide-react";
4 2
5 3
import {
6 4
	Collapsible,
src/components/nav-user.tsx +1 −15
1 -
import {
2 -
	BadgeCheck,
3 -
	Bell,
4 -
	BookKey,
5 -
	Copy,
6 -
	CreditCard,
7 -
	Eye,
8 -
	EyeOff,
9 -
	Info,
10 -
	LogOut,
11 -
	Sparkles,
12 -
	Trash2,
13 -
	Upload,
14 -
} from "lucide-react";
1 +
import { BookKey, Copy, Eye, EyeOff, Info, Trash2, Upload } from "lucide-react";
15 2
import {
16 3
	Dialog,
17 4
	DialogContent,
29 16
	DropdownMenuContent,
30 17
	DropdownMenuGroup,
31 18
	DropdownMenuItem,
32 -
	DropdownMenuLabel,
33 19
	DropdownMenuSeparator,
34 20
	DropdownMenuTrigger,
35 21
} from "@/components/ui/dropdown-menu";
src/components/team-switcher.tsx +71 −73
1 -
"use client"
2 -
3 -
import * as React from "react"
4 -
import { ChevronDown, Plus } from "lucide-react"
1 +
import * as React from "react";
2 +
import { ChevronDown, Plus } from "lucide-react";
5 3
6 4
import {
7 -
  DropdownMenu,
8 -
  DropdownMenuContent,
9 -
  DropdownMenuItem,
10 -
  DropdownMenuLabel,
11 -
  DropdownMenuSeparator,
12 -
  DropdownMenuShortcut,
13 -
  DropdownMenuTrigger,
14 -
} from "@/components/ui/dropdown-menu"
5 +
	DropdownMenu,
6 +
	DropdownMenuContent,
7 +
	DropdownMenuItem,
8 +
	DropdownMenuLabel,
9 +
	DropdownMenuSeparator,
10 +
	DropdownMenuShortcut,
11 +
	DropdownMenuTrigger,
12 +
} from "@/components/ui/dropdown-menu";
15 13
import {
16 -
  SidebarMenu,
17 -
  SidebarMenuButton,
18 -
  SidebarMenuItem,
19 -
} from "@/components/ui/sidebar"
14 +
	SidebarMenu,
15 +
	SidebarMenuButton,
16 +
	SidebarMenuItem,
17 +
} from "@/components/ui/sidebar";
20 18
21 19
export function TeamSwitcher({
22 -
  teams,
20 +
	teams,
23 21
}: {
24 -
  teams: {
25 -
    name: string
26 -
    logo: React.ElementType
27 -
    plan: string
28 -
  }[]
22 +
	teams: {
23 +
		name: string;
24 +
		logo: React.ElementType;
25 +
		plan: string;
26 +
	}[];
29 27
}) {
30 -
  const [activeTeam, setActiveTeam] = React.useState(teams[0])
28 +
	const [activeTeam, setActiveTeam] = React.useState(teams[0]);
31 29
32 -
  if (!activeTeam) {
33 -
    return null
34 -
  }
30 +
	if (!activeTeam) {
31 +
		return null;
32 +
	}
35 33
36 -
  return (
37 -
    <SidebarMenu>
38 -
      <SidebarMenuItem>
39 -
        <DropdownMenu>
40 -
          <DropdownMenuTrigger asChild>
41 -
            <SidebarMenuButton className="w-fit px-1.5">
42 -
              <div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-5 items-center justify-center rounded-md">
43 -
                <activeTeam.logo className="size-3" />
44 -
              </div>
45 -
              <span className="truncate font-medium">{activeTeam.name}</span>
46 -
              <ChevronDown className="opacity-50" />
47 -
            </SidebarMenuButton>
48 -
          </DropdownMenuTrigger>
49 -
          <DropdownMenuContent
50 -
            className="w-64 rounded-lg"
51 -
            align="start"
52 -
            side="bottom"
53 -
            sideOffset={4}
54 -
          >
55 -
            <DropdownMenuLabel className="text-muted-foreground text-xs">
56 -
              Teams
57 -
            </DropdownMenuLabel>
58 -
            {teams.map((team, index) => (
59 -
              <DropdownMenuItem
60 -
                key={team.name}
61 -
                onClick={() => setActiveTeam(team)}
62 -
                className="gap-2 p-2"
63 -
              >
64 -
                <div className="flex size-6 items-center justify-center rounded-xs border">
65 -
                  <team.logo className="size-4 shrink-0" />
66 -
                </div>
67 -
                {team.name}
68 -
                <DropdownMenuShortcut>⌘{index + 1}</DropdownMenuShortcut>
69 -
              </DropdownMenuItem>
70 -
            ))}
71 -
            <DropdownMenuSeparator />
72 -
            <DropdownMenuItem className="gap-2 p-2">
73 -
              <div className="bg-background flex size-6 items-center justify-center rounded-md border">
74 -
                <Plus className="size-4" />
75 -
              </div>
76 -
              <div className="text-muted-foreground font-medium">Add team</div>
77 -
            </DropdownMenuItem>
78 -
          </DropdownMenuContent>
79 -
        </DropdownMenu>
80 -
      </SidebarMenuItem>
81 -
    </SidebarMenu>
82 -
  )
34 +
	return (
35 +
		<SidebarMenu>
36 +
			<SidebarMenuItem>
37 +
				<DropdownMenu>
38 +
					<DropdownMenuTrigger asChild>
39 +
						<SidebarMenuButton className="w-fit px-1.5">
40 +
							<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-5 items-center justify-center rounded-md">
41 +
								<activeTeam.logo className="size-3" />
42 +
							</div>
43 +
							<span className="truncate font-medium">{activeTeam.name}</span>
44 +
							<ChevronDown className="opacity-50" />
45 +
						</SidebarMenuButton>
46 +
					</DropdownMenuTrigger>
47 +
					<DropdownMenuContent
48 +
						className="w-64 rounded-lg"
49 +
						align="start"
50 +
						side="bottom"
51 +
						sideOffset={4}
52 +
					>
53 +
						<DropdownMenuLabel className="text-muted-foreground text-xs">
54 +
							Teams
55 +
						</DropdownMenuLabel>
56 +
						{teams.map((team, index) => (
57 +
							<DropdownMenuItem
58 +
								key={team.name}
59 +
								onClick={() => setActiveTeam(team)}
60 +
								className="gap-2 p-2"
61 +
							>
62 +
								<div className="flex size-6 items-center justify-center rounded-xs border">
63 +
									<team.logo className="size-4 shrink-0" />
64 +
								</div>
65 +
								{team.name}
66 +
								<DropdownMenuShortcut>⌘{index + 1}</DropdownMenuShortcut>
67 +
							</DropdownMenuItem>
68 +
						))}
69 +
						<DropdownMenuSeparator />
70 +
						<DropdownMenuItem className="gap-2 p-2">
71 +
							<div className="bg-background flex size-6 items-center justify-center rounded-md border">
72 +
								<Plus className="size-4" />
73 +
							</div>
74 +
							<div className="text-muted-foreground font-medium">Add team</div>
75 +
						</DropdownMenuItem>
76 +
					</DropdownMenuContent>
77 +
				</DropdownMenu>
78 +
			</SidebarMenuItem>
79 +
		</SidebarMenu>
80 +
	);
83 81
}