forked from
rocksky.app/rocksky
A decentralized music tracking and discovery platform built on AT Protocol 馃幍
1import styled from "@emotion/styled";
2import { Copy } from "@styled-icons/ionicons-outline";
3import { useQuery } from "@tanstack/react-query";
4import { Link, useNavigate } from "@tanstack/react-router";
5import { Avatar } from "baseui/avatar";
6import { Checkbox, LABEL_PLACEMENT, STYLE_TYPE } from "baseui/checkbox";
7import { NestedMenus, StatefulMenu } from "baseui/menu";
8import { Modal, ModalBody, ModalHeader } from "baseui/modal";
9import { PLACEMENT, StatefulPopover } from "baseui/popover";
10import { DURATION, useSnackbar } from "baseui/snackbar";
11import { StatefulTooltip } from "baseui/tooltip";
12import { LabelMedium } from "baseui/typography";
13import copy from "copy-to-clipboard";
14import { useAtom, useAtomValue, useSetAtom } from "jotai";
15import numeral from "numeral";
16import * as R from "ramda";
17import { useEffect, useMemo, useState } from "react";
18import { profileAtom } from "../../atoms/profile";
19import { themeAtom } from "../../atoms/theme";
20import { API_URL } from "../../consts";
21import { useProfileStatsByDidQuery } from "../../hooks/useProfile";
22
23const Container = styled.div`
24 position: fixed;
25 top: 0;
26 width: 1090px;
27 z-index: 1;
28 display: flex;
29 justify-content: space-between;
30 align-items: center;
31 height: 80px;
32
33 @media (max-width: 1152px) {
34 width: 100%;
35 padding: 0 20px;
36 }
37`;
38
39export const Code = styled.div`
40 background-color: #000;
41 color: #fff;
42 padding: 5px;
43 display: inline-block;
44 border-radius: 5px;
45`;
46
47function Navbar() {
48 const [isOpen, setIsOpen] = useState(false);
49 const [{ darkMode }, setTheme] = useAtom(themeAtom);
50 const setProfile = useSetAtom(profileAtom);
51 const profile = useAtomValue(profileAtom);
52 const navigate = useNavigate();
53 const jwt = localStorage.getItem("token");
54 const { enqueue } = useSnackbar();
55 const profileStats = useProfileStatsByDidQuery(
56 R.propOr(undefined, "did", profile),
57 );
58
59 const { data } = useQuery({
60 queryKey: ["webscrobbler"],
61 queryFn: async () => {
62 const response = await fetch(`${API_URL}/webscrobbler`, {
63 method: "GET",
64 headers: {
65 Authorization: `Bearer ${jwt}`,
66 },
67 });
68 return response.json();
69 },
70 });
71
72 const webscrobblerWebhook = useMemo(() => {
73 if (data) {
74 return `https://webscrobbler.rocksky.app/${data.uuid}`;
75 }
76 return "";
77 }, [data]);
78
79 useEffect(() => {
80 if (profile?.spotifyConnected && !!localStorage.getItem("spotify")) {
81 localStorage.removeItem("spotify");
82 enqueue(
83 {
84 message: "Spotify account connected successfully!",
85 },
86 DURATION.long,
87 );
88 }
89 }, [enqueue, profile]);
90
91 const close = () => {
92 setIsOpen(false);
93 };
94
95 return (
96 <Container className="bg-[var(--color-background)] text-[var(--color-text)]">
97 <div>
98 <Link to="/" style={{ textDecoration: "none" }}>
99 <h2 className="text-[var(--color-primary)] text-[26px] font-bold">
100 Rocksky
101 </h2>
102 </Link>
103 </div>
104
105 {profile && jwt && (
106 <StatefulPopover
107 placement={PLACEMENT.bottomRight}
108 overrides={{
109 Body: {
110 style: {
111 zIndex: 2,
112 backgroundColor: "var(--color-background)",
113 width: "282px",
114 },
115 },
116 }}
117 content={({ close }) => (
118 <div className="border-[var(--color-border)] border-[1px] pt-[20px] pb-[20px] bg-[var(--color-background)] rounded-[6px]">
119 <div>
120 <div className="flex items-center justify-center bg-[var(--color-background)] pl-[20px] pr-[20px]">
121 <div className="flex flex-col items-center">
122 <div className="mb-[5px]">
123 <Link to="/profile/$did" params={{ did: profile.handle }}>
124 <Avatar
125 src={profile.avatar}
126 name={profile.displayName}
127 size="80px"
128 />
129 </Link>
130 </div>
131
132 <Link
133 to="/profile/$did"
134 params={{ did: profile.handle }}
135 className="no-underline"
136 >
137 <LabelMedium className="text-center text-[20px] !text-[var(--color-text)]">
138 {profile.displayName}
139 </LabelMedium>
140 </Link>
141 <a
142 href={`https://bsky.app/profile/${profile.handle}`}
143 target="_blank"
144 className="no-underline"
145 >
146 <LabelMedium
147 color="var(--color-primary)"
148 className="text-center"
149 >
150 @{profile.handle}
151 </LabelMedium>
152 </a>
153
154 <div className="flex flex-row mt-[5px]">
155 <LabelMedium
156 margin={0}
157 color="var(--color-text-muted)"
158 className="text-center !mr-[5px]"
159 >
160 {numeral(profileStats.data.scrobbles).format("0,0")}
161 </LabelMedium>
162 <LabelMedium color="var(--color-text-muted)">
163 scrobbles
164 </LabelMedium>
165 </div>
166 </div>
167 </div>
168 </div>
169 <NestedMenus>
170 <StatefulMenu
171 items={[
172 {
173 id: "api-applications",
174 label: (
175 <LabelMedium className="!text-[var(--color-text)]">
176 API Applications
177 </LabelMedium>
178 ),
179 },
180 {
181 id: "webscrobbler",
182 label: (
183 <LabelMedium className="!text-[var(--color-text)]">
184 Web Scrobbler
185 </LabelMedium>
186 ),
187 },
188 {
189 id: "dark-mode",
190 label: (
191 <div className="flex flex-row items-center">
192 <LabelMedium className="!text-[var(--color-text)] flex-1">
193 Dark Mode
194 </LabelMedium>
195 <Checkbox
196 checked={darkMode}
197 checkmarkType={STYLE_TYPE.toggle_round}
198 onChange={(e) => {
199 setTheme({
200 darkMode: e.target.checked,
201 });
202 localStorage.setItem(
203 "darkMode",
204 e.target.checked ? "true" : "false",
205 );
206 }}
207 labelPlacement={LABEL_PLACEMENT.right}
208 overrides={{
209 Toggle: {
210 style: {
211 backgroundColor: "#fff",
212 },
213 },
214 ToggleTrack: {
215 style: {
216 backgroundColor: "var(--color-toggle-track)",
217 },
218 },
219 }}
220 />
221 </div>
222 ),
223 },
224 {
225 id: "signout",
226 label: (
227 <LabelMedium className="!text-[var(--color-text)]">
228 Sign out
229 </LabelMedium>
230 ),
231 },
232 ]}
233 onItemSelect={({ item }) => {
234 switch (item.id) {
235 case "profile":
236 navigate({
237 to: "/profile/$did",
238 params: { did: profile.handle },
239 });
240 break;
241 case "api-applications":
242 navigate({
243 to: "/apikeys",
244 });
245 break;
246 case "signout":
247 setProfile(null);
248 localStorage.removeItem("token");
249 localStorage.removeItem("did");
250 window.location.href = "/";
251 break;
252 case "webscrobbler":
253 setIsOpen(true);
254 break;
255 case "dark-mode":
256 return;
257 default:
258 break;
259 }
260 close();
261 }}
262 overrides={{
263 List: {
264 style: {
265 boxShadow: "none",
266 backgroundColor: "var(--color-background)",
267 },
268 },
269 Option: {
270 style: {
271 height: "44px",
272 },
273 },
274 ListItem: {
275 style: ({ $isHighlighted }) => ({
276 backgroundColor: $isHighlighted
277 ? "var(--color-menu-hover)"
278 : "var(--color-background)",
279 color: "var(--color-text)",
280 }),
281 },
282 }}
283 />
284 </NestedMenus>
285 </div>
286 )}
287 >
288 <button
289 style={{
290 border: "none",
291 backgroundColor: "transparent",
292 cursor: "pointer",
293 }}
294 >
295 <Avatar
296 src={profile.avatar}
297 name={profile.displayName}
298 size="scale1200"
299 />
300 </button>
301 </StatefulPopover>
302 )}
303
304 <Modal
305 onClose={close}
306 isOpen={isOpen}
307 overrides={{
308 Root: {
309 style: {
310 zIndex: 1,
311 },
312 },
313 Dialog: {
314 style: {
315 backgroundColor: "var(--color-background)",
316 },
317 },
318 Close: {
319 style: {
320 color: "var(--color-text)",
321 ":hover": {
322 color: "var(--color-text)",
323 opacity: 0.8,
324 },
325 },
326 },
327 }}
328 size={650}
329 >
330 <ModalHeader className="!text-[var(--color-text)]">
331 Setup Web Scrobbler
332 </ModalHeader>
333 <ModalBody>
334 <LabelMedium className="!text-[var(--color-text)]">
335 To use the Web Scrobbler, you need to install the browser extension
336 and connect it to Rocksky.
337 </LabelMedium>
338 <div className="mt-[20px]">
339 <a
340 href="https://github.com/web-scrobbler/web-scrobbler"
341 target="_blank"
342 rel="noopener noreferrer"
343 className="text-[var(--color-primary)]"
344 >
345 Install Web Scrobbler
346 </a>
347 </div>
348 <div className="mt-[20px]">
349 <LabelMedium className="!text-[var(--color-text)]">
350 After installing the extension, add the following URL to the
351 extension settings as a custom API URL:
352 </LabelMedium>
353 <Code className="mt-[15px]">{webscrobblerWebhook}</Code>
354 <StatefulTooltip content="Copy API Key">
355 <Copy
356 onClick={() => copy(webscrobblerWebhook)}
357 size={18}
358 color="var(--color-text)"
359 className="ml-[5px] cursor-pointer"
360 />
361 </StatefulTooltip>
362 </div>
363 </ModalBody>
364 </Modal>
365 </Container>
366 );
367}
368
369export default Navbar;