(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import { useEffect, useState } from "react";
2import {
3 Home,
4 Bookmark,
5 Settings,
6 LogOut,
7 Bell,
8 Sun,
9 Moon,
10 Monitor,
11 Folder,
12 LogIn,
13 PenSquare,
14 MessageSquareText,
15 Highlighter,
16 Compass,
17} from "lucide-react";
18import { useStore } from "@nanostores/react";
19import { $user, logout } from "../../store/auth";
20import { $theme, cycleTheme } from "../../store/theme";
21import { getUnreadNotificationCount } from "../../api/client";
22import { Avatar, CountBadge } from "../ui";
23import { useTranslation } from "react-i18next";
24
25interface SidebarProps {
26 currentPath?: string;
27 onNavigate?: (path: string) => void;
28}
29
30export default function Sidebar({
31 currentPath: initialPath,
32 onNavigate,
33}: SidebarProps) {
34 const { t } = useTranslation();
35 const user = useStore($user);
36 const theme = useStore($theme);
37 const currentPath = initialPath || "/";
38 const [unreadCount, setUnreadCount] = useState(0);
39
40 useEffect(() => {
41 if (!user) return;
42
43 const checkNotifications = async () => {
44 const count = await getUnreadNotificationCount();
45 setUnreadCount(count);
46 };
47
48 checkNotifications();
49 const interval = setInterval(checkNotifications, 30000);
50 return () => clearInterval(interval);
51 }, [user]);
52
53 const publicNavItems = [
54 { icon: Home, label: t("nav.feed"), href: "/home", badge: undefined },
55 {
56 icon: Compass,
57 label: t("nav.discover"),
58 href: "/discover",
59 badge: undefined,
60 },
61 {
62 icon: MessageSquareText,
63 label: t("nav.annotations"),
64 href: "/annotations",
65 badge: undefined,
66 },
67 {
68 icon: Highlighter,
69 label: t("nav.highlights"),
70 href: "/highlights",
71 badge: undefined,
72 },
73 {
74 icon: Bookmark,
75 label: t("nav.bookmarks"),
76 href: "/bookmarks",
77 badge: undefined,
78 },
79 ];
80
81 const authNavItems = [
82 { icon: Home, label: t("nav.feed"), href: "/home" },
83 { icon: Compass, label: t("nav.discover"), href: "/discover" },
84 {
85 icon: Bell,
86 label: t("nav.activity"),
87 href: "/notifications",
88 badge: unreadCount,
89 },
90 {
91 icon: MessageSquareText,
92 label: t("nav.annotations"),
93 href: "/annotations",
94 },
95 { icon: Highlighter, label: t("nav.highlights"), href: "/highlights" },
96 { icon: Bookmark, label: t("nav.bookmarks"), href: "/bookmarks" },
97 { icon: Folder, label: t("nav.collections"), href: "/collections" },
98 ];
99
100 const navItems = user ? authNavItems : publicNavItems;
101
102 const themeLabel =
103 theme === "light"
104 ? t("nav.themeLight")
105 : theme === "dark"
106 ? t("nav.themeDark")
107 : t("nav.themeSystem");
108
109 return (
110 <aside className="sticky top-0 h-screen hidden md:flex flex-col justify-between py-6 px-2 lg:px-4 z-50 w-[68px] lg:w-[260px] transition-all duration-200">
111 <div className="flex flex-col gap-6">
112 <a
113 href="/home"
114 onClick={
115 onNavigate
116 ? (e) => {
117 e.preventDefault();
118 onNavigate("/home");
119 }
120 : undefined
121 }
122 className="px-3 hover:opacity-80 transition-opacity w-fit flex items-center gap-2.5"
123 >
124 <img src="/logo.svg" alt="Margin" className="w-8 h-8" />
125 </a>
126
127 <nav className="flex flex-col gap-0.5">
128 {navItems.map((item) => {
129 const isActive =
130 currentPath === item.href ||
131 (item.href !== "/home" && currentPath.startsWith(item.href));
132 return (
133 <a
134 key={item.href}
135 href={item.href}
136 title={item.label}
137 onClick={
138 onNavigate
139 ? (e) => {
140 e.preventDefault();
141 onNavigate(item.href);
142 }
143 : undefined
144 }
145 className={`flex items-center justify-center lg:justify-start gap-3 px-0 lg:px-3 py-2.5 rounded-lg transition-all duration-150 text-[14px] group ${
146 isActive
147 ? "font-semibold text-primary-700 dark:text-primary-300 bg-primary-50 dark:bg-primary-950/40"
148 : "font-medium text-surface-600 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-800 hover:text-surface-900 dark:hover:text-white"
149 }`}
150 >
151 <item.icon
152 size={20}
153 className={`transition-colors ${isActive ? "text-primary-600 dark:text-primary-400" : ""}`}
154 strokeWidth={isActive ? 2.25 : 1.75}
155 />
156 <span className="flex-1 hidden lg:inline">{item.label}</span>
157 {(item.badge ?? 0) > 0 && (
158 <CountBadge count={item.badge ?? 0} />
159 )}
160 </a>
161 );
162 })}
163
164 {user && (
165 <a
166 href="/new"
167 title={t("nav.new")}
168 onClick={
169 onNavigate
170 ? (e) => {
171 e.preventDefault();
172 onNavigate("/new");
173 }
174 : undefined
175 }
176 className="flex items-center justify-center lg:justify-start gap-3 px-0 lg:px-3 py-2.5 mt-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white hover:bg-primary-700 dark:hover:bg-primary-400 transition-colors text-[14px] font-semibold"
177 >
178 <PenSquare size={20} strokeWidth={1.75} />
179 <span className="hidden lg:inline">{t("nav.new")}</span>
180 </a>
181 )}
182 </nav>
183 </div>
184
185 <div className="space-y-1">
186 <button
187 onClick={cycleTheme}
188 title={themeLabel}
189 className="flex items-center justify-center lg:justify-start gap-3 px-0 lg:px-3 py-2.5 rounded-lg hover:bg-surface-100 dark:hover:bg-surface-800 text-[13px] font-medium text-surface-500 dark:text-surface-400 w-full transition-colors"
190 >
191 {theme === "light" ? (
192 <Sun size={18} />
193 ) : theme === "dark" ? (
194 <Moon size={18} />
195 ) : (
196 <Monitor size={18} />
197 )}
198 <span className="hidden lg:inline">{themeLabel}</span>
199 </button>
200
201 {user ? (
202 <>
203 <a
204 href="/settings"
205 title={t("nav.settings")}
206 onClick={
207 onNavigate
208 ? (e) => {
209 e.preventDefault();
210 onNavigate("/settings");
211 }
212 : undefined
213 }
214 className="flex items-center justify-center lg:justify-start gap-3 px-0 lg:px-3 py-2.5 rounded-lg hover:bg-surface-100 dark:hover:bg-surface-800 text-[13px] font-medium text-surface-500 dark:text-surface-400 transition-colors"
215 >
216 <Settings size={18} />
217 <span className="hidden lg:inline">{t("nav.settings")}</span>
218 </a>
219
220 <div className="h-px bg-surface-200/60 dark:bg-surface-800/60 my-2" />
221
222 <a
223 href={`/profile/${user.did}`}
224 title={user.displayName || user.handle}
225 onClick={
226 onNavigate
227 ? (e) => {
228 e.preventDefault();
229 onNavigate(`/profile/${user.did}`);
230 }
231 : undefined
232 }
233 className="flex items-center justify-center lg:justify-start gap-2.5 p-2 rounded-lg hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors w-full"
234 >
235 <Avatar did={user.did} avatar={user.avatar} size="sm" />
236 <div className="flex-1 min-w-0 hidden lg:block">
237 <p className="font-medium text-surface-900 dark:text-white truncate text-[13px]">
238 {user.displayName || user.handle}
239 </p>
240 <p className="text-[11px] text-surface-500 dark:text-surface-400 truncate">
241 @{user.handle}
242 </p>
243 </div>
244 </a>
245
246 <button
247 onClick={logout}
248 title={t("nav.logOut")}
249 className="flex items-center justify-center lg:justify-start gap-3 px-0 lg:px-3 py-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 text-[13px] font-medium text-surface-400 dark:text-surface-500 hover:text-red-600 dark:hover:text-red-400 w-full text-left transition-colors"
250 >
251 <LogOut size={16} />
252 <span className="hidden lg:inline">{t("nav.logOut")}</span>
253 </button>
254 </>
255 ) : (
256 <>
257 <div className="h-px bg-surface-200/60 dark:bg-surface-800/60 my-2" />
258
259 <a
260 href="/login"
261 title={t("nav.signIn")}
262 className="flex items-center justify-center lg:justify-start gap-3 px-0 lg:px-3 py-2.5 rounded-lg bg-primary-50 dark:bg-primary-950/40 text-primary-700 dark:text-primary-300 hover:bg-primary-100 dark:hover:bg-primary-950/60 text-[13px] font-semibold transition-colors"
263 >
264 <LogIn size={18} />
265 <span className="hidden lg:inline">{t("nav.signIn")}</span>
266 </a>
267 </>
268 )}
269 </div>
270 </aside>
271 );
272}