From b17d0d19e432b33237a45a691e75cabf6ea1ed98 Mon Sep 17 00:00:00 2001 From: Brandon Scott Date: Sun, 4 Aug 2024 17:08:49 -0400 Subject: [PATCH] feat: initial commit --- .czrc | 3 + .eslintignore | 1 + .eslintrc.js | 10 + .github/workflows/build.yml | 29 + .gitignore | 38 + .husky/pre-commit | 4 + .husky/prepare-commit-msg | 4 + .npmrc | 1 + .nvmrc | 1 + .prettierrc | 10 + Dockerfile | 75 + README.md | 66 + apps/api/.env.example | 4 + apps/api/.eslintrc.js | 11 + apps/api/package.json | 38 + apps/api/src/analytics.ts | 38 + apps/api/src/app.ts | 43 + apps/api/src/config.ts | 11 + .../api/src/controllers/artists-controller.ts | 90 + apps/api/src/controllers/meta-controller.ts | 19 + apps/api/src/database.ts | 11 + apps/api/src/error-handler.ts | 30 + apps/api/src/server.ts | 7 + apps/api/src/services/artist-service.ts | 134 + apps/api/src/services/meta-service.ts | 20 + apps/api/src/spotify.ts | 9 + apps/api/src/utilities/collection-utils.ts | 4 + apps/api/src/utilities/date-utils.ts | 11 + apps/api/src/utilities/environment.ts | 3 + apps/api/src/utilities/rate-limiter.ts | 19 + apps/api/src/utilities/responses.ts | 51 + apps/api/tsconfig.json | 8 + apps/web/.env.example | 1 + apps/web/.eslintrc.js | 9 + apps/web/README.md | 28 + apps/web/global.css | 11 + apps/web/hooks/index.ts | 7 + apps/web/hooks/use-breakpoint.ts | 13 + apps/web/hooks/use-get-artist-snapshots.ts | 29 + apps/web/hooks/use-get-latest-meta.ts | 21 + apps/web/hooks/use-list-artist-snapshots.ts | 29 + apps/web/hooks/use-list-artists.ts | 29 + apps/web/hooks/use-request-artist.ts | 26 + apps/web/hooks/use-search-artists-by-name.ts | 30 + apps/web/next-env.d.ts | 5 + apps/web/next.config.js | 6 + apps/web/package.json | 39 + apps/web/pages/_app.tsx | 24 + apps/web/pages/_document.tsx | 15 + apps/web/pages/index.tsx | 410 + apps/web/tsconfig.json | 22 + apps/web/utils/config.ts | 3 + apps/web/utils/fetch.ts | 45 + apps/web/utils/number-utils.ts | 24 + docker-compose.yml | 10 + package-lock.json | 15857 ++++++++++++++++ package.json | 34 + packages/common/.eslintrc.js | 12 + packages/common/api/endpoints/index.ts | 61 + packages/common/api/errors.ts | 65 + packages/common/api/index.ts | 5 + packages/common/api/responses.ts | 13 + packages/common/api/routes.ts | 20 + packages/common/api/types/artist.ts | 44 + packages/common/api/types/git-history.ts | 11 + packages/common/api/types/index.ts | 2 + .../common/constants/default-artist-ids.ts | 6 + packages/common/constants/index.ts | 1 + packages/common/index.ts | 2 + packages/common/package.json | 7 + packages/common/tsconfig.json | 5 + packages/eslint-config/README.md | 3 + packages/eslint-config/library.js | 47 + packages/eslint-config/next.js | 39 + packages/eslint-config/package.json | 20 + packages/eslint-config/react-internal.js | 42 + packages/typescript-config/base.json | 20 + packages/typescript-config/nextjs.json | 13 + packages/typescript-config/package.json | 9 + packages/typescript-config/react-library.json | 8 + packages/ui/.eslintrc.js | 10 + packages/ui/package.json | 92 + packages/ui/src/footer.tsx | 66 + packages/ui/src/header.tsx | 48 + packages/ui/src/index.ts | 3 + packages/ui/src/search-select/index.ts | 2 + .../ui/src/search-select/removable-badge.tsx | 23 + .../ui/src/search-select/search-select.tsx | 69 + packages/ui/tsconfig.json | 8 + packages/ui/tsconfig.lint.json | 8 + packages/ui/turbo/generators/config.ts | 31 + .../turbo/generators/templates/component.hbs | 8 + process.yml | 4 + render.yaml | 36 + tsconfig.json | 3 + turbo.json | 20 + 96 files changed, 18416 insertions(+) create mode 100644 .czrc create mode 100644 .eslintignore create mode 100644 .eslintrc.js create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 .husky/pre-commit create mode 100644 .husky/prepare-commit-msg create mode 100644 .npmrc create mode 100644 .nvmrc create mode 100644 .prettierrc create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 apps/api/.env.example create mode 100644 apps/api/.eslintrc.js create mode 100644 apps/api/package.json create mode 100644 apps/api/src/analytics.ts create mode 100644 apps/api/src/app.ts create mode 100644 apps/api/src/config.ts create mode 100644 apps/api/src/controllers/artists-controller.ts create mode 100644 apps/api/src/controllers/meta-controller.ts create mode 100644 apps/api/src/database.ts create mode 100644 apps/api/src/error-handler.ts create mode 100644 apps/api/src/server.ts create mode 100644 apps/api/src/services/artist-service.ts create mode 100644 apps/api/src/services/meta-service.ts create mode 100644 apps/api/src/spotify.ts create mode 100644 apps/api/src/utilities/collection-utils.ts create mode 100644 apps/api/src/utilities/date-utils.ts create mode 100644 apps/api/src/utilities/environment.ts create mode 100644 apps/api/src/utilities/rate-limiter.ts create mode 100644 apps/api/src/utilities/responses.ts create mode 100644 apps/api/tsconfig.json create mode 100644 apps/web/.env.example create mode 100644 apps/web/.eslintrc.js create mode 100644 apps/web/README.md create mode 100644 apps/web/global.css create mode 100644 apps/web/hooks/index.ts create mode 100644 apps/web/hooks/use-breakpoint.ts create mode 100644 apps/web/hooks/use-get-artist-snapshots.ts create mode 100644 apps/web/hooks/use-get-latest-meta.ts create mode 100644 apps/web/hooks/use-list-artist-snapshots.ts create mode 100644 apps/web/hooks/use-list-artists.ts create mode 100644 apps/web/hooks/use-request-artist.ts create mode 100644 apps/web/hooks/use-search-artists-by-name.ts create mode 100644 apps/web/next-env.d.ts create mode 100644 apps/web/next.config.js create mode 100644 apps/web/package.json create mode 100644 apps/web/pages/_app.tsx create mode 100644 apps/web/pages/_document.tsx create mode 100644 apps/web/pages/index.tsx create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/utils/config.ts create mode 100644 apps/web/utils/fetch.ts create mode 100644 apps/web/utils/number-utils.ts create mode 100644 docker-compose.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/common/.eslintrc.js create mode 100644 packages/common/api/endpoints/index.ts create mode 100644 packages/common/api/errors.ts create mode 100644 packages/common/api/index.ts create mode 100644 packages/common/api/responses.ts create mode 100644 packages/common/api/routes.ts create mode 100644 packages/common/api/types/artist.ts create mode 100644 packages/common/api/types/git-history.ts create mode 100644 packages/common/api/types/index.ts create mode 100644 packages/common/constants/default-artist-ids.ts create mode 100644 packages/common/constants/index.ts create mode 100644 packages/common/index.ts create mode 100644 packages/common/package.json create mode 100644 packages/common/tsconfig.json create mode 100644 packages/eslint-config/README.md create mode 100644 packages/eslint-config/library.js create mode 100644 packages/eslint-config/next.js create mode 100644 packages/eslint-config/package.json create mode 100644 packages/eslint-config/react-internal.js create mode 100644 packages/typescript-config/base.json create mode 100644 packages/typescript-config/nextjs.json create mode 100644 packages/typescript-config/package.json create mode 100644 packages/typescript-config/react-library.json create mode 100644 packages/ui/.eslintrc.js create mode 100644 packages/ui/package.json create mode 100644 packages/ui/src/footer.tsx create mode 100644 packages/ui/src/header.tsx create mode 100644 packages/ui/src/index.ts create mode 100644 packages/ui/src/search-select/index.ts create mode 100644 packages/ui/src/search-select/removable-badge.tsx create mode 100644 packages/ui/src/search-select/search-select.tsx create mode 100644 packages/ui/tsconfig.json create mode 100644 packages/ui/tsconfig.lint.json create mode 100644 packages/ui/turbo/generators/config.ts create mode 100644 packages/ui/turbo/generators/templates/component.hbs create mode 100644 process.yml create mode 100644 render.yaml create mode 100644 tsconfig.json create mode 100644 turbo.json diff --git a/.czrc b/.czrc new file mode 100644 index 0000000..e6f6f0b --- /dev/null +++ b/.czrc @@ -0,0 +1,3 @@ +{ + "path": "cz-conventional-changelog" +} \ No newline at end of file diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..f80dd3e --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +**/*.eslintrc.js \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..d089755 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,10 @@ +// This configuration only applies to the package manager root. +/** @type {import("eslint").Linter.Config} */ +module.exports = { + ignorePatterns: ["apps/**", "packages/**"], + extends: ["@repo/eslint-config/library.js"], + parser: "@typescript-eslint/parser", + parserOptions: { + project: true, + }, +}; diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..e46cef3 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,29 @@ +name: build + +on: + push: + branches: ["*"] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup node 20 + uses: actions/setup-node@v4 + with: + cache: 'npm' + node-version: 20.15.0 + + - name: npm ci + run: npm ci + + - name: npm run build + run: npm run build + + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c03a39 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# Dependencies +node_modules +.pnp +.pnp.js + +# Local env files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Testing +coverage + +# Turbo +.turbo + +# Vercel +.vercel + +# Build Outputs +.next/ +out/ +build +dist +.eslintcache + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Misc +.DS_Store +*.pem diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..25d2235 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/_/husky.sh" + +npx lint-staged \ No newline at end of file diff --git a/.husky/prepare-commit-msg b/.husky/prepare-commit-msg new file mode 100644 index 0000000..bb64b42 --- /dev/null +++ b/.husky/prepare-commit-msg @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/_/husky.sh" + +exec < /dev/tty && npx cz --hook || true \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..5e3cde5 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +force=true diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..9075659 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20.15.0 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0a627b3 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "arrowParens": "always", + "bracketSameLine": true, + "bracketSpacing": true, + "jsxSingleQuote": false, + "singleQuote": false, + "tabWidth": 4, + "trailingComma": "es5", + "useTabs": false +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0285d6e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,75 @@ +## Base +FROM node:20-alpine3.19 AS base +ARG AWS_ACCESS_KEY_ID +ENV AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} +ARG AWS_SECRET_ACCESS_KEY +ENV AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} +WORKDIR /app +RUN apk add aws-cli + +# Download the sqlite db from s3 +RUN aws s3 cp s3://arthistory-spotify-data/$(aws s3 ls s3://arthistory-spotify-data | sort | tail -n 1 | awk '{print $4}') _spotify-data.db + +## Builder +FROM base AS builder +RUN apk update +RUN apk add --no-cache libc6-compat +WORKDIR /app +RUN npm install turbo@2 --global + +COPY . . + +# Generate a partial monorepo with a pruned lockfile for a target workspace. +# Assuming "api" is the name entered in the project's package.json: { name: "api" } +RUN npx turbo prune api --docker + +## Installer +FROM base AS installer +RUN apk update +RUN apk add python3 make gcc g++ libc-dev +WORKDIR /app + +COPY --from=builder /app/out/json/ . +COPY --from=builder /app/out/package-lock.json ./package-lock.json +RUN npm install + +# Build the project +COPY --from=builder /app/out/full/ . +RUN npx turbo run build --filter=api... + +## Runner +FROM base AS runner +WORKDIR /app + +RUN npm install pm2 --global + +# Don't run production as root +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nodejs +USER nodejs + +COPY --from=builder /app/process.yml ./process.yml +COPY --from=builder /app/package.json ./package.json +COPY --from=installer /app/apps/api/dist ./dist +COPY --from=installer /app/apps/api/build ./build +COPY --from=base /app/_spotify-data.db ./_spotify-data.db + +ARG PORT=3001 +ENV PORT=${PORT} + +ARG CLIENT_ID +ENV CLIENT_ID=${CLIENT_ID} + +ARG CLIENT_SECRET +ENV CLIENT_SECRET=${CLIENT_SECRET} + +ARG DATABASE_PATH=/app/_spotify-data.db +ENV DATABASE_PATH=${DATABASE_PATH} + +ARG POSTHOG_KEY +ENV POSTHOG_KEY=${POSTHOG_KEY} + +ENV NODE_ENV=production + +CMD pm2 start process.yml && tail -f /dev/null + diff --git a/README.md b/README.md new file mode 100644 index 0000000..b400513 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +

arthistory

+ +

+ + + + + code style: prettier + + + TypeScript + + + Commitizen friendly + +

+ +Web application for viewing historical Spotify artist data. + +## Quick Start + +The app can be accessed at [arthistory.brandonscott.me](https://arthistory.brandonscott.me). + +## About + +The Spotify API provides the current follower count and a popularity score for an artist, but does not provide any historical data. I always wanted to track how my favorite artists were growing over time, so maybe you'll find this app useful too. + +## How it works + +Artist data is synced and pushed everyday to the [spotify-data](https://github.com/brandongregoryscott/spotify-data) repo. To easily query the data over time, a SQLite database is built by iterating through the git history and pushed up to S3, which is pulled down and bundled with the API server. + +Not every artist on Spotify is tracked, but new artists can be requested through the web UI or by opening up a pull request to the [spotify-data](https://github.com/brandongregoryscott/spotify-data) repo. + +## Development + +### Database + +The API requires at least a partial SQLite database to query. See the [spotify-data](https://github.com/brandongregoryscott/spotify-data/tree/main?tab=readme-ov-file#building-a-sqlite-database) repo for instructions on how to build a SQLite database to use. + +### Spotify API keys + +The search endpoint in the API hits the Spotify API, so you'll need API keys to search for artists. See the [Spotify API](https://developer.spotify.com/documentation/web-api/tutorials/getting-started) documentation on signing up for developer access. + +### Setup + +```sh +# Edit the environment file to add your Spotify API keys and path to the SQLite database file +cp apps/api/.env.example apps/api/.env + +# The environment file for the web app probably doesn't need to be changed. +cp apps/web/.env.example apps/web/.env + +# Install packages (ensure you are using Node v20+, run `nvm use` if you have `nvm` installed.) +npm i + +# Run the development servers for the web app and API +npm run dev + +# Now, open http://localhost:3000 in your browser to view the app. +``` + +## Issues + +If you find a bug, feel free to [open up an issue](https://github.com/brandongregoryscott/arthistory/issues/new) and try to describe it in detail with reproduction steps if possible. + +If you would like to see a feature, and it isn't [already documented](https://github.com/brandongregoryscott/arthistory/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement), feel free to open up a new issue and describe the desired behavior. diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..bd3712c --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,4 @@ +PORT=3001 +CLIENT_ID=abc +CLIENT_SECRET=xyz +DATABASE_PATH=~/spotify-data/output/_spotify-data.db diff --git a/apps/api/.eslintrc.js b/apps/api/.eslintrc.js new file mode 100644 index 0000000..9e9ef3e --- /dev/null +++ b/apps/api/.eslintrc.js @@ -0,0 +1,11 @@ +module.exports = { + root: true, + extends: [ + "@brandongregoryscott/eslint-config", + "@brandongregoryscott/eslint-config/typescript", + ], + parserOptions: { + tsconfigRootDir: __dirname, + project: "tsconfig.json", + }, +}; diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..490c585 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,38 @@ +{ + "dependencies": { + "@repo/common": "*", + "@spotify/web-api-ts-sdk": "1.2.0", + "body-parser": "1.20.2", + "cors": "2.8.5", + "date-fns": "3.6.0", + "dotenv": "16.3.1", + "esbuild": "0.18.18", + "express": "5.0.0-beta.3", + "express-rate-limit": "6.8.1", + "lodash": "4.17.21", + "posthog-js": "1.144.2", + "sqlite": "5.1.1", + "sqlite3": "5.1.7" + }, + "devDependencies": { + "@repo/eslint-config": "*", + "@repo/typescript-config": "*", + "@types/cors": "2.8.13", + "@types/express": "4.17.17", + "@types/lodash": "4.14.195", + "@types/node": "20.4.2", + "concurrently": "8.2.0", + "nodemon": "3.0.1", + "typescript": "5.5.3" + }, + "name": "api", + "private": true, + "scripts": { + "build": "esbuild src/server.ts --bundle --platform=node --target=node20 --outfile=dist/server.js", + "clean": "rm -rf dist", + "dev": "concurrently --names esbuild,nodemon \"npm run build -- --watch\" \"nodemon -q dist/server.js\"", + "start": "node dist/server.js", + "postbuild": "rm -rf ../../build build && cd ../../node_modules/sqlite3 && npm run rebuild && cd - && cp -r ../../node_modules/sqlite3/build ../.. && cp -r ../../node_modules/sqlite3/build ." + }, + "version": "1.0.0" +} diff --git a/apps/api/src/analytics.ts b/apps/api/src/analytics.ts new file mode 100644 index 0000000..d099d41 --- /dev/null +++ b/apps/api/src/analytics.ts @@ -0,0 +1,38 @@ +import posthog from "posthog-js"; +import { POSTHOG_KEY } from "./config"; +import { DEFAULT_ARTIST_IDS } from "@repo/common"; + +posthog.init(POSTHOG_KEY); + +interface ArtistRequestedProperties { + id: string; +} + +const artistRequested = (properties: ArtistRequestedProperties): void => { + posthog.capture("Artist Requested", properties); +}; + +interface ArtistSelectedProperties { + id: string; +} + +const artistSelected = (properties: ArtistSelectedProperties): void => { + const { id } = properties; + if (DEFAULT_ARTIST_IDS.includes(id)) { + return; + } + + posthog.capture("Artist Selected", properties); +}; + +interface SearchQueryEnteredProperties { + query: string; + totalCount: number; + trackedCount: number; +} + +const searchQueryEntered = (properties: SearchQueryEnteredProperties): void => { + posthog.capture("Search Query Entered", properties); +}; + +export { artistRequested, artistSelected, searchQueryEntered }; diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts new file mode 100644 index 0000000..88cdde3 --- /dev/null +++ b/apps/api/src/app.ts @@ -0,0 +1,43 @@ +import type { Request, Response } from "express"; +import express from "express"; +import bodyParser from "body-parser"; +import { errorHandler } from "./error-handler"; +import { readRateLimiter } from "./utilities/rate-limiter"; +import cors from "cors"; +import { ArtistsController } from "./controllers/artists-controller"; +import { + SEARCH_ARTISTS_BY_NAME_ROUTE, + REQUEST_ARTIST_ROUTE, + LIST_ARTIST_SNAPSHOTS_ROUTE, + GET_ARTIST_SNAPSHOTS_ROUTE, + LIST_ARTISTS_ROUTE, + GET_LATEST_META_ROUTE, +} from "@repo/common"; +import { MetaController } from "./controllers/meta-controller"; + +const app = express(); + +app.use(bodyParser.json()); +app.use(cors()); + +app.get( + "/healthcheck", + (_request: Request, response: Response): Response => response.json("✅") +); + +app.get(GET_LATEST_META_ROUTE, MetaController.latest); + +app.post(REQUEST_ARTIST_ROUTE, ArtistsController.requestArtist); + +app.get(SEARCH_ARTISTS_BY_NAME_ROUTE, ArtistsController.searchArtistsByName); + +app.get(GET_ARTIST_SNAPSHOTS_ROUTE, ArtistsController.getArtistSnapshots); + +app.get(LIST_ARTISTS_ROUTE, ArtistsController.listArtists); + +app.get(LIST_ARTIST_SNAPSHOTS_ROUTE, ArtistsController.listArtistSnapshots); + +app.use(errorHandler); +app.use(readRateLimiter); + +export { app }; diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts new file mode 100644 index 0000000..5053cdb --- /dev/null +++ b/apps/api/src/config.ts @@ -0,0 +1,11 @@ +import dotenv from "dotenv"; + +dotenv.config(); + +const PORT = process.env.PORT ?? 3001; +const CLIENT_ID = process.env.CLIENT_ID ?? ""; +const CLIENT_SECRET = process.env.CLIENT_SECRET ?? ""; +const DATABASE_PATH = process.env.DATABASE_PATH ?? ""; +const POSTHOG_KEY = process.env.POSTHOG_KEY ?? ""; + +export { CLIENT_ID, CLIENT_SECRET, DATABASE_PATH, PORT, POSTHOG_KEY }; diff --git a/apps/api/src/controllers/artists-controller.ts b/apps/api/src/controllers/artists-controller.ts new file mode 100644 index 0000000..5582823 --- /dev/null +++ b/apps/api/src/controllers/artists-controller.ts @@ -0,0 +1,90 @@ +import type { Request, Response } from "express"; +import type { Resolution } from "../services/artist-service"; +import { ArtistService } from "../services/artist-service"; +import { SpotifyClient } from "../spotify"; +import { ok } from "../utilities/responses"; +import { artistRequested, searchQueryEntered } from "../analytics"; +import { isEmpty } from "lodash"; + +const ArtistsController = { + listArtists: async (request: Request, response: Response) => { + const ids = (request.query.ids as string).split(","); + if (isEmpty(ids)) { + return ok(response, {}); + } + + const artists = await ArtistService.listArtists({ + ids, + }); + + return ok(response, artists); + }, + getArtistSnapshots: async ( + request: Request, + response: Response + ): Promise => { + const id = request.params.id as string; + const resolution = request.query.resolution as Resolution | undefined; + + const snapshots = await ArtistService.listArtistSnapshots({ + ids: [id], + resolution, + }); + + return ok(response, snapshots); + }, + listArtistSnapshots: async ( + request: Request, + response: Response + ): Promise => { + const ids = (request.query.ids as string).split(","); + const resolution = request.query.resolution as Resolution | undefined; + if (isEmpty(ids)) { + return ok(response, []); + } + + const snapshots = await ArtistService.listArtistSnapshots({ + ids, + resolution, + }); + + return ok(response, snapshots); + }, + requestArtist: async ( + request: Request, + response: Response + ): Promise => { + const id = request.params.id as string; + artistRequested({ id }); + + return ok(response, { requested: true }); + }, + searchArtistsByName: async ( + request: Request, + response: Response + ): Promise => { + const name = request.query.name as string; + + const result = await SpotifyClient.search(name, ["artist"]); + const artists = result.artists.items; + const ids = artists.map((artist) => artist.id); + const trackedArtistMap = await ArtistService.isTracked(ids); + + const artistsWithTrackingData = artists.map((artist) => ({ + ...artist, + isTracked: trackedArtistMap[artist.id], + })); + + searchQueryEntered({ + query: name, + totalCount: result.artists.total, + trackedCount: Object.values(trackedArtistMap).filter( + (isTracked) => isTracked === true + ).length, + }); + + return ok(response, artistsWithTrackingData); + }, +}; + +export { ArtistsController }; diff --git a/apps/api/src/controllers/meta-controller.ts b/apps/api/src/controllers/meta-controller.ts new file mode 100644 index 0000000..542c107 --- /dev/null +++ b/apps/api/src/controllers/meta-controller.ts @@ -0,0 +1,19 @@ +import type { Request, Response } from "express"; +import { notFound, ok } from "../utilities/responses"; +import { MetaService } from "../services/meta-service"; + +const MetaController = { + latest: async ( + _request: Request, + response: Response + ): Promise => { + const latest = await MetaService.latest(); + if (latest === undefined) { + return notFound(response, "Latest data could not be found"); + } + + return ok(response, latest); + }, +}; + +export { MetaController }; diff --git a/apps/api/src/database.ts b/apps/api/src/database.ts new file mode 100644 index 0000000..a3e8d83 --- /dev/null +++ b/apps/api/src/database.ts @@ -0,0 +1,11 @@ +import sqlite3 from "sqlite3"; +import { DATABASE_PATH } from "./config"; +import { open } from "sqlite"; + +const getDb = async () => + open({ + filename: DATABASE_PATH, + driver: sqlite3.cached.Database, + }); + +export { getDb }; diff --git a/apps/api/src/error-handler.ts b/apps/api/src/error-handler.ts new file mode 100644 index 0000000..3b79d45 --- /dev/null +++ b/apps/api/src/error-handler.ts @@ -0,0 +1,30 @@ +import type { NextFunction, Request, Response } from "express"; +import { badRequest, internalError, notFound } from "./utilities/responses"; +import { isNotFoundError, isValidationError } from "@repo/common"; + +/** + * Global error handler for uncaught exceptions, which attempts to properly set the status code + * and mask any sensitive error data before responding to the client. All four arguments need to be + * specified, even if not used, for express to register the function as an error handler. + * @see https://expressjs.com/en/guide/error-handling.html#writing-error-handlers + */ +const errorHandler = async ( + error: unknown, + _request: Request, + response: Response, + _next: NextFunction +) => { + // eslint-disable-next-line no-console -- Keeping this log in for debugging + console.error(error); + if (isNotFoundError(error)) { + return notFound(response, error); + } + + if (isValidationError(error)) { + return badRequest(response, error); + } + + return internalError(response, error); +}; + +export { errorHandler }; diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts new file mode 100644 index 0000000..0b35e86 --- /dev/null +++ b/apps/api/src/server.ts @@ -0,0 +1,7 @@ +import { app } from "./app"; +import { PORT } from "./config"; + +app.listen(PORT, () => { + // eslint-disable-next-line no-console + console.log(`⚡️[server]: Server is running at http://localhost:${PORT}`); +}); diff --git a/apps/api/src/services/artist-service.ts b/apps/api/src/services/artist-service.ts new file mode 100644 index 0000000..ca25606 --- /dev/null +++ b/apps/api/src/services/artist-service.ts @@ -0,0 +1,134 @@ +import type { + ArtistRow, + ArtistSnapshot, + ArtistSnapshotRow, +} from "@repo/common"; +import { Artist } from "@repo/common"; +import { getDb } from "../database"; +import { + fromUnixTime, + getDayOfYear, + getMonth, + getQuarter, + getWeek, + getYear, +} from "date-fns"; +import { uniqBy } from "lodash"; + +interface ListArtistSnapshotsOptions { + ids: string[]; + resolution?: Resolution; +} + +interface ListArtistsOptions { + ids: string[]; +} + +type Resolution = "daily" | "monthly" | "quarterly" | "weekly" | "yearly"; + +const ArtistService = { + isTracked: async (ids: string[]): Promise> => { + const db = await getDb(); + const results = await db.all( + `SELECT id FROM artist_snapshots WHERE id IN (${queryPlaceholder(ids.length)}) GROUP BY id;`, + ids + ); + const result: Record = ids.reduce( + (accumulated, id) => ({ + ...accumulated, + [id]: results.some((artist) => artist.id === id), + }), + {} + ); + + return result; + }, + listArtists: async ( + options: ListArtistsOptions + ): Promise> => { + const { ids } = options; + const db = await getDb(); + const results = await db.all( + `SELECT * FROM artists WHERE id IN (${queryPlaceholder(ids.length)});`, + ids + ); + + const result = ids.reduce( + (accumulated, id) => { + const result = results.find((result) => result.id === id); + if (result === undefined) { + return accumulated; + } + return { + ...accumulated, + [id]: result, + }; + }, + {} as Record + ); + + return result; + }, + listArtistSnapshots: async ( + options: ListArtistSnapshotsOptions + ): Promise => { + const { ids, resolution } = options; + const db = await getDb(); + const results = await db.all( + `SELECT * FROM artist_snapshots WHERE id IN (${queryPlaceholder(ids.length)});`, + ids + ); + let snapshots = results.map(normalizeArtistSnapshot); + + switch (resolution) { + case "yearly": + snapshots = uniqBy( + snapshots, + (snapshot) => + `${snapshot.id}_${getYear(snapshot.timestamp)}` + ); + break; + case "quarterly": + snapshots = uniqBy( + snapshots, + (snapshot) => + `${snapshot.id}_${getQuarter(snapshot.timestamp)}` + ); + break; + case "monthly": + snapshots = uniqBy( + snapshots, + (snapshot) => + `${snapshot.id}_${getMonth(snapshot.timestamp)}` + ); + break; + case "daily": + snapshots = uniqBy( + snapshots, + (snapshot) => + `${snapshot.id}_${getDayOfYear(snapshot.timestamp)}` + ); + break; + case "weekly": + default: + snapshots = uniqBy( + snapshots, + (snapshot) => + `${snapshot.id}_${getWeek(snapshot.timestamp)}` + ); + } + + return snapshots; + }, +}; + +const queryPlaceholder = (count: number): string => + "?".repeat(count).split("").join(","); + +const normalizeArtistSnapshot = (row: ArtistSnapshotRow): ArtistSnapshot => ({ + ...row, + timestamp: fromUnixTime(row.timestamp).toISOString(), +}); + +export type { Resolution }; +export { ArtistService }; diff --git a/apps/api/src/services/meta-service.ts b/apps/api/src/services/meta-service.ts new file mode 100644 index 0000000..549acfe --- /dev/null +++ b/apps/api/src/services/meta-service.ts @@ -0,0 +1,20 @@ +import type { GitHistory, GitHistoryRow } from "@repo/common"; +import { getDb } from "../database"; +import { fromUnixTime } from "date-fns"; + +const MetaService = { + latest: async (): Promise => { + const db = await getDb(); + const row = await db.get( + `SELECT * FROM git_history ORDER BY timestamp DESC LIMIT 1;` + ); + + if (row === undefined) { + return undefined; + } + + return { ...row, timestamp: fromUnixTime(row.timestamp).toISOString() }; + }, +}; + +export { MetaService }; diff --git a/apps/api/src/spotify.ts b/apps/api/src/spotify.ts new file mode 100644 index 0000000..34c60d7 --- /dev/null +++ b/apps/api/src/spotify.ts @@ -0,0 +1,9 @@ +import { SpotifyApi } from "@spotify/web-api-ts-sdk"; +import { CLIENT_ID, CLIENT_SECRET } from "./config"; + +const SpotifyClient = SpotifyApi.withClientCredentials( + CLIENT_ID, + CLIENT_SECRET +); + +export { SpotifyClient }; diff --git a/apps/api/src/utilities/collection-utils.ts b/apps/api/src/utilities/collection-utils.ts new file mode 100644 index 0000000..f57afe6 --- /dev/null +++ b/apps/api/src/utilities/collection-utils.ts @@ -0,0 +1,4 @@ +const arrify = (value: T | T[]): T[] => + Array.isArray(value) ? value : [value]; + +export { arrify }; diff --git a/apps/api/src/utilities/date-utils.ts b/apps/api/src/utilities/date-utils.ts new file mode 100644 index 0000000..01d929e --- /dev/null +++ b/apps/api/src/utilities/date-utils.ts @@ -0,0 +1,11 @@ +import { isValid, parseISO } from "date-fns"; + +const parseDate = (value: string | undefined): Date | undefined => { + if (value === undefined || !isValid(parseISO(value))) { + return undefined; + } + + return parseISO(value); +}; + +export { parseDate }; diff --git a/apps/api/src/utilities/environment.ts b/apps/api/src/utilities/environment.ts new file mode 100644 index 0000000..bf69335 --- /dev/null +++ b/apps/api/src/utilities/environment.ts @@ -0,0 +1,3 @@ +const isDevelopment = (): boolean => process.env.NODE_ENV !== "production"; + +export { isDevelopment }; diff --git a/apps/api/src/utilities/rate-limiter.ts b/apps/api/src/utilities/rate-limiter.ts new file mode 100644 index 0000000..1ba168d --- /dev/null +++ b/apps/api/src/utilities/rate-limiter.ts @@ -0,0 +1,19 @@ +import rateLimit from "express-rate-limit"; +import type { Request, Response } from "express"; +import { tooManyRequests } from "./responses"; + +const ONE_HOUR_IN_MS = 60 * 60 * 1000; + +const readRateLimiter = rateLimit({ + windowMs: ONE_HOUR_IN_MS, + max: 1000, + message: (_request: Request, response: Response) => + tooManyRequests(response, { + name: "ERROR_RATE_LIMIT", + message: + "You've sent too many requests in a short period of time. Please try again later.", + }), + standardHeaders: true, +}); + +export { readRateLimiter }; diff --git a/apps/api/src/utilities/responses.ts b/apps/api/src/utilities/responses.ts new file mode 100644 index 0000000..0c7e1b5 --- /dev/null +++ b/apps/api/src/utilities/responses.ts @@ -0,0 +1,51 @@ +import type { Response } from "express"; +import type { ApiSuccessResponse, ApiErrorResponse } from "@repo/common"; + +const created = ( + response: Response, + data: T +): Response> => + response.status(201).json({ data, error: null }); + +const ok = (response: Response, data: T): Response> => + response.status(200).json({ data, error: null }); + +const conflict = ( + response: Response, + error: unknown +): Response => + response.status(409).json({ data: null, error }); + +const badRequest = ( + response: Response, + error: unknown +): Response => + response.status(400).json({ data: null, error }); + +const notFound = ( + response: Response, + error: unknown +): Response => + response.status(404).json({ data: null, error }); + +const tooManyRequests = ( + response: Response, + error: unknown +): Response => + response.status(429).json({ data: null, error }); + +const internalError = ( + response: Response, + error: unknown +): Response => + response.status(500).json({ data: null, error }); + +export { + badRequest, + conflict, + created, + internalError, + notFound, + ok, + tooManyRequests, +}; diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..e6c04d7 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@repo/typescript-config/base.json", + "include": ["src/**/*.ts"], + "compilerOptions": { + "outDir": "dist" + }, + "exclude": ["node_modules", "dist"] +} diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..d658484 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1 @@ +NEXT_PUBLIC_API_URL=http://localhost:3001 diff --git a/apps/web/.eslintrc.js b/apps/web/.eslintrc.js new file mode 100644 index 0000000..697b5a4 --- /dev/null +++ b/apps/web/.eslintrc.js @@ -0,0 +1,9 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ["@repo/eslint-config/next.js"], + parser: "@typescript-eslint/parser", + parserOptions: { + project: true, + }, +}; diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..a7368ea --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,28 @@ +## Getting Started + +First, run the development server: + +```bash +yarn dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +To create [API routes](https://nextjs.org/docs/app/building-your-application/routing/router-handlers) add an `api/` directory to the `app/` directory with a `route.ts` file. For individual endpoints, create a subfolder in the `api` directory, like `api/hello/route.ts` would map to [http://localhost:3000/api/hello](http://localhost:3000/api/hello). + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn/foundations/about-nextjs) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_source=github.com&utm_medium=referral&utm_campaign=turborepo-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/apps/web/global.css b/apps/web/global.css new file mode 100644 index 0000000..66b7791 --- /dev/null +++ b/apps/web/global.css @@ -0,0 +1,11 @@ +* { + box-sizing: border-box; + padding: 0; + margin: 0; +} + +body, #__next { + width: 100vw; + height: 100vh; + overflow: hidden; +} diff --git a/apps/web/hooks/index.ts b/apps/web/hooks/index.ts new file mode 100644 index 0000000..683a502 --- /dev/null +++ b/apps/web/hooks/index.ts @@ -0,0 +1,7 @@ +export * from "./use-breakpoint"; +export * from "./use-get-latest-meta"; +export * from "./use-get-artist-snapshots"; +export * from "./use-list-artist-snapshots"; +export * from "./use-list-artists"; +export * from "./use-request-artist"; +export * from "./use-search-artists-by-name"; diff --git a/apps/web/hooks/use-breakpoint.ts b/apps/web/hooks/use-breakpoint.ts new file mode 100644 index 0000000..8538f40 --- /dev/null +++ b/apps/web/hooks/use-breakpoint.ts @@ -0,0 +1,13 @@ +import { createBreakpoint } from "react-use"; + +const BREAKPOINTS = { + mobile: 576, + desktop: 577, +}; + +type BreakpointName = keyof typeof BREAKPOINTS; + +const useBreakpoint = createBreakpoint(BREAKPOINTS) as () => BreakpointName; + +export type { BreakpointName }; +export { useBreakpoint }; diff --git a/apps/web/hooks/use-get-artist-snapshots.ts b/apps/web/hooks/use-get-artist-snapshots.ts new file mode 100644 index 0000000..f34daeb --- /dev/null +++ b/apps/web/hooks/use-get-artist-snapshots.ts @@ -0,0 +1,29 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { + GET_ARTIST_SNAPSHOTS_ROUTE, + GetArtistSnapshotsOptions, + GetArtistSnapshotsResult, +} from "@repo/common"; +import { get } from "@/utils/fetch"; +import { isEmpty } from "lodash"; + +const useGetArtistSnapshots = (options: GetArtistSnapshotsOptions) => { + const { id } = options; + return useQuery({ + enabled: !isEmpty(id), + placeholderData: keepPreviousData, + queryFn: async () => { + const { data, error } = (await get( + GET_ARTIST_SNAPSHOTS_ROUTE.replace(":id", id) + )) as GetArtistSnapshotsResult; + if (error !== null) { + throw error; + } + + return data; + }, + queryKey: ["get-artist-snapshots", id], + }); +}; + +export { useGetArtistSnapshots }; diff --git a/apps/web/hooks/use-get-latest-meta.ts b/apps/web/hooks/use-get-latest-meta.ts new file mode 100644 index 0000000..3c66626 --- /dev/null +++ b/apps/web/hooks/use-get-latest-meta.ts @@ -0,0 +1,21 @@ +import { GET_LATEST_META_ROUTE, GetLatestMetaResult } from "@repo/common"; +import { useQuery } from "@tanstack/react-query"; +import { get } from "@/utils/fetch"; + +const useGetLatestMeta = () => { + return useQuery({ + queryFn: async () => { + const { data, error } = (await get( + GET_LATEST_META_ROUTE + )) as GetLatestMetaResult; + if (error !== null) { + throw error; + } + + return data; + }, + queryKey: ["get-latest-meta"], + }); +}; + +export { useGetLatestMeta }; diff --git a/apps/web/hooks/use-list-artist-snapshots.ts b/apps/web/hooks/use-list-artist-snapshots.ts new file mode 100644 index 0000000..be1a8d0 --- /dev/null +++ b/apps/web/hooks/use-list-artist-snapshots.ts @@ -0,0 +1,29 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { + LIST_ARTIST_SNAPSHOTS_ROUTE, + ListArtistSnapshotsOptions, + ListArtistSnapshotsResult, +} from "@repo/common"; +import { get } from "@/utils/fetch"; +import { isEmpty } from "lodash"; + +const useListArtistSnapshots = (options: ListArtistSnapshotsOptions) => { + const { ids } = options; + return useQuery({ + enabled: !isEmpty(ids), + placeholderData: keepPreviousData, + queryFn: async () => { + const { data, error } = (await get( + `${LIST_ARTIST_SNAPSHOTS_ROUTE}?ids=${ids.join(",")}` + )) as ListArtistSnapshotsResult; + if (error !== null) { + throw error; + } + + return data; + }, + queryKey: ["list-artist-snapshots", ...ids], + }); +}; + +export { useListArtistSnapshots }; diff --git a/apps/web/hooks/use-list-artists.ts b/apps/web/hooks/use-list-artists.ts new file mode 100644 index 0000000..6164ef1 --- /dev/null +++ b/apps/web/hooks/use-list-artists.ts @@ -0,0 +1,29 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { + LIST_ARTISTS_ROUTE, + ListArtistsOptions, + ListArtistsResult, +} from "@repo/common"; +import { get } from "@/utils/fetch"; +import { isEmpty } from "lodash"; + +const useListArtists = (options: ListArtistsOptions) => { + const { ids } = options; + return useQuery({ + enabled: !isEmpty(ids), + placeholderData: keepPreviousData, + queryFn: async () => { + const { data, error } = (await get( + `${LIST_ARTISTS_ROUTE}?ids=${ids.join(",")}` + )) as ListArtistsResult; + if (error !== null) { + throw error; + } + + return data; + }, + queryKey: ["list-artists", ...ids], + }); +}; + +export { useListArtists }; diff --git a/apps/web/hooks/use-request-artist.ts b/apps/web/hooks/use-request-artist.ts new file mode 100644 index 0000000..a612cf1 --- /dev/null +++ b/apps/web/hooks/use-request-artist.ts @@ -0,0 +1,26 @@ +import { useMutation } from "@tanstack/react-query"; +import { + REQUEST_ARTIST_ROUTE, + RequestArtistOptions, + RequestArtistResult, +} from "@repo/common"; +import { post } from "@/utils/fetch"; + +const useRequestArtist = () => { + return useMutation({ + mutationFn: async (options: RequestArtistOptions) => { + const { id } = options; + const { error } = (await post( + REQUEST_ARTIST_ROUTE.replace(":id", id), + {} + )) as RequestArtistResult; + if (error !== null) { + throw error; + } + + return true; + }, + }); +}; + +export { useRequestArtist }; diff --git a/apps/web/hooks/use-search-artists-by-name.ts b/apps/web/hooks/use-search-artists-by-name.ts new file mode 100644 index 0000000..3ca863e --- /dev/null +++ b/apps/web/hooks/use-search-artists-by-name.ts @@ -0,0 +1,30 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { + SEARCH_ARTISTS_BY_NAME_ROUTE, + SearchArtistByNameResult, + SearchArtistsByNameOptions, +} from "@repo/common"; +import { get } from "@/utils/fetch"; +import { isEmpty } from "lodash"; + +const useSearchArtistsByName = (options: SearchArtistsByNameOptions) => { + const { name } = options; + return useQuery({ + enabled: !isEmpty(name), + placeholderData: keepPreviousData, + queryFn: async () => { + const { data, error } = (await get( + `${SEARCH_ARTISTS_BY_NAME_ROUTE}?name=${name}` + )) as SearchArtistByNameResult; + + if (error !== null) { + throw error; + } + + return data; + }, + queryKey: ["search-artists-by-name", name], + }); +}; + +export { useSearchArtistsByName }; diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/web/next.config.js b/apps/web/next.config.js new file mode 100644 index 0000000..58c8980 --- /dev/null +++ b/apps/web/next.config.js @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +module.exports = { + reactStrictMode: true, + transpilePackages: ["@repo/ui"], + output: "export", +}; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..149e86a --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,39 @@ +{ + "name": "web", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint . --max-warnings 0" + }, + "dependencies": { + "@repo/common": "*", + "@repo/ui": "*", + "@tanstack/react-query": "5.45.1", + "chart.js": "4.4.3", + "lodash": "4.17.21", + "next": "14.2.4", + "react": "18.2.0", + "react-chartjs-2": "5.2.0", + "react-dom": "18.2.0", + "react-use": "17.5.1" + }, + "overrides": { + "react": "18.2.0", + "react-dom": "18.2.0" + }, + "devDependencies": { + "@next/eslint-plugin-next": "14.1.1", + "@repo/eslint-config": "*", + "@repo/typescript-config": "*", + "@types/eslint": "8.56.5", + "@types/lodash": "4.17.4", + "@types/node": "20.11.24", + "@types/react": "18.2.61", + "@types/react-dom": "18.2.19", + "eslint": "8.57.0", + "typescript": "5.5.3" + } +} diff --git a/apps/web/pages/_app.tsx b/apps/web/pages/_app.tsx new file mode 100644 index 0000000..c5cace9 --- /dev/null +++ b/apps/web/pages/_app.tsx @@ -0,0 +1,24 @@ +import type { AppProps } from "next/app"; +import "@/global.css"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: Infinity, + retry: false, + refetchOnWindowFocus: false, + }, + }, +}); + +const App: React.FC = (props) => { + const { Component, pageProps } = props; + return ( + + + + ); +}; + +export default App; diff --git a/apps/web/pages/_document.tsx b/apps/web/pages/_document.tsx new file mode 100644 index 0000000..436b33c --- /dev/null +++ b/apps/web/pages/_document.tsx @@ -0,0 +1,15 @@ +import { Html, Head, Main, NextScript } from "next/document"; + +const Document: React.FC = () => { + return ( + + + +
+ + + + ); +}; + +export default Document; diff --git a/apps/web/pages/index.tsx b/apps/web/pages/index.tsx new file mode 100644 index 0000000..40ddc2f --- /dev/null +++ b/apps/web/pages/index.tsx @@ -0,0 +1,410 @@ +"use client"; + +import Box from "@leafygreen-ui/box"; +import { Link } from "@leafygreen-ui/typography"; +import { css } from "@leafygreen-ui/emotion"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import LeafyGreenProvider from "@leafygreen-ui/leafygreen-provider"; +import { Bar } from "react-chartjs-2"; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + BarElement, + Title, + Tooltip, + Legend, +} from "chart.js"; +import { + useBreakpoint, + useGetLatestMeta, + useListArtists, + useListArtistSnapshots, + useRequestArtist, + useSearchArtistsByName, +} from "@/hooks"; +import { compact, isEmpty, uniq } from "lodash"; +import { color } from "@leafygreen-ui/tokens"; +import { palette } from "@leafygreen-ui/palette"; +import { + Footer, + Header, + RemovableBadgeProps, + SearchResultProps, + SearchSelect, +} from "@repo/ui"; +import { + ArtistRow, + ArtistWithTrackingStatus, + DEFAULT_ARTIST_IDS, +} from "@repo/common"; +import { humanizeNumber } from "@/utils/number-utils"; +import { BasicEmptyState } from "@leafygreen-ui/empty-state"; +import Icon from "@leafygreen-ui/icon"; + +ChartJS.register( + CategoryScale, + LinearScale, + BarElement, + Title, + Tooltip, + Legend +); + +const EMPTY_ARTISTS: Record = {}; + +const HomePage: React.FC = () => { + const [isMounted, setIsMounted] = useState(false); + const breakpoint = useBreakpoint(); + const [darkTheme, setDarkTheme] = useState(getDefaultDarkTheme); + const [searchValue, setSearchValue] = useState(""); + const [selectedArtistIds, setSelectedArtistIds] = + useState(DEFAULT_ARTIST_IDS); + const [requestedArtistIds, setRequestedArtistIds] = useState([]); + const { data: searchResults } = useSearchArtistsByName({ + name: searchValue, + }); + const { mutate: requestArtist } = useRequestArtist(); + const { data: latestMeta } = useGetLatestMeta(); + const { data: artists = EMPTY_ARTISTS } = useListArtists({ + ids: selectedArtistIds, + }); + const { data: snapshots } = useListArtistSnapshots({ + ids: selectedArtistIds, + }); + + const data = useMemo(() => { + const labels = uniq( + compact( + snapshots?.map((snapshot) => + new Date(snapshot.timestamp).toLocaleDateString() + ) + ) + ); + + const datasets = selectedArtistIds.map((artistId) => { + const artistSnapshots = + snapshots?.filter((snapshot) => snapshot.id === artistId) ?? []; + + const label = artists[artistId]?.name; + if (label === undefined) { + return undefined; + } + + return { + label, + data: artistSnapshots.map((snapshot) => snapshot.followers), + ...getHashedBarChartColor(artistId, darkTheme), + }; + }); + + return { labels: compact(labels), datasets: compact(datasets) }; + }, [snapshots, selectedArtistIds, artists, darkTheme]); + + const textColor = darkTheme + ? color.dark.text.primary.default + : color.light.text.primary.default; + + const invertedTextColor = darkTheme + ? color.light.text.primary.default + : color.dark.text.primary.default; + + const invertedBackgroundColor = darkTheme + ? color.light.background.primary.default + : color.dark.background.primary.default; + + const gridColor = darkTheme + ? color.dark.background.disabled.default + : color.light.background.disabled.default; + + const options = useMemo( + () => ({ + indexAxis: "x" as const, + elements: { + bar: { + borderWidth: 2, + }, + }, + responsive: true, + plugins: { + tooltip: { + bodyColor: invertedTextColor, + titleColor: invertedTextColor, + backgroundColor: invertedBackgroundColor, + }, + legend: { + position: + breakpoint === "mobile" + ? ("bottom" as const) + : ("right" as const), + labels: { + color: textColor, + }, + }, + title: { + display: true, + text: "Spotify followers over time", + color: textColor, + }, + }, + scales: { + y: { + ticks: { + color: textColor, + callback: (value: string | number) => { + if (typeof value === "number") { + return humanizeNumber(value); + } + + return value; + }, + }, + grid: { + color: gridColor, + }, + title: { + display: true, + text: "Followers", + color: textColor, + }, + }, + x: { + ticks: { + color: textColor, + }, + grid: { + color: gridColor, + }, + }, + }, + }), + [ + breakpoint, + gridColor, + invertedBackgroundColor, + invertedTextColor, + textColor, + ] + ); + + const addArtist = useCallback( + (id: string) => + setSelectedArtistIds((selectedArtistIds) => + selectedArtistIds.includes(id) || selectedArtistIds.length >= 10 + ? selectedArtistIds + : [...selectedArtistIds, id] + ), + [] + ); + + const removeArtist = useCallback( + (id: string) => + setSelectedArtistIds((selectedArtistIds) => + selectedArtistIds.filter( + (selectedArtistId) => selectedArtistId !== id + ) + ), + [] + ); + + const getSearchResultProps = useCallback( + (result: ArtistWithTrackingStatus): SearchResultProps => { + return { + children: result.name, + description: result.isTracked ? ( + result.genres.join(", ") + ) : ( + + This artist is not yet being tracked. + + ) => { + event.stopPropagation(); + if (requestedArtistIds.includes(result.id)) { + return; + } + + requestArtist({ id: result.id }); + setRequestedArtistIds((requestedArtistIds) => [ + ...requestedArtistIds, + result.id, + ]); + }}> + {requestedArtistIds.includes(result.id) + ? "Requested" + : "Request"} + + + ), + disabled: !result.isTracked, + selected: selectedArtistIds.includes(result.id), + onClick: () => { + if (result.isTracked) { + addArtist(result.id); + } + }, + }; + }, + [addArtist, requestArtist, requestedArtistIds, selectedArtistIds] + ); + + const getRemovableBadgeProps = useCallback( + (artist: ArtistRow): RemovableBadgeProps => { + return { + onRemove: () => { + removeArtist(artist.id); + }, + children: artist.name, + }; + }, + [removeArtist] + ); + + useEffect(() => { + setIsMounted(true); + }, []); + + if (!isMounted) { + return; + } + + return ( + + + +
+ + {isEmpty(selectedArtistIds) ? ( + + } + title="No artists selected" + /> + ) : ( + + )} + +