diff --git a/apps/web/src/api/library.ts b/apps/web/src/api/library.ts
index 52f4f61c..cd38d530 100644
--- a/apps/web/src/api/library.ts
+++ b/apps/web/src/api/library.ts
@@ -1,5 +1,7 @@
import { client } from ".";
+import { Album } from "../types/album";
import { Artist } from "../types/artist";
+import { Track } from "../types/track";
export const getSongByUri = async (uri: string) => {
if (uri.includes("app.rocksky.scrobble")) {
@@ -162,3 +164,57 @@ export const getArtistListeners = async (uri: string, limit: number) => {
);
return response.data;
};
+
+export const getAlbumsByGenre = async (
+ genre: string,
+ offset = 0,
+ limit = 20,
+) => {
+ const response = await client.get<{ albums: Album[] }>(
+ "/xrpc/app.rocksky.album.getAlbums",
+ {
+ params: {
+ genre,
+ limit,
+ offset,
+ },
+ },
+ );
+ return response.data;
+};
+
+export const getArtistsByGenre = async (
+ genre: string,
+ offset = 0,
+ limit = 20,
+) => {
+ const response = await client.get<{ artists: Artist[] }>(
+ "/xrpc/app.rocksky.artist.getArtists",
+ {
+ params: {
+ genre,
+ limit,
+ offset,
+ },
+ },
+ );
+ return response.data;
+};
+
+export const getTracksByGenre = async (
+ genre: string,
+ offset = 0,
+ limit = 20,
+) => {
+ const response = await client.get<{ tracks: Track[] }>(
+ "/xrpc/app.rocksky.song.getSongs",
+ {
+ params: {
+ genre,
+ limit,
+ offset,
+ },
+ },
+ );
+ return response.data;
+};
diff --git a/apps/web/src/components/Handle/Handle.tsx b/apps/web/src/components/Handle/Handle.tsx
index bc365944..57e5d3b5 100644
--- a/apps/web/src/components/Handle/Handle.tsx
+++ b/apps/web/src/components/Handle/Handle.tsx
@@ -261,12 +261,13 @@ function Handle(props: HandleProps) {
{tags.length > 0 && (
{tags.map((genre) => (
-
# {genre}
-
+
))}
)}
diff --git a/apps/web/src/hooks/useLibrary.tsx b/apps/web/src/hooks/useLibrary.tsx
index 4f538c0e..a69b22a6 100644
--- a/apps/web/src/hooks/useLibrary.tsx
+++ b/apps/web/src/hooks/useLibrary.tsx
@@ -2,14 +2,17 @@ import { useQuery } from "@tanstack/react-query";
import {
getAlbum,
getAlbums,
+ getAlbumsByGenre,
getArtist,
getArtistAlbums,
getArtistListeners,
getArtists,
+ getArtistsByGenre,
getArtistTracks,
getLovedTracks,
getSongByUri,
getTracks,
+ getTracksByGenre,
} from "../api/library";
export const useSongByUriQuery = (uri: string) =>
@@ -117,3 +120,40 @@ export const useArtistListenersQuery = (uri: string, limit = 10) =>
enabled: !!uri,
select: (data) => data.listeners,
});
+
+export const useTracksByGenreQuery = (genre: string, offset = 0, limit = 20) =>
+ useQuery({
+ queryKey: ["tracks", genre, offset, limit],
+ queryFn: () => getTracksByGenre(genre, offset, limit),
+ enabled: !!genre,
+ select: (data) =>
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ data?.tracks.map((x: any) => ({
+ ...x,
+ scrobbles: x.playCount,
+ })),
+ });
+
+export const useAlbumsByGenreQuery = (genre: string, offset = 0, limit = 20) =>
+ useQuery({
+ queryKey: ["albums", genre, offset, limit],
+ queryFn: () => getAlbumsByGenre(genre, offset, limit),
+ enabled: !!genre,
+ select: (data) =>
+ data?.albums.map((x) => ({
+ ...x,
+ scrobbles: x.playCount,
+ })),
+ });
+
+export const useArtistsByGenreQuery = (genre: string, offset = 0, limit = 20) =>
+ useQuery({
+ queryKey: ["artists", genre, offset, limit],
+ queryFn: () => getArtistsByGenre(genre, offset, limit),
+ enabled: !!genre,
+ select: (data) =>
+ data?.artists.map((x) => ({
+ ...x,
+ scrobbles: x.playCount,
+ })),
+ });
diff --git a/apps/web/src/pages/album/Album.tsx b/apps/web/src/pages/album/Album.tsx
index 5d7006c4..d9d60515 100644
--- a/apps/web/src/pages/album/Album.tsx
+++ b/apps/web/src/pages/album/Album.tsx
@@ -198,12 +198,13 @@ const Album = () => {
{(album?.tags || []).map((genre) => (
-
# {genre}
-
+
))}
diff --git a/apps/web/src/pages/artist/Artist.tsx b/apps/web/src/pages/artist/Artist.tsx
index 25d617a2..d5f83d2c 100644
--- a/apps/web/src/pages/artist/Artist.tsx
+++ b/apps/web/src/pages/artist/Artist.tsx
@@ -1,6 +1,6 @@
import styled from "@emotion/styled";
import { ExternalLink } from "@styled-icons/evaicons-solid";
-import { useParams } from "@tanstack/react-router";
+import { Link, useParams } from "@tanstack/react-router";
import { Avatar } from "baseui/avatar";
import { HeadingMedium, HeadingXSmall, LabelMedium } from "baseui/typography";
import { useAtomValue, useSetAtom } from "jotai";
@@ -215,12 +215,13 @@ const Artist = () => {
{artist && (
{(artist?.tags || []).map((genre) => (
-
# {genre}
-
+
))}
)}
diff --git a/apps/web/src/pages/genre/Genre.tsx b/apps/web/src/pages/genre/Genre.tsx
new file mode 100644
index 00000000..68da4dd5
--- /dev/null
+++ b/apps/web/src/pages/genre/Genre.tsx
@@ -0,0 +1,87 @@
+import { HeadingMedium } from "baseui/typography";
+import Main from "../../layouts/Main";
+import { Tab, Tabs } from "baseui/tabs-motion";
+import React, { useState } from "react";
+import Artists from "./artists";
+import Albums from "./albums";
+import Tracks from "./tracks";
+import _ from "lodash";
+import { useParams } from "@tanstack/react-router";
+
+export default function Genre() {
+ const { id: genre } = useParams({ strict: false });
+ const [activeKey, setActiveKey] = useState("0");
+ return (
+
+
+
+ {_.upperFirst(genre)} music
+
+
+
{
+ setActiveKey(activeKey);
+ }}
+ overrides={{
+ TabHighlight: {
+ style: {
+ backgroundColor: "var(--color-purple)",
+ },
+ },
+ TabBorder: {
+ style: {
+ display: "none",
+ },
+ },
+ }}
+ activateOnFocus
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/pages/genre/albums/Albums.tsx b/apps/web/src/pages/genre/albums/Albums.tsx
new file mode 100644
index 00000000..890ca772
--- /dev/null
+++ b/apps/web/src/pages/genre/albums/Albums.tsx
@@ -0,0 +1,77 @@
+import { Link, useParams } from "@tanstack/react-router";
+import { useAlbumsByGenreQuery } from "../../../hooks/useLibrary";
+import { FlexGrid, FlexGridItem } from "baseui/flex-grid";
+import { BlockProps } from "baseui/block";
+import numeral from "numeral";
+import dayjs from "dayjs";
+
+const itemProps: BlockProps = {
+ display: "flex",
+ alignItems: "flex-start",
+ flexDirection: "column",
+};
+
+function Albums() {
+ const { id: genre } = useParams({ strict: false });
+ const { data, isLoading } = useAlbumsByGenreQuery(genre!, 0, 20);
+ return (
+ <>
+ {!isLoading && (
+
+ {data?.map((album) => (
+
+
+
+

+
+
+
{album.title}
+
+
+
+ {album.artist}
+
+
+
+ {numeral(album.playCount).format("0,0")} plays
+
+
+ {album.releaseDate
+ ? dayjs(album.releaseDate).format("MMMM D, YYYY")
+ : album.year}
+
+
+
+ ))}
+
+ )}
+ >
+ );
+}
+
+export default Albums;
diff --git a/apps/web/src/pages/genre/albums/index.tsx b/apps/web/src/pages/genre/albums/index.tsx
new file mode 100644
index 00000000..7203af7d
--- /dev/null
+++ b/apps/web/src/pages/genre/albums/index.tsx
@@ -0,0 +1,3 @@
+import Albums from "./Albums";
+
+export default Albums;
diff --git a/apps/web/src/pages/genre/artists/Artists.tsx b/apps/web/src/pages/genre/artists/Artists.tsx
new file mode 100644
index 00000000..3aa8449d
--- /dev/null
+++ b/apps/web/src/pages/genre/artists/Artists.tsx
@@ -0,0 +1,61 @@
+import { Link, useParams } from "@tanstack/react-router";
+import { useArtistsByGenreQuery } from "../../../hooks/useLibrary";
+import { FlexGrid, FlexGridItem } from "baseui/flex-grid";
+import { BlockProps } from "baseui/block";
+import numeral from "numeral";
+
+const itemProps: BlockProps = {
+ display: "flex",
+ alignItems: "flex-start",
+ flexDirection: "column",
+};
+
+function Artists() {
+ const { id: genre } = useParams({ strict: false });
+ const { data, isLoading } = useArtistsByGenreQuery(genre!, 0, 20);
+ return (
+ <>
+ {!isLoading && (
+
+ {data?.map((artist) => (
+
+
+
+

+
+
+
{artist.name}
+
+
+ {numeral(artist.playCount).format("0,0")} plays
+
+
+
+ ))}
+
+ )}
+ >
+ );
+}
+
+export default Artists;
diff --git a/apps/web/src/pages/genre/artists/index.tsx b/apps/web/src/pages/genre/artists/index.tsx
new file mode 100644
index 00000000..ae0d5e14
--- /dev/null
+++ b/apps/web/src/pages/genre/artists/index.tsx
@@ -0,0 +1,3 @@
+import Artists from "./Artists";
+
+export default Artists;
diff --git a/apps/web/src/pages/genre/index.tsx b/apps/web/src/pages/genre/index.tsx
new file mode 100644
index 00000000..380cc642
--- /dev/null
+++ b/apps/web/src/pages/genre/index.tsx
@@ -0,0 +1,3 @@
+import Genre from "./Genre";
+
+export default Genre;
diff --git a/apps/web/src/pages/genre/tracks/Tracks.tsx b/apps/web/src/pages/genre/tracks/Tracks.tsx
new file mode 100644
index 00000000..e9f2a0c6
--- /dev/null
+++ b/apps/web/src/pages/genre/tracks/Tracks.tsx
@@ -0,0 +1,166 @@
+import { Link, useParams } from "@tanstack/react-router";
+import { useTracksByGenreQuery } from "../../../hooks/useLibrary";
+import { TableBuilder, TableBuilderColumn } from "baseui/table-semantic";
+import numeral from "numeral";
+
+type Row = {
+ id: string;
+ title: string;
+ artist: string;
+ albumArtist: string;
+ albumArt: string;
+ albumUri?: string;
+ artistUri?: string;
+ uri: string;
+ scrobbles: number;
+ index: number;
+};
+
+function Tracks() {
+ const { id: genre } = useParams({ strict: false });
+ const { data, isLoading } = useTracksByGenreQuery(genre!, 0, 20);
+ return (
+ <>
+ {!isLoading && (
+ <>
+ ({
+ id: x.id,
+ title: x.title,
+ artist: x.artist,
+ albumArtist: x.albumArtist,
+ albumArt: x.albumArt,
+ uri: x.uri,
+ scrobbles: x.scrobbles,
+ albumUri: x.albumUri,
+ artistUri: x.artistUri,
+ index,
+ }))}
+ divider="clean"
+ overrides={{
+ TableHeadRow: {
+ style: {
+ display: "none",
+ },
+ },
+ TableBodyCell: {
+ style: {
+ verticalAlign: "center",
+ },
+ },
+ TableBodyRow: {
+ style: {
+ backgroundColor: "var(--color-background)",
+ ":hover": {
+ backgroundColor: "var(--color-menu-hover)",
+ },
+ },
+ },
+ TableEmptyMessage: {
+ style: {
+ backgroundColor: "var(--color-background)",
+ },
+ },
+ Table: {
+ style: {
+ backgroundColor: "var(--color-background)",
+ },
+ },
+ }}
+ >
+
+ {(row: Row) => (
+
+
+ {row.albumUri && (
+
+ {!!row.albumArt && (
+

+ )}
+ {!row.albumArt && (
+
+ )}
+
+ )}
+ {!row.albumUri && (
+
+ {!!row.albumArt && (
+

+ )}
+ {!row.albumArt && (
+
+ )}
+
+ )}
+
+
+ {row.title}
+
+ {row.artistUri && (
+
+ {row.albumArtist}
+
+ )}
+ {!row.artistUri && (
+
+ {row.albumArtist}
+
+ )}
+
+
+ )}
+
+
+ {(row: Row, index?: number) => (
+
+
+ {numeral(row.scrobbles).format("0,0")}{" "}
+ {index == 0 && " scrobbles"}
+
+
+
+ )}
+
+
+ >
+ )}
+ >
+ );
+}
+
+export default Tracks;
diff --git a/apps/web/src/pages/genre/tracks/index.tsx b/apps/web/src/pages/genre/tracks/index.tsx
new file mode 100644
index 00000000..62b1b28a
--- /dev/null
+++ b/apps/web/src/pages/genre/tracks/index.tsx
@@ -0,0 +1,3 @@
+import Tracks from "./Tracks";
+
+export default Tracks;
diff --git a/apps/web/src/pages/genre/tracks/styles.tsx b/apps/web/src/pages/genre/tracks/styles.tsx
new file mode 100644
index 00000000..d7874a02
--- /dev/null
+++ b/apps/web/src/pages/genre/tracks/styles.tsx
@@ -0,0 +1,135 @@
+export default {
+ pagination: {
+ Root: {
+ style: {
+ justifyContent: "center",
+ marginTop: "30px",
+ },
+ },
+ DropdownContainer: {
+ style: {
+ backgroundColor: "var(--color-background)",
+ },
+ },
+ Select: {
+ props: {
+ overrides: {
+ Root: {
+ style: {
+ backgroundColor: "var(--color-background)",
+ color: "var(--color-text)",
+ ":hover": {
+ backgroundColor: "var(--color-background)",
+ color: "var(--color-text)",
+ },
+ },
+ },
+ ControlContainer: {
+ style: {
+ outline: "none",
+ backgroundColor: "var(--color-background)",
+ color: "var(--color-text) !important",
+ ":hover": {
+ backgroundColor: "var(--color-background)",
+ color: "var(--color-text) !important",
+ },
+ },
+ },
+ SelectArrow: {
+ props: {
+ overrides: {
+ Svg: {
+ style: { color: "var(--color-text)" },
+ },
+ },
+ },
+ },
+ DropdownListItem: {
+ style: {
+ backgroundColor: "var(--color-background)",
+ color: "var(--color-text)",
+ ":hover": {
+ backgroundColor: "var(--color-menu-hover)",
+ color: "var(--color-text)",
+ },
+ },
+ },
+ SingleValue: {
+ style: {
+ color: "var(--color-text)",
+ },
+ },
+ Dropdown: {
+ style: {
+ outline: "none",
+ backgroundColor: "var(--color-background)",
+ },
+ },
+ DropdownContainer: {
+ style: {
+ backgroundColor: "var(--color-background)",
+ color: "var(--color-text)",
+ },
+ },
+ Input: {
+ style: {
+ backgroundColor: "var(--color-background)",
+ color: "var(--color-text)",
+ ":hover": {
+ backgroundColor: "var(--color-background) !important",
+ color: "var(--color-text) !important",
+ },
+ },
+ },
+ InputContainer: {
+ style: {
+ backgroundColor: "var(--color-background)",
+ ":hover": {
+ backgroundColor: "var(--color-background)",
+ color: "var(--color-text)",
+ },
+ },
+ },
+ Popover: {
+ style: {
+ backgroundColor: "var(--color-background)",
+ color: "var(--color-text)",
+ },
+ },
+ OptionContent: {
+ style: {
+ backgroundColor: "var(--color-background)",
+ color: "var(--color-text)",
+ },
+ },
+ },
+ },
+ },
+ PrevButton: {
+ style: {
+ backgroundColor: "var(--color-background) !important",
+ color: "var(--color-text) !important",
+ ":hover": {
+ backgroundColor: "var(--color-background) !important",
+ color: "var(--color-text) !important",
+ },
+ },
+ },
+ NextButton: {
+ style: {
+ backgroundColor: "var(--color-background)",
+ color: "var(--color-text)",
+ ":hover": {
+ backgroundColor: "var(--color-background)",
+ color: "var(--color-text) ",
+ },
+ },
+ },
+ MaxLabel: {
+ style: {
+ backgroundColor: "var(--color-background)",
+ color: "var(--color-text)",
+ },
+ },
+ },
+};
diff --git a/apps/web/src/pages/home/feed/Feed.tsx b/apps/web/src/pages/home/feed/Feed.tsx
index c365f9c4..5813faa7 100644
--- a/apps/web/src/pages/home/feed/Feed.tsx
+++ b/apps/web/src/pages/home/feed/Feed.tsx
@@ -206,12 +206,13 @@ function Feed() {
{(song?.tags || []).length > 0 && (
{(song?.tags || []).map((genre: string) => (
-
# {genre}
-
+
))}
)}
diff --git a/apps/web/src/pages/profile/Profile.tsx b/apps/web/src/pages/profile/Profile.tsx
index c469b491..f5b11779 100644
--- a/apps/web/src/pages/profile/Profile.tsx
+++ b/apps/web/src/pages/profile/Profile.tsx
@@ -33,6 +33,7 @@ import Circles from "./circles";
import TopTrack from "./toptrack";
import { useArtistsQuery } from "../../hooks/useLibrary";
import { getLastDays } from "../../lib/date";
+import { Link } from "@tanstack/react-router";
const Group = styled.div`
display: flex;
@@ -306,12 +307,13 @@ function Profile(props: ProfileProps) {
{tags.length > 0 && (
{tags.map((genre) => (
-
# {genre}
-
+
))}
)}
diff --git a/apps/web/src/pages/profile/overview/topalbums/TopAlbums.tsx b/apps/web/src/pages/profile/overview/topalbums/TopAlbums.tsx
index 12dd30ac..fb53a687 100644
--- a/apps/web/src/pages/profile/overview/topalbums/TopAlbums.tsx
+++ b/apps/web/src/pages/profile/overview/topalbums/TopAlbums.tsx
@@ -144,23 +144,23 @@ function TopAlbums() {
-
+
{album.title}
-
+
{album.artistUri && (
-
+
{album.artist}
-
+
)}
{!album.artistUri && (
-
+
{album.artist}
-
+
)}
{album.scrobbles} plays
diff --git a/apps/web/src/pages/song/Song.tsx b/apps/web/src/pages/song/Song.tsx
index e1df473d..27e42e23 100644
--- a/apps/web/src/pages/song/Song.tsx
+++ b/apps/web/src/pages/song/Song.tsx
@@ -320,12 +320,13 @@ const Song = () => {
{(song?.tags || []).length > 0 && (
{(song?.tags || []).map((genre) => (
-
# {genre}
-
+
))}
)}
diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts
index 8a60e90c..063d8687 100644
--- a/apps/web/src/routeTree.gen.ts
+++ b/apps/web/src/routeTree.gen.ts
@@ -16,6 +16,7 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as GoogledriveIndexRouteImport } from './routes/googledrive/index'
import { Route as DropboxIndexRouteImport } from './routes/dropbox/index'
import { Route as GoogledriveIdRouteImport } from './routes/googledrive/$id'
+import { Route as GenreIdRouteImport } from './routes/genre/$id'
import { Route as DropboxIdRouteImport } from './routes/dropbox/$id'
import { Route as ProfileDidIndexRouteImport } from './routes/profile/$did/index'
import { Route as ProfileDidTracksRouteImport } from './routes/profile/$did/tracks'
@@ -67,6 +68,11 @@ const GoogledriveIdRoute = GoogledriveIdRouteImport.update({
path: '/googledrive/$id',
getParentRoute: () => rootRouteImport,
} as any)
+const GenreIdRoute = GenreIdRouteImport.update({
+ id: '/genre/$id',
+ path: '/genre/$id',
+ getParentRoute: () => rootRouteImport,
+} as any)
const DropboxIdRoute = DropboxIdRouteImport.update({
id: '/dropbox/$id',
path: '/dropbox/$id',
@@ -149,6 +155,7 @@ export interface FileRoutesByFullPath {
'/loading': typeof LoadingRoute
'/scrobble': typeof ScrobbleRoute
'/dropbox/$id': typeof DropboxIdRoute
+ '/genre/$id': typeof GenreIdRoute
'/googledrive/$id': typeof GoogledriveIdRoute
'/dropbox': typeof DropboxIndexRoute
'/googledrive': typeof GoogledriveIndexRoute
@@ -173,6 +180,7 @@ export interface FileRoutesByTo {
'/loading': typeof LoadingRoute
'/scrobble': typeof ScrobbleRoute
'/dropbox/$id': typeof DropboxIdRoute
+ '/genre/$id': typeof GenreIdRoute
'/googledrive/$id': typeof GoogledriveIdRoute
'/dropbox': typeof DropboxIndexRoute
'/googledrive': typeof GoogledriveIndexRoute
@@ -198,6 +206,7 @@ export interface FileRoutesById {
'/loading': typeof LoadingRoute
'/scrobble': typeof ScrobbleRoute
'/dropbox/$id': typeof DropboxIdRoute
+ '/genre/$id': typeof GenreIdRoute
'/googledrive/$id': typeof GoogledriveIdRoute
'/dropbox/': typeof DropboxIndexRoute
'/googledrive/': typeof GoogledriveIndexRoute
@@ -224,6 +233,7 @@ export interface FileRouteTypes {
| '/loading'
| '/scrobble'
| '/dropbox/$id'
+ | '/genre/$id'
| '/googledrive/$id'
| '/dropbox'
| '/googledrive'
@@ -248,6 +258,7 @@ export interface FileRouteTypes {
| '/loading'
| '/scrobble'
| '/dropbox/$id'
+ | '/genre/$id'
| '/googledrive/$id'
| '/dropbox'
| '/googledrive'
@@ -272,6 +283,7 @@ export interface FileRouteTypes {
| '/loading'
| '/scrobble'
| '/dropbox/$id'
+ | '/genre/$id'
| '/googledrive/$id'
| '/dropbox/'
| '/googledrive/'
@@ -297,6 +309,7 @@ export interface RootRouteChildren {
LoadingRoute: typeof LoadingRoute
ScrobbleRoute: typeof ScrobbleRoute
DropboxIdRoute: typeof DropboxIdRoute
+ GenreIdRoute: typeof GenreIdRoute
GoogledriveIdRoute: typeof GoogledriveIdRoute
DropboxIndexRoute: typeof DropboxIndexRoute
GoogledriveIndexRoute: typeof GoogledriveIndexRoute
@@ -367,6 +380,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof GoogledriveIdRouteImport
parentRoute: typeof rootRouteImport
}
+ '/genre/$id': {
+ id: '/genre/$id'
+ path: '/genre/$id'
+ fullPath: '/genre/$id'
+ preLoaderRoute: typeof GenreIdRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/dropbox/$id': {
id: '/dropbox/$id'
path: '/dropbox/$id'
@@ -481,6 +501,7 @@ const rootRouteChildren: RootRouteChildren = {
LoadingRoute: LoadingRoute,
ScrobbleRoute: ScrobbleRoute,
DropboxIdRoute: DropboxIdRoute,
+ GenreIdRoute: GenreIdRoute,
GoogledriveIdRoute: GoogledriveIdRoute,
DropboxIndexRoute: DropboxIndexRoute,
GoogledriveIndexRoute: GoogledriveIndexRoute,
diff --git a/apps/web/src/routes/genre/$id.tsx b/apps/web/src/routes/genre/$id.tsx
new file mode 100644
index 00000000..7bb08048
--- /dev/null
+++ b/apps/web/src/routes/genre/$id.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from "@tanstack/react-router";
+import GenrePage from "../../pages/genre";
+
+export const Route = createFileRoute("/genre/$id")({
+ component: GenrePage,
+});
diff --git a/apps/web/src/types/album.ts b/apps/web/src/types/album.ts
new file mode 100644
index 00000000..5255b58f
--- /dev/null
+++ b/apps/web/src/types/album.ts
@@ -0,0 +1,13 @@
+export type Album = {
+ id: string;
+ uri: string;
+ title: string;
+ artist: string;
+ artistUri: string;
+ year: number;
+ albumArt: string;
+ releaseDate: string;
+ sha256: string;
+ playCount: number;
+ uniqueListeners: number;
+};
diff --git a/apps/web/src/types/track.ts b/apps/web/src/types/track.ts
new file mode 100644
index 00000000..1fa0d7d0
--- /dev/null
+++ b/apps/web/src/types/track.ts
@@ -0,0 +1,19 @@
+export type Track = {
+ id: string;
+ uri: string;
+ unique_listeners: number;
+ play_count: number;
+ title: string;
+ artist: string;
+ artist_uri: string;
+ album: string;
+ album_uri: string;
+ album_art: string;
+ album_artist: string;
+ copyright_message: string;
+ disc_number: number;
+ duration: number;
+ sha256: string;
+ track_number: number;
+ created_at: string;
+};