diff --git a/bun.lock b/bun.lock --- a/bun.lock +++ b/bun.lock @@ -186,6 +186,7 @@ "@styled-icons/remix-fill": "^10.46.0", "@styled-icons/simple-icons": "^10.46.0", "@styled-icons/zondicons": "^10.46.0", + "@tabler/icons-react": "^3.36.0", "@tailwindcss/vite": "^4.1.4", "@tanstack/react-query": "^5.76.0", "@tanstack/react-query-devtools": "^5.76.0", @@ -1152,6 +1153,10 @@ "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], "@swc/types": ["@swc/types@0.1.25", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g=="], + + "@tabler/icons": ["@tabler/icons@3.36.0", "", {}, "sha512-z9OfTEG6QbaQWM9KBOxxUdpgvMUn0atageXyiaSc2gmYm51ORO8Ua7eUcjlks+Dc0YMK4rrodAFdK9SfjJ4ZcA=="], + + "@tabler/icons-react": ["@tabler/icons-react@3.36.0", "", { "dependencies": { "@tabler/icons": "3.36.0" }, "peerDependencies": { "react": ">= 16" } }, "sha512-sSZ00bEjTdTTskVFykq294RJq+9cFatwy4uYa78HcYBCXU1kSD1DIp5yoFsQXmybkIOKCjp18OnhAYk553UIfQ=="], "@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=="], diff --git a/apps/web/package.json b/apps/web/package.json --- a/apps/web/package.json +++ b/apps/web/package.json @@ -35,6 +35,7 @@ "@styled-icons/remix-fill": "^10.46.0", "@styled-icons/simple-icons": "^10.46.0", "@styled-icons/zondicons": "^10.46.0", + "@tabler/icons-react": "^3.36.0", "@tailwindcss/vite": "^4.1.4", "@tanstack/react-query": "^5.76.0", "@tanstack/react-query-devtools": "^5.76.0", diff --git a/apps/web/src/index.css b/apps/web/src/index.css --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -54,8 +54,17 @@ body { margin: 0; font-family: - RockfordSansLight, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", - "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", + RockfordSansLight, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + "Roboto", + "Oxygen", + "Ubuntu", + "Cantarell", + "Fira Sans", + "Droid Sans", + "Helvetica Neue", sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; @@ -118,4 +127,8 @@ transition: background-color 0.1s ease, color 0.1s ease; +} + +button { + font-family: RockfordSansMedium; } diff --git a/apps/feeds/src/algos/all.ts b/apps/feeds/src/algos/all.ts new file mode 100644 --- /dev/null +++ b/apps/feeds/src/algos/all.ts @@ -0,0 +1,51 @@ +import { Context } from "../context.ts"; +import { Algorithm, feedParams } from "./types.ts"; +import schema from "../schema/mod.ts"; +import { and, desc, eq, lt } from "drizzle-orm"; + +const handler = async ( + ctx: Context, + params: feedParams, + _did?: string | null, +) => { + const { limit = 50, cursor } = params; + + const whereConditions = []; + + if (cursor) { + const cursorDate = new Date(parseInt(cursor, 10)); + whereConditions.push(lt(schema.scrobbles.timestamp, cursorDate)); + } + + const scrobbles = await ctx.db + .select() + .from(schema.scrobbles) + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) + .where(and(...whereConditions)) + .orderBy(desc(schema.scrobbles.timestamp)) + .limit(limit) + .execute(); + + const feed = scrobbles.map(({ scrobbles }) => ({ scrobble: scrobbles.uri })); + + const { scrobbles: lastScrobble } = + scrobbles.length > 0 ? scrobbles.at(-1)! : { scrobbles: null }; + const nextCursor = lastScrobble + ? lastScrobble.timestamp.getTime().toString(10) + : undefined; + + return { + cursor: nextCursor, + feed, + }; +}; + +export const publisherDid = "did:plc:vegqomyce4ssoqs7zwqvgqty"; +export const rkey = "all"; + +export const info = { + handler, + needsAuth: false, + publisherDid, + rkey, +} as Algorithm; diff --git a/apps/web/src/api/feed.ts b/apps/web/src/api/feed.ts --- a/apps/web/src/api/feed.ts +++ b/apps/web/src/api/feed.ts @@ -34,3 +34,27 @@ uri: response.data?.uri, }; }; + +export const getFeedGenerators = async () => { + const response = await client.get<{ + feeds: { + id: string; + name: string; + uri: string; + description: string; + did: string; + avatar?: string; + creator: { + avatar?: string; + displayName: string; + handle: string; + did: string; + id: string; + }; + }[]; + }>("/xrpc/app.rocksky.feed.getFeedGenerators"); + if (response.status !== 200) { + return null; + } + return response.data; +}; diff --git a/apps/web/src/hooks/useFeed.tsx b/apps/web/src/hooks/useFeed.tsx --- a/apps/web/src/hooks/useFeed.tsx +++ b/apps/web/src/hooks/useFeed.tsx @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { client } from "../api"; -import { getFeedByUri } from "../api/feed"; +import { getFeedByUri, getFeedGenerators } from "../api/feed"; export const useFeedQuery = (limit = 114) => useQuery({ @@ -16,4 +16,10 @@ useQuery({ queryKey: ["feed", uri], queryFn: () => getFeedByUri(uri), + }); + +export const useFeedGeneratorsQuery = () => + useQuery({ + queryKey: ["feedGenerators"], + queryFn: () => getFeedGenerators(), }); diff --git a/apps/web/src/layouts/Main.tsx b/apps/web/src/layouts/Main.tsx --- a/apps/web/src/layouts/Main.tsx +++ b/apps/web/src/layouts/Main.tsx @@ -142,7 +142,7 @@ {withRightPane && ( -
+
diff --git a/apps/web/src/pages/home/feed/Feed.tsx b/apps/web/src/pages/home/feed/Feed.tsx --- a/apps/web/src/pages/home/feed/Feed.tsx +++ b/apps/web/src/pages/home/feed/Feed.tsx @@ -4,7 +4,7 @@ import type { BlockProps } from "baseui/block"; import { FlexGrid, FlexGridItem } from "baseui/flex-grid"; import { StatefulTooltip } from "baseui/tooltip"; -import { HeadingMedium, LabelSmall } from "baseui/typography"; +import { LabelSmall } from "baseui/typography"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import ContentLoader from "react-content-loader"; @@ -14,6 +14,7 @@ import { useEffect, useRef } from "react"; import { WS_URL } from "../../../consts"; import { useQueryClient } from "@tanstack/react-query"; +import FeedGenerators from "./FeedGenerators"; dayjs.extend(relativeTime); @@ -77,14 +78,7 @@ return ( - - Recently played - - + {isLoading && ( +
(null); + + // Check scroll position and update chevron visibility + const handleScroll = () => { + const container = scrollContainerRef.current; + if (!container) return; + + const { scrollLeft, scrollWidth, clientWidth } = container; + + // Check if content overflows + const overflow = scrollWidth > clientWidth; + setHasOverflow(overflow); + + // Show left chevron if scrolled from the start + setShowLeftChevron(scrollLeft > 10); + + // Show right chevron if not scrolled to the end + setShowRightChevron(scrollLeft < scrollWidth - clientWidth - 10); + }; + + // Scroll left/right + const scroll = (direction: "left" | "right") => { + const container = scrollContainerRef.current; + if (!container) return; + + const scrollAmount = 200; + const newScrollLeft = + direction === "left" + ? container.scrollLeft - scrollAmount + : container.scrollLeft + scrollAmount; + + container.scrollTo({ + left: newScrollLeft, + behavior: "smooth", + }); + }; + + const handleCategoryClick = (category: string, index: number) => { + setActiveCategory(category); + + const container = scrollContainerRef.current; + if (container) { + const buttons = container.children; + const button = buttons[index] as HTMLElement; + + if (button) { + const containerWidth = container.offsetWidth; + const buttonLeft = button.offsetLeft; + const buttonWidth = button.offsetWidth; + + // Center the clicked button + const scrollPosition = + buttonLeft - containerWidth / 2 + buttonWidth / 2; + container.scrollTo({ + left: scrollPosition, + behavior: "smooth", + }); + } + } + }; + + // Check overflow on mount and window resize + useEffect(() => { + handleScroll(); + + const handleResize = () => { + handleScroll(); + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + return ( +
+ + +
+
+ {/* Left chevron */} + {showLeftChevron && ( + + )} + +
+
+ {categories.map((category, index) => ( + + ))} +
+
+ + {/* Right chevron */} + {showRightChevron && ( + + )} +
+
+
+ ); +} + +export default FeedGenerators; diff --git a/apps/web/src/pages/home/feed/FeedGenerators/constants.ts b/apps/web/src/pages/home/feed/FeedGenerators/constants.ts new file mode 100644 --- /dev/null +++ b/apps/web/src/pages/home/feed/FeedGenerators/constants.ts @@ -0,0 +1,54 @@ +export const categories = [ + "all", + "afrobeat", + "afrobeats", + "alternative metal", + "alternative r&b", + "anime", + "art pop", + "breakcore", + "chicago drill", + "chillwave", + "country hip hop", + "crunk", + "dance pop", + "deep house", + "drill", + "dubstep", + "emo", + "grunge", + "hard rock", + "heavy metal", + "hip hop", + "house", + "hyperpop", + "indie", + "indie rock", + "j-pop", + "j-rock", + "jazz", + "k-pop", + "lo-fi", + "metal", + "metalcore", + "midwest emo", + "nu metal", + "pop punk", + "post-grunge", + "rap", + "rap metal", + "r&b", + "rock", + "southern hip hop", + "speedcore", + "swedish pop", + "synthwave", + "thrash metal", + "trap", + "trap soul", + "tropical house", + "vaporwave", + "visual kei", + "vocaloid", + "west coast hip hop", +]; diff --git a/apps/web/src/pages/home/feed/FeedGenerators/index.tsx b/apps/web/src/pages/home/feed/FeedGenerators/index.tsx new file mode 100644 --- /dev/null +++ b/apps/web/src/pages/home/feed/FeedGenerators/index.tsx @@ -0,0 +1,3 @@ +import FeedGenerators from "./FeedGenerators"; + +export default FeedGenerators;