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 72 73 73 const state = crypto.randomBytes(16).toString("hex"); 74 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}`; 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}`; 76 87 c.header( 77 88 "Set-Cookie", 78 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 1 import styled from "@emotion/styled"; 2 2 import { useSearch } from "@tanstack/react-router"; 3 - import { Button } from "baseui/button"; 4 - import { Input } from "baseui/input"; 5 3 import { PLACEMENT, ToasterContainer } from "baseui/toast"; 6 - import { LabelMedium } from "baseui/typography"; 7 4 import { useAtomValue } from "jotai"; 8 5 import { useEffect, useState } from "react"; 9 6 import { profileAtom } from "../atoms/profile"; ··· 12 9 import { API_URL } from "../consts"; 13 10 import useProfile from "../hooks/useProfile"; 14 11 import CloudDrive from "./CloudDrive"; 15 - import ExternalLinks from "./ExternalLinks"; 16 12 import Navbar from "./Navbar"; 17 13 import Search from "./Search"; 18 14 import SpotifyLogin from "./SpotifyLogin"; 19 - import { IconEye, IconEyeOff, IconLock } from "@tabler/icons-react"; 20 15 import { consola } from "consola"; 16 + import { displayDrawerAtom } from "../atoms/drawer"; 21 17 22 18 const Container = styled.div` 23 19 display: flex; ··· 41 37 } 42 38 `; 43 39 44 - const Link = styled.a` 45 - text-decoration: none; 46 - cursor: pointer; 47 - display: block; 48 - font-size: 13px; 40 + const Drawer = styled.div` 41 + @media (max-width: 1152px) { 42 + display: block; 43 + } 49 44 50 - &:hover { 51 - text-decoration: underline; 45 + @media (min-width: 1153px) { 46 + display: none; 52 47 } 53 48 `; 54 49 ··· 57 52 withRightPane?: boolean; 58 53 }; 59 54 55 + import LoginForm from "./LoginForm"; 56 + import ExternalLinks from "./ExternalLinks"; 57 + import Links from "./Links"; 58 + 60 59 function Main(props: MainProps) { 61 60 const { children } = props; 61 + const displayDrawer = useAtomValue(displayDrawerAtom); 62 62 const withRightPane = props.withRightPane ?? true; 63 63 const [handle, setHandle] = useState(""); 64 64 const [password, setPassword] = useState(""); ··· 186 186 }, 187 187 }} 188 188 /> 189 + <Navbar /> 190 + 189 191 <Flex style={{ width: withRightPane ? "770px" : "1090px" }}> 190 - <Navbar /> 191 - <div 192 - style={{ 193 - position: "relative", 194 - }} 195 - > 196 - {children} 197 - </div> 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 + )} 198 221 </Flex> 199 222 {withRightPane && ( 200 223 <RightPane className="relative w-[300px]"> ··· 206 229 {jwt && profile && <CloudDrive />} 207 230 {!jwt && ( 208 231 <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> 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 + /> 352 242 </div> 353 243 )} 354 244 ··· 356 246 <ScrobblesAreaChart /> 357 247 </div> 358 248 <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> 249 + <Links /> 403 250 </div> 404 251 </RightPane> 405 252 )}
+35 -1
apps/web/src/layouts/Navbar/Navbar.tsx
··· 21 21 import { useProfileStatsByDidQuery } from "../../hooks/useProfile"; 22 22 import LogoDark from "../../assets/rocksky-logo-dark.png"; 23 23 import LogoLight from "../../assets/rocksky-logo-light.png"; 24 - import { IconUser } from "@tabler/icons-react"; 24 + import { IconUser, IconMenu2, IconX } from "@tabler/icons-react"; 25 + import { displayDrawerAtom } from "../../atoms/drawer"; 25 26 26 27 const Container = styled.div` 27 28 position: fixed; ··· 35 36 36 37 @media (max-width: 1152px) { 37 38 width: 100%; 39 + max-width: 770px; 38 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; 39 51 } 40 52 `; 41 53 ··· 69 81 `; 70 82 71 83 function Navbar() { 84 + const [displayDrawer, setDisplayDrawer] = useAtom(displayDrawerAtom); 72 85 const [isOpen, setIsOpen] = useState(false); 73 86 const [{ darkMode }, setTheme] = useAtom(themeAtom); 74 87 const setProfile = useSetAtom(profileAtom); ··· 155 168 </AnimatedLink> 156 169 </Link> 157 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> 158 192 159 193 {profile && jwt && ( 160 194 <StatefulPopover
+1 -1
apps/web/src/layouts/SpotifyLogin/SpotifyLogin.tsx
··· 125 125 overrides={{ 126 126 Root: { 127 127 style: { 128 - zIndex: 1, 128 + zIndex: 50, 129 129 }, 130 130 }, 131 131 Dialog: {