A decentralized music tracking and discovery platform built on AT Protocol 🎵

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: {