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

Add Feed Generators UI and backend algorithm

Server: add algorithm to return paginated scrobbles feed. Client: add
API endpoint, react-query hook, and FeedGenerators component (category
scroller with chevrons). Add constants and index export. Add
@tabler/icons-react dependency and small UI/style tweaks (padding,
z-index, font settings).

+348 -15
+51
apps/feeds/src/algos/all.ts
··· 1 + import { Context } from "../context.ts"; 2 + import { Algorithm, feedParams } from "./types.ts"; 3 + import schema from "../schema/mod.ts"; 4 + import { and, desc, eq, lt } from "drizzle-orm"; 5 + 6 + const handler = async ( 7 + ctx: Context, 8 + params: feedParams, 9 + _did?: string | null, 10 + ) => { 11 + const { limit = 50, cursor } = params; 12 + 13 + const whereConditions = []; 14 + 15 + if (cursor) { 16 + const cursorDate = new Date(parseInt(cursor, 10)); 17 + whereConditions.push(lt(schema.scrobbles.timestamp, cursorDate)); 18 + } 19 + 20 + const scrobbles = await ctx.db 21 + .select() 22 + .from(schema.scrobbles) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 + .where(and(...whereConditions)) 25 + .orderBy(desc(schema.scrobbles.timestamp)) 26 + .limit(limit) 27 + .execute(); 28 + 29 + const feed = scrobbles.map(({ scrobbles }) => ({ scrobble: scrobbles.uri })); 30 + 31 + const { scrobbles: lastScrobble } = 32 + scrobbles.length > 0 ? scrobbles.at(-1)! : { scrobbles: null }; 33 + const nextCursor = lastScrobble 34 + ? lastScrobble.timestamp.getTime().toString(10) 35 + : undefined; 36 + 37 + return { 38 + cursor: nextCursor, 39 + feed, 40 + }; 41 + }; 42 + 43 + export const publisherDid = "did:plc:vegqomyce4ssoqs7zwqvgqty"; 44 + export const rkey = "all"; 45 + 46 + export const info = { 47 + handler, 48 + needsAuth: false, 49 + publisherDid, 50 + rkey, 51 + } as Algorithm;
+1
apps/web/package.json
··· 35 35 "@styled-icons/remix-fill": "^10.46.0", 36 36 "@styled-icons/simple-icons": "^10.46.0", 37 37 "@styled-icons/zondicons": "^10.46.0", 38 + "@tabler/icons-react": "^3.36.0", 38 39 "@tailwindcss/vite": "^4.1.4", 39 40 "@tanstack/react-query": "^5.76.0", 40 41 "@tanstack/react-query-devtools": "^5.76.0",
+24
apps/web/src/api/feed.ts
··· 34 34 uri: response.data?.uri, 35 35 }; 36 36 }; 37 + 38 + export const getFeedGenerators = async () => { 39 + const response = await client.get<{ 40 + feeds: { 41 + id: string; 42 + name: string; 43 + uri: string; 44 + description: string; 45 + did: string; 46 + avatar?: string; 47 + creator: { 48 + avatar?: string; 49 + displayName: string; 50 + handle: string; 51 + did: string; 52 + id: string; 53 + }; 54 + }[]; 55 + }>("/xrpc/app.rocksky.feed.getFeedGenerators"); 56 + if (response.status !== 200) { 57 + return null; 58 + } 59 + return response.data; 60 + };
+7 -1
apps/web/src/hooks/useFeed.tsx
··· 1 1 import { useQuery } from "@tanstack/react-query"; 2 2 import { client } from "../api"; 3 - import { getFeedByUri } from "../api/feed"; 3 + import { getFeedByUri, getFeedGenerators } from "../api/feed"; 4 4 5 5 export const useFeedQuery = (limit = 114) => 6 6 useQuery({ ··· 17 17 queryKey: ["feed", uri], 18 18 queryFn: () => getFeedByUri(uri), 19 19 }); 20 + 21 + export const useFeedGeneratorsQuery = () => 22 + useQuery({ 23 + queryKey: ["feedGenerators"], 24 + queryFn: () => getFeedGenerators(), 25 + });
+15 -2
apps/web/src/index.css
··· 54 54 body { 55 55 margin: 0; 56 56 font-family: 57 - RockfordSansLight, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", 58 - "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 57 + RockfordSansLight, 58 + -apple-system, 59 + BlinkMacSystemFont, 60 + "Segoe UI", 61 + "Roboto", 62 + "Oxygen", 63 + "Ubuntu", 64 + "Cantarell", 65 + "Fira Sans", 66 + "Droid Sans", 67 + "Helvetica Neue", 59 68 sans-serif; 60 69 -webkit-font-smoothing: antialiased; 61 70 -moz-osx-font-smoothing: grayscale; ··· 119 128 background-color 0.1s ease, 120 129 color 0.1s ease; 121 130 } 131 + 132 + button { 133 + font-family: RockfordSansMedium; 134 + }
+1 -1
apps/web/src/layouts/Main.tsx
··· 142 142 </Flex> 143 143 {withRightPane && ( 144 144 <RightPane className="relative w-[300px]"> 145 - <div className="fixed top-[100px] h-[calc(100vh-100px)] w-[300px] bg-white p-[20px] overflow-y-auto"> 145 + <div className="fixed top-[100px] h-[calc(100vh-100px)] w-[300px] bg-white p-[20px] overflow-y-auto pt-[0px]"> 146 146 <div className="mb-[30px]"> 147 147 <Search /> 148 148 </div>
+4 -10
apps/web/src/pages/home/feed/Feed.tsx
··· 4 4 import type { BlockProps } from "baseui/block"; 5 5 import { FlexGrid, FlexGridItem } from "baseui/flex-grid"; 6 6 import { StatefulTooltip } from "baseui/tooltip"; 7 - import { HeadingMedium, LabelSmall } from "baseui/typography"; 7 + import { LabelSmall } from "baseui/typography"; 8 8 import dayjs from "dayjs"; 9 9 import relativeTime from "dayjs/plugin/relativeTime"; 10 10 import ContentLoader from "react-content-loader"; ··· 14 14 import { useEffect, useRef } from "react"; 15 15 import { WS_URL } from "../../../consts"; 16 16 import { useQueryClient } from "@tanstack/react-query"; 17 + import FeedGenerators from "./FeedGenerators"; 17 18 18 19 dayjs.extend(relativeTime); 19 20 ··· 77 78 78 79 return ( 79 80 <Container> 80 - <HeadingMedium 81 - marginTop={"0px"} 82 - marginBottom={"25px"} 83 - className="!text-[var(--color-text)]" 84 - > 85 - Recently played 86 - </HeadingMedium> 87 - 81 + <FeedGenerators /> 88 82 {isLoading && ( 89 83 <ContentLoader 90 84 width={800} ··· 112 106 )} 113 107 114 108 {!isLoading && ( 115 - <div className="pb-[100px]"> 109 + <div className="pb-[100px] pt-[20px]"> 116 110 <FlexGrid 117 111 flexGridColumnCount={[1, 2, 3]} 118 112 flexGridColumnGap="scale800"
+182
apps/web/src/pages/home/feed/FeedGenerators/FeedGenerators.tsx
··· 1 + import { useState, useRef, useEffect } from "react"; 2 + import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react"; 3 + import { categories } from "./constants"; 4 + 5 + function FeedGenerators() { 6 + const jwt = localStorage.getItem("token"); 7 + const [activeCategory, setActiveCategory] = useState("All"); 8 + const [showLeftChevron, setShowLeftChevron] = useState(false); 9 + const [showRightChevron, setShowRightChevron] = useState(true); 10 + const [hasOverflow, setHasOverflow] = useState(false); 11 + const scrollContainerRef = useRef<HTMLDivElement>(null); 12 + 13 + // Check scroll position and update chevron visibility 14 + const handleScroll = () => { 15 + const container = scrollContainerRef.current; 16 + if (!container) return; 17 + 18 + const { scrollLeft, scrollWidth, clientWidth } = container; 19 + 20 + // Check if content overflows 21 + const overflow = scrollWidth > clientWidth; 22 + setHasOverflow(overflow); 23 + 24 + // Show left chevron if scrolled from the start 25 + setShowLeftChevron(scrollLeft > 10); 26 + 27 + // Show right chevron if not scrolled to the end 28 + setShowRightChevron(scrollLeft < scrollWidth - clientWidth - 10); 29 + }; 30 + 31 + // Scroll left/right 32 + const scroll = (direction: "left" | "right") => { 33 + const container = scrollContainerRef.current; 34 + if (!container) return; 35 + 36 + const scrollAmount = 200; 37 + const newScrollLeft = 38 + direction === "left" 39 + ? container.scrollLeft - scrollAmount 40 + : container.scrollLeft + scrollAmount; 41 + 42 + container.scrollTo({ 43 + left: newScrollLeft, 44 + behavior: "smooth", 45 + }); 46 + }; 47 + 48 + const handleCategoryClick = (category: string, index: number) => { 49 + setActiveCategory(category); 50 + 51 + const container = scrollContainerRef.current; 52 + if (container) { 53 + const buttons = container.children; 54 + const button = buttons[index] as HTMLElement; 55 + 56 + if (button) { 57 + const containerWidth = container.offsetWidth; 58 + const buttonLeft = button.offsetLeft; 59 + const buttonWidth = button.offsetWidth; 60 + 61 + // Center the clicked button 62 + const scrollPosition = 63 + buttonLeft - containerWidth / 2 + buttonWidth / 2; 64 + container.scrollTo({ 65 + left: scrollPosition, 66 + behavior: "smooth", 67 + }); 68 + } 69 + } 70 + }; 71 + 72 + // Check overflow on mount and window resize 73 + useEffect(() => { 74 + handleScroll(); 75 + 76 + const handleResize = () => { 77 + handleScroll(); 78 + }; 79 + 80 + window.addEventListener("resize", handleResize); 81 + return () => window.removeEventListener("resize", handleResize); 82 + }, []); 83 + 84 + return ( 85 + <div 86 + className={`sticky ${jwt ? "top-[80px]" : "top-[80px]"} bg-[var(--color-background)] z-50`} 87 + > 88 + <style>{` 89 + .no-scrollbar::-webkit-scrollbar { 90 + display: none; 91 + } 92 + .no-scrollbar { 93 + -ms-overflow-style: none; 94 + scrollbar-width: none; 95 + } 96 + `}</style> 97 + 98 + <div className="bg-[var(--color-background)]"> 99 + <div className="relative h-[50px] flex items-center"> 100 + {/* Left chevron */} 101 + {showLeftChevron && ( 102 + <button 103 + onClick={() => scroll("left")} 104 + className="flex-shrink-0 w-8 h-8 rounded-full bg-transparent hover:bg-[var(--color-input-background)] flex items-center justify-center transition-all outline-none border-none cursor-pointer shadow-md z-30 h-[30px] w-[30px] mt-[3px]" 105 + > 106 + <IconChevronLeft size={16} className="text-[var(--color-text)]" /> 107 + </button> 108 + )} 109 + 110 + <div 111 + className="relative flex-1 overflow-hidden" 112 + style={ 113 + hasOverflow 114 + ? { 115 + maskImage: 116 + showLeftChevron && showRightChevron 117 + ? "linear-gradient(to right, transparent, black 40px, black calc(100% - 40px), transparent)" 118 + : showLeftChevron 119 + ? "linear-gradient(to right, transparent, black 40px, black 100%)" 120 + : showRightChevron 121 + ? "linear-gradient(to right, black 0%, black calc(100% - 40px), transparent)" 122 + : undefined, 123 + WebkitMaskImage: 124 + showLeftChevron && showRightChevron 125 + ? "linear-gradient(to right, transparent, black 40px, black calc(100% - 40px), transparent)" 126 + : showLeftChevron 127 + ? "linear-gradient(to right, transparent, black 40px, black 100%)" 128 + : showRightChevron 129 + ? "linear-gradient(to right, black 0%, black calc(100% - 40px), transparent)" 130 + : undefined, 131 + } 132 + : undefined 133 + } 134 + > 135 + <div 136 + ref={scrollContainerRef} 137 + onScroll={handleScroll} 138 + className="flex gap-[8px] overflow-x-auto no-scrollbar px-4 py-3 h-full" 139 + > 140 + {categories.map((category, index) => ( 141 + <button 142 + key={category} 143 + onClick={() => handleCategoryClick(category, index)} 144 + className={` 145 + relative flex-shrink-0 px-3.5 py-1.5 rounded-full text-sm font-medium 146 + transition-all duration-200 whitespace-nowrap outline-none border-none p-[8px] pl-[12px] pr-[12px] cursor-pointer 147 + ${ 148 + activeCategory === category 149 + ? "bg-[var(--color-input-background)] text-[var(--color-text)]" 150 + : "bg-transparent text-[var(--color-text)] hover:bg-[var(--color-input-background)]" 151 + } 152 + `} 153 + > 154 + {category} 155 + {/* Active indicator underline */} 156 + {activeCategory === category && ( 157 + <div className="absolute bottom-[-12px] left-1/2 transform -translate-x-1/2 w-8 h-0.5 bg-[var(--color-primary)]" /> 158 + )} 159 + </button> 160 + ))} 161 + </div> 162 + </div> 163 + 164 + {/* Right chevron */} 165 + {showRightChevron && ( 166 + <button 167 + onClick={() => scroll("right")} 168 + className="flex-shrink-0 w-8 h-8 rounded-full bg-transparent hover:bg-[var(--color-input-background)] flex items-center justify-center transition-all outline-none border-none cursor-pointer shadow-md z-30 h-[30px] w-[30px] mt-[3px]" 169 + > 170 + <IconChevronRight 171 + size={16} 172 + className="text-[var(--color-text)]" 173 + /> 174 + </button> 175 + )} 176 + </div> 177 + </div> 178 + </div> 179 + ); 180 + } 181 + 182 + export default FeedGenerators;
+54
apps/web/src/pages/home/feed/FeedGenerators/constants.ts
··· 1 + export const categories = [ 2 + "all", 3 + "afrobeat", 4 + "afrobeats", 5 + "alternative metal", 6 + "alternative r&b", 7 + "anime", 8 + "art pop", 9 + "breakcore", 10 + "chicago drill", 11 + "chillwave", 12 + "country hip hop", 13 + "crunk", 14 + "dance pop", 15 + "deep house", 16 + "drill", 17 + "dubstep", 18 + "emo", 19 + "grunge", 20 + "hard rock", 21 + "heavy metal", 22 + "hip hop", 23 + "house", 24 + "hyperpop", 25 + "indie", 26 + "indie rock", 27 + "j-pop", 28 + "j-rock", 29 + "jazz", 30 + "k-pop", 31 + "lo-fi", 32 + "metal", 33 + "metalcore", 34 + "midwest emo", 35 + "nu metal", 36 + "pop punk", 37 + "post-grunge", 38 + "rap", 39 + "rap metal", 40 + "r&b", 41 + "rock", 42 + "southern hip hop", 43 + "speedcore", 44 + "swedish pop", 45 + "synthwave", 46 + "thrash metal", 47 + "trap", 48 + "trap soul", 49 + "tropical house", 50 + "vaporwave", 51 + "visual kei", 52 + "vocaloid", 53 + "west coast hip hop", 54 + ];
+3
apps/web/src/pages/home/feed/FeedGenerators/index.tsx
··· 1 + import FeedGenerators from "./FeedGenerators"; 2 + 3 + export default FeedGenerators;
+1 -1
apps/web/src/pages/home/nowplayings/styles.tsx
··· 2 2 modal: { 3 3 Root: { 4 4 style: { 5 - zIndex: 2, 5 + zIndex: 60, 6 6 }, 7 7 }, 8 8 Dialog: {
+5
bun.lock
··· 186 186 "@styled-icons/remix-fill": "^10.46.0", 187 187 "@styled-icons/simple-icons": "^10.46.0", 188 188 "@styled-icons/zondicons": "^10.46.0", 189 + "@tabler/icons-react": "^3.36.0", 189 190 "@tailwindcss/vite": "^4.1.4", 190 191 "@tanstack/react-query": "^5.76.0", 191 192 "@tanstack/react-query-devtools": "^5.76.0", ··· 1152 1153 "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], 1153 1154 1154 1155 "@swc/types": ["@swc/types@0.1.25", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g=="], 1156 + 1157 + "@tabler/icons": ["@tabler/icons@3.36.0", "", {}, "sha512-z9OfTEG6QbaQWM9KBOxxUdpgvMUn0atageXyiaSc2gmYm51ORO8Ua7eUcjlks+Dc0YMK4rrodAFdK9SfjJ4ZcA=="], 1158 + 1159 + "@tabler/icons-react": ["@tabler/icons-react@3.36.0", "", { "dependencies": { "@tabler/icons": "3.36.0" }, "peerDependencies": { "react": ">= 16" } }, "sha512-sSZ00bEjTdTTskVFykq294RJq+9cFatwy4uYa78HcYBCXU1kSD1DIp5yoFsQXmybkIOKCjp18OnhAYk553UIfQ=="], 1155 1160 1156 1161 "@tailwindcss/node": ["@tailwindcss/node@4.1.14", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.0", "lightningcss": "1.30.1", "magic-string": "^0.30.19", "source-map-js": "^1.2.1", "tailwindcss": "4.1.14" } }, "sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw=="], 1157 1162