A decentralized music tracking and discovery platform built on AT Protocol 🎵 rocksky.app
spotify atproto lastfm musicbrainz scrobbling listenbrainz

Add mobile drawer, login UI, and Spotify scopes

Introduce displayDrawer jotai atom and toggle button in Navbar.
Add LoginForm and Links components and render a mobile drawer
that shows search/login/CloudDrive on small screens. Expand Spotify
OAuth scopes, include state param in the authorize URL, and raise
SpotifyLogin z-index for proper overlay.

+346 -210
+12 -1
apps/api/src/spotify/app.ts
··· 72 73 const state = crypto.randomBytes(16).toString("hex"); 74 ctx.kv.set(state, did); 75 - const redirectUrl = `https://accounts.spotify.com/en/authorize?client_id=${spotifyAccount?.spotify_apps?.spotifyAppId}&response_type=code&redirect_uri=${env.SPOTIFY_REDIRECT_URI}&scope=user-read-private%20user-read-email%20user-read-playback-state%20user-read-currently-playing%20user-modify-playback-state%20playlist-modify-public%20playlist-modify-private%20playlist-read-private%20playlist-read-collaborative&state=${state}`; 76 c.header( 77 "Set-Cookie", 78 `session-id=${state}; Path=/; HttpOnly; SameSite=Strict; Secure`,
··· 72 73 const state = crypto.randomBytes(16).toString("hex"); 74 ctx.kv.set(state, did); 75 + const scopes = [ 76 + "user-read-private", 77 + "user-read-email", 78 + "user-read-playback-state", 79 + "user-read-currently-playing", 80 + "user-modify-playback-state", 81 + "playlist-modify-public", 82 + "playlist-modify-private", 83 + "playlist-read-private", 84 + "playlist-read-collaborative", 85 + ]; 86 + const redirectUrl = `https://accounts.spotify.com/en/authorize?client_id=${spotifyAccount?.spotify_apps?.spotifyAppId}&response_type=code&redirect_uri=${env.SPOTIFY_REDIRECT_URI}&scope=${scopes.join("%20")}&state=${state}`; 87 c.header( 88 "Set-Cookie", 89 `session-id=${state}; Path=/; HttpOnly; SameSite=Strict; Secure`,
+3
apps/web/src/atoms/drawer.ts
···
··· 1 + import { atom } from "jotai"; 2 + 3 + export const displayDrawerAtom = atom<boolean>(false);
+63
apps/web/src/layouts/Links.tsx
···
··· 1 + import styled from "@emotion/styled"; 2 + 3 + const Link = styled.a` 4 + text-decoration: none; 5 + cursor: pointer; 6 + display: block; 7 + font-size: 13px; 8 + 9 + &:hover { 10 + text-decoration: underline; 11 + } 12 + `; 13 + 14 + function Links() { 15 + return ( 16 + <div className="inline-flex mt-[40px]"> 17 + <Link 18 + href="https://docs.rocksky.app/introduction-918639m0" 19 + target="_blank" 20 + className="mr-[10px] text-[var(--color-primary)]" 21 + > 22 + About 23 + </Link> 24 + <Link 25 + href="https://docs.rocksky.app/faq-918661m0" 26 + target="_blank" 27 + className="mr-[10px] text-[var(--color-primary)]" 28 + > 29 + FAQ 30 + </Link> 31 + <Link 32 + href="https://doc.rocksky.app/" 33 + target="_blank" 34 + className="mr-[10px] text-[var(--color-primary)]" 35 + > 36 + API Docs 37 + </Link> 38 + <Link 39 + href="https://docs.rocksky.app/overview-957781m0" 40 + target="_blank" 41 + className="mr-[10px] text-[var(--color-primary)]" 42 + > 43 + CLI 44 + </Link> 45 + <Link 46 + href="https://tangled.org/@rocksky.app/rocksky" 47 + target="_blank" 48 + className="mr-[10px] text-[var(--color-primary)]" 49 + > 50 + Source 51 + </Link> 52 + <Link 53 + href="https://discord.gg/EVcBy2fVa3" 54 + target="_blank" 55 + className="mr-[10px] text-[var(--color-primary)]" 56 + > 57 + Discord 58 + </Link> 59 + </div> 60 + ); 61 + } 62 + 63 + export default Links;
+178
apps/web/src/layouts/LoginForm.tsx
···
··· 1 + import React from "react"; 2 + import { Input } from "baseui/input"; 3 + import { LabelMedium } from "baseui/typography"; 4 + import { IconEye, IconEyeOff, IconLock } from "@tabler/icons-react"; 5 + import { Button } from "baseui/button"; 6 + 7 + interface LoginFormProps { 8 + handle: string; 9 + setHandle: (value: string) => void; 10 + password: string; 11 + setPassword: (value: string) => void; 12 + passwordLogin: boolean; 13 + setPasswordLogin: (value: boolean) => void; 14 + onLogin: () => void; 15 + onCreateAccount: () => void; 16 + } 17 + 18 + const LoginForm: React.FC<LoginFormProps> = ({ 19 + handle, 20 + setHandle, 21 + password, 22 + setPassword, 23 + passwordLogin, 24 + setPasswordLogin, 25 + onLogin, 26 + onCreateAccount, 27 + }) => { 28 + return ( 29 + <div className="max-w-[400px] mt-[40px] mx-auto"> 30 + <div className="mb-[20px]"> 31 + <div className="flex flex-row mb-[15px]"> 32 + <LabelMedium className="!text-[var(--color-text)] flex-1"> 33 + Handle 34 + </LabelMedium> 35 + <LabelMedium 36 + className="!text-[var(--color-primary)] cursor-pointer" 37 + onClick={() => setPasswordLogin(!passwordLogin)} 38 + > 39 + {passwordLogin ? "OAuth Login" : "Password Login"} 40 + </LabelMedium> 41 + </div> 42 + <Input 43 + name="handle" 44 + startEnhancer={ 45 + <div className="text-[var(--color-text-muted)] bg-[var(--color-input-background)]"> 46 + @ 47 + </div> 48 + } 49 + placeholder="<username>.bsky.social" 50 + value={handle} 51 + onChange={(e) => setHandle(e.target.value)} 52 + overrides={{ 53 + Root: { 54 + style: { 55 + backgroundColor: "var(--color-input-background)", 56 + borderColor: "var(--color-input-background)", 57 + }, 58 + }, 59 + StartEnhancer: { 60 + style: { 61 + backgroundColor: "var(--color-input-background)", 62 + }, 63 + }, 64 + InputContainer: { 65 + style: { 66 + backgroundColor: "var(--color-input-background)", 67 + }, 68 + }, 69 + Input: { 70 + style: { 71 + color: "var(--color-text)", 72 + caretColor: "var(--color-text)", 73 + }, 74 + }, 75 + }} 76 + /> 77 + {passwordLogin && ( 78 + <Input 79 + name="password" 80 + startEnhancer={ 81 + <div className="text-[var(--color-text-muted)] bg-[var(--color-input-background)]"> 82 + <IconLock size={19} className="mt-[8px]" /> 83 + </div> 84 + } 85 + type="password" 86 + placeholder="Password" 87 + value={password} 88 + onChange={(e) => setPassword(e.target.value)} 89 + overrides={{ 90 + Root: { 91 + style: { 92 + backgroundColor: "var(--color-input-background)", 93 + borderColor: "var(--color-input-background)", 94 + marginTop: "1rem", 95 + }, 96 + }, 97 + StartEnhancer: { 98 + style: { 99 + backgroundColor: "var(--color-input-background)", 100 + }, 101 + }, 102 + InputContainer: { 103 + style: { 104 + backgroundColor: "var(--color-input-background)", 105 + }, 106 + }, 107 + Input: { 108 + style: { 109 + color: "var(--color-text)", 110 + caretColor: "var(--color-text)", 111 + }, 112 + }, 113 + MaskToggleHideIcon: { 114 + component: () => ( 115 + <IconEyeOff 116 + className="text-[var(--color-text-muted)]" 117 + size={20} 118 + /> 119 + ), 120 + }, 121 + MaskToggleShowIcon: { 122 + component: () => ( 123 + <IconEye 124 + className="text-[var(--color-text-muted)]" 125 + size={20} 126 + /> 127 + ), 128 + }, 129 + }} 130 + /> 131 + )} 132 + </div> 133 + <Button 134 + onClick={() => onLogin()} 135 + overrides={{ 136 + BaseButton: { 137 + style: { 138 + width: "100%", 139 + backgroundColor: "var(--color-primary)", 140 + ":hover": { 141 + backgroundColor: "var(--color-primary)", 142 + }, 143 + ":focus": { 144 + backgroundColor: "var(--color-primary)", 145 + }, 146 + }, 147 + }, 148 + }} 149 + > 150 + Sign In 151 + </Button> 152 + <LabelMedium className="text-center mt-[20px] !text-[var(--color-text-muted)]"> 153 + Don't have an atproto handle yet? 154 + </LabelMedium> 155 + <div className="text-center text-[var(--color-text-muted)] "> 156 + You can create one at{" "} 157 + <span 158 + onClick={onCreateAccount} 159 + className="no-underline cursor-pointer !text-[var(--color-primary)]" 160 + > 161 + selfhosted.social 162 + </span> 163 + ,{" "} 164 + <a 165 + href="https://bsky.app" 166 + className="no-underline cursor-pointer !text-[var(--color-primary)]" 167 + target="_blank" 168 + rel="noopener noreferrer" 169 + > 170 + Bluesky 171 + </a>{" "} 172 + or any other AT Protocol service. 173 + </div> 174 + </div> 175 + ); 176 + }; 177 + 178 + export default LoginForm;
+54 -207
apps/web/src/layouts/Main.tsx
··· 1 import styled from "@emotion/styled"; 2 import { useSearch } from "@tanstack/react-router"; 3 - import { Button } from "baseui/button"; 4 - import { Input } from "baseui/input"; 5 import { PLACEMENT, ToasterContainer } from "baseui/toast"; 6 - import { LabelMedium } from "baseui/typography"; 7 import { useAtomValue } from "jotai"; 8 import { useEffect, useState } from "react"; 9 import { profileAtom } from "../atoms/profile"; ··· 12 import { API_URL } from "../consts"; 13 import useProfile from "../hooks/useProfile"; 14 import CloudDrive from "./CloudDrive"; 15 - import ExternalLinks from "./ExternalLinks"; 16 import Navbar from "./Navbar"; 17 import Search from "./Search"; 18 import SpotifyLogin from "./SpotifyLogin"; 19 - import { IconEye, IconEyeOff, IconLock } from "@tabler/icons-react"; 20 import { consola } from "consola"; 21 22 const Container = styled.div` 23 display: flex; ··· 41 } 42 `; 43 44 - const Link = styled.a` 45 - text-decoration: none; 46 - cursor: pointer; 47 - display: block; 48 - font-size: 13px; 49 50 - &:hover { 51 - text-decoration: underline; 52 } 53 `; 54 ··· 57 withRightPane?: boolean; 58 }; 59 60 function Main(props: MainProps) { 61 const { children } = props; 62 const withRightPane = props.withRightPane ?? true; 63 const [handle, setHandle] = useState(""); 64 const [password, setPassword] = useState(""); ··· 186 }, 187 }} 188 /> 189 <Flex style={{ width: withRightPane ? "770px" : "1090px" }}> 190 - <Navbar /> 191 - <div 192 - style={{ 193 - position: "relative", 194 - }} 195 - > 196 - {children} 197 - </div> 198 </Flex> 199 {withRightPane && ( 200 <RightPane className="relative w-[300px]"> ··· 206 {jwt && profile && <CloudDrive />} 207 {!jwt && ( 208 <div className="mt-[40px]"> 209 - <div className="mb-[20px]"> 210 - <div className="flex flex-row mb-[15px]"> 211 - <LabelMedium className="!text-[var(--color-text)] flex-1"> 212 - Handle 213 - </LabelMedium> 214 - <LabelMedium 215 - className="!text-[var(--color-primary)] cursor-pointer" 216 - onClick={() => setPasswordLogin(!passwordLogin)} 217 - > 218 - {passwordLogin ? "OAuth Login" : "Password Login"} 219 - </LabelMedium> 220 - </div> 221 - <Input 222 - name="handle" 223 - startEnhancer={ 224 - <div className="text-[var(--color-text-muted)] bg-[var(--color-input-background)]"> 225 - @ 226 - </div> 227 - } 228 - placeholder="<username>.bsky.social" 229 - value={handle} 230 - onChange={(e) => setHandle(e.target.value)} 231 - overrides={{ 232 - Root: { 233 - style: { 234 - backgroundColor: "var(--color-input-background)", 235 - borderColor: "var(--color-input-background)", 236 - }, 237 - }, 238 - StartEnhancer: { 239 - style: { 240 - backgroundColor: "var(--color-input-background)", 241 - }, 242 - }, 243 - InputContainer: { 244 - style: { 245 - backgroundColor: "var(--color-input-background)", 246 - }, 247 - }, 248 - Input: { 249 - style: { 250 - color: "var(--color-text)", 251 - caretColor: "var(--color-text)", 252 - }, 253 - }, 254 - }} 255 - /> 256 - {passwordLogin && ( 257 - <Input 258 - name="password" 259 - startEnhancer={ 260 - <div className="text-[var(--color-text-muted)] bg-[var(--color-input-background)]"> 261 - <IconLock size={19} className="mt-[8px]" /> 262 - </div> 263 - } 264 - type="password" 265 - placeholder="Password" 266 - value={password} 267 - onChange={(e) => setPassword(e.target.value)} 268 - overrides={{ 269 - Root: { 270 - style: { 271 - backgroundColor: "var(--color-input-background)", 272 - borderColor: "var(--color-input-background)", 273 - marginTop: "1rem", 274 - }, 275 - }, 276 - StartEnhancer: { 277 - style: { 278 - backgroundColor: "var(--color-input-background)", 279 - }, 280 - }, 281 - InputContainer: { 282 - style: { 283 - backgroundColor: "var(--color-input-background)", 284 - }, 285 - }, 286 - Input: { 287 - style: { 288 - color: "var(--color-text)", 289 - caretColor: "var(--color-text)", 290 - }, 291 - }, 292 - MaskToggleHideIcon: { 293 - component: () => ( 294 - <IconEyeOff 295 - className="text-[var(--color-text-muted)]" 296 - size={20} 297 - /> 298 - ), 299 - }, 300 - MaskToggleShowIcon: { 301 - component: () => ( 302 - <IconEye 303 - className="text-[var(--color-text-muted)]" 304 - size={20} 305 - /> 306 - ), 307 - }, 308 - }} 309 - /> 310 - )} 311 - </div> 312 - <Button 313 - onClick={() => onLogin()} 314 - overrides={{ 315 - BaseButton: { 316 - style: { 317 - width: "100%", 318 - backgroundColor: "var(--color-primary)", 319 - ":hover": { 320 - backgroundColor: "var(--color-primary)", 321 - }, 322 - ":focus": { 323 - backgroundColor: "var(--color-primary)", 324 - }, 325 - }, 326 - }, 327 - }} 328 - > 329 - Sign In 330 - </Button> 331 - <LabelMedium className="text-center mt-[20px] !text-[var(--color-text-muted)]"> 332 - Don't have an atproto handle yet? 333 - </LabelMedium> 334 - <div className="text-center text-[var(--color-text-muted)] "> 335 - You can create one at{" "} 336 - <span 337 - onClick={onCreateAccount} 338 - className="no-underline cursor-pointer !text-[var(--color-primary)]" 339 - > 340 - selfhosted.social 341 - </span> 342 - ,{" "} 343 - <a 344 - href="https://bsky.app" 345 - className="no-underline cursor-pointer !text-[var(--color-primary)]" 346 - target="_blank" 347 - > 348 - Bluesky 349 - </a>{" "} 350 - or any other AT Protocol service. 351 - </div> 352 </div> 353 )} 354 ··· 356 <ScrobblesAreaChart /> 357 </div> 358 <ExternalLinks /> 359 - <div className="inline-flex mt-[40px]"> 360 - <Link 361 - href="https://docs.rocksky.app/introduction-918639m0" 362 - target="_blank" 363 - className="mr-[10px] text-[var(--color-primary)]" 364 - > 365 - About 366 - </Link> 367 - <Link 368 - href="https://docs.rocksky.app/faq-918661m0" 369 - target="_blank" 370 - className="mr-[10px] text-[var(--color-primary)]" 371 - > 372 - FAQ 373 - </Link> 374 - <Link 375 - href="https://doc.rocksky.app/" 376 - target="_blank" 377 - className="mr-[10px] text-[var(--color-primary)]" 378 - > 379 - API Docs 380 - </Link> 381 - <Link 382 - href="https://docs.rocksky.app/overview-957781m0" 383 - target="_blank" 384 - className="mr-[10px] text-[var(--color-primary)]" 385 - > 386 - CLI 387 - </Link> 388 - <Link 389 - href="https://tangled.org/@rocksky.app/rocksky" 390 - target="_blank" 391 - className="mr-[10px] text-[var(--color-primary)]" 392 - > 393 - Source 394 - </Link> 395 - <Link 396 - href="https://discord.gg/EVcBy2fVa3" 397 - target="_blank" 398 - className="mr-[10px] text-[var(--color-primary)]" 399 - > 400 - Discord 401 - </Link> 402 - </div> 403 </div> 404 </RightPane> 405 )}
··· 1 import styled from "@emotion/styled"; 2 import { useSearch } from "@tanstack/react-router"; 3 import { PLACEMENT, ToasterContainer } from "baseui/toast"; 4 import { useAtomValue } from "jotai"; 5 import { useEffect, useState } from "react"; 6 import { profileAtom } from "../atoms/profile"; ··· 9 import { API_URL } from "../consts"; 10 import useProfile from "../hooks/useProfile"; 11 import CloudDrive from "./CloudDrive"; 12 import Navbar from "./Navbar"; 13 import Search from "./Search"; 14 import SpotifyLogin from "./SpotifyLogin"; 15 import { consola } from "consola"; 16 + import { displayDrawerAtom } from "../atoms/drawer"; 17 18 const Container = styled.div` 19 display: flex; ··· 37 } 38 `; 39 40 + const Drawer = styled.div` 41 + @media (max-width: 1152px) { 42 + display: block; 43 + } 44 45 + @media (min-width: 1153px) { 46 + display: none; 47 } 48 `; 49 ··· 52 withRightPane?: boolean; 53 }; 54 55 + import LoginForm from "./LoginForm"; 56 + import ExternalLinks from "./ExternalLinks"; 57 + import Links from "./Links"; 58 + 59 function Main(props: MainProps) { 60 const { children } = props; 61 + const displayDrawer = useAtomValue(displayDrawerAtom); 62 const withRightPane = props.withRightPane ?? true; 63 const [handle, setHandle] = useState(""); 64 const [password, setPassword] = useState(""); ··· 186 }, 187 }} 188 /> 189 + <Navbar /> 190 + 191 <Flex style={{ width: withRightPane ? "770px" : "1090px" }}> 192 + {!displayDrawer && <div className="relative">{children}</div>} 193 + {displayDrawer && ( 194 + <Drawer> 195 + <div className="fixed top-[100px] h-[calc(100vh-100px)] w-[calc(100%-100px)] bg-white p-[20px] overflow-y-auto pt-[0px]"> 196 + {jwt && profile && ( 197 + <div className="mb-[30px]"> 198 + <Search /> 199 + </div> 200 + )} 201 + {jwt && profile && !profile.spotifyConnected && <SpotifyLogin />} 202 + {jwt && profile && <CloudDrive />} 203 + {!jwt && ( 204 + <div className="mt-[40px] max-w-[770px]"> 205 + <LoginForm 206 + handle={handle} 207 + setHandle={setHandle} 208 + password={password} 209 + setPassword={setPassword} 210 + passwordLogin={passwordLogin} 211 + setPasswordLogin={setPasswordLogin} 212 + onLogin={onLogin} 213 + onCreateAccount={onCreateAccount} 214 + /> 215 + </div> 216 + )} 217 + <ExternalLinks /> 218 + </div> 219 + </Drawer> 220 + )} 221 </Flex> 222 {withRightPane && ( 223 <RightPane className="relative w-[300px]"> ··· 229 {jwt && profile && <CloudDrive />} 230 {!jwt && ( 231 <div className="mt-[40px]"> 232 + <LoginForm 233 + handle={handle} 234 + setHandle={setHandle} 235 + password={password} 236 + setPassword={setPassword} 237 + passwordLogin={passwordLogin} 238 + setPasswordLogin={setPasswordLogin} 239 + onLogin={onLogin} 240 + onCreateAccount={onCreateAccount} 241 + /> 242 </div> 243 )} 244 ··· 246 <ScrobblesAreaChart /> 247 </div> 248 <ExternalLinks /> 249 + <Links /> 250 </div> 251 </RightPane> 252 )}
+35 -1
apps/web/src/layouts/Navbar/Navbar.tsx
··· 21 import { useProfileStatsByDidQuery } from "../../hooks/useProfile"; 22 import LogoDark from "../../assets/rocksky-logo-dark.png"; 23 import LogoLight from "../../assets/rocksky-logo-light.png"; 24 - import { IconUser } from "@tabler/icons-react"; 25 26 const Container = styled.div` 27 position: fixed; ··· 35 36 @media (max-width: 1152px) { 37 width: 100%; 38 padding: 0 20px; 39 } 40 `; 41 ··· 69 `; 70 71 function Navbar() { 72 const [isOpen, setIsOpen] = useState(false); 73 const [{ darkMode }, setTheme] = useAtom(themeAtom); 74 const setProfile = useSetAtom(profileAtom); ··· 155 </AnimatedLink> 156 </Link> 157 </div> 158 159 {profile && jwt && ( 160 <StatefulPopover
··· 21 import { useProfileStatsByDidQuery } from "../../hooks/useProfile"; 22 import LogoDark from "../../assets/rocksky-logo-dark.png"; 23 import LogoLight from "../../assets/rocksky-logo-light.png"; 24 + import { IconUser, IconMenu2, IconX } from "@tabler/icons-react"; 25 + import { displayDrawerAtom } from "../../atoms/drawer"; 26 27 const Container = styled.div` 28 position: fixed; ··· 36 37 @media (max-width: 1152px) { 38 width: 100%; 39 + max-width: 770px; 40 padding: 0 20px; 41 + } 42 + `; 43 + 44 + export const Menu = styled.div` 45 + @media (max-width: 1152px) { 46 + display: block; 47 + } 48 + 49 + @media (min-width: 1153px) { 50 + display: none; 51 } 52 `; 53 ··· 81 `; 82 83 function Navbar() { 84 + const [displayDrawer, setDisplayDrawer] = useAtom(displayDrawerAtom); 85 const [isOpen, setIsOpen] = useState(false); 86 const [{ darkMode }, setTheme] = useAtom(themeAtom); 87 const setProfile = useSetAtom(profileAtom); ··· 168 </AnimatedLink> 169 </Link> 170 </div> 171 + <Menu> 172 + <button 173 + onClick={() => setDisplayDrawer(!displayDrawer)} 174 + className="bg-[initial] border-none cursor-pointer" 175 + > 176 + {!displayDrawer && ( 177 + <IconMenu2 178 + size={24} 179 + color="var(--color-text)" 180 + className="ml-[20px] mt-[4px]" 181 + /> 182 + )} 183 + {displayDrawer && ( 184 + <IconX 185 + size={24} 186 + color="var(--color-text)" 187 + className="ml-[20px] mt-[4px]" 188 + /> 189 + )} 190 + </button> 191 + </Menu> 192 193 {profile && jwt && ( 194 <StatefulPopover
+1 -1
apps/web/src/layouts/SpotifyLogin/SpotifyLogin.tsx
··· 125 overrides={{ 126 Root: { 127 style: { 128 - zIndex: 1, 129 }, 130 }, 131 Dialog: {
··· 125 overrides={{ 126 Root: { 127 style: { 128 + zIndex: 50, 129 }, 130 }, 131 Dialog: {