Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import { useStore } from "@nanostores/react";
2import { clsx } from "clsx";
3import { Bookmark, Highlighter, MessageSquareText } from "lucide-react";
4import { useState } from "react";
5import { useSearchParams } from "react-router-dom";
6import FeedItems from "../../components/feed/FeedItems";
7import { Button, Tabs } from "../../components/ui";
8import LayoutToggle from "../../components/ui/LayoutToggle";
9import { $user } from "../../store/auth";
10import { $feedLayout } from "../../store/feedLayout";
11
12interface FeedProps {
13 initialType?: string;
14 motivation?: string;
15 showTabs?: boolean;
16 emptyMessage?: string;
17}
18
19export default function Feed({
20 initialType = "all",
21 motivation,
22 showTabs = true,
23 emptyMessage = "No items found.",
24}: FeedProps) {
25 const [searchParams, setSearchParams] = useSearchParams();
26 const tag = searchParams.get("tag") || undefined;
27 const user = useStore($user);
28 const layout = useStore($feedLayout);
29 const [activeTab, setActiveTab] = useState(initialType);
30 const [activeFilter, setActiveFilter] = useState<string | undefined>(
31 motivation,
32 );
33
34 const handleTabChange = (id: string) => {
35 if (id === activeTab) return;
36 setActiveTab(id);
37 setSearchParams((prev) => {
38 const newParams = new URLSearchParams(prev);
39 newParams.delete("tag");
40 return newParams;
41 });
42 window.scrollTo({ top: 0, behavior: "smooth" });
43 };
44
45 const handleFilterChange = (id: string) => {
46 const next = id === "all" ? undefined : id;
47 if (next === activeFilter) return;
48 setActiveFilter(next);
49 setSearchParams((prev) => {
50 const newParams = new URLSearchParams(prev);
51 newParams.delete("tag");
52 return newParams;
53 });
54 window.scrollTo({ top: 0, behavior: "smooth" });
55 };
56
57 const tabs = [
58 { id: "all", label: "Recent" },
59 { id: "popular", label: "Popular" },
60 { id: "shelved", label: "Shelved" },
61 { id: "margin", label: "Margin" },
62 { id: "semble", label: "Semble" },
63 ];
64
65 const filters = [
66 { id: "all", label: "All", icon: null },
67 { id: "commenting", label: "Annotations", icon: MessageSquareText },
68 { id: "highlighting", label: "Highlights", icon: Highlighter },
69 { id: "bookmarking", label: "Bookmarks", icon: Bookmark },
70 ];
71
72 return (
73 <div className="mx-auto max-w-2xl xl:max-w-none">
74 {!user && (
75 <div className="text-center py-10 px-6 mb-4 animate-fade-in">
76 <h1 className="text-2xl font-display font-bold mb-2 tracking-tight text-surface-900 dark:text-white">
77 Welcome to Margin
78 </h1>
79 <p className="text-surface-500 dark:text-surface-400 mb-4 max-w-md mx-auto">
80 Annotate, highlight, and bookmark anything on the web.
81 </p>
82 <div className="flex gap-3 justify-center">
83 <Button onClick={() => (window.location.href = "/login")}>
84 Get Started
85 </Button>
86 <Button
87 variant="secondary"
88 onClick={() => window.open("/about", "_blank")}
89 >
90 Learn More
91 </Button>
92 </div>
93 </div>
94 )}
95
96 {showTabs && (
97 <div className="sticky top-0 z-10 bg-white/95 dark:bg-surface-800/95 backdrop-blur-sm pb-3 mb-2 -mx-1 px-1 pt-1 space-y-2">
98 {!tag && (
99 <Tabs
100 tabs={tabs}
101 activeTab={activeTab}
102 onChange={handleTabChange}
103 />
104 )}
105 {tag && (
106 <div className="flex items-center justify-between mb-2">
107 <h2 className="text-xl font-bold flex items-center gap-2">
108 <span className="text-surface-500 font-normal">
109 Items with tag:
110 </span>
111 <span className="bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 px-2 py-0.5 rounded-lg">
112 #{tag}
113 </span>
114 </h2>
115 <button
116 onClick={() => {
117 setSearchParams((prev) => {
118 const newParams = new URLSearchParams(prev);
119 newParams.delete("tag");
120 return newParams;
121 });
122 }}
123 className="text-sm text-surface-500 hover:text-surface-900 dark:hover:text-white"
124 >
125 Clear filter
126 </button>
127 </div>
128 )}
129 <div className="flex items-center gap-1.5 flex-wrap">
130 {filters.map((f) => {
131 const isActive =
132 f.id === "all" ? !activeFilter : activeFilter === f.id;
133 return (
134 <button
135 key={f.id}
136 onClick={() => handleFilterChange(f.id)}
137 className={clsx(
138 "inline-flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-full border transition-all",
139 isActive
140 ? "bg-primary-600 dark:bg-primary-500 text-white border-transparent shadow-sm"
141 : "bg-white dark:bg-surface-900 text-surface-500 dark:text-surface-400 border-surface-200 dark:border-surface-700 hover:border-primary-300 dark:hover:border-primary-700 hover:text-primary-600 dark:hover:text-primary-400",
142 )}
143 >
144 {f.icon && <f.icon size={12} />}
145 {f.label}
146 </button>
147 );
148 })}
149 <div className="ml-auto">
150 <LayoutToggle className="hidden sm:inline-flex" />
151 </div>
152 </div>
153 </div>
154 )}
155
156 <FeedItems
157 key={`${activeTab}-${activeFilter || "all"}-${tag || ""}`}
158 type={activeTab}
159 motivation={activeFilter}
160 emptyMessage={emptyMessage}
161 layout={layout}
162 tag={tag}
163 />
164 </div>
165 );
166}