diff --git a/.babelrc b/.babelrc index cc7b03d..3457add 100644 --- a/.babelrc +++ b/.babelrc @@ -1,12 +1,12 @@ { - "presets": [ - "next/babel" - ], + "presets": ["next/babel"], "plugins": [ - ["styled-components", { - "displayName": false, - "ssr": true - }], - "transform-class-properties" + [ + "styled-components", + { + "displayName": false, + "ssr": true + } + ] ] } diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 0d892b6..0000000 --- a/.eslintignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules -.next -.storybook -coverage -static diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 05c6de9..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,27 +0,0 @@ -module.exports = { - "env": { - "browser": true, - "es6": true, - "node": true, - "jest/globals": true, - }, - "extends": ["eslint:recommended", "plugin:react/recommended", "plugin:jest/recommended", "airbnb"], - "plugins": ["react", "jest"], - "parser": "babel-eslint", - "rules": { - "import/extensions": "off", - "import/no-extraneous-dependencies": ["error", { "devDependencies": true }], - "import/no-unresolved": "off", - "jsx-a11y/anchor-is-valid": ["error", { - "components": ["Link"], - "specialLink": ["route"], - "aspects": ["invalidHref", "preferButton"], - }], - "no-unused-vars": ["error", { "args": "none" }], - "react/destructuring-assignment": "off", - "react/forbid-prop-types": "off", - "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }], - "react/jsx-one-expression-per-line": "off", - "react/react-in-jsx-scope": "off", - } -}; diff --git a/.github/workflows/build-production.yaml b/.github/workflows/build-production.yaml index 5b84004..2260b39 100644 --- a/.github/workflows/build-production.yaml +++ b/.github/workflows/build-production.yaml @@ -22,13 +22,13 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} @@ -36,7 +36,7 @@ jobs: - name: Build and push by digest id: build - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . platforms: ${{ matrix.platform }} @@ -49,7 +49,7 @@ jobs: touch "/tmp/digests/${digest#sha256:}" - name: Upload digest - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: digest-production-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }} path: /tmp/digests/* @@ -65,17 +65,17 @@ jobs: steps: - name: Download digests - uses: actions/download-artifact@v4 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: path: /tmp/digests pattern: digest-production-* merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/build-staging.yaml b/.github/workflows/build-staging.yaml index b871cb6..283174a 100644 --- a/.github/workflows/build-staging.yaml +++ b/.github/workflows/build-staging.yaml @@ -22,13 +22,13 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} @@ -36,7 +36,7 @@ jobs: - name: Build and push by digest id: build - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . platforms: ${{ matrix.platform }} @@ -49,7 +49,7 @@ jobs: touch "/tmp/digests/${digest#sha256:}" - name: Upload digest - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: digest-staging-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }} path: /tmp/digests/* @@ -65,17 +65,17 @@ jobs: steps: - name: Download digests - uses: actions/download-artifact@v4 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: path: /tmp/digests pattern: digest-staging-* merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml new file mode 100644 index 0000000..f15a81d --- /dev/null +++ b/.github/workflows/pr-ci.yml @@ -0,0 +1,53 @@ +name: PR CI + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + +permissions: + contents: read + +concurrency: + group: pr-ci-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + validate: + if: ${{ !github.event.pull_request.draft }} + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + NEXT_TELEMETRY_DISABLED: 1 + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Enable Corepack + run: corepack enable + + - name: Set up Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 + with: + node-version-file: .nvmrc + cache: yarn + cache-dependency-path: yarn.lock + + - name: Install dependencies + run: yarn install --immutable + + - name: Lint JS/TS + run: yarn lint + + - name: Lint CSS + run: yarn lint:css + + - name: Run tests + run: yarn test + + - name: Build + run: yarn build diff --git a/.github/workflows/staging-auto-pr.yaml b/.github/workflows/staging-auto-pr.yaml index c3d9f56..228cb0b 100644 --- a/.github/workflows/staging-auto-pr.yaml +++ b/.github/workflows/staging-auto-pr.yaml @@ -5,18 +5,90 @@ on: jobs: pull-request: - name: Open PR to main + name: Open or Update PR to Production runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + concurrency: + group: staging-auto-pr + cancel-in-progress: true steps: - - uses: actions/checkout@v2 - name: checkout - - - uses: repo-sync/pull-request@v2 - name: pull-request - with: - destination_branch: "production" - pr_title: "Staging to Production" - pr_body: "This PR was auto-generated via a workflow action." - pr_reviewer: ${{ github.actor }} - source_branch: "staging" - github_token: ${{ secrets.GITHUB_TOKEN }} + - name: Create or update staging -> production PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + OWNER: ${{ github.repository_owner }} + SOURCE_BRANCH: staging + DESTINATION_BRANCH: production + PR_TITLE: Staging to Production + REVIEWER: ${{ github.actor }} + run: | + set -euo pipefail + + compare_json="$(gh api "repos/${REPO}/compare/${DESTINATION_BRANCH}...${SOURCE_BRANCH}")" + compare_url="$(echo "${compare_json}" | jq -r '.html_url')" + ahead_by="$(echo "${compare_json}" | jq -r '.ahead_by')" + + if [ "${ahead_by}" -eq 0 ]; then + echo "staging is not ahead of production (ahead_by=0). Nothing to do." + exit 0 + fi + + prs_tsv="$(mktemp)" + while IFS= read -r sha; do + [ -n "${sha}" ] || continue + gh api "repos/${REPO}/commits/${sha}/pulls" \ + --jq '.[] | select(.merged_at != null) | "\(.number)\t\(.title)\t\(.html_url)"' \ + >> "${prs_tsv}" || true + done < <(echo "${compare_json}" | jq -r '.commits[].sha') + + if [ -s "${prs_tsv}" ]; then + prs_markdown="$(sort -t $'\t' -k1,1n -u "${prs_tsv}" | awk -F '\t' '{printf "- #%s %s (%s)\n", $1, $2, $3}')" + else + prs_markdown="- No linked pull requests found (direct commits or metadata unavailable)." + fi + + pr_body_file="$(mktemp)" + { + echo "This PR was auto-generated via a workflow action." + echo + echo "## Included Pull Requests" + echo "${prs_markdown}" + echo + echo "## Diff" + echo "- Source: \`${SOURCE_BRANCH}\`" + echo "- Target: \`${DESTINATION_BRANCH}\`" + echo "- Commits ahead: ${ahead_by}" + echo "- Compare: ${compare_url}" + } > "${pr_body_file}" + + pr_number="$(gh pr list \ + --repo "${REPO}" \ + --base "${DESTINATION_BRANCH}" \ + --head "${SOURCE_BRANCH}" \ + --state open \ + --json number \ + --jq '.[0].number // empty')" + + if [ -z "${pr_number}" ]; then + echo "No open PR found for ${SOURCE_BRANCH} -> ${DESTINATION_BRANCH}. Creating one." + pr_url="$(gh pr create \ + --repo "${REPO}" \ + --base "${DESTINATION_BRANCH}" \ + --head "${SOURCE_BRANCH}" \ + --title "${PR_TITLE}" \ + --body-file "${pr_body_file}")" + pr_number="${pr_url##*/}" + else + echo "Updating existing PR #${pr_number}." + gh pr edit "${pr_number}" \ + --repo "${REPO}" \ + --title "${PR_TITLE}" \ + --body-file "${pr_body_file}" + fi + + gh pr edit "${pr_number}" \ + --repo "${REPO}" \ + --add-reviewer "${REVIEWER}" || \ + echo "Reviewer assignment skipped for ${REVIEWER}." diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..9841c7a --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +yarn lint-staged diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..d93ee08 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,11 @@ +.next +node_modules +coverage +dist +build +out +test-results +public/static +.yarn +.pnp.* +yarn.lock diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..578076d --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "arrowParens": "avoid", + "printWidth": 120 +} diff --git a/.stylelintrc b/.stylelintrc deleted file mode 100644 index 3ebb062..0000000 --- a/.stylelintrc +++ /dev/null @@ -1,10 +0,0 @@ -{ - "processors": [ - "stylelint-processor-styled-components" - ], - "extends": [ - "stylelint-config-recommended", - "stylelint-config-styled-components", - "stylelint-config-property-sort-order-smacss" - ] -} diff --git a/Dockerfile b/Dockerfile index 6f04cdc..a252b18 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,6 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 -ENV NODE_OPTIONS=--openssl-legacy-provider RUN yarn build FROM base AS runner @@ -28,8 +27,6 @@ RUN addgroup --system --gid 1001 nodejs && \ COPY --from=deps /app/node_modules ./node_modules COPY --from=builder /app/.next ./.next COPY --from=builder /app/public ./public -COPY --from=builder /app/server.js ./server.js -COPY --from=builder /app/config ./config COPY --from=builder /app/app ./app COPY --from=builder /app/lib ./lib COPY package.json ./ @@ -38,4 +35,4 @@ USER nextjs EXPOSE 3000 -CMD ["node", "server.js"] +CMD ["node_modules/.bin/next", "start"] diff --git a/README.md b/README.md index 0adbe08..ead4fd3 100755 --- a/README.md +++ b/README.md @@ -22,7 +22,6 @@ In addition to the software requirements, you need access to the following APIs: - [Discogs](https://www.discogs.com/applications/edit) for searching and getting release info - [Last.fm](https://www.last.fm/api/account/create) for authentication and scrobbling data - ### Installing First you need to clone the repository from github: @@ -78,16 +77,17 @@ You can adjust the port by setting the `PORT` environment variable. ## Running the tests -Jest is used as a test runner in this project: +Vitest is used as a test runner in this project: ``` yarn test ``` You can also use watch-mode and display the current test coverage: + ``` yarn test:watch -yarm test:coverage +yarn test:coverage ``` ### Coding style tests @@ -110,7 +110,7 @@ yarn start ## Authors -* **Daniel Puscher** - *Initial work* - [dpuscher](https://github.com/dpuscher) +- **Daniel Puscher** - _Initial work_ - [dpuscher](https://github.com/dpuscher) See also the list of [contributors](https://github.com/dpuscher/code-scrobble/contributors) who participated in this project. diff --git a/app/__mocks__/redis.js b/app/__mocks__/redis.js deleted file mode 100644 index 2f6dead..0000000 --- a/app/__mocks__/redis.js +++ /dev/null @@ -1,15 +0,0 @@ -/* eslint-disable no-underscore-dangle */ - -const redis = require('redis-mock'); - -const client = redis.createClient(); -client.get = jest.fn(client.get); -client.set = jest.fn(client.set); - -redis.createClient = () => client; - -redis._get = client.get; -redis._set = client.set; -redis._reset = client.flushall; - -module.exports = redis; diff --git a/app/__mocks__/redis.ts b/app/__mocks__/redis.ts new file mode 100644 index 0000000..e17b055 --- /dev/null +++ b/app/__mocks__/redis.ts @@ -0,0 +1,20 @@ +/* eslint-disable no-underscore-dangle */ + +const store = new Map(); + +export const _get = vi.fn(async (key: string) => store.get(key) || null); +export const _set = vi.fn(async (key: string, value: string) => { + store.set(key, value); + return "OK"; +}); +const mockConnect = vi.fn(async () => {}); +const mockOn = vi.fn(); + +export const createClient = vi.fn(() => ({ + get: _get, + set: _set, + connect: mockConnect, + on: mockOn, +})); + +export const _reset = () => store.clear(); diff --git a/app/cache.js b/app/cache.js deleted file mode 100644 index 664b0a5..0000000 --- a/app/cache.js +++ /dev/null @@ -1,24 +0,0 @@ -const redis = require('redis'); -const { promisify } = require('util'); - -const client = redis.createClient(process.env.REDISCLOUD_URL); - -const getAsync = promisify(client.get).bind(client); -const setAsync = promisify(client.set).bind(client); - -module.exports = { - set: (key, value, ttl = 86400) => ( - setAsync(key, JSON.stringify(value), 'EX', ttl) - ), - - get: key => ( - new Promise(async (resolve, reject) => { - const value = await getAsync(key); - if (value) { - resolve(JSON.parse(value)); - } else { - reject(); - } - }) - ), -}; diff --git a/app/cache.ts b/app/cache.ts new file mode 100644 index 0000000..9c1deb5 --- /dev/null +++ b/app/cache.ts @@ -0,0 +1,16 @@ +import { createClient } from "redis"; + +const client = createClient({ url: process.env.REDISCLOUD_URL }); +client.connect().catch(console.error); +client.on("error", console.error); + +export const set = (key: string, value: unknown, ttl = 86400) => client.set(key, JSON.stringify(value), { EX: ttl }); + +export const get = async (key: string): Promise => { + const value = await client.get(key); + if (value) { + return JSON.parse(value as string) as T; + } + // eslint-disable-next-line prefer-promise-reject-errors + return Promise.reject(); +}; diff --git a/app/discogs.js b/app/discogs.js deleted file mode 100644 index 21bce40..0000000 --- a/app/discogs.js +++ /dev/null @@ -1,125 +0,0 @@ -const Discogs = require('disconnect').Client; -const dig = require('object-dig'); -const orderBy = require('lodash/orderBy'); -const pick = require('lodash/pick'); -const find = require('lodash/find'); -const Cache = require('./cache'); - -const Database = new Discogs({ - consumerKey: process.env.DISCOGS_KEY, - consumerSecret: process.env.DISCOGS_SECRET, -}).database(); - -const convertTimecode = timecode => ( - timecode - .split(':') - .map(n => (parseInt(n, 10) || 0)) - .reverse().map((n, i) => ( - n * (60 ** i) - )) - .reduce((pv, cv) => pv + cv) -); - -const normalizeTracklist = (tracks) => { - const vinylPositionRegex = /^[A-Z]-?[0-9]+$/; - let tracklist = tracks - // eslint-disable-next-line no-underscore-dangle - .filter(track => track.type_ === 'track') - .filter(track => !/video/i.test(track.position)); - - // Remove Bonus CDs from vinyl releases: - if (tracklist.length && vinylPositionRegex.test(tracklist[0].position)) { - const filteredTracks = tracklist.filter(track => vinylPositionRegex.test(track.position)); - if (filteredTracks.length !== tracklist.length) { - tracklist = filteredTracks; - } - } - - return tracklist; -}; - -const getBarcode = (data = []) => ( - (find(data, { type: 'Barcode' }) || {}).value -); - -module.exports = { - barcode: barcode => ( - new Promise((resolve) => { - const cacheKey = `barcode--${barcode}`; - - Cache.get(cacheKey) - .then((results) => { - resolve(results); - }) - .catch(() => { - Database.search(undefined, { barcode, type: 'release' }, (err, data) => { - if (err || !data || !data.results || !data.results.length) { - return resolve(); - } - const results = orderBy( - data.results, - ['community.have', 'community.want'], - ['desc', 'desc'], - ); - - Cache.set(cacheKey, results); - - return resolve(results[0].id); - }); - }); - }) - ), - - search: query => ( - new Promise((resolve) => { - const cacheKey = `search--${query}`; - - Cache.get(cacheKey) - .then((results) => { - resolve(results); - }) - .catch(() => { - // eslint-disable-next-line consistent-return - Database.search(query, { type: 'release' }, (err, data) => { - if (err || !data || !data.results || !data.results.length) { - return resolve(); - } - - const results = data.results.map(result => ( - pick(result, ['id', 'title', 'thumb', 'country', 'year', 'format', 'uri']) - )); - - Cache.set(cacheKey, results); - - resolve(results); - }); - }); - }) - ), - - getRelease: id => ( - new Promise((resolve, reject) => { - Database.getRelease(id, (err, data) => { - if (err || !data) { - reject(); - } else { - resolve({ - id, - artist: data.artists.map(a => a.name).join(', '), - title: data.title, - image: dig(data, 'images', 0, 'uri'), - url: data.uri, - year: data.year, - tracks: normalizeTracklist(data.tracklist) - .map((track, index) => ({ - title: track.title, - trackNumber: index + 1, - duration: convertTimecode(track.duration), - })), - barcode: getBarcode(data.identifiers), - }); - } - }); - }) - ), -}; diff --git a/app/discogs.ts b/app/discogs.ts new file mode 100644 index 0000000..43bdf72 --- /dev/null +++ b/app/discogs.ts @@ -0,0 +1,192 @@ +import orderBy from "lodash/orderBy"; +import pick from "lodash/pick"; +import find from "lodash/find"; +import * as Cache from "./cache"; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const DiscogsClient = require("disconnect").Client; + +interface DiscogsError { + statusCode: number; + message: string; +} + +interface DiscogsArtist { + name: string; +} + +interface DiscogsTrack { + type_: string; + position: string; + title: string; + duration: string; +} + +interface DiscogsIdentifier { + type: string; + value: string; +} + +interface DiscogsData { + artists?: DiscogsArtist[]; + title: string; + images?: Array<{ uri: string }>; + uri: string; + year: string; + tracklist?: DiscogsTrack[]; + identifiers?: DiscogsIdentifier[]; +} + +interface DiscogsSearchResult { + id: number; + community: { have: number; want: number }; +} + +export interface ReleaseData { + id: number; + artist: string; + title: string; + image: string | undefined; + url: string; + year: string; + tracks: Array<{ title: string; trackNumber: number; duration: number }>; + barcode: string | undefined; +} + +export interface SearchResult { + id: number; + title: string; + thumb: string; + country: string; + year: string; + format: string; + uri: string; +} + +const Database = new DiscogsClient({ + consumerKey: process.env.DISCOGS_KEY, + consumerSecret: process.env.DISCOGS_SECRET, +}).database(); + +const convertTimecode = (timecode: string | undefined): number => { + if (!timecode) return 0; + return timecode + .split(":") + .map(n => parseInt(n, 10) || 0) + .reverse() + .map((n, i) => n * 60 ** i) + .reduce((pv, cv) => pv + cv); +}; + +const normalizeTracklist = (tracks: DiscogsTrack[]): DiscogsTrack[] => { + const vinylPositionRegex = /^[A-Z]-?[0-9]+$/; + // eslint-disable-next-line no-underscore-dangle + let tracklist = tracks.filter(track => track.type_ === "track").filter(track => !/video/i.test(track.position)); + + // Remove Bonus CDs from vinyl releases: + if (tracklist.length && vinylPositionRegex.test(tracklist[0].position)) { + const filteredTracks = tracklist.filter(track => vinylPositionRegex.test(track.position)); + if (filteredTracks.length !== tracklist.length) { + tracklist = filteredTracks; + } + } + + return tracklist; +}; + +const getBarcode = (data: DiscogsIdentifier[] = []): string | undefined => + (find(data, { type: "Barcode" }) || {}).value; + +const buildRelease = (id: number, data: DiscogsData): ReleaseData => ({ + id, + artist: (data.artists || []).map(a => a.name).join(", "), + title: data.title, + image: data?.images?.[0]?.uri, + url: data.uri, + year: data.year, + tracks: normalizeTracklist(data.tracklist || []).map((track, index) => ({ + title: track.title, + trackNumber: index + 1, + duration: convertTimecode(track.duration), + })), + barcode: getBarcode(data.identifiers), +}); + +export const barcode = (barcodeValue: string): Promise => + new Promise(resolve => { + const cacheKey = `barcode--${barcodeValue}`; + + Cache.get(cacheKey) + .then(result => { + resolve(result); + }) + .catch(() => { + Database.search( + undefined, + { barcode: barcodeValue, type: "release" }, + (err: DiscogsError, data: { results: DiscogsSearchResult[] }) => { + if (err || !data || !data.results || !data.results.length) { + return resolve(undefined); + } + const results = orderBy( + data.results, + ["community.have", "community.want"], + ["desc", "desc"], + ) as DiscogsSearchResult[]; + + Cache.set(cacheKey, results[0].id); + + return resolve(results[0].id); + }, + ); + }); + }); + +export const search = (query: string): Promise => + new Promise(resolve => { + const cacheKey = `search--${query}`; + + Cache.get(cacheKey) + .then(results => { + resolve(results); + }) + .catch(() => { + // eslint-disable-next-line consistent-return + Database.search(query, { type: "release" }, (err: DiscogsError, data: { results: any[] }) => { + if (err || !data || !data.results || !data.results.length) { + return resolve(undefined); + } + + const results: SearchResult[] = data.results.map(result => + pick(result, ["id", "title", "thumb", "country", "year", "format", "uri"]), + ); + + Cache.set(cacheKey, results); + + resolve(results); + }); + }); + }); + +export const getRelease = (id: number): Promise => + new Promise((resolve, reject) => { + Database.getRelease(id, (err: DiscogsError, data: DiscogsData) => { + if (err) { + if (err.statusCode === 404) { + Database.getMaster(id, (masterErr: DiscogsError, masterData: DiscogsData) => { + if (masterErr || !masterData) { + reject(masterErr || new Error("No data returned from Discogs")); + } else { + resolve(buildRelease(id, masterData)); + } + }); + } else { + reject(err); + } + } else if (!data) { + reject(new Error("No data returned from Discogs")); + } else { + resolve(buildRelease(id, data)); + } + }); + }); diff --git a/app/lastfm.js b/app/lastfm.js deleted file mode 100644 index 1d46a7c..0000000 --- a/app/lastfm.js +++ /dev/null @@ -1,54 +0,0 @@ -const LastFM = require('lastfmapi'); - -const LastFmApi = (username, key) => { - const lfm = new LastFM({ - api_key: process.env.LASTFM_KEY, - secret: process.env.LASTFM_SECRET, - }); - lfm.setSessionCredentials(username, key); - return lfm; -}; - -const getScrobble = (data) => { - let nextTimestamp = Math.floor(Date.now() / 1000); - const trackData = []; - - data.tracks.forEach((track) => { - trackData.push({ - album: data.title, - artist: data.artist, - timestamp: nextTimestamp, - track: track.title, - trackNumber: track.trackNumber, - }); - nextTimestamp += track.duration; - }); - - return trackData; -}; - -module.exports = { - scrobbleTracks: (username, key, data) => ( - new Promise((resolve, reject) => { - LastFmApi(username, key).track.scrobble( - getScrobble(data), - (err, scrobbles) => { - if (err) reject(err); - resolve(scrobbles); - }, - ); - }) - ), - - getUserData: (username, key) => ( - new Promise((resolve, reject) => { - LastFmApi(username, key).user.getInfo( - null, - (err, userData) => { - if (err) reject(err); - resolve(userData); - }, - ); - }) - ), -}; diff --git a/app/lastfm.ts b/app/lastfm.ts new file mode 100644 index 0000000..5757963 --- /dev/null +++ b/app/lastfm.ts @@ -0,0 +1,64 @@ +// eslint-disable-next-line @typescript-eslint/no-require-imports +const LastFMApi = require("lastfmapi"); + +interface ScrobbleTrack { + album: string; + artist: string; + timestamp: number; + track: string; + trackNumber: number; +} + +interface ReleaseForScrobble { + title: string; + artist: string; + tracks: Array<{ title: string; trackNumber: number; duration: number }>; +} + +const createApiClient = (username: string, key: string) => { + const lfm = new LastFMApi({ + api_key: process.env.LASTFM_KEY, + secret: process.env.LASTFM_SECRET, + }); + lfm.setSessionCredentials(username, key); + return lfm; +}; + +const getScrobble = (data: ReleaseForScrobble): ScrobbleTrack[] => { + let nextTimestamp = Math.floor(Date.now() / 1000); + const trackData: ScrobbleTrack[] = []; + + data.tracks.forEach(track => { + trackData.push({ + album: data.title, + artist: data.artist, + timestamp: nextTimestamp, + track: track.title, + trackNumber: track.trackNumber, + }); + nextTimestamp += track.duration; + }); + + return trackData; +}; + +export const scrobbleTracks = (username: string, key: string, data: ReleaseForScrobble): Promise => + new Promise((resolve, reject) => { + createApiClient(username, key).track.scrobble(getScrobble(data), (err: unknown, scrobbles: unknown) => { + if (err) reject(err); + resolve(scrobbles); + }); + }); + +export interface LastFMUserData { + url: string; + image?: Array<{ "#text": string }>; +} + +export const getUserData = (username: string, key: string): Promise => + new Promise((resolve, reject) => { + createApiClient(username, key).user.getInfo(null, (err: unknown, userData: unknown) => { + if (err) reject(err); + resolve(userData as LastFMUserData); + }); + }); diff --git a/app/middlewares/loggedIn.js b/app/middlewares/loggedIn.js deleted file mode 100644 index e0cc97e..0000000 --- a/app/middlewares/loggedIn.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = function loggedIn(req, res, next) { - if (req.isAuthenticated()) { return next(); } - return res.redirect('/login'); -}; diff --git a/app/middlewares/loggedInApi.js b/app/middlewares/loggedInApi.js deleted file mode 100644 index 7321da4..0000000 --- a/app/middlewares/loggedInApi.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = function loggedIn(req, res, next) { - if (!req.isAuthenticated()) { - return res.status(401).send({ error: 'Unauthorized' }); - } - return next(); -}; diff --git a/app/middlewares/notLoggedIn.js b/app/middlewares/notLoggedIn.js deleted file mode 100644 index 2431412..0000000 --- a/app/middlewares/notLoggedIn.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = function isNotLoggedIn(req, res, next) { - if (!req.isAuthenticated()) { return next(); } - return res.redirect('/'); -}; diff --git a/app/models/release.js b/app/models/release.js deleted file mode 100644 index a86332c..0000000 --- a/app/models/release.js +++ /dev/null @@ -1,80 +0,0 @@ -const mongoose = require('mongoose'); -const pick = require('lodash/pick'); -const Discogs = require('../discogs'); - -const releaseSchema = mongoose.Schema( - { - id: { - type: Number, - unique: true, - }, - artist: String, - title: String, - image: String, - url: String, - year: String, - tracks: [{ - title: String, - trackNumber: Number, - duration: Number, - }], - barcode: String, - }, - { timestamps: true }, -); - -releaseSchema.methods.toJSON = function toJSON() { - return { - // eslint-disable-next-line no-underscore-dangle - id: this._id, - artist: this.artist, - title: this.title, - image: this.image, - url: this.url, - year: this.year, - tracks: this.tracks.map(track => pick(track, ['title', 'trackNumber', 'duration'])), - }; -}; - -releaseSchema.methods.updateFromDiscogs = async function updateFromDiscogs() { - const data = await Discogs.getRelease(this.id); - - if (!data) return false; - - this.artist = data.artist; - this.title = data.title; - this.image = data.image; - this.tracks = data.tracks; - this.url = data.url; - this.year = data.year; - this.barcode = data.barcode; - await this.save(); - - return true; -}; - -releaseSchema.statics.createFromDiscogs = async function createFromDiscogs(id, barcode) { - const release = new (this)({ id }); - if (barcode) release.barcode = barcode; - await release.updateFromDiscogs(); - return release; -}; - -releaseSchema.statics.firstOrCreate = async function firstOrCreate(param) { - const release = await this.findOne(param).exec(); - if (!release) { - const id = param.id || await Discogs.barcode(param.barcode); - if (!id) return null; - - return this.createFromDiscogs(id, param.barcode); - } - - // Data is older than one week - if (new Date().getTime() - release.updatedAt.getTime() > 604800000) { - await release.updateFromDiscogs(); - } - - return release; -}; - -module.exports = mongoose.model('Release', releaseSchema); diff --git a/app/models/release.ts b/app/models/release.ts new file mode 100644 index 0000000..2408b06 --- /dev/null +++ b/app/models/release.ts @@ -0,0 +1,125 @@ +import mongoose, { Schema, HydratedDocument, Model } from "mongoose"; +import pick from "lodash/pick"; +import * as Discogs from "../discogs"; + +interface ITrack { + title: string; + trackNumber: number; + duration: number; +} + +export interface IRelease { + id: number; + artist: string; + title: string; + image: string; + url: string; + year: string; + tracks: ITrack[]; + barcode: string; + updatedAt: Date; +} + +interface IReleaseMethods { + toJSON(): object; + updateFromDiscogs(): Promise; +} + +interface IReleaseModel extends Model { + createFromDiscogs(id: number, barcode?: string): Promise>; + firstOrCreate(param: { + id?: string | number; + barcode?: string; + }): Promise | null>; +} + +const releaseSchema = new Schema( + { + id: { + type: Number, + unique: true, + }, + artist: String, + title: String, + image: String, + url: String, + year: String, + tracks: [ + { + title: String, + trackNumber: Number, + duration: Number, + }, + ], + barcode: String, + }, + { timestamps: true }, +); + +releaseSchema.methods.toJSON = function toJSON() { + return { + // eslint-disable-next-line no-underscore-dangle + id: this._id, + artist: this.artist, + title: this.title, + image: this.image, + url: this.url, + year: this.year, + tracks: this.tracks.map((track: ITrack) => pick(track, ["title", "trackNumber", "duration"])), + }; +}; + +releaseSchema.methods.updateFromDiscogs = async function updateFromDiscogs() { + const data = await Discogs.getRelease(this.id); + + if (!data) return false; + + this.artist = data.artist; + this.title = data.title; + this.image = data.image; + this.tracks = data.tracks; + this.url = data.url; + this.year = data.year; + if (!this.barcode && data.barcode) this.barcode = data.barcode; + await this.save(); + + return true; +}; + +releaseSchema.statics.createFromDiscogs = async function createFromDiscogs(id: number, barcodeValue?: string) { + const release = new this({ id }); + if (barcodeValue) release.barcode = barcodeValue; + await release.updateFromDiscogs(); + return release; +}; + +releaseSchema.statics.firstOrCreate = async function firstOrCreate(param: { id?: string | number; barcode?: string }) { + const release = await this.findOne(param).exec(); + if (!release) { + const paramId = param.id ? Number(param.id) : undefined; + const id = paramId || (await Discogs.barcode(param.barcode)); + if (!id) return null; + + try { + return await this.createFromDiscogs(id, param.barcode); + } catch (err: any) { + // eslint-disable-line @typescript-eslint/no-explicit-any + if (err.code === 11000) { + return this.findOne({ id }).exec(); + } + throw err; + } + } + + // Data is older than one week + if (new Date().getTime() - release.updatedAt.getTime() > 604800000) { + await release.updateFromDiscogs(); + } + + return release; +}; + +const Release = + (mongoose.models.Release as IReleaseModel) || mongoose.model("Release", releaseSchema); + +export default Release; diff --git a/app/models/user.js b/app/models/user.js deleted file mode 100644 index d82039a..0000000 --- a/app/models/user.js +++ /dev/null @@ -1,33 +0,0 @@ -const mongoose = require('mongoose'); - -const userSchema = mongoose.Schema({ - name: String, - key: String, - url: String, - image: String, - imageLarge: String, - imageXLarge: String, - instantScrobbles: [String], - history: [{ - id: String, - time: { type: Date, default: Date.now }, - }], -}); - -userSchema.methods.toJSON = function toJSON() { - return { - // eslint-disable-next-line no-underscore-dangle - id: this._id, - name: this.name, - url: this.url, - image: this.image, - imageLarge: this.imageLarge, - imageXLarge: this.imageXLarge, - }; -}; - -userSchema.methods.isInstantScrobble = function isInstantScrobble(id) { - return (this.instantScrobbles || []).includes(String(id)); -}; - -module.exports = mongoose.model('User', userSchema); diff --git a/app/models/user.ts b/app/models/user.ts new file mode 100644 index 0000000..34ecfab --- /dev/null +++ b/app/models/user.ts @@ -0,0 +1,71 @@ +import mongoose, { Schema, HydratedDocument, Model } from "mongoose"; + +interface IHistoryItem { + id: string; + time: Date; +} + +export interface IUser { + name: string; + key: string; + url: string; + image: string; + imageLarge: string; + imageXLarge: string; + instantScrobbles: string[]; + history: IHistoryItem[]; +} + +export interface UserJSON { + id: string; + name: string; + url: string; + image: string; + imageLarge: string; + imageXLarge: string; +} + +interface IUserMethods { + toJSON(): UserJSON; + isInstantScrobble(id: string): boolean; +} + +type UserModel = Model; + +const userSchema = new Schema({ + name: String, + key: String, + url: String, + image: String, + imageLarge: String, + imageXLarge: String, + instantScrobbles: [String], + history: [ + { + id: String, + time: { type: Date, default: Date.now }, + }, + ], +}); + +userSchema.methods.toJSON = function toJSON(): UserJSON { + return { + // eslint-disable-next-line no-underscore-dangle + id: String(this._id), + name: this.name, + url: this.url, + image: this.image, + imageLarge: this.imageLarge, + imageXLarge: this.imageXLarge, + }; +}; + +userSchema.methods.isInstantScrobble = function isInstantScrobble(id: string) { + return (this.instantScrobbles || []).includes(String(id)); +}; + +const User = + (mongoose.models.User as UserModel & { new (): HydratedDocument }) || + mongoose.model("User", userSchema); + +export default User; diff --git a/app/routes/api/barcode.js b/app/routes/api/barcode.js deleted file mode 100644 index 5cebb7b..0000000 --- a/app/routes/api/barcode.js +++ /dev/null @@ -1,21 +0,0 @@ -const Release = require('../../models/release'); - -// eslint-disable-next-line consistent-return -module.exports = async function apiBarcode(req, res) { - try { - const [barcode, id] = req.params.id.split('id:'); - - const query = id ? { id } : { barcode }; - const release = await Release.firstOrCreate(query); - if (!release) return res.send('{}'); - - // eslint-disable-next-line no-underscore-dangle - const instantScrobble = req.user.isInstantScrobble(release._id); - return res.send(JSON.stringify(Object.assign( - { instantScrobble }, - release.toJSON(), - ))); - } catch (error) { - return res.status(400).send({ error }); - } -}; diff --git a/app/routes/api/index.js b/app/routes/api/index.js deleted file mode 100644 index 90bfb2a..0000000 --- a/app/routes/api/index.js +++ /dev/null @@ -1,19 +0,0 @@ -const Router = require('express').Router(); - -Router.use((req, res, next) => { - res.setHeader('Content-Type', 'application/json'); - next(); -}); -Router.use(require('../../middlewares/loggedInApi')); - -Router.get('/session', require('./session')); -Router.get('/barcode/:id', require('./barcode')); -Router.get('/search/:query', require('./search')); -Router.post('/scrobble', require('./scrobble')); -Router.use('/user', require('./user')); - -Router.use((req, res) => { - res.status(404).send('404 Not Found\n\n(╯°□°)╯︵ ┻━┻'); -}); - -module.exports = Router; diff --git a/app/routes/api/scrobble.js b/app/routes/api/scrobble.js deleted file mode 100644 index 9783862..0000000 --- a/app/routes/api/scrobble.js +++ /dev/null @@ -1,36 +0,0 @@ -const Release = require('../../models/release'); -const LastFM = require('../../lastfm'); - -// eslint-disable-next-line consistent-return -module.exports = function apiScrobble(req, res) { - try { - const { body: { id: releaseId, autoScrobble }, user } = req; - - Release.findOne({ _id: releaseId }, async (err, release) => { - if (err || !release) { - return res.status(400).send(JSON.stringify({ error: 'Release not found' })); - } - - user.history = [{ id: releaseId }].concat(user.history.slice(0, 19)); - if (autoScrobble) user.instantScrobbles.addToSet(releaseId); - await user.save(); - - // Dont scrobble to Last.fm when in development mode - if (process.env.NODE_ENV !== 'production') { - // eslint-disable-next-line no-console - console.log([ - 'Scrobble:', - '-------------------------------', - `User: ${user.name}`, - `Release: ${JSON.stringify(release.toJSON(), ['id', 'artist', 'title'], 2)}`, - ].join('\n')); - return res.send('{}'); - } - - const response = await LastFM.scrobbleTracks(req.user.name, req.user.key, release); - return res.send(JSON.stringify(response)); - }); - } catch (error) { - return res.status(400).send({ error }); - } -}; diff --git a/app/routes/api/search.js b/app/routes/api/search.js deleted file mode 100644 index 112d792..0000000 --- a/app/routes/api/search.js +++ /dev/null @@ -1,10 +0,0 @@ -const Discogs = require('../../discogs'); - -module.exports = async function apiSearch(req, res) { - try { - const results = await Discogs.search(req.params.query); - return res.send(JSON.stringify(results)); - } catch (error) { - return res.status(400).send({ error }); - } -}; diff --git a/app/routes/api/session.js b/app/routes/api/session.js deleted file mode 100644 index 5d6797f..0000000 --- a/app/routes/api/session.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = function apiSession(req, res) { - return res.send(JSON.stringify(req.user.toJSON())); -}; diff --git a/app/routes/api/user/autoscrobbles.js b/app/routes/api/user/autoscrobbles.js deleted file mode 100644 index b6baa8d..0000000 --- a/app/routes/api/user/autoscrobbles.js +++ /dev/null @@ -1,38 +0,0 @@ -const Router = require('express').Router(); -const sortBy = require('lodash/sortBy'); -const Release = require('../../../models/release'); -const User = require('../../../models/user'); - -Router.get('/', (req, res) => { - const { user } = req; - - Release.find({ _id: { $in: user.instantScrobbles } }, (err, releases = []) => { - if (err) { - return res.status(400).send({ err }); - } - - const data = releases.map(release => ({ - // eslint-disable-next-line no-underscore-dangle - id: release._id, - artist: release.artist, - title: release.title, - year: release.year, - })); - return res.send(sortBy(data, ['artist', 'title'])); - }); -}); - -Router.delete('/', async (req, res) => { - try { - const { body: { id }, user } = req; - - // eslint-disable-next-line no-underscore-dangle - await User.update({ _id: user._id }, { $pullAll: { instantScrobbles: [id] } }); - - return res.send(); - } catch (error) { - return res.status(400).send({ error }); - } -}); - -module.exports = Router; diff --git a/app/routes/api/user/history.js b/app/routes/api/user/history.js deleted file mode 100644 index 0e2cbda..0000000 --- a/app/routes/api/user/history.js +++ /dev/null @@ -1,31 +0,0 @@ -const find = require('lodash/find'); -const sortBy = require('lodash/sortBy'); -const compact = require('lodash/compact'); -const Release = require('../../../models/release'); - -module.exports = function userHistory(req, res) { - const { user: { history } } = req; - Release.find({ _id: { $in: history.map(h => h.id) } }, (err, releases = []) => { - if (err) { - return res.status(400).send({ err }); - } - - const data = history.map((item) => { - // eslint-disable-next-line no-underscore-dangle - const release = find(releases, r => String(r._id) === item.id); - if (!release) return undefined; - return ({ - // eslint-disable-next-line no-underscore-dangle - id: item._id, - time: item.time, - artist: release.artist, - title: release.title, - year: release.year, - barcode: release.barcode, - discogsId: release.id, - }); - }); - - return res.send(sortBy(compact(data), ['time']).reverse()); - }); -}; diff --git a/app/routes/api/user/index.js b/app/routes/api/user/index.js deleted file mode 100644 index 2630e0b..0000000 --- a/app/routes/api/user/index.js +++ /dev/null @@ -1,6 +0,0 @@ -const Router = require('express').Router(); - -Router.use('/autoscrobbles', require('./autoscrobbles')); -Router.get('/history', require('./history')); - -module.exports = Router; diff --git a/app/routes/auth.js b/app/routes/auth.js deleted file mode 100644 index 4c3ca35..0000000 --- a/app/routes/auth.js +++ /dev/null @@ -1,18 +0,0 @@ -const passport = require('passport'); -const Router = require('express').Router(); - -Router.get('/lastfm', function (req, res, next) { - passport.authenticate('lastfm', { - callbackURL: `${req.protocol}://${req.headers.host}/auth/lastfm/callback`, - })(req, res, next) -}) - -Router.get( - '/lastfm/callback', - passport.authenticate('lastfm', { - successRedirect: '/', - failureRedirect: '/login', - }), -); - -module.exports = Router; diff --git a/app/routes/index.js b/app/routes/index.js deleted file mode 100644 index ef0ed31..0000000 --- a/app/routes/index.js +++ /dev/null @@ -1,8 +0,0 @@ -/* eslint-disable global-require */ - -module.exports = function routes(server, app) { - server.use('/api', require('./api')); - server.use('/auth', require('./auth')); - server.get('/logout', require('./logout')); - server.use('/', require('./pages')(app)); -}; diff --git a/app/routes/logout.js b/app/routes/logout.js deleted file mode 100644 index 5094464..0000000 --- a/app/routes/logout.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = function logout(req, res) { - req.logout(); - res.redirect('/'); -}; diff --git a/app/routes/pages.js b/app/routes/pages.js deleted file mode 100644 index 902e68b..0000000 --- a/app/routes/pages.js +++ /dev/null @@ -1,41 +0,0 @@ -const path = require('path'); -const Router = require('express').Router(); - -const isLoggedIn = require('../middlewares/loggedIn'); -const isNotLoggedIn = require('../middlewares/notLoggedIn'); - -module.exports = function pages(app) { - Router.get('/detected/:barcode', isLoggedIn, (req, res) => { - app.render(req, res, '/detected', { barcode: req.params.barcode }); - }); - - Router.get('/scrobbled/:barcode', isLoggedIn, (req, res) => { - app.render(req, res, '/scrobbled', { barcode: req.params.barcode }); - }); - - Router.get('/login', isNotLoggedIn, (req, res) => { - app.render(req, res, '/login'); - }); - - Router.get('/legal', (req, res) => { - app.render(req, res, '/legal'); - }); - - Router.get('/privacy', (req, res) => { - app.render(req, res, '/privacy'); - }); - - Router.get('/profile', (req, res) => { - app.render(req, res, '/profile'); - }); - - Router.get('/service-worker.js', (req, res) => { - app.serveStatic(req, res, path.resolve('./static/service-worker.js')); - }); - - Router.get('/', isLoggedIn, (req, res) => { - app.render(req, res, '/'); - }); - - return Router; -}; diff --git a/app/spec/cache.spec.js b/app/spec/cache.spec.js deleted file mode 100644 index e015a6a..0000000 --- a/app/spec/cache.spec.js +++ /dev/null @@ -1,78 +0,0 @@ -/* eslint-disable no-underscore-dangle */ - -const redis = require('redis'); -const Cache = require('../cache'); - -jest.mock('redis'); - -describe('cache', () => { - beforeEach(() => { - redis._get.mockClear(); - redis._set.mockClear(); - redis._reset(); - }); - - describe('set', () => { - it('writes given data to redis store', async () => { - const key = 'foo'; - const value = ['bar']; - - await Cache.set(key, value); - - expect(redis._set.mock.calls.length).toBe(1); - expect(redis._set.mock.calls[0][0]).toBe(key); - expect(redis._set.mock.calls[0][1]).toBe(JSON.stringify(value)); - expect(redis._set.mock.calls[0][2]).toBe('EX'); - }); - - it('passes given ttl to redis store', async () => { - const key = 'foo'; - const value = ['bar']; - const ttl = 1337; - - await Cache.set(key, value, ttl); - - expect(redis._set.mock.calls[0][3]).toBe(ttl); - }); - - it('uses 24 hours as default ttl', async () => { - const key = 'foo'; - const value = ['bar']; - - await Cache.set(key, value); - - expect(redis._set.mock.calls[0][3]).toBe(24 * 60 * 60); - }); - }); - - describe('get', () => { - it('queries data from redis store', async () => { - const key = 'foo'; - - try { - await Cache.get(key); - } catch (e) { - // ignore errors - } - - expect(redis._get.mock.calls.length).toBe(1); - expect(redis._get.mock.calls[0][0]).toBe(key); - }); - - it('returns correct data from redis store after it was saved', async () => { - const key = 'foo'; - const value = ['bar']; - - await Cache.set(key, value); - const cachedData = await Cache.get(key); - - expect(cachedData).toEqual(value); - }); - - it('rejects the promise when no data is stored in redis', () => { - const key = 'foo'; - - expect(Cache.get(key)).rejects.toBeUndefined(); - }); - }); -}); diff --git a/app/spec/cache.spec.ts b/app/spec/cache.spec.ts new file mode 100644 index 0000000..dfdcd0f --- /dev/null +++ b/app/spec/cache.spec.ts @@ -0,0 +1,78 @@ +/* eslint-disable no-underscore-dangle */ + +import * as redis from "redis"; +import * as Cache from "../cache"; + +vi.mock("redis", async () => import("../__mocks__/redis")); + +describe("cache", () => { + beforeEach(() => { + (redis as any)._get.mockClear(); + (redis as any)._set.mockClear(); + (redis as any)._reset(); + }); + + describe("set", () => { + it("writes given data to redis store", async () => { + const key = "foo"; + const value = ["bar"]; + + await Cache.set(key, value); + + expect((redis as any)._set.mock.calls.length).toBe(1); + expect((redis as any)._set.mock.calls[0][0]).toBe(key); + expect((redis as any)._set.mock.calls[0][1]).toBe(JSON.stringify(value)); + expect((redis as any)._set.mock.calls[0][2]).toEqual({ EX: 86400 }); + }); + + it("passes given ttl to redis store", async () => { + const key = "foo"; + const value = ["bar"]; + const ttl = 1337; + + await Cache.set(key, value, ttl); + + expect((redis as any)._set.mock.calls[0][2]).toEqual({ EX: ttl }); + }); + + it("uses 24 hours as default ttl", async () => { + const key = "foo"; + const value = ["bar"]; + + await Cache.set(key, value); + + expect((redis as any)._set.mock.calls[0][2]).toEqual({ EX: 24 * 60 * 60 }); + }); + }); + + describe("get", () => { + it("queries data from redis store", async () => { + const key = "foo"; + + try { + await Cache.get(key); + } catch { + // ignore errors + } + + expect((redis as any)._get.mock.calls.length).toBe(1); + expect((redis as any)._get.mock.calls[0][0]).toBe(key); + }); + + it("returns correct data from redis store after it was saved", async () => { + const key = "foo"; + const value = ["bar"]; + + await Cache.set(key, value); + const cachedData = await Cache.get(key); + + expect(cachedData).toEqual(value); + }); + + it("rejects the promise when no data is stored in redis", async () => { + const key = "foo"; + + await expect(Cache.get(key)).rejects.toBeUndefined(); + }); + }); +}); diff --git a/client/reduxStore.js b/client/reduxStore.js deleted file mode 100644 index 7ef9772..0000000 --- a/client/reduxStore.js +++ /dev/null @@ -1,25 +0,0 @@ -import thunkMiddleware from 'redux-thunk'; -import { combineReducers, createStore, applyMiddleware } from 'redux'; -import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction'; - -import sessionReducer from '../components/session/reducers/sessionReducer'; -import historyReducer from '../components/profile/reducers/historyReducer'; -import autoScrobbleReducer from '../components/profile/reducers/autoScrobbleReducer'; -import releaseReducer from '../components/release/reducers/releaseReducer'; -import queryReducer from '../components/query/reducers/queryReducer'; - -const reducer = combineReducers({ - session: sessionReducer, - history: historyReducer, - autoScrobbles: autoScrobbleReducer, - release: releaseReducer, - query: queryReducer, -}); - -export default function initializeStore(initialState = {}) { - return createStore( - reducer, - initialState, - composeWithDevTools(applyMiddleware(thunkMiddleware)), - ); -} diff --git a/client/reduxStore.ts b/client/reduxStore.ts new file mode 100644 index 0000000..1253fb7 --- /dev/null +++ b/client/reduxStore.ts @@ -0,0 +1,23 @@ +import { thunk as thunkMiddleware } from "redux-thunk"; +import { combineReducers, createStore, applyMiddleware } from "redux"; +import { composeWithDevToolsLogOnlyInProduction as composeWithDevTools } from "@redux-devtools/extension"; +import { createWrapper } from "next-redux-wrapper"; + +import sessionReducer from "../components/session/reducers/sessionReducer"; +import historyReducer from "../components/profile/reducers/historyReducer"; +import autoScrobbleReducer from "../components/profile/reducers/autoScrobbleReducer"; +import releaseReducer from "../components/release/reducers/releaseReducer"; +import queryReducer from "../components/query/reducers/queryReducer"; + +const reducer = combineReducers({ + session: sessionReducer, + history: historyReducer, + autoScrobbles: autoScrobbleReducer, + release: releaseReducer, + query: queryReducer, +}); + +const makeStore = () => createStore(reducer, composeWithDevTools(applyMiddleware(thunkMiddleware))); + +export const wrapper = createWrapper(makeStore); +export default makeStore; diff --git a/components/assets/Logo.jsx b/components/assets/Logo.jsx deleted file mode 100644 index 611b308..0000000 --- a/components/assets/Logo.jsx +++ /dev/null @@ -1,22 +0,0 @@ -import PropTypes from 'prop-types'; - -const Logo = ({ className, ...props }) => ( - - CodeScrobble - - - - - - -); - -Logo.propTypes = { - className: PropTypes.string, -}; - -Logo.defaultProps = { - className: undefined, -}; - -export default Logo; diff --git a/components/assets/Logo.tsx b/components/assets/Logo.tsx new file mode 100644 index 0000000..0d33585 --- /dev/null +++ b/components/assets/Logo.tsx @@ -0,0 +1,15 @@ +const Logo = ({ className = undefined, ...props }: { className?: string; [key: string]: any }) => ( + + CodeScrobble + + + + + + +); + +export default Logo; diff --git a/components/assets/LogoSmall.jsx b/components/assets/LogoSmall.jsx deleted file mode 100644 index 5971c3b..0000000 --- a/components/assets/LogoSmall.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import PropTypes from 'prop-types'; - -const Logo = ({ className, ...props }) => ( - - CodeScrobble - - -); - -Logo.propTypes = { - className: PropTypes.string, -}; - -Logo.defaultProps = { - className: undefined, -}; - -export default Logo; diff --git a/components/assets/LogoSmall.tsx b/components/assets/LogoSmall.tsx new file mode 100644 index 0000000..8b0e0e4 --- /dev/null +++ b/components/assets/LogoSmall.tsx @@ -0,0 +1,11 @@ +const Logo = ({ className = undefined, ...props }: { className?: string; [key: string]: any }) => ( + + CodeScrobble + + +); + +export default Logo; diff --git a/components/icons/ErrorIcon.jsx b/components/icons/ErrorIcon.tsx similarity index 64% rename from components/icons/ErrorIcon.jsx rename to components/icons/ErrorIcon.tsx index d18a984..8e7bc0d 100644 --- a/components/icons/ErrorIcon.jsx +++ b/components/icons/ErrorIcon.tsx @@ -1,6 +1,10 @@ -import PropTypes from 'prop-types'; +interface ErrorIconProps { + color?: string; + size?: number; + className?: string; +} -const ErrorIcon = ({ color, size, className }) => ( +const ErrorIcon = ({ color = "#000", size = 100, className }: ErrorIconProps) => ( Error @@ -10,16 +14,4 @@ const ErrorIcon = ({ color, size, className }) => ( ); -ErrorIcon.propTypes = { - color: PropTypes.string, - size: PropTypes.number, - className: PropTypes.string, -}; - -ErrorIcon.defaultProps = { - color: '#000', - size: 100, - className: undefined, -}; - export default ErrorIcon; diff --git a/components/icons/LastfmIcon.jsx b/components/icons/LastfmIcon.jsx deleted file mode 100644 index 1f24eb5..0000000 --- a/components/icons/LastfmIcon.jsx +++ /dev/null @@ -1,20 +0,0 @@ -import PropTypes from 'prop-types'; - -const LastfmIcon = ({ color, ...props }) => ( - - Last.fm - - -); - -LastfmIcon.propTypes = { - className: PropTypes.string, - color: PropTypes.string, -}; - -LastfmIcon.defaultProps = { - className: undefined, - color: '#fff', -}; - -export default LastfmIcon; diff --git a/components/icons/LastfmIcon.tsx b/components/icons/LastfmIcon.tsx new file mode 100644 index 0000000..3db2d1f --- /dev/null +++ b/components/icons/LastfmIcon.tsx @@ -0,0 +1,17 @@ +interface LastfmIconProps { + className?: string; + color?: string; + [key: string]: any; +} + +const LastfmIcon = ({ color = "#fff", ...props }: LastfmIconProps) => ( + + Last.fm + + +); + +export default LastfmIcon; diff --git a/components/icons/LogoIcon.jsx b/components/icons/LogoIcon.tsx similarity index 94% rename from components/icons/LogoIcon.jsx rename to components/icons/LogoIcon.tsx index fd352c5..d310102 100644 --- a/components/icons/LogoIcon.jsx +++ b/components/icons/LogoIcon.tsx @@ -1,6 +1,10 @@ -import PropTypes from 'prop-types'; +interface LogoIconProps { + color?: string; + size?: number; + className?: string; +} -const LogoIcon = ({ color, size, className }) => ( +const LogoIcon = ({ color = "#000", size = 100, className }: LogoIconProps) => ( @@ -9,16 +13,4 @@ const LogoIcon = ({ color, size, className }) => ( ); -LogoIcon.propTypes = { - color: PropTypes.string, - size: PropTypes.number, - className: PropTypes.string, -}; - -LogoIcon.defaultProps = { - color: '#000', - size: 100, - className: undefined, -}; - export default LogoIcon; diff --git a/components/icons/NoResultsIcon.jsx b/components/icons/NoResultsIcon.tsx similarity index 81% rename from components/icons/NoResultsIcon.jsx rename to components/icons/NoResultsIcon.tsx index 3624d63..d3d202f 100644 --- a/components/icons/NoResultsIcon.jsx +++ b/components/icons/NoResultsIcon.tsx @@ -1,6 +1,10 @@ -import PropTypes from 'prop-types'; +interface NoResultsIconProps { + color?: string; + size?: number; + className?: string; +} -const NoResultsIcon = ({ color, size, className }) => ( +const NoResultsIcon = ({ color = "#000", size = 100, className }: NoResultsIconProps) => ( @@ -11,16 +15,4 @@ const NoResultsIcon = ({ color, size, className }) => ( ); -NoResultsIcon.propTypes = { - color: PropTypes.string, - size: PropTypes.number, - className: PropTypes.string, -}; - -NoResultsIcon.defaultProps = { - color: '#000', - size: 100, - className: undefined, -}; - export default NoResultsIcon; diff --git a/components/layout/BaseStyles.js b/components/layout/BaseStyles.ts similarity index 90% rename from components/layout/BaseStyles.js rename to components/layout/BaseStyles.ts index e896885..2d99977 100644 --- a/components/layout/BaseStyles.js +++ b/components/layout/BaseStyles.ts @@ -1,5 +1,5 @@ -import { createGlobalStyle } from 'styled-components'; -import { dark, silver } from '../../lib/colors'; +import { createGlobalStyle } from "styled-components"; +import { dark, silver } from "../../lib/colors"; export default createGlobalStyle` html { diff --git a/components/layout/CircleLayout.jsx b/components/layout/CircleLayout.jsx deleted file mode 100644 index f85664d..0000000 --- a/components/layout/CircleLayout.jsx +++ /dev/null @@ -1,58 +0,0 @@ -import { createGlobalStyle } from 'styled-components'; -import PropTypes from 'prop-types'; -import Link from 'next/link'; -import { - Center, Content, Footer, Header, HeightWrapper, Logo, LogoWrapper, SessionWrapper, Wrapper, -} from '../../styles/layout.styles'; -import Session from '../session/Session'; -import LegalLinks from '../ui/LegalLinks'; - -const ScrollLock = createGlobalStyle` - body { - position: fixed; - width: 100%; - overflow: hidden; - } -`; - -const CircleLayout = ({ children, header, footer }) => ( -
- - -
- - - - - - - - - {header} -
- - - {children} - - -
- - {footer} -
-
-
-); - -CircleLayout.propTypes = { - children: PropTypes.any, - header: PropTypes.any, - footer: PropTypes.any, -}; - -CircleLayout.defaultProps = { - children: null, - header: null, - footer: null, -}; - -export default CircleLayout; diff --git a/components/layout/CircleLayout.tsx b/components/layout/CircleLayout.tsx new file mode 100644 index 0000000..b61130a --- /dev/null +++ b/components/layout/CircleLayout.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { createGlobalStyle } from "styled-components"; +import Link from "next/link"; +import { + Center, + Content, + Footer, + Header, + HeightWrapper, + Logo, + LogoWrapper, + SessionWrapper, + Wrapper, +} from "../../styles/layout.styles"; +import Session from "../session/Session"; +import LegalLinks from "../ui/LegalLinks"; + +const ScrollLock = createGlobalStyle` + body { + position: fixed; + width: 100%; + overflow: hidden; + } +`; + +interface CircleLayoutProps { + children?: React.ReactNode; + header?: React.ReactNode; + footer?: React.ReactNode; +} + +const CircleLayout = ({ children = null, header = null, footer = null }: CircleLayoutProps) => ( +
+ + +
+ + + + + + + + + {header} +
+ + {children} + +
+ + {footer} +
+
+
+); + +export default CircleLayout; diff --git a/components/layout/Loading.jsx b/components/layout/Loading.tsx similarity index 63% rename from components/layout/Loading.jsx rename to components/layout/Loading.tsx index cdad63d..3874a95 100644 --- a/components/layout/Loading.jsx +++ b/components/layout/Loading.tsx @@ -1,4 +1,4 @@ -import { LoadingSpinner, LoadingWrapper } from './styles/Loading.styles'; +import { LoadingSpinner, LoadingWrapper } from "./styles/Loading.styles"; const Loading = () => ( diff --git a/components/layout/Spinner.jsx b/components/layout/Spinner.tsx similarity index 59% rename from components/layout/Spinner.jsx rename to components/layout/Spinner.tsx index 5d191b0..c6f3aa6 100644 --- a/components/layout/Spinner.jsx +++ b/components/layout/Spinner.tsx @@ -1,5 +1,5 @@ -import styled, { keyframes } from 'styled-components'; -import { yellow, yellowRGB } from '../../lib/colors'; +import styled, { keyframes } from "styled-components"; +import { yellow, yellowRGB } from "../../lib/colors"; export const animation = keyframes` to { @@ -7,12 +7,12 @@ export const animation = keyframes` } `; -const Spinner = styled.div` +const Spinner = styled.div<{ size?: number | string }>` display: inline-block; width: ${props => props.size}px; height: ${props => props.size}px; animation: ${animation} 1s ease-in-out infinite; - border: 3px solid rgba(${yellowRGB}, .3); + border: 3px solid rgba(${yellowRGB}, 0.3); border-radius: 50%; border-top-color: ${yellow}; `; diff --git a/components/layout/styles/Error.styles.js b/components/layout/styles/Error.styles.ts similarity index 88% rename from components/layout/styles/Error.styles.js rename to components/layout/styles/Error.styles.ts index 7d01e8c..819bace 100644 --- a/components/layout/styles/Error.styles.js +++ b/components/layout/styles/Error.styles.ts @@ -1,5 +1,5 @@ -import styled from 'styled-components'; -import ErrorIconSVG from '../../icons/ErrorIcon'; +import styled from "styled-components"; +import ErrorIconSVG from "../../icons/ErrorIcon"; export const Error = styled.div` display: flex; diff --git a/components/layout/styles/Loading.styles.js b/components/layout/styles/Loading.styles.ts similarity index 76% rename from components/layout/styles/Loading.styles.js rename to components/layout/styles/Loading.styles.ts index e06e205..86891ef 100644 --- a/components/layout/styles/Loading.styles.js +++ b/components/layout/styles/Loading.styles.ts @@ -1,5 +1,5 @@ -import styled from 'styled-components'; -import Spinner from '../Spinner'; +import styled from "styled-components"; +import Spinner from "../Spinner"; export const LoadingWrapper = styled.div` display: flex; diff --git a/components/profile/ProfileAutoScrobbleItem.jsx b/components/profile/ProfileAutoScrobbleItem.jsx deleted file mode 100644 index c8c5ac0..0000000 --- a/components/profile/ProfileAutoScrobbleItem.jsx +++ /dev/null @@ -1,54 +0,0 @@ -import { bindActionCreators } from 'redux'; -import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { TrashAlt as DeleteIcon } from 'styled-icons/fa-regular/TrashAlt'; -import { deleteAutoScrobble } from './actions/autoScrobbleActions'; -import { DeleteButton, ListCaption, ListItem } from '../../styles/profile.styles'; - -class ProfileAutoScrobbleItem extends React.PureComponent { - handleDelete = () => { - const { id } = this.props; - this.props.deleteAutoScrobble(id); - } - - render() { - const { - id, artist, title, year, isDeleting, - } = this.props; - return ( - - - {`${artist} - ${title}`} - {year && ` (${year})`} - - - - - - ); - } -} - -ProfileAutoScrobbleItem.propTypes = { - id: PropTypes.string.isRequired, - artist: PropTypes.string.isRequired, - title: PropTypes.string.isRequired, - year: PropTypes.string, - isDeleting: PropTypes.bool, - deleteAutoScrobble: PropTypes.func.isRequired, -}; - -ProfileAutoScrobbleItem.defaultProps = { - isDeleting: false, - year: undefined, -}; - -const mapDispatchToProps = dispatch => ( - bindActionCreators({ deleteAutoScrobble }, dispatch) -); - -export default connect( - null, - mapDispatchToProps, -)(ProfileAutoScrobbleItem); diff --git a/components/profile/ProfileAutoScrobbleItem.tsx b/components/profile/ProfileAutoScrobbleItem.tsx new file mode 100644 index 0000000..f692315 --- /dev/null +++ b/components/profile/ProfileAutoScrobbleItem.tsx @@ -0,0 +1,41 @@ +import { bindActionCreators } from "redux"; +import { connect } from "react-redux"; +import React from "react"; +import { TrashAlt as DeleteIcon } from "styled-icons/fa-regular"; +import { deleteAutoScrobble } from "./actions/autoScrobbleActions"; +import { DeleteButton, ListCaption, ListItem } from "../../styles/profile.styles"; + +interface ProfileAutoScrobbleItemProps { + id: string; + artist: string; + title: string; + year?: string; + isDeleting?: boolean; + deleteAutoScrobble: (id: string) => void; +} + +class ProfileAutoScrobbleItem extends React.PureComponent { + handleDelete = () => { + const { id } = this.props; + this.props.deleteAutoScrobble(id); + }; + + render() { + const { id, artist, title, year, isDeleting = false } = this.props; + return ( + + + {`${artist} - ${title}`} + {year && ` (${year})`} + + + + + + ); + } +} + +const mapDispatchToProps = dispatch => bindActionCreators({ deleteAutoScrobble }, dispatch); + +export default connect(null, mapDispatchToProps)(ProfileAutoScrobbleItem); diff --git a/components/profile/ProfileAutoScrobbles.jsx b/components/profile/ProfileAutoScrobbles.jsx deleted file mode 100644 index c807ee7..0000000 --- a/components/profile/ProfileAutoScrobbles.jsx +++ /dev/null @@ -1,76 +0,0 @@ -import { bindActionCreators } from 'redux'; -import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { fetchAutoScrobbles } from './actions/autoScrobbleActions'; -import { - Fallback, H3, List, Meta, -} from '../../styles/profile.styles'; -import ProfileAutoScrobbleItem from './ProfileAutoScrobbleItem'; -import Spinner from '../layout/Spinner'; - -class ProfileAutoScrobbles extends React.PureComponent { - componentDidMount() { - this.props.fetchAutoScrobbles(); - } - - render() { - const { - data, loading, deleting, - } = this.props; - return ( - <> -

Auto-scrobbles

- These items are automatically scrobbled the next time they are scanned - {loading - ? - : ( - - {!data.length && ( - - No entries found. Activate the option "Auto-scrobble" - during your next scan. - - )} - {data.map(item => ( - - ))} - - ) - } - - ); - } -} - -ProfileAutoScrobbles.propTypes = { - data: PropTypes.array, - loading: PropTypes.bool, - deleting: PropTypes.array, - fetchAutoScrobbles: PropTypes.func.isRequired, -}; - -ProfileAutoScrobbles.defaultProps = { - data: [], - loading: true, - deleting: [], -}; - -const mapStateToProps = state => ({ - data: state.autoScrobbles.data, - loading: state.autoScrobbles.loading, - deleting: state.autoScrobbles.deleting, -}); - -const mapDispatchToProps = dispatch => ( - bindActionCreators({ fetchAutoScrobbles }, dispatch) -); - -export default connect( - mapStateToProps, - mapDispatchToProps, -)(ProfileAutoScrobbles); diff --git a/components/profile/ProfileAutoScrobbles.tsx b/components/profile/ProfileAutoScrobbles.tsx new file mode 100644 index 0000000..e0cae5f --- /dev/null +++ b/components/profile/ProfileAutoScrobbles.tsx @@ -0,0 +1,54 @@ +import { bindActionCreators } from "redux"; +import { connect } from "react-redux"; +import React from "react"; +import { fetchAutoScrobbles } from "./actions/autoScrobbleActions"; +import { Fallback, H3, List, Meta } from "../../styles/profile.styles"; +import ProfileAutoScrobbleItem from "./ProfileAutoScrobbleItem"; +import Spinner from "../layout/Spinner"; + +interface ProfileAutoScrobblesProps { + data?: any[]; + loading?: boolean; + deleting?: any[]; + fetchAutoScrobbles: () => void; +} + +class ProfileAutoScrobbles extends React.PureComponent { + componentDidMount() { + this.props.fetchAutoScrobbles(); + } + + render() { + const { data = [], loading = true, deleting = [] } = this.props; + return ( + <> +

Auto-scrobbles

+ These items are automatically scrobbled the next time they are scanned + {loading ? ( + + ) : ( + + {!data.length && ( + + No entries found. Activate the option "Auto-scrobble" during your next scan. + + )} + {data.map(item => ( + + ))} + + )} + + ); + } +} + +const mapStateToProps = state => ({ + data: state.autoScrobbles.data, + loading: state.autoScrobbles.loading, + deleting: state.autoScrobbles.deleting, +}); + +const mapDispatchToProps = dispatch => bindActionCreators({ fetchAutoScrobbles }, dispatch); + +export default connect(mapStateToProps, mapDispatchToProps)(ProfileAutoScrobbles); diff --git a/components/profile/ProfileHistory.jsx b/components/profile/ProfileHistory.jsx deleted file mode 100644 index dcbe3c2..0000000 --- a/components/profile/ProfileHistory.jsx +++ /dev/null @@ -1,69 +0,0 @@ -import { bindActionCreators } from 'redux'; -import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { fetchHistory } from './actions/historyActions'; -import { - Fallback, H3, List, Meta, -} from '../../styles/profile.styles'; -import ProfileHistoryItem from './ProfileHistoryItem'; -import Spinner from '../layout/Spinner'; - -class ProfileHistorys extends React.PureComponent { - componentDidMount() { - this.props.fetchHistory(); - } - - render() { - const { history, loading } = this.props; - return ( - <> -

History

- Your recently scanned items. Tap one to scrobble it again. - {loading - ? - : ( - - {!history.length && ( - - No entries found. - - )} - {history.map(item => ( - - ))} - - ) - } - - ); - } -} - -ProfileHistorys.propTypes = { - history: PropTypes.array, - loading: PropTypes.bool, - fetchHistory: PropTypes.func.isRequired, -}; - -ProfileHistorys.defaultProps = { - history: [], - loading: true, -}; - -const mapStateToProps = state => ({ - history: state.history.data, - loading: state.history.loading, -}); - -const mapDispatchToProps = dispatch => ( - bindActionCreators({ fetchHistory }, dispatch) -); - -export default connect( - mapStateToProps, - mapDispatchToProps, -)(ProfileHistorys); diff --git a/components/profile/ProfileHistory.tsx b/components/profile/ProfileHistory.tsx new file mode 100644 index 0000000..0189f0e --- /dev/null +++ b/components/profile/ProfileHistory.tsx @@ -0,0 +1,48 @@ +import { bindActionCreators } from "redux"; +import { connect } from "react-redux"; +import React from "react"; +import { fetchHistory } from "./actions/historyActions"; +import { Fallback, H3, List, Meta } from "../../styles/profile.styles"; +import ProfileHistoryItem from "./ProfileHistoryItem"; +import Spinner from "../layout/Spinner"; + +interface ProfileHistoryProps { + history?: any[]; + loading?: boolean; + fetchHistory: () => void; +} + +class ProfileHistory extends React.PureComponent { + componentDidMount() { + this.props.fetchHistory(); + } + + render() { + const { history = [], loading = true } = this.props; + return ( + <> +

History

+ Your recently scanned items. Tap one to scrobble it again. + {loading ? ( + + ) : ( + + {!history.length && No entries found.} + {history.map(item => ( + + ))} + + )} + + ); + } +} + +const mapStateToProps = state => ({ + history: state.history.data, + loading: state.history.loading, +}); + +const mapDispatchToProps = dispatch => bindActionCreators({ fetchHistory }, dispatch); + +export default connect(mapStateToProps, mapDispatchToProps)(ProfileHistory); diff --git a/components/profile/ProfileHistoryItem.jsx b/components/profile/ProfileHistoryItem.jsx deleted file mode 100644 index 8500066..0000000 --- a/components/profile/ProfileHistoryItem.jsx +++ /dev/null @@ -1,48 +0,0 @@ - -import PropTypes from 'prop-types'; -import React from 'react'; -import Link from 'next/link'; -import { ListCaption, ListItem, Time } from '../../styles/profile.styles'; - -class ProfileHistoryItem extends React.PureComponent { - render() { - const { - id, artist, title, year, barcode, discogsId, time, isDeleting, - } = this.props; - - const barcodeParam = barcode || `id:${discogsId}`; - - return ( - - - - - {`${artist} - ${title}`} - {year && ` (${year})`} - - - - - ); - } -} - -ProfileHistoryItem.propTypes = { - id: PropTypes.string.isRequired, - artist: PropTypes.string.isRequired, - title: PropTypes.string.isRequired, - year: PropTypes.string, - isDeleting: PropTypes.bool, - barcode: PropTypes.string, - time: PropTypes.string.isRequired, - discogsId: PropTypes.number.isRequired, -}; - -ProfileHistoryItem.defaultProps = { - isDeleting: false, - year: undefined, - barcode: undefined, -}; - -export default ProfileHistoryItem; diff --git a/components/profile/ProfileHistoryItem.tsx b/components/profile/ProfileHistoryItem.tsx new file mode 100644 index 0000000..6a12026 --- /dev/null +++ b/components/profile/ProfileHistoryItem.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import Link from "next/link"; +import { ListCaption, ListItem, Time } from "../../styles/profile.styles"; + +interface ProfileHistoryItemProps { + id: string; + artist: string; + title: string; + year?: string; + isDeleting?: boolean; + barcode?: string; + time: string; + discogsId: number; +} + +class ProfileHistoryItem extends React.PureComponent { + render() { + const { id, artist, title, year, barcode, discogsId, time, isDeleting = false } = this.props; + + const barcodeParam = barcode || `id:${discogsId}`; + + return ( + + + + + {`${artist} - ${title}`} + {year && ` (${year})`} + + + + + ); + } +} + +export default ProfileHistoryItem; diff --git a/components/profile/actions/autoScrobbleActionCreators.js b/components/profile/actions/autoScrobbleActionCreators.ts similarity index 75% rename from components/profile/actions/autoScrobbleActionCreators.js rename to components/profile/actions/autoScrobbleActionCreators.ts index 20f27f1..9962faa 100644 --- a/components/profile/actions/autoScrobbleActionCreators.js +++ b/components/profile/actions/autoScrobbleActionCreators.ts @@ -1,7 +1,11 @@ import { - SET_LOADING_STATE, SET_ERROR_STATE, RECEIVED_AUTO_SCROBBLES, START_DELETING, - REMOVE_AUTO_SCROBBLE, END_DELETING, -} from '../constants/autoScrobbleConstants'; + SET_LOADING_STATE, + SET_ERROR_STATE, + RECEIVED_AUTO_SCROBBLES, + START_DELETING, + REMOVE_AUTO_SCROBBLE, + END_DELETING, +} from "../constants/autoScrobbleConstants"; export const setLoadingState = loading => ({ type: SET_LOADING_STATE, diff --git a/components/profile/actions/autoScrobbleActions.js b/components/profile/actions/autoScrobbleActions.js deleted file mode 100644 index 9d94129..0000000 --- a/components/profile/actions/autoScrobbleActions.js +++ /dev/null @@ -1,35 +0,0 @@ -import { - setLoadingState, receivedAutoScrobbles, setErrorState, startDeleting, - removeAutoScrobble, endDeleting, -} from './autoScrobbleActionCreators'; - -export const fetchAutoScrobbles = () => ( - async (dispatch) => { - try { - dispatch(setLoadingState(true)); - const data = await fetch('/api/user/autoscrobbles', { credentials: 'include' }).then(r => r.json()); - dispatch(receivedAutoScrobbles(data)); - } catch (error) { - dispatch(setErrorState(error)); - } - dispatch(setLoadingState(false)); - } -); - -export const deleteAutoScrobble = id => ( - async (dispatch) => { - try { - dispatch(startDeleting(id)); - await fetch('/api/user/autoscrobbles', { - method: 'DELETE', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id }), - }); - dispatch(removeAutoScrobble(id)); - } catch (error) { - dispatch(setErrorState(error)); - } - dispatch(endDeleting(id)); - } -); diff --git a/components/profile/actions/autoScrobbleActions.ts b/components/profile/actions/autoScrobbleActions.ts new file mode 100644 index 0000000..0a0fdb8 --- /dev/null +++ b/components/profile/actions/autoScrobbleActions.ts @@ -0,0 +1,37 @@ +import { + setLoadingState, + receivedAutoScrobbles, + setErrorState, + startDeleting, + removeAutoScrobble, + endDeleting, +} from "./autoScrobbleActionCreators"; + +export const fetchAutoScrobbles = () => async dispatch => { + try { + dispatch(setLoadingState(true)); + const response = await fetch("/api/user/autoscrobbles", { credentials: "include" }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + dispatch(receivedAutoScrobbles(data)); + } catch (error) { + dispatch(setErrorState(error)); + } + dispatch(setLoadingState(false)); +}; + +export const deleteAutoScrobble = id => async dispatch => { + try { + dispatch(startDeleting(id)); + await fetch("/api/user/autoscrobbles", { + method: "DELETE", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id }), + }); + dispatch(removeAutoScrobble(id)); + } catch (error) { + dispatch(setErrorState(error)); + } + dispatch(endDeleting(id)); +}; diff --git a/components/profile/actions/historyActionCreators.js b/components/profile/actions/historyActionCreators.ts similarity index 89% rename from components/profile/actions/historyActionCreators.js rename to components/profile/actions/historyActionCreators.ts index 503fed4..d346f72 100644 --- a/components/profile/actions/historyActionCreators.js +++ b/components/profile/actions/historyActionCreators.ts @@ -1,4 +1,4 @@ -import { SET_LOADING_STATE, SET_ERROR_STATE, RECEIVED_HISTORY } from '../constants/historyConstants'; +import { SET_LOADING_STATE, SET_ERROR_STATE, RECEIVED_HISTORY } from "../constants/historyConstants"; export const setLoadingState = loading => ({ type: SET_LOADING_STATE, diff --git a/components/profile/actions/historyActions.js b/components/profile/actions/historyActions.js deleted file mode 100644 index 742fba6..0000000 --- a/components/profile/actions/historyActions.js +++ /dev/null @@ -1,15 +0,0 @@ -import { setLoadingState, receivedHistory, setErrorState } from './historyActionCreators'; - -// eslint-disable-next-line import/prefer-default-export -export const fetchHistory = () => ( - async (dispatch) => { - try { - dispatch(setLoadingState(true)); - const data = await fetch('/api/user/history', { credentials: 'include' }).then(r => r.json()); - dispatch(receivedHistory(data)); - } catch (error) { - dispatch(setErrorState(error)); - } - dispatch(setLoadingState(false)); - } -); diff --git a/components/profile/actions/historyActions.ts b/components/profile/actions/historyActions.ts new file mode 100644 index 0000000..1ac0604 --- /dev/null +++ b/components/profile/actions/historyActions.ts @@ -0,0 +1,15 @@ +import { setLoadingState, receivedHistory, setErrorState } from "./historyActionCreators"; + +// eslint-disable-next-line import/prefer-default-export +export const fetchHistory = () => async dispatch => { + try { + dispatch(setLoadingState(true)); + const response = await fetch("/api/user/history", { credentials: "include" }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + dispatch(receivedHistory(data)); + } catch (error) { + dispatch(setErrorState(error)); + } + dispatch(setLoadingState(false)); +}; diff --git a/components/profile/actions/spec/autoScrobbleActionCreators.spec.js b/components/profile/actions/spec/autoScrobbleActionCreators.spec.ts similarity index 56% rename from components/profile/actions/spec/autoScrobbleActionCreators.spec.js rename to components/profile/actions/spec/autoScrobbleActionCreators.spec.ts index 3886d96..30ba317 100644 --- a/components/profile/actions/spec/autoScrobbleActionCreators.spec.js +++ b/components/profile/actions/spec/autoScrobbleActionCreators.spec.ts @@ -1,86 +1,94 @@ import { - setLoadingState, setErrorState, receivedAutoScrobbles, startDeleting, removeAutoScrobble, + setLoadingState, + setErrorState, + receivedAutoScrobbles, + startDeleting, + removeAutoScrobble, endDeleting, -} from '../autoScrobbleActionCreators'; +} from "../autoScrobbleActionCreators"; import { - SET_LOADING_STATE, SET_ERROR_STATE, RECEIVED_AUTO_SCROBBLES, START_DELETING, END_DELETING, + SET_LOADING_STATE, + SET_ERROR_STATE, + RECEIVED_AUTO_SCROBBLES, + START_DELETING, + END_DELETING, REMOVE_AUTO_SCROBBLE, -} from '../../constants/autoScrobbleConstants'; +} from "../../constants/autoScrobbleConstants"; -describe('autoScrobbleActionCreators', () => { - describe('setLoadingState', () => { +describe("autoScrobbleActionCreators", () => { + describe("setLoadingState", () => { const setLoadingStateAction = setLoadingState(true); - it('returns correct type', () => { + it("returns correct type", () => { expect(setLoadingStateAction).toMatchObject({ type: SET_LOADING_STATE }); }); - it('returns passed loading state in action', () => { + it("returns passed loading state in action", () => { expect(setLoadingStateAction).toMatchObject({ loading: true }); }); }); - describe('setErrorState', () => { - const error = 'FooBar'; + describe("setErrorState", () => { + const error = "FooBar"; const setErrorStateAction = setErrorState(error); - it('returns correct type', () => { + it("returns correct type", () => { expect(setErrorStateAction).toMatchObject({ type: SET_ERROR_STATE }); }); - it('returns passed error state in action', () => { + it("returns passed error state in action", () => { expect(setErrorStateAction).toMatchObject({ error }); }); }); - describe('receivedAutoScrobbles', () => { - const autoScrobbles = ['foo', 'bar']; + describe("receivedAutoScrobbles", () => { + const autoScrobbles = ["foo", "bar"]; const receivedAutoScrobblesAction = receivedAutoScrobbles(autoScrobbles); - it('returns correct type', () => { + it("returns correct type", () => { expect(receivedAutoScrobblesAction).toMatchObject({ type: RECEIVED_AUTO_SCROBBLES }); }); - it('returns autoScrobbles in action', () => { + it("returns autoScrobbles in action", () => { expect(receivedAutoScrobblesAction).toMatchObject({ autoScrobbles }); }); }); - describe('startDeleting', () => { + describe("startDeleting", () => { const id = 1337; const startDeletingAction = startDeleting(id); - it('returns correct type', () => { + it("returns correct type", () => { expect(startDeletingAction).toMatchObject({ type: START_DELETING }); }); - it('returns id in action', () => { + it("returns id in action", () => { expect(startDeletingAction).toMatchObject({ id }); }); }); - describe('removeAutoScrobble', () => { + describe("removeAutoScrobble", () => { const id = 1337; const removeAutoScrobbleAction = removeAutoScrobble(id); - it('returns correct type', () => { + it("returns correct type", () => { expect(removeAutoScrobbleAction).toMatchObject({ type: REMOVE_AUTO_SCROBBLE }); }); - it('returns id in action', () => { + it("returns id in action", () => { expect(removeAutoScrobbleAction).toMatchObject({ id }); }); }); - describe('endDeleting', () => { + describe("endDeleting", () => { const id = 1337; const endDeletingAction = endDeleting(id); - it('returns correct type', () => { + it("returns correct type", () => { expect(endDeletingAction).toMatchObject({ type: END_DELETING }); }); - it('returns id in action', () => { + it("returns id in action", () => { expect(endDeletingAction).toMatchObject({ id }); }); }); diff --git a/components/profile/actions/spec/autoScrobbleActions.spec.js b/components/profile/actions/spec/autoScrobbleActions.spec.js deleted file mode 100644 index 2da5366..0000000 --- a/components/profile/actions/spec/autoScrobbleActions.spec.js +++ /dev/null @@ -1,144 +0,0 @@ -import configureMockStore from 'redux-mock-store'; -import thunk from 'redux-thunk'; - -import * as actionCreators from '../autoScrobbleActionCreators'; -import { - fetchAutoScrobbles, deleteAutoScrobble, -} from '../autoScrobbleActions'; - -const middlewares = [thunk]; -const mockStore = configureMockStore(middlewares); - -const emptyStore = () => mockStore(); - -const demoAutoScrobbleData = [ - { - id: '5ccb6ad74c5f76adff751342', - artist: 'Farin Urlaub', - title: 'Am Ende Der Sonne', - year: '2005', - }, -]; - -describe('historyActions', () => { - beforeEach(() => { - fetch.mockResponse(JSON.stringify(demoAutoScrobbleData)); - }); - afterEach(() => { - fetch.resetMocks(); - }); - - describe('fetchAutoScrobbles', () => { - it('sets loading state to true as first action', () => { - const expectedAction = actionCreators.setLoadingState(true); - const store = emptyStore(); - - return store.dispatch(fetchAutoScrobbles()) - .then(() => expect(store.getActions()[0]).toEqual(expectedAction)); - }); - - it('sets loading state back to false as last action', () => { - const expectedAction = actionCreators.setLoadingState(false); - const store = emptyStore(); - - return store.dispatch(fetchAutoScrobbles()) - .then(() => expect(store.getActions().slice(-1)[0]).toEqual(expectedAction)); - }); - - it('creates RECEIVED_HISTORY when fetching history has been done', () => { - const expectedAction = actionCreators.receivedAutoScrobbles(demoAutoScrobbleData); - const store = emptyStore(); - - return store.dispatch(fetchAutoScrobbles()) - .then(() => expect(store.getActions()).toContainEqual(expectedAction)); - }); - - it('sets error state to store when loading fails', () => { - const error = new Error('Foooo!'); - fetch.mockReject(error); - const expectedAction = actionCreators.setErrorState(error); - const store = emptyStore(); - - return store.dispatch(fetchAutoScrobbles()) - .then(() => expect(store.getActions()).toContainEqual(expectedAction)); - }); - - it('sets loading state back to false after an error occured', () => { - const expectedAction = actionCreators.setLoadingState(false); - const store = emptyStore(); - - return store.dispatch(fetchAutoScrobbles()) - .then(() => expect(store.getActions().slice(-1)[0]).toEqual(expectedAction)); - }); - - it('sends a GET request to the api to get the history data', () => { - const store = emptyStore(); - - return store.dispatch(fetchAutoScrobbles()).then(() => { - expect(fetch.mock.calls.length).toEqual(1); - expect(fetch.mock.calls[0][0]).toEqual('/api/user/autoscrobbles'); - }); - }); - }); - - describe('deleteAutoScrobble', () => { - it('adds given id to deleting array as first action', () => { - const id = 1337; - const expectedAction = actionCreators.startDeleting(id); - const store = emptyStore(); - - return store.dispatch(deleteAutoScrobble(id)) - .then(() => expect(store.getActions()[0]).toEqual(expectedAction)); - }); - - it('removes given id from deleting array as last action', () => { - const id = 1337; - const expectedAction = actionCreators.endDeleting(id); - const store = emptyStore(); - - return store.dispatch(deleteAutoScrobble(id)) - .then(() => expect(store.getActions().slice(-1)[0]).toEqual(expectedAction)); - }); - - it('removes the given id from autoscrobbles after deleting', () => { - const id = 1337; - const expectedAction = actionCreators.removeAutoScrobble(id); - const store = emptyStore(); - - return store.dispatch(deleteAutoScrobble(id)) - .then(() => expect(store.getActions()).toContainEqual(expectedAction)); - }); - - it('sets error state to store when loading fails', () => { - const id = 1337; - const error = new Error('Foooo!'); - fetch.mockReject(error); - const expectedAction = actionCreators.setErrorState(error); - const store = emptyStore(); - - return store.dispatch(deleteAutoScrobble(id)) - .then(() => expect(store.getActions()).toContainEqual(expectedAction)); - }); - - it('sets loading state back to false after an error occured', () => { - const id = 1337; - const expectedAction = actionCreators.endDeleting(id); - const store = emptyStore(); - - return store.dispatch(deleteAutoScrobble(id)) - .then(() => expect(store.getActions().slice(-1)[0]).toEqual(expectedAction)); - }); - - it('sends a DELETE request to the api to delete the data', () => { - const id = 1337; - const store = emptyStore(); - - return store.dispatch(deleteAutoScrobble(id)).then(() => { - expect(fetch.mock.calls.length).toEqual(1); - expect(fetch.mock.calls[0][0]).toEqual('/api/user/autoscrobbles'); - expect(fetch.mock.calls[0][1].method).toEqual('DELETE'); - expect(fetch.mock.calls[0][1].body).toEqual(JSON.stringify({ id })); - }); - }); - }); -}); diff --git a/components/profile/actions/spec/autoScrobbleActions.spec.ts b/components/profile/actions/spec/autoScrobbleActions.spec.ts new file mode 100644 index 0000000..1759482 --- /dev/null +++ b/components/profile/actions/spec/autoScrobbleActions.spec.ts @@ -0,0 +1,151 @@ +import configureMockStore from "redux-mock-store"; +import { thunk } from "redux-thunk"; + +import * as actionCreators from "../autoScrobbleActionCreators"; +import { fetchAutoScrobbles, deleteAutoScrobble } from "../autoScrobbleActions"; + +const middlewares = [thunk]; +const mockStore = configureMockStore(middlewares); + +const emptyStore = () => mockStore(); + +const demoAutoScrobbleData = [ + { + id: "5ccb6ad74c5f76adff751342", + artist: "Farin Urlaub", + title: "Am Ende Der Sonne", + year: "2005", + }, +]; + +const mockJsonResponse = data => + ({ + ok: true, + status: 200, + json: async () => data, +}) as Response; + +describe("historyActions", () => { + let fetchMock; + beforeEach(() => { + fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(mockJsonResponse(demoAutoScrobbleData)); + }); + + describe("fetchAutoScrobbles", () => { + it("sets loading state to true as first action", () => { + const expectedAction = actionCreators.setLoadingState(true); + const store = emptyStore(); + + return store.dispatch(fetchAutoScrobbles()).then(() => expect(store.getActions()[0]).toEqual(expectedAction)); + }); + + it("sets loading state back to false as last action", () => { + const expectedAction = actionCreators.setLoadingState(false); + const store = emptyStore(); + + return store + .dispatch(fetchAutoScrobbles()) + .then(() => expect(store.getActions().slice(-1)[0]).toEqual(expectedAction)); + }); + + it("creates RECEIVED_HISTORY when fetching history has been done", () => { + const expectedAction = actionCreators.receivedAutoScrobbles(demoAutoScrobbleData); + const store = emptyStore(); + + return store.dispatch(fetchAutoScrobbles()).then(() => expect(store.getActions()).toContainEqual(expectedAction)); + }); + + it("sets error state to store when loading fails", () => { + const error = new Error("Foooo!"); + fetchMock.mockRejectedValue(error); + const expectedAction = actionCreators.setErrorState(error); + const store = emptyStore(); + + return store.dispatch(fetchAutoScrobbles()).then(() => expect(store.getActions()).toContainEqual(expectedAction)); + }); + + it("sets loading state back to false after an error occured", () => { + const expectedAction = actionCreators.setLoadingState(false); + const store = emptyStore(); + + return store + .dispatch(fetchAutoScrobbles()) + .then(() => expect(store.getActions().slice(-1)[0]).toEqual(expectedAction)); + }); + + it("sends a GET request to the api to get the history data", () => { + const store = emptyStore(); + + return store.dispatch(fetchAutoScrobbles()).then(() => { + expect(fetchMock.mock.calls.length).toEqual(1); + expect(fetchMock.mock.calls[0][0]).toEqual("/api/user/autoscrobbles"); + }); + }); + }); + + describe("deleteAutoScrobble", () => { + it("adds given id to deleting array as first action", () => { + const id = 1337; + const expectedAction = actionCreators.startDeleting(id); + const store = emptyStore(); + + return store.dispatch(deleteAutoScrobble(id)).then(() => expect(store.getActions()[0]).toEqual(expectedAction)); + }); + + it("removes given id from deleting array as last action", () => { + const id = 1337; + const expectedAction = actionCreators.endDeleting(id); + const store = emptyStore(); + + return store + .dispatch(deleteAutoScrobble(id)) + .then(() => expect(store.getActions().slice(-1)[0]).toEqual(expectedAction)); + }); + + it("removes the given id from autoscrobbles after deleting", () => { + const id = 1337; + const expectedAction = actionCreators.removeAutoScrobble(id); + const store = emptyStore(); + + return store + .dispatch(deleteAutoScrobble(id)) + .then(() => expect(store.getActions()).toContainEqual(expectedAction)); + }); + + it("sets error state to store when loading fails", () => { + const id = 1337; + const error = new Error("Foooo!"); + fetchMock.mockRejectedValue(error); + const expectedAction = actionCreators.setErrorState(error); + const store = emptyStore(); + + return store + .dispatch(deleteAutoScrobble(id)) + .then(() => expect(store.getActions()).toContainEqual(expectedAction)); + }); + + it("sets loading state back to false after an error occured", () => { + const id = 1337; + const expectedAction = actionCreators.endDeleting(id); + const store = emptyStore(); + + return store + .dispatch(deleteAutoScrobble(id)) + .then(() => expect(store.getActions().slice(-1)[0]).toEqual(expectedAction)); + }); + + it("sends a DELETE request to the api to delete the data", () => { + const id = 1337; + const store = emptyStore(); + + return store.dispatch(deleteAutoScrobble(id)).then(() => { + expect(fetchMock.mock.calls.length).toEqual(1); + expect(fetchMock.mock.calls[0][0]).toEqual("/api/user/autoscrobbles"); + expect(fetchMock.mock.calls[0][1].method).toEqual("DELETE"); + expect(fetchMock.mock.calls[0][1].body).toEqual(JSON.stringify({ id })); + }); + }); + }); +}); diff --git a/components/profile/actions/spec/historyActionCreators.spec.js b/components/profile/actions/spec/historyActionCreators.spec.ts similarity index 59% rename from components/profile/actions/spec/historyActionCreators.spec.js rename to components/profile/actions/spec/historyActionCreators.spec.ts index eaad1c2..c28c314 100644 --- a/components/profile/actions/spec/historyActionCreators.spec.js +++ b/components/profile/actions/spec/historyActionCreators.spec.ts @@ -1,41 +1,41 @@ -import { setLoadingState, setErrorState, receivedHistory } from '../historyActionCreators'; -import { SET_LOADING_STATE, SET_ERROR_STATE, RECEIVED_HISTORY } from '../../constants/historyConstants'; +import { setLoadingState, setErrorState, receivedHistory } from "../historyActionCreators"; +import { SET_LOADING_STATE, SET_ERROR_STATE, RECEIVED_HISTORY } from "../../constants/historyConstants"; -describe('historyActionCreators', () => { - describe('setLoadingState', () => { +describe("historyActionCreators", () => { + describe("setLoadingState", () => { const setLoadingStateAction = setLoadingState(true); - it('returns correct type', () => { + it("returns correct type", () => { expect(setLoadingStateAction).toMatchObject({ type: SET_LOADING_STATE }); }); - it('returns passed loading state in action', () => { + it("returns passed loading state in action", () => { expect(setLoadingStateAction).toMatchObject({ loading: true }); }); }); - describe('setErrorState', () => { - const error = 'FooBar'; + describe("setErrorState", () => { + const error = "FooBar"; const setErrorStateAction = setErrorState(error); - it('returns correct type', () => { + it("returns correct type", () => { expect(setErrorStateAction).toMatchObject({ type: SET_ERROR_STATE }); }); - it('returns passed error state in action', () => { + it("returns passed error state in action", () => { expect(setErrorStateAction).toMatchObject({ error }); }); }); - describe('receivedHistory', () => { - const history = ['foo', 'bar']; + describe("receivedHistory", () => { + const history = ["foo", "bar"]; const receivedHistoryAction = receivedHistory(history); - it('returns correct type', () => { + it("returns correct type", () => { expect(receivedHistoryAction).toMatchObject({ type: RECEIVED_HISTORY }); }); - it('returns history in action', () => { + it("returns history in action", () => { expect(receivedHistoryAction).toMatchObject({ history }); }); }); diff --git a/components/profile/actions/spec/historyActions.spec.js b/components/profile/actions/spec/historyActions.spec.js deleted file mode 100644 index 4af32e3..0000000 --- a/components/profile/actions/spec/historyActions.spec.js +++ /dev/null @@ -1,80 +0,0 @@ -import configureMockStore from 'redux-mock-store'; -import thunk from 'redux-thunk'; - -import * as actionCreators from '../historyActionCreators'; -import { fetchHistory } from '../historyActions'; - -const middlewares = [thunk]; -const mockStore = configureMockStore(middlewares); - -const emptyStore = () => mockStore({}); - -const demoHistoryData = [{ - id: '5ccb6ae44c5f76adff751352', - time: '2019-05-02T22:10:44.378Z', - artist: 'Farin Urlaub', - title: 'Am Ende Der Sonne', - year: '2005', - barcode: '0419594000028', - discogsId: 2852926, -}]; - -describe('historyActions', () => { - beforeEach(() => { - fetch.mockResponse(JSON.stringify(demoHistoryData)); - }); - afterEach(() => { - fetch.resetMocks(); - }); - - it('sets loading state to true as first action', () => { - const expectedAction = actionCreators.setLoadingState(true); - const store = emptyStore(); - - return store.dispatch(fetchHistory()) - .then(() => expect(store.getActions()[0]).toEqual(expectedAction)); - }); - - it('sets loading state back to false as last action', () => { - const expectedAction = actionCreators.setLoadingState(false); - const store = emptyStore(); - - return store.dispatch(fetchHistory()) - .then(() => expect(store.getActions().slice(-1)[0]).toEqual(expectedAction)); - }); - - it('creates RECEIVED_HISTORY when fetching history has been done', () => { - const expectedAction = actionCreators.receivedHistory(demoHistoryData); - const store = emptyStore(); - - return store.dispatch(fetchHistory()) - .then(() => expect(store.getActions()).toContainEqual(expectedAction)); - }); - - it('sets error state to store when loading fails', () => { - const error = new Error('Foooo!'); - fetch.mockReject(error); - const expectedAction = actionCreators.setErrorState(error); - const store = emptyStore(); - - return store.dispatch(fetchHistory()) - .then(() => expect(store.getActions()).toContainEqual(expectedAction)); - }); - - it('sets loading state back to false after an error occured', () => { - const expectedAction = actionCreators.setLoadingState(false); - const store = emptyStore(); - - return store.dispatch(fetchHistory()) - .then(() => expect(store.getActions().slice(-1)[0]).toEqual(expectedAction)); - }); - - it('sends a GET request to the api to get the history data', () => { - const store = emptyStore(); - - return store.dispatch(fetchHistory()).then(() => { - expect(fetch.mock.calls.length).toEqual(1); - expect(fetch.mock.calls[0][0]).toEqual('/api/user/history'); - }); - }); -}); diff --git a/components/profile/actions/spec/historyActions.spec.ts b/components/profile/actions/spec/historyActions.spec.ts new file mode 100644 index 0000000..b5aa01a --- /dev/null +++ b/components/profile/actions/spec/historyActions.spec.ts @@ -0,0 +1,84 @@ +import configureMockStore from "redux-mock-store"; +import { thunk } from "redux-thunk"; + +import * as actionCreators from "../historyActionCreators"; +import { fetchHistory } from "../historyActions"; + +const middlewares = [thunk]; +const mockStore = configureMockStore(middlewares); + +const emptyStore = () => mockStore({}); + +const demoHistoryData = [ + { + id: "5ccb6ae44c5f76adff751352", + time: "2019-05-02T22:10:44.378Z", + artist: "Farin Urlaub", + title: "Am Ende Der Sonne", + year: "2005", + barcode: "0419594000028", + discogsId: 2852926, + }, +]; + +const mockJsonResponse = data => + ({ + ok: true, + status: 200, + json: async () => data, +}) as Response; + +describe("historyActions", () => { + let fetchMock; + beforeEach(() => { + fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(mockJsonResponse(demoHistoryData)); + }); + + it("sets loading state to true as first action", () => { + const expectedAction = actionCreators.setLoadingState(true); + const store = emptyStore(); + + return store.dispatch(fetchHistory()).then(() => expect(store.getActions()[0]).toEqual(expectedAction)); + }); + + it("sets loading state back to false as last action", () => { + const expectedAction = actionCreators.setLoadingState(false); + const store = emptyStore(); + + return store.dispatch(fetchHistory()).then(() => expect(store.getActions().slice(-1)[0]).toEqual(expectedAction)); + }); + + it("creates RECEIVED_HISTORY when fetching history has been done", () => { + const expectedAction = actionCreators.receivedHistory(demoHistoryData); + const store = emptyStore(); + + return store.dispatch(fetchHistory()).then(() => expect(store.getActions()).toContainEqual(expectedAction)); + }); + + it("sets error state to store when loading fails", () => { + const error = new Error("Foooo!"); + fetchMock.mockRejectedValue(error); + const expectedAction = actionCreators.setErrorState(error); + const store = emptyStore(); + + return store.dispatch(fetchHistory()).then(() => expect(store.getActions()).toContainEqual(expectedAction)); + }); + + it("sets loading state back to false after an error occured", () => { + const expectedAction = actionCreators.setLoadingState(false); + const store = emptyStore(); + + return store.dispatch(fetchHistory()).then(() => expect(store.getActions().slice(-1)[0]).toEqual(expectedAction)); + }); + + it("sends a GET request to the api to get the history data", () => { + const store = emptyStore(); + + return store.dispatch(fetchHistory()).then(() => { + expect(fetchMock.mock.calls.length).toEqual(1); + expect(fetchMock.mock.calls[0][0]).toEqual("/api/user/history"); + }); + }); +}); diff --git a/components/profile/constants/autoScrobbleConstants.js b/components/profile/constants/autoScrobbleConstants.js deleted file mode 100644 index 95c537c..0000000 --- a/components/profile/constants/autoScrobbleConstants.js +++ /dev/null @@ -1,6 +0,0 @@ -export const SET_LOADING_STATE = 'SET_AUTO_SCROBBLES_LOADING_STATE'; -export const SET_ERROR_STATE = 'SET_AUTO_SCROBBLES_ERROR_STATE'; -export const RECEIVED_AUTO_SCROBBLES = 'RECEIVED_AUTO_SCROBBLES'; -export const START_DELETING = 'START_DELETING_AUTO_SCROBBLE'; -export const END_DELETING = 'END_DELETING_AUTO_SCROBBLE'; -export const REMOVE_AUTO_SCROBBLE = 'REMOVE_AUTO_SCROBBLE'; diff --git a/components/profile/constants/autoScrobbleConstants.ts b/components/profile/constants/autoScrobbleConstants.ts new file mode 100644 index 0000000..1dce47d --- /dev/null +++ b/components/profile/constants/autoScrobbleConstants.ts @@ -0,0 +1,6 @@ +export const SET_LOADING_STATE = "SET_AUTO_SCROBBLES_LOADING_STATE"; +export const SET_ERROR_STATE = "SET_AUTO_SCROBBLES_ERROR_STATE"; +export const RECEIVED_AUTO_SCROBBLES = "RECEIVED_AUTO_SCROBBLES"; +export const START_DELETING = "START_DELETING_AUTO_SCROBBLE"; +export const END_DELETING = "END_DELETING_AUTO_SCROBBLE"; +export const REMOVE_AUTO_SCROBBLE = "REMOVE_AUTO_SCROBBLE"; diff --git a/components/profile/constants/historyConstants.js b/components/profile/constants/historyConstants.js deleted file mode 100644 index fdf1c6b..0000000 --- a/components/profile/constants/historyConstants.js +++ /dev/null @@ -1,3 +0,0 @@ -export const SET_LOADING_STATE = 'SET_HISTORY_LOADING_STATE'; -export const SET_ERROR_STATE = 'SET_HISTORY_ERROR_STATE'; -export const RECEIVED_HISTORY = 'RECEIVED_HISTORY'; diff --git a/components/profile/constants/historyConstants.ts b/components/profile/constants/historyConstants.ts new file mode 100644 index 0000000..bf7c9a6 --- /dev/null +++ b/components/profile/constants/historyConstants.ts @@ -0,0 +1,3 @@ +export const SET_LOADING_STATE = "SET_HISTORY_LOADING_STATE"; +export const SET_ERROR_STATE = "SET_HISTORY_ERROR_STATE"; +export const RECEIVED_HISTORY = "RECEIVED_HISTORY"; diff --git a/components/profile/reducers/autoScrobbleReducer.js b/components/profile/reducers/autoScrobbleReducer.ts similarity index 72% rename from components/profile/reducers/autoScrobbleReducer.js rename to components/profile/reducers/autoScrobbleReducer.ts index 218a8c1..4bf93ce 100644 --- a/components/profile/reducers/autoScrobbleReducer.js +++ b/components/profile/reducers/autoScrobbleReducer.ts @@ -1,7 +1,11 @@ import { - RECEIVED_AUTO_SCROBBLES, SET_LOADING_STATE, SET_ERROR_STATE, START_DELETING, - END_DELETING, REMOVE_AUTO_SCROBBLE, -} from '../constants/autoScrobbleConstants'; + RECEIVED_AUTO_SCROBBLES, + SET_LOADING_STATE, + SET_ERROR_STATE, + START_DELETING, + END_DELETING, + REMOVE_AUTO_SCROBBLE, +} from "../constants/autoScrobbleConstants"; const initialState = { data: null, @@ -10,7 +14,7 @@ const initialState = { deleting: [], }; -const autoScrobbleReducer = (state = initialState, action = {}) => { +const autoScrobbleReducer = (state = initialState, action: any = {}) => { switch (action.type) { case RECEIVED_AUTO_SCROBBLES: return { @@ -45,7 +49,7 @@ const autoScrobbleReducer = (state = initialState, action = {}) => { case REMOVE_AUTO_SCROBBLE: return { ...state, - data: state.data.filter(item => item.id !== action.id), + data: state.data ? state.data.filter(item => item.id !== action.id) : null, }; default: diff --git a/components/profile/reducers/historyReducer.js b/components/profile/reducers/historyReducer.ts similarity index 82% rename from components/profile/reducers/historyReducer.js rename to components/profile/reducers/historyReducer.ts index 3a6b226..7b54e83 100644 --- a/components/profile/reducers/historyReducer.js +++ b/components/profile/reducers/historyReducer.ts @@ -1,6 +1,6 @@ -import { RECEIVED_HISTORY, SET_LOADING_STATE, SET_ERROR_STATE } from '../constants/historyConstants'; +import { RECEIVED_HISTORY, SET_LOADING_STATE, SET_ERROR_STATE } from "../constants/historyConstants"; -const historyReducer = (state = {}, action = {}) => { +const historyReducer = (state = {}, action: any = {}) => { switch (action.type) { case RECEIVED_HISTORY: return { diff --git a/components/profile/reducers/spec/autoScrobbleReducer.spec.js b/components/profile/reducers/spec/autoScrobbleReducer.spec.ts similarity index 59% rename from components/profile/reducers/spec/autoScrobbleReducer.spec.js rename to components/profile/reducers/spec/autoScrobbleReducer.spec.ts index 1250c2f..2273ab8 100644 --- a/components/profile/reducers/spec/autoScrobbleReducer.spec.js +++ b/components/profile/reducers/spec/autoScrobbleReducer.spec.ts @@ -1,11 +1,15 @@ -import autoScrobbleReducer from '../autoScrobbleReducer'; +import autoScrobbleReducer from "../autoScrobbleReducer"; import { - setLoadingState, setErrorState, receivedAutoScrobbles, startDeleting, removeAutoScrobble, + setLoadingState, + setErrorState, + receivedAutoScrobbles, + startDeleting, + removeAutoScrobble, endDeleting, -} from '../../actions/autoScrobbleActionCreators'; +} from "../../actions/autoScrobbleActionCreators"; -describe('autoScrobbleReducer', () => { - it('uses the correct initial state', () => { +describe("autoScrobbleReducer", () => { + it("uses the correct initial state", () => { expect(autoScrobbleReducer()).toEqual({ data: null, deleting: [], @@ -14,11 +18,11 @@ describe('autoScrobbleReducer', () => { }); }); - it('saves autoScrobbles to store', () => { + it("saves autoScrobbles to store", () => { const state = { data: null, }; - const autoScrobbles = ['foo', 'bar']; + const autoScrobbles = ["foo", "bar"]; const action = receivedAutoScrobbles(autoScrobbles); const nextState = autoScrobbleReducer(state, action); @@ -27,7 +31,7 @@ describe('autoScrobbleReducer', () => { }); }); - it('saves loading state to store', () => { + it("saves loading state to store", () => { const state = { loading: false, }; @@ -39,11 +43,11 @@ describe('autoScrobbleReducer', () => { }); }); - it('saves error state to store', () => { + it("saves error state to store", () => { const state = { error: null, }; - const error = 'FooBar'; + const error = "FooBar"; const action = setErrorState(error); const nextState = autoScrobbleReducer(state, action); @@ -52,42 +56,42 @@ describe('autoScrobbleReducer', () => { }); }); - it('saves starting of delete-process to store', () => { + it("saves starting of delete-process to store", () => { const state = { - deleting: ['foo'], + deleting: ["foo"], }; - const deleting = 'bar'; + const deleting = "bar"; const action = startDeleting(deleting); const nextState = autoScrobbleReducer(state, action); expect(nextState).toEqual({ - deleting: ['foo', 'bar'], + deleting: ["foo", "bar"], }); }); - it('removes autoScrobble from store', () => { + it("removes autoScrobble from store", () => { const state = { - data: [{ id: 'foo' }, { id: 'bar' }], + data: [{ id: "foo" }, { id: "bar" }], }; - const deleting = 'foo'; + const deleting = "foo"; const action = removeAutoScrobble(deleting); const nextState = autoScrobbleReducer(state, action); expect(nextState).toEqual({ - data: [{ id: 'bar' }], + data: [{ id: "bar" }], }); }); - it('saves ending of delete-process to store', () => { + it("saves ending of delete-process to store", () => { const state = { - deleting: ['foo', 'bar'], + deleting: ["foo", "bar"], }; - const deleting = 'bar'; + const deleting = "bar"; const action = endDeleting(deleting); const nextState = autoScrobbleReducer(state, action); expect(nextState).toEqual({ - deleting: ['foo'], + deleting: ["foo"], }); }); }); diff --git a/components/profile/reducers/spec/historyReducer.spec.js b/components/profile/reducers/spec/historyReducer.spec.ts similarity index 66% rename from components/profile/reducers/spec/historyReducer.spec.js rename to components/profile/reducers/spec/historyReducer.spec.ts index 3e25f6c..9bc089a 100644 --- a/components/profile/reducers/spec/historyReducer.spec.js +++ b/components/profile/reducers/spec/historyReducer.spec.ts @@ -1,16 +1,16 @@ -import historyReducer from '../historyReducer'; -import { setLoadingState, setErrorState, receivedHistory } from '../../actions/historyActionCreators'; +import historyReducer from "../historyReducer"; +import { setLoadingState, setErrorState, receivedHistory } from "../../actions/historyActionCreators"; -describe('historyReducer', () => { - it('uses an empty object as initial state', () => { +describe("historyReducer", () => { + it("uses an empty object as initial state", () => { expect(historyReducer()).toEqual({}); }); - it('saves history to store', () => { + it("saves history to store", () => { const state = { data: null, }; - const history = ['foo', 'bar']; + const history = ["foo", "bar"]; const action = receivedHistory(history); const nextState = historyReducer(state, action); @@ -19,7 +19,7 @@ describe('historyReducer', () => { }); }); - it('saves loading state to store', () => { + it("saves loading state to store", () => { const state = { loading: false, }; @@ -31,11 +31,11 @@ describe('historyReducer', () => { }); }); - it('saves error state to store', () => { + it("saves error state to store", () => { const state = { error: null, }; - const error = 'FooBar'; + const error = "FooBar"; const action = setErrorState(error); const nextState = historyReducer(state, action); diff --git a/components/query/QueryRelease.jsx b/components/query/QueryRelease.jsx deleted file mode 100644 index 7cbb2da..0000000 --- a/components/query/QueryRelease.jsx +++ /dev/null @@ -1,154 +0,0 @@ -import { bindActionCreators } from 'redux'; -import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; -import React from 'react'; -import Head from 'next/head'; -import Link from 'next/link'; -import { IoIosSearch } from 'react-icons/io'; -import { MdClose } from 'react-icons/md'; -import compact from 'lodash/compact'; -import { trackEvent } from '../../lib/analytics'; -import { silver } from '../../lib/colors'; -import NoResultsIcon from '../icons/NoResultsIcon'; -import Loading from '../layout/Loading'; -import { queryRelease, resetResults, setQuery } from './actions/queryActions'; -import { - Button, CloseButton, Content, FallbackIcon, FallbackWrapper, HeadWrapper, Icon, Input, - LoadingWrapper, Meta, Overlay, Result, ResultInfo, ResultWrapper, Submit, Thumbnail, - ThumbnailWrapper, Title, Wrapper, -} from './styles/QueryRelease.styles'; - -class QueryRelease extends React.Component { - inputRef = React.createRef(); - - state = { - open: false, - searched: false, - } - - reset = () => { - this.props.resetResults(); - this.props.setQuery(); - this.setState({ searched: false }); - } - - open = () => { - this.reset(); - trackEvent('Detect', 'Query Release'); - this.setState({ open: true }); - } - - close = () => { - this.setState({ open: false, searched: false }); - } - - onInput = (e) => { - this.props.setQuery(e.target.value); - } - - onSubmit = (e) => { - e.preventDefault(); - this.inputRef.current.blur(); - this.props.queryRelease(); - this.setState({ searched: true }); - } - - render() { - const { open, searched } = this.state; - const { loading, results, query } = this.props; - - let content = ; - if (loading) { - content = ; - } else if (results.length) { - content = ( - - {results.map(({ - id, title, thumb, country, year, format = [], - }) => ( - - - - - - - - {title} - {year && ` (${year})`} - - - {compact([country, (format || []).join(', ')]).join(' · ')} - - - - - ))} - - ); - } else if (searched) { - content = ( - - - No results were found -
- for your query -
- ); - } - - return ( - - - {open && ( - - - - - - - - - - - - - - - {content} - - - )} - - ); - } -} - -QueryRelease.propTypes = { - results: PropTypes.array, - query: PropTypes.string, - loading: PropTypes.bool, - queryRelease: PropTypes.func.isRequired, - resetResults: PropTypes.func.isRequired, - setQuery: PropTypes.func.isRequired, -}; - -QueryRelease.defaultProps = { - results: [], - loading: false, - query: '', -}; - -const mapStateToProps = state => ({ - ...state.query, -}); - -const mapDispatchToProps = dispatch => ( - bindActionCreators({ queryRelease, resetResults, setQuery }, dispatch) -); - -export default connect( - mapStateToProps, - mapDispatchToProps, -)(QueryRelease); diff --git a/components/query/QueryRelease.tsx b/components/query/QueryRelease.tsx new file mode 100644 index 0000000..2592449 --- /dev/null +++ b/components/query/QueryRelease.tsx @@ -0,0 +1,167 @@ +import { bindActionCreators } from "redux"; +import { connect } from "react-redux"; +import type { ConnectedProps } from "react-redux"; +import React from "react"; +import Head from "next/head"; +import Link from "next/link"; +import { IoIosSearch } from "react-icons/io"; +import { MdClose } from "react-icons/md"; +import compact from "lodash/compact"; +import { trackEvent } from "../../lib/analytics"; +import { silver } from "../../lib/colors"; +import NoResultsIcon from "../icons/NoResultsIcon"; +import Loading from "../layout/Loading"; +import { queryRelease, resetResults, setQuery } from "./actions/queryActions"; +import { + Button, + CloseButton, + Content, + FallbackIcon, + FallbackWrapper, + HeadWrapper, + Icon, + Input, + LoadingWrapper, + Meta, + Overlay, + Result, + ResultInfo, + ResultWrapper, + Submit, + Thumbnail, + ThumbnailWrapper, + Title, + Wrapper, +} from "./styles/QueryRelease.styles"; + +const mapStateToProps = (state: any) => ({ + ...(state.query as { results?: any[]; query?: string; loading?: boolean }), +}); + +const mapDispatchToProps = (dispatch: any) => bindActionCreators({ queryRelease, resetResults, setQuery }, dispatch); + +const connector = connect(mapStateToProps, mapDispatchToProps); +type PropsFromRedux = ConnectedProps; + +class QueryRelease extends React.Component { + inputRef = React.createRef(); + + state = { + open: false, + searched: false, + }; + + reset = () => { + this.props.resetResults(); + this.props.setQuery(); + this.setState({ searched: false }); + }; + + open = () => { + this.reset(); + trackEvent("Detect", "Query Release"); + this.setState({ open: true }); + }; + + close = () => { + this.setState({ open: false, searched: false }); + }; + + onInput = e => { + this.props.setQuery(e.target.value); + }; + + onSubmit = e => { + e.preventDefault(); + this.inputRef.current.blur(); + this.props.queryRelease(); + this.setState({ searched: true }); + }; + + render() { + const { open, searched } = this.state; + const { loading = false, results = [], query = "" } = this.props; + + let content = ( + + + + ); + if (loading) { + content = ( + + + + + + ); + } else if (results.length) { + content = ( + + {results.map(({ id, title, thumb, country, year, format = [] }) => ( + + + + + + + + {title} + {year && ` (${year})`} + + {compact([country, (format || []).join(", ")]).join(" · ")} + + + + ))} + + ); + } else if (searched) { + content = ( + + + No results were found +
+ for your query +
+ ); + } + + return ( + + + {open && ( + + + + + + + + + + {/* eslint-disable jsx-a11y/no-autofocus */} + + {/* eslint-enable jsx-a11y/no-autofocus */} + + + + + {content} + + + )} + + ); + } +} + +export default connector(QueryRelease); diff --git a/components/query/actions/queryActionCreators.js b/components/query/actions/queryActionCreators.ts similarity index 73% rename from components/query/actions/queryActionCreators.js rename to components/query/actions/queryActionCreators.ts index 669060d..cec7fb0 100644 --- a/components/query/actions/queryActionCreators.js +++ b/components/query/actions/queryActionCreators.ts @@ -1,6 +1,4 @@ -import { - SET_LOADING_STATE, SET_ERROR_STATE, SET_QUERY_STRING, RECEIVED_RESULTS, -} from '../constants/queryConstants'; +import { SET_LOADING_STATE, SET_ERROR_STATE, SET_QUERY_STRING, RECEIVED_RESULTS } from "../constants/queryConstants"; export const setLoadingState = loading => ({ type: SET_LOADING_STATE, diff --git a/components/query/actions/queryActions.js b/components/query/actions/queryActions.js deleted file mode 100644 index 9d3ce44..0000000 --- a/components/query/actions/queryActions.js +++ /dev/null @@ -1,30 +0,0 @@ -import { - setLoadingState, setErrorState, setQueryString, receivedResults, -} from './queryActionCreators'; - -export const queryRelease = () => ( - async (dispatch, getState) => { - try { - dispatch(setLoadingState(true)); - const { query } = getState().query; - const data = await fetch(`/api/search/${encodeURIComponent(query)}`, { credentials: 'include' }).then(r => r.json()); - dispatch(receivedResults(data)); - } catch (error) { - dispatch(receivedResults([])); - dispatch(setErrorState(error)); - } - dispatch(setLoadingState(false)); - } -); - -export const setQuery = (query = '') => ( - (dispatch) => { - dispatch(setQueryString(query)); - } -); - -export const resetResults = () => ( - (dispatch) => { - dispatch(receivedResults([])); - } -); diff --git a/components/query/actions/queryActions.ts b/components/query/actions/queryActions.ts new file mode 100644 index 0000000..a24f944 --- /dev/null +++ b/components/query/actions/queryActions.ts @@ -0,0 +1,26 @@ +import { setLoadingState, setErrorState, setQueryString, receivedResults } from "./queryActionCreators"; + +export const queryRelease = () => async (dispatch, getState) => { + try { + dispatch(setLoadingState(true)); + const { query } = getState().query; + const data = await fetch(`/api/search/${encodeURIComponent(query)}`, { credentials: "include" }).then(r => + r.json(), + ); + dispatch(receivedResults(data)); + } catch (error) { + dispatch(receivedResults([])); + dispatch(setErrorState(error)); + } + dispatch(setLoadingState(false)); +}; + +export const setQuery = + (query = "") => + dispatch => { + dispatch(setQueryString(query)); + }; + +export const resetResults = () => dispatch => { + dispatch(receivedResults([])); +}; diff --git a/components/query/constants/queryConstants.js b/components/query/constants/queryConstants.js deleted file mode 100644 index 13729ba..0000000 --- a/components/query/constants/queryConstants.js +++ /dev/null @@ -1,4 +0,0 @@ -export const SET_LOADING_STATE = 'SET_QUERY_LOADING_STATE'; -export const SET_ERROR_STATE = 'SET_QUERY_ERROR_STATE'; -export const SET_QUERY_STRING = 'SET_QUERY_STRING'; -export const RECEIVED_RESULTS = 'RECEIVED_QUERY_RESULTS'; diff --git a/components/query/constants/queryConstants.ts b/components/query/constants/queryConstants.ts new file mode 100644 index 0000000..b5d8c50 --- /dev/null +++ b/components/query/constants/queryConstants.ts @@ -0,0 +1,4 @@ +export const SET_LOADING_STATE = "SET_QUERY_LOADING_STATE"; +export const SET_ERROR_STATE = "SET_QUERY_ERROR_STATE"; +export const SET_QUERY_STRING = "SET_QUERY_STRING"; +export const RECEIVED_RESULTS = "RECEIVED_QUERY_RESULTS"; diff --git a/components/query/reducers/queryReducer.js b/components/query/reducers/queryReducer.ts similarity index 74% rename from components/query/reducers/queryReducer.js rename to components/query/reducers/queryReducer.ts index bb8d008..e5a0bfe 100644 --- a/components/query/reducers/queryReducer.js +++ b/components/query/reducers/queryReducer.ts @@ -1,15 +1,13 @@ -import { - SET_LOADING_STATE, SET_ERROR_STATE, SET_QUERY_STRING, RECEIVED_RESULTS, -} from '../constants/queryConstants'; +import { SET_LOADING_STATE, SET_ERROR_STATE, SET_QUERY_STRING, RECEIVED_RESULTS } from "../constants/queryConstants"; const initialState = { - query: '', + query: "", results: [], error: null, loading: false, }; -const queryReducer = (state = initialState, action = {}) => { +const queryReducer = (state = initialState, action: any = {}) => { switch (action.type) { case SET_LOADING_STATE: return { diff --git a/components/query/styles/QueryRelease.styles.js b/components/query/styles/QueryRelease.styles.ts similarity index 89% rename from components/query/styles/QueryRelease.styles.js rename to components/query/styles/QueryRelease.styles.ts index 7d025a7..c1eedf4 100644 --- a/components/query/styles/QueryRelease.styles.js +++ b/components/query/styles/QueryRelease.styles.ts @@ -1,9 +1,8 @@ -import { IoIosSearch } from 'react-icons/io'; -import styled from 'styled-components'; -import { Lazy } from 'react-lazy'; -import LogoIcon from '../../icons/LogoIcon'; -import { dark } from '../../../lib/colors'; -import { buttonReset } from '../../../styles/mixins'; +import { IoIosSearch } from "react-icons/io"; +import styled from "styled-components"; +import LogoIcon from "../../icons/LogoIcon"; +import { dark } from "../../../lib/colors"; +import { buttonReset } from "../../../styles/mixins"; export const Wrapper = styled.div` position: relative; @@ -44,7 +43,7 @@ export const Overlay = styled.div` width: 100%; height: 100%; overflow: auto; - background: rgba(0,0,0,.7); + background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(10px); -webkit-overflow-scrolling: touch; `; @@ -122,7 +121,7 @@ export const Result = styled.a` text-decoration: none; `; -export const ThumbnailWrapper = styled(Lazy)` +export const ThumbnailWrapper = styled.div` display: block; flex: 0 0 60px; width: 60px; diff --git a/components/release/ReleaseInfo.jsx b/components/release/ReleaseInfo.tsx similarity index 57% rename from components/release/ReleaseInfo.jsx rename to components/release/ReleaseInfo.tsx index 93e5623..18c0549 100644 --- a/components/release/ReleaseInfo.jsx +++ b/components/release/ReleaseInfo.tsx @@ -1,33 +1,63 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { MdClose } from 'react-icons/md'; // TODO: replace -import durationFormat from '../../lib/durationFormat'; -import targetBlank from '../../lib/targetBlank'; +import React from "react"; +import { MdClose } from "react-icons/md"; // TODO: replace +import durationFormat from "../../lib/durationFormat"; +import targetBlank from "../../lib/targetBlank"; import { - Artist, Button, CloseButton, Content, Cover, ExternalButton, Head, HeadWrapper, Icon, Meta, - Overlay, Title, TrackDuration, TrackListWrapper, TrackNumber, TrackTitle, Wrapper, Year, -} from './styles/ReleaseInfo.styles'; -import { silver } from '../../lib/colors'; -import { autotrackParams, trackEvent } from '../../lib/analytics'; + Artist, + Button, + CloseButton, + Content, + Cover, + ExternalButton, + Head, + HeadWrapper, + Icon, + Meta, + Overlay, + Title, + TrackDuration, + TrackListWrapper, + TrackNumber, + TrackTitle, + Wrapper, + Year, +} from "./styles/ReleaseInfo.styles"; +import { silver } from "../../lib/colors"; +import { autotrackParams, trackEvent } from "../../lib/analytics"; -class ReleaseInfo extends React.Component { +type Track = { + trackNumber: string; + title: string; + duration: number; +}; + +type Release = { + image?: string; + title: string; + year?: string; + artist: string; + tracks: Track[]; + url: string; +}; + +interface ReleaseInfoProps { + release?: Release; +} + +class ReleaseInfo extends React.Component { state = { open: false, - } + }; handleButton = () => { const { open } = this.state; - if (!open) trackEvent('Detected', 'Show Release Info'); + if (!open) trackEvent("Detected", "Show Release Info"); this.setState(state => ({ open: !state.open })); - } + }; render() { const { open } = this.state; - const { - release: { - image, title, year, artist, tracks, url, - }, - } = this.props; + const { release: { image, title, year, artist, tracks, url } = {} as Release } = this.props; return ( - - - {!data.image && ( -
- {data.artist} -
{data.title}
-
- )} - - )} - - ); - } -} - -SearchRelease.propTypes = { - code: PropTypes.string.isRequired, - onScrobble: PropTypes.func.isRequired, - onCancel: PropTypes.func.isRequired, - fetchRelease: PropTypes.func.isRequired, - error: PropTypes.any, - loading: PropTypes.bool, - data: PropTypes.object, -}; - -SearchRelease.defaultProps = { - error: null, - loading: true, - data: {}, -}; - -const mapStateToProps = (state, { code }) => ({ - ...state.release[code], -}); - -const mapDispatchToProps = dispatch => ( - bindActionCreators({ fetchRelease }, dispatch) -); - -export default connect( - mapStateToProps, - mapDispatchToProps, -)(SearchRelease); diff --git a/components/release/SearchRelease.tsx b/components/release/SearchRelease.tsx new file mode 100644 index 0000000..cb21393 --- /dev/null +++ b/components/release/SearchRelease.tsx @@ -0,0 +1,73 @@ +import { bindActionCreators } from "redux"; +import { connect } from "react-redux"; +import React from "react"; +import { FaLastfm } from "react-icons/fa"; +import { MdClose } from "react-icons/md"; +import { fetchRelease } from "./actions/releaseActions"; +import Loading from "../layout/Loading"; +import SearchReleaseError from "./SearchReleaseError"; +import { Button, Poster, PosterContent } from "./styles/SearchRelease.styles"; +import { autotrackParams } from "../../lib/analytics"; + +interface SearchReleaseProps { + code: string; + onScrobble: () => void; + onCancel: () => void; + fetchRelease: (code: string) => void; + error?: any; + loading?: boolean; + data?: any; +} + +class SearchRelease extends React.Component { + componentDidMount() { + this.props.fetchRelease(this.props.code); + } + + componentDidUpdate(prevProps: SearchReleaseProps) { + const { data } = this.props; + if (prevProps.data !== data && data.instantScrobble) { + this.props.onScrobble(); + } + } + + render() { + const { code, error = null, loading = true, data = {}, onCancel, onScrobble } = this.props; + return ( + <> + {loading && } + {!loading && error && ( + + )} + {!loading && !error && ( + + + + + + {!data.image && ( +
+ {data.artist} +
{data.title}
+
+ )} +
+ )} + + ); + } +} + +const mapStateToProps = (state, { code }) => ({ + ...state.release[code], +}); + +const mapDispatchToProps = dispatch => bindActionCreators({ fetchRelease }, dispatch); + +export default connect(mapStateToProps, mapDispatchToProps)(SearchRelease) as any; diff --git a/components/release/SearchReleaseError.jsx b/components/release/SearchReleaseError.jsx deleted file mode 100644 index 8460ebf..0000000 --- a/components/release/SearchReleaseError.jsx +++ /dev/null @@ -1,24 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { IoIosRefresh } from 'react-icons/io'; -import { yellow } from '../../lib/colors'; -import { FlexContent } from '../../styles/layout.styles'; -import { ErrorIcon, RetryButton } from '../layout/styles/Error.styles'; - -const SearchReleaseError = ({ code, onRetry }) => ( - - - No release found
{code}
- - - Retry - -
-); - -SearchReleaseError.propTypes = { - code: PropTypes.string.isRequired, - onRetry: PropTypes.func.isRequired, -}; - -export default SearchReleaseError; diff --git a/components/release/SearchReleaseError.tsx b/components/release/SearchReleaseError.tsx new file mode 100644 index 0000000..a95aca7 --- /dev/null +++ b/components/release/SearchReleaseError.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { IoIosRefresh } from "react-icons/io"; +import { yellow } from "../../lib/colors"; +import { FlexContent } from "../../styles/layout.styles"; +import { ErrorIcon, RetryButton } from "../layout/styles/Error.styles"; + +interface SearchReleaseErrorProps { + code: string; + onRetry: () => void; +} + +const SearchReleaseError = ({ code, onRetry }: SearchReleaseErrorProps) => ( + + + + No release found +
+ {code} +
+ + + Retry + +
+); + +export default SearchReleaseError; diff --git a/components/release/actions/releaseActionCreators.js b/components/release/actions/releaseActionCreators.ts similarity index 90% rename from components/release/actions/releaseActionCreators.js rename to components/release/actions/releaseActionCreators.ts index c197b35..77836d3 100644 --- a/components/release/actions/releaseActionCreators.js +++ b/components/release/actions/releaseActionCreators.ts @@ -1,4 +1,4 @@ -import { SET_LOADING_STATE, SET_ERROR_STATE, RECEIVED_RELEASE } from '../constants/releaseConstants'; +import { SET_LOADING_STATE, SET_ERROR_STATE, RECEIVED_RELEASE } from "../constants/releaseConstants"; export const setLoadingState = (code, loading) => ({ type: SET_LOADING_STATE, diff --git a/components/release/actions/releaseActions.js b/components/release/actions/releaseActions.js deleted file mode 100644 index d0a5876..0000000 --- a/components/release/actions/releaseActions.js +++ /dev/null @@ -1,31 +0,0 @@ -import { setLoadingState, receivedRelease, setErrorState } from './releaseActionCreators'; - -const shouldFetchRelease = release => (!release || !release.data.id); - -export const fetchRelease = code => ( - async (dispatch) => { - try { - dispatch(setErrorState(code, null)); - dispatch(setLoadingState(code, true)); - - const data = await fetch(`/api/barcode/${code}`, { credentials: 'include' }).then(r => r.json()); - - if (data.id) { - dispatch(receivedRelease(code, data)); - } else { - dispatch(setErrorState(code, 'No release found')); - } - } catch (error) { - dispatch(setErrorState(code, error)); - } - dispatch(setLoadingState(code, false)); - } -); - -export const fetchReleaseIfNeeded = code => ( - (dispatch, getState) => { - if (shouldFetchRelease(getState().release[code])) { - dispatch(fetchRelease(code)); - } - } -); diff --git a/components/release/actions/releaseActions.ts b/components/release/actions/releaseActions.ts new file mode 100644 index 0000000..3e3beb3 --- /dev/null +++ b/components/release/actions/releaseActions.ts @@ -0,0 +1,29 @@ +import { setLoadingState, receivedRelease, setErrorState } from "./releaseActionCreators"; + +const shouldFetchRelease = release => !release || !release.data?.id; + +export const fetchRelease = code => async dispatch => { + try { + dispatch(setErrorState(code, null)); + dispatch(setLoadingState(code, true)); + + const response = await fetch(`/api/barcode/${code}`, { credentials: "include" }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + + if (data.id) { + dispatch(receivedRelease(code, data)); + } else { + dispatch(setErrorState(code, "No release found")); + } + } catch (error) { + dispatch(setErrorState(code, error)); + } + dispatch(setLoadingState(code, false)); +}; + +export const fetchReleaseIfNeeded = code => (dispatch, getState) => { + if (shouldFetchRelease(getState().release[code])) { + dispatch(fetchRelease(code)); + } +}; diff --git a/components/release/constants/releaseConstants.js b/components/release/constants/releaseConstants.js deleted file mode 100644 index 14307e1..0000000 --- a/components/release/constants/releaseConstants.js +++ /dev/null @@ -1,3 +0,0 @@ -export const SET_LOADING_STATE = 'SET_RELEASE_LOADING_STATE'; -export const SET_ERROR_STATE = 'SET_RELEASE_ERROR_STATE'; -export const RECEIVED_RELEASE = 'RECEIVED_RELEASE'; diff --git a/components/release/constants/releaseConstants.ts b/components/release/constants/releaseConstants.ts new file mode 100644 index 0000000..38d7e4b --- /dev/null +++ b/components/release/constants/releaseConstants.ts @@ -0,0 +1,3 @@ +export const SET_LOADING_STATE = "SET_RELEASE_LOADING_STATE"; +export const SET_ERROR_STATE = "SET_RELEASE_ERROR_STATE"; +export const RECEIVED_RELEASE = "RECEIVED_RELEASE"; diff --git a/components/release/reducers/releaseReducer.js b/components/release/reducers/releaseReducer.ts similarity index 86% rename from components/release/reducers/releaseReducer.js rename to components/release/reducers/releaseReducer.ts index dcae5d1..64d6651 100644 --- a/components/release/reducers/releaseReducer.js +++ b/components/release/reducers/releaseReducer.ts @@ -1,6 +1,6 @@ -import { SET_LOADING_STATE, SET_ERROR_STATE, RECEIVED_RELEASE } from '../constants/releaseConstants'; +import { SET_LOADING_STATE, SET_ERROR_STATE, RECEIVED_RELEASE } from "../constants/releaseConstants"; -const releaseReducer = (state = {}, action = {}) => { +const releaseReducer = (state: any = {}, action: any = {}) => { switch (action.type) { case RECEIVED_RELEASE: return { diff --git a/components/release/styles/ReleaseInfo.styles.js b/components/release/styles/ReleaseInfo.styles.ts similarity index 91% rename from components/release/styles/ReleaseInfo.styles.js rename to components/release/styles/ReleaseInfo.styles.ts index c6f487e..799610f 100644 --- a/components/release/styles/ReleaseInfo.styles.js +++ b/components/release/styles/ReleaseInfo.styles.ts @@ -1,6 +1,6 @@ -import { IoIosInformationCircleOutline } from 'react-icons/io'; -import styled from 'styled-components'; -import { dark } from '../../../lib/colors'; +import { IoIosInformationCircleOutline } from "react-icons/io"; +import styled from "styled-components"; +import { dark } from "../../../lib/colors"; export const Wrapper = styled.div` position: relative; @@ -41,7 +41,7 @@ export const Overlay = styled.div` width: 100%; height: 100%; overflow: auto; - background: rgba(0,0,0,.7); + background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(10px); -webkit-overflow-scrolling: touch; `; @@ -79,7 +79,7 @@ export const Cover = styled.img` height: 100%; object-fit: cover; filter: blur(10px); - opacity: .4; + opacity: 0.4; `; export const Meta = styled.div` @@ -97,12 +97,12 @@ export const Title = styled.div` `; export const Year = styled.div` - font-size: .5em; + font-size: 0.5em; `; export const Artist = styled.div` margin-bottom: 8px; - font-size: .8em; + font-size: 0.8em; `; export const TrackListWrapper = styled.div` diff --git a/components/release/styles/SearchRelease.styles.js b/components/release/styles/SearchRelease.styles.ts similarity index 83% rename from components/release/styles/SearchRelease.styles.js rename to components/release/styles/SearchRelease.styles.ts index 76135b0..a2157d8 100644 --- a/components/release/styles/SearchRelease.styles.js +++ b/components/release/styles/SearchRelease.styles.ts @@ -1,6 +1,6 @@ -import styled from 'styled-components'; +import styled from "styled-components"; -export const Poster = styled.div` +export const Poster = styled.div<{ image?: string }>` display: flex; position: absolute; top: 0; @@ -18,7 +18,7 @@ export const PosterContent = styled.div` display: flex; width: 100%; padding: 0 20%; - background: rgba(0, 0, 0, .5); + background: rgba(0, 0, 0, 0.5); text-align: center; backdrop-filter: blur(10px); `; diff --git a/components/scanner/Scanner.jsx b/components/scanner/Scanner.jsx deleted file mode 100644 index 8dbf47a..0000000 --- a/components/scanner/Scanner.jsx +++ /dev/null @@ -1,98 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Quagga from 'quagga'; -import { yellow } from '../../lib/colors'; -import { FlexContent } from '../../styles/layout.styles'; -import { ErrorDescription, ErrorIcon } from '../layout/styles/Error.styles'; -import { Camera } from './styles/Scanner.styles'; -import Loading from '../layout/Loading'; - -class Scanner extends React.Component { - constructor(props) { - super(props); - this.state = { - loading: true, - videoError: false, - }; - } - - componentDidMount() { - if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { - Quagga.init({ - inputStream: { - name: 'Live', - type: 'LiveStream', - target: document.querySelector('#camera'), - constraints: { - width: { min: 1280 }, - height: { min: 720 }, - facingMode: 'environment', - frameRate: 15, - aspectRatio: { min: 1, max: 2 }, - }, - }, - locator: { - patchSize: 'large', - halfSample: true, - }, - numOfWorkers: 2, - locate: true, - frequency: 10, - decoder: { - readers: ['ean_8_reader', 'ean_reader'], - }, - }, (err) => { - if (err) { - this.setState({ videoError: true, loading: false }); - return; - } - this.onInitSuccess(); - }); - Quagga.onDetected(this.onDetected); - } - } - - componentWillUnmount() { - Quagga.stop(); - } - - onInitSuccess = () => { - Quagga.start(); - this.setState({ loading: false }); - } - - onDetected = (result) => { - const { onDetected } = this.props; - this.setState({ loading: true }); - - Quagga.offDetected(this.onDetected); - onDetected(result); - } - - render() { - const { videoError, loading } = this.state; - const ready = !loading && !videoError; - return ( - <> - {loading && } - {videoError && ( - - - An error occurred - - Please make sure this website is allowed to use the camera. - - - )} - {/* eslint-disable-next-line jsx-a11y/media-has-caption */} - - - ); - } -} - -Scanner.propTypes = { - onDetected: PropTypes.func.isRequired, -}; - -export default Scanner; diff --git a/components/scanner/Scanner.tsx b/components/scanner/Scanner.tsx new file mode 100644 index 0000000..5cf5b85 --- /dev/null +++ b/components/scanner/Scanner.tsx @@ -0,0 +1,100 @@ +import React from "react"; +import Quagga from "quagga"; +import { yellow } from "../../lib/colors"; +import { FlexContent } from "../../styles/layout.styles"; +import { ErrorDescription, ErrorIcon } from "../layout/styles/Error.styles"; +import { Camera } from "./styles/Scanner.styles"; +import Loading from "../layout/Loading"; + +interface ScannerProps { + onDetected: (result: any) => void; +} + +class Scanner extends React.Component { + constructor(props: ScannerProps) { + super(props); + this.state = { + loading: true, + videoError: false, + }; + } + + componentDidMount() { + if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { + Quagga.init( + { + inputStream: { + name: "Live", + type: "LiveStream", + target: document.querySelector("#camera"), + constraints: { + width: { min: 1280 }, + height: { min: 720 }, + facingMode: "environment", + frameRate: 15, + aspectRatio: { min: 1, max: 2 }, + }, + }, + locator: { + patchSize: "large", + halfSample: true, + }, + numOfWorkers: 2, + locate: true, + frequency: 10, + decoder: { + readers: ["ean_8_reader", "ean_reader"], + }, + }, + err => { + if (err) { + this.setState({ videoError: true, loading: false }); + return; + } + this.onInitSuccess(); + }, + ); + Quagga.onDetected(this.onDetected); + } + } + + componentWillUnmount() { + Quagga.stop(); + } + + onInitSuccess = () => { + Quagga.start(); + this.setState({ loading: false }); + }; + + onDetected = result => { + const { onDetected } = this.props; + this.setState({ loading: true }); + + Quagga.offDetected(this.onDetected); + onDetected(result); + }; + + render() { + const { videoError, loading } = this.state; + const ready = !loading && !videoError; + return ( + <> + {loading && } + {videoError && ( + + + An error occurred + Please make sure this website is allowed to use the camera. + + )} + + {/* eslint-disable-next-line jsx-a11y/media-has-caption */} + + + ); + } +} + +export default Scanner; diff --git a/components/scanner/styles/Scanner.styles.js b/components/scanner/styles/Scanner.styles.ts similarity index 58% rename from components/scanner/styles/Scanner.styles.js rename to components/scanner/styles/Scanner.styles.ts index f6e5bd4..c8765a8 100644 --- a/components/scanner/styles/Scanner.styles.js +++ b/components/scanner/styles/Scanner.styles.ts @@ -1,14 +1,15 @@ -import styled from 'styled-components'; +import styled from "styled-components"; // eslint-disable-next-line import/prefer-default-export -export const Camera = styled.div` - visibility: ${props => (props.visible ? 'visible' : 'hidden')}; +export const Camera = styled.div<{ $visible?: boolean }>` + visibility: ${props => (props.$visible ? "visible" : "hidden")}; position: absolute; width: 100%; height: 100%; transform: translate3d(0, 0, 0); - video, canvas { + video, + canvas { position: absolute; top: 0; left: 0; diff --git a/components/scrobble/Scrobble.jsx b/components/scrobble/Scrobble.jsx deleted file mode 100644 index b0deedd..0000000 --- a/components/scrobble/Scrobble.jsx +++ /dev/null @@ -1,54 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { Loading, LoadingContent, LoadingWrapper } from './styles/Scrobble.styles'; -import ScrobbleError from './ScrobbleError'; - -class Scrobble extends React.Component { - state = { - loadingError: false, - }; - - componentDidMount() { - this.doRequest(); - } - - doRequest = async () => { - const { release: { id }, autoScrobble, onScrobbled } = this.props; - - try { - this.setState({ loadingError: false }); - - await fetch('/api/scrobble', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id, autoScrobble }), - }); - } catch (error) { - return this.setState({ loadingError: true }); - } - return onScrobbled(); - } - - render() { - const { loadingError } = this.state; - const { release } = this.props; - return ( - loadingError - ? - : ( - - - Sending data to Last.fm - - ) - ); - } -} - -Scrobble.propTypes = { - release: PropTypes.object.isRequired, - autoScrobble: PropTypes.bool.isRequired, - onScrobbled: PropTypes.func.isRequired, -}; - -export default Scrobble; diff --git a/components/scrobble/Scrobble.tsx b/components/scrobble/Scrobble.tsx new file mode 100644 index 0000000..a118bb1 --- /dev/null +++ b/components/scrobble/Scrobble.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { Loading, LoadingContent, LoadingWrapper } from "./styles/Scrobble.styles"; +import ScrobbleError from "./ScrobbleError"; + +interface ScrobbleProps { + release: { id: string; image?: string; [key: string]: any }; + autoScrobble: boolean; + onScrobbled: () => void; +} + +class Scrobble extends React.Component { + state = { + loadingError: false, + }; + + componentDidMount() { + this.doRequest(); + } + + doRequest = async () => { + const { + release: { id }, + autoScrobble, + onScrobbled, + } = this.props; + + try { + this.setState({ loadingError: false }); + + await fetch("/api/scrobble", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, autoScrobble }), + }); + } catch { + return this.setState({ loadingError: true }); + } + return onScrobbled(); + }; + + render() { + const { loadingError } = this.state; + const { release } = this.props; + return loadingError ? ( + + ) : ( + + + Sending data to Last.fm + + ); + } +} + +export default Scrobble; diff --git a/components/scrobble/ScrobbleError.jsx b/components/scrobble/ScrobbleError.jsx deleted file mode 100644 index b3a84fb..0000000 --- a/components/scrobble/ScrobbleError.jsx +++ /dev/null @@ -1,23 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { IoIosRefresh } from 'react-icons/io'; -import { yellow } from '../../lib/colors'; -import { FlexContent } from '../../styles/layout.styles'; -import { ErrorIcon, RetryButton } from '../layout/styles/Error.styles'; - -const ScrobbleError = ({ onRetry }) => ( - - - An error occured while sending data to Last.fm - - - Retry - - -); - -ScrobbleError.propTypes = { - onRetry: PropTypes.func.isRequired, -}; - -export default ScrobbleError; diff --git a/components/scrobble/ScrobbleError.tsx b/components/scrobble/ScrobbleError.tsx new file mode 100644 index 0000000..c98f73f --- /dev/null +++ b/components/scrobble/ScrobbleError.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { IoIosRefresh } from "react-icons/io"; +import { yellow } from "../../lib/colors"; +import { FlexContent } from "../../styles/layout.styles"; +import { ErrorIcon, RetryButton } from "../layout/styles/Error.styles"; + +interface ScrobbleErrorProps { + onRetry: () => void; +} + +const ScrobbleError = ({ onRetry }: ScrobbleErrorProps) => ( + + + An error occured while sending data to Last.fm + + + Retry + + +); + +export default ScrobbleError; diff --git a/components/scrobble/styles/Scrobble.styles.js b/components/scrobble/styles/Scrobble.styles.ts similarity index 65% rename from components/scrobble/styles/Scrobble.styles.js rename to components/scrobble/styles/Scrobble.styles.ts index 042989a..3447303 100644 --- a/components/scrobble/styles/Scrobble.styles.js +++ b/components/scrobble/styles/Scrobble.styles.ts @@ -1,12 +1,12 @@ -import styled from 'styled-components'; -import Spinner from '../../layout/Spinner'; +import styled from "styled-components"; +import Spinner from "../../layout/Spinner"; export const Loading = styled(Spinner)` width: 100%; height: 100%; `; -export const LoadingWrapper = styled.div` +export const LoadingWrapper = styled.div<{ image?: string }>` display: flex; position: absolute; align-items: center; @@ -23,8 +23,11 @@ export const LoadingContent = styled.div` left: 0; width: 100%; padding: 12% 20%; - background: rgba(0, 0, 0, .5); + background: rgba(0, 0, 0, 0.5); text-align: center; backdrop-filter: blur(10px); - text-shadow: 0 0 3px black, 0 0 3px black, 0 0 3px black; + text-shadow: + 0 0 3px black, + 0 0 3px black, + 0 0 3px black; `; diff --git a/components/session/Session.jsx b/components/session/Session.jsx deleted file mode 100644 index 65bdff7..0000000 --- a/components/session/Session.jsx +++ /dev/null @@ -1,103 +0,0 @@ -import { bindActionCreators } from 'redux'; -import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; -import React from 'react'; -import Head from 'next/head'; -import Link from 'next/link'; -import { fetchSessionIfNeeded } from './actions/sessionActions'; -import { - Arrow, Image, ImageAndUser, Loader, Menu, MenuItem, Username, -} from './styles/Session.styles'; -import targetBlank from '../../lib/targetBlank'; -import { autotrackParams } from '../../lib/analytics'; - -class Session extends React.Component { - overlayRef = React.createRef(); - - state = { - open: false, - } - - componentDidMount() { - this.props.fetchSessionIfNeeded(); - document.addEventListener('click', this.handleClickOutside); - } - - componentWillUnmount() { - document.removeEventListener('click', this.handleClickOutside); - } - - handleClickOutside = (event) => { - const { open } = this.state; - const ref = this.overlayRef.current; - - if (open && !ref.contains(event.target) && document.body.contains(event.target)) { - this.setState({ open: false }); - } - } - - handleClick = () => { - this.setState(state => ({ open: !state.open })); - } - - render() { - const { session, error } = this.props; - - const { open } = this.state; - if (error) return null; - return ( -
- - - - - {session && session.name ? ( - <> - {session.name} - - - ) : ( - - - - )} - - - - - Profile - - - Logout - - -
- ); - } -} - -Session.propTypes = { - session: PropTypes.object, - error: PropTypes.any, - fetchSessionIfNeeded: PropTypes.func.isRequired, -}; - -Session.defaultProps = { - session: {}, - error: null, -}; - - -const mapStateToProps = state => ({ - session: state.session.data, - error: state.session.error, -}); - -const mapDispatchToProps = dispatch => ( - bindActionCreators({ fetchSessionIfNeeded }, dispatch) -); - -export default connect( - mapStateToProps, - mapDispatchToProps, -)(Session); diff --git a/components/session/Session.tsx b/components/session/Session.tsx new file mode 100644 index 0000000..c766789 --- /dev/null +++ b/components/session/Session.tsx @@ -0,0 +1,98 @@ +import { bindActionCreators } from "redux"; +import { connect } from "react-redux"; +import React from "react"; +import Head from "next/head"; +import Link from "next/link"; +import Router from "next/router"; +import { fetchSessionIfNeeded } from "./actions/sessionActions"; +import { Arrow, Avatar, ImageAndUser, Loader, Menu, MenuItem, Username } from "./styles/Session.styles"; +import targetBlank from "../../lib/targetBlank"; +import { autotrackParams } from "../../lib/analytics"; + +interface SessionProps { + session?: any; + error?: any; + fetchSessionIfNeeded: () => void; +} + +class Session extends React.Component { + overlayRef = React.createRef(); + + state = { + open: false, + }; + + componentDidMount() { + this.props.fetchSessionIfNeeded(); + document.addEventListener("click", this.handleClickOutside); + } + + componentDidUpdate(prevProps: SessionProps) { + if (!prevProps.error && this.props.error) { + Router.push("/login"); + } + } + + componentWillUnmount() { + document.removeEventListener("click", this.handleClickOutside); + } + + handleClickOutside = event => { + const { open } = this.state; + const ref = this.overlayRef.current; + + if (open && !ref.contains(event.target) && document.body.contains(event.target)) { + this.setState({ open: false }); + } + }; + + handleClick = () => { + this.setState(state => ({ open: !state.open })); + }; + + render() { + const { session = {}, error = null } = this.props; + + const { open } = this.state; + if (error) return null; + return ( +
+ + + + + {session && session.name ? ( + <> + + {session.name} + + + + ) : ( + + + + )} + + + + + Profile + + + Logout + + +
+ ); + } +} + +const mapStateToProps = state => ({ + session: state.session.data, + error: state.session.error, +}); + +const mapDispatchToProps = dispatch => bindActionCreators({ fetchSessionIfNeeded }, dispatch); + +export default connect(mapStateToProps, mapDispatchToProps)(Session); diff --git a/components/session/actions/sessionActionCreators.js b/components/session/actions/sessionActionCreators.ts similarity index 89% rename from components/session/actions/sessionActionCreators.js rename to components/session/actions/sessionActionCreators.ts index f6567c7..4abdfcb 100644 --- a/components/session/actions/sessionActionCreators.js +++ b/components/session/actions/sessionActionCreators.ts @@ -1,4 +1,4 @@ -import { SET_LOADING_STATE, SET_ERROR_STATE, RECEIVED_SESSION } from '../constants/sessionConstants'; +import { SET_LOADING_STATE, SET_ERROR_STATE, RECEIVED_SESSION } from "../constants/sessionConstants"; export const setLoadingState = loading => ({ type: SET_LOADING_STATE, diff --git a/components/session/actions/sessionActions.js b/components/session/actions/sessionActions.js deleted file mode 100644 index d87d9a4..0000000 --- a/components/session/actions/sessionActions.js +++ /dev/null @@ -1,25 +0,0 @@ -import { setLoadingState, receivedSession, setErrorState } from './sessionActionCreators'; - -const shouldFetchSession = session => !session.data; - -export const fetchSession = () => ( - async (dispatch) => { - try { - dispatch(setLoadingState(true)); - const data = await fetch('/api/session', { credentials: 'include' }).then(r => r.json()); - dispatch(receivedSession(data)); - } catch (error) { - dispatch(setErrorState(error)); - } - dispatch(setLoadingState(false)); - } -); - -export const fetchSessionIfNeeded = () => ( - (dispatch, getState) => { - const state = getState().session; - if (shouldFetchSession(state)) { - dispatch(fetchSession()); - } - } -); diff --git a/components/session/actions/sessionActions.ts b/components/session/actions/sessionActions.ts new file mode 100644 index 0000000..7ebb4dc --- /dev/null +++ b/components/session/actions/sessionActions.ts @@ -0,0 +1,26 @@ +import { setLoadingState, receivedSession, setErrorState } from "./sessionActionCreators"; + +const shouldFetchSession = session => !session.data; + +export const fetchSession = () => async dispatch => { + try { + dispatch(setLoadingState(true)); + const response = await fetch("/api/session", { credentials: "include" }); + if (!response.ok) { + dispatch(setErrorState(response.status)); + } else { + const data = await response.json(); + dispatch(receivedSession(data)); + } + } catch (error) { + dispatch(setErrorState(error)); + } + dispatch(setLoadingState(false)); +}; + +export const fetchSessionIfNeeded = () => (dispatch, getState) => { + const state = getState().session; + if (shouldFetchSession(state)) { + dispatch(fetchSession()); + } +}; diff --git a/components/session/constants/sessionConstants.js b/components/session/constants/sessionConstants.js deleted file mode 100644 index 53e27f7..0000000 --- a/components/session/constants/sessionConstants.js +++ /dev/null @@ -1,3 +0,0 @@ -export const SET_LOADING_STATE = 'SET_SESSION_LOADING_STATE'; -export const SET_ERROR_STATE = 'SET_SESSION_ERROR_STATE'; -export const RECEIVED_SESSION = 'RECEIVED_SESSION'; diff --git a/components/session/constants/sessionConstants.ts b/components/session/constants/sessionConstants.ts new file mode 100644 index 0000000..00cbd39 --- /dev/null +++ b/components/session/constants/sessionConstants.ts @@ -0,0 +1,3 @@ +export const SET_LOADING_STATE = "SET_SESSION_LOADING_STATE"; +export const SET_ERROR_STATE = "SET_SESSION_ERROR_STATE"; +export const RECEIVED_SESSION = "RECEIVED_SESSION"; diff --git a/components/session/reducers/sessionReducer.js b/components/session/reducers/sessionReducer.ts similarity index 82% rename from components/session/reducers/sessionReducer.js rename to components/session/reducers/sessionReducer.ts index 29dcbc5..006952b 100644 --- a/components/session/reducers/sessionReducer.js +++ b/components/session/reducers/sessionReducer.ts @@ -1,6 +1,6 @@ -import { RECEIVED_SESSION, SET_LOADING_STATE, SET_ERROR_STATE } from '../constants/sessionConstants'; +import { RECEIVED_SESSION, SET_LOADING_STATE, SET_ERROR_STATE } from "../constants/sessionConstants"; -const sessionReducer = (state = {}, action = {}) => { +const sessionReducer = (state = {}, action: any = {}) => { switch (action.type) { case RECEIVED_SESSION: return { diff --git a/components/session/styles/Session.styles.js b/components/session/styles/Session.styles.ts similarity index 65% rename from components/session/styles/Session.styles.js rename to components/session/styles/Session.styles.ts index cda8a96..c27932a 100644 --- a/components/session/styles/Session.styles.js +++ b/components/session/styles/Session.styles.ts @@ -1,19 +1,21 @@ -import styled, { css } from 'styled-components'; -import { dark, yellow, yellowRGB } from '../../../lib/colors'; -import { animation } from '../../layout/Spinner'; -import { buttonReset } from '../../../styles/mixins'; +import styled, { css } from "styled-components"; +import { dark, yellow, yellowRGB } from "../../../lib/colors"; +import { animation } from "../../layout/Spinner"; +import { buttonReset } from "../../../styles/mixins"; -const fadeInOnOpen = css` - transition: opacity .3s; +const fadeInOnOpen = css<{ open?: boolean }>` + transition: opacity 0.3s; opacity: 0; - pointer-events:none; - ${props => props.open && css` - opacity: 1; - pointer-events: auto; - `} + pointer-events: none; + ${props => + props.open && + css` + opacity: 1; + pointer-events: auto; + `} `; -export const Image = styled.div` +export const Avatar = styled.div<{ image?: string }>` z-index: 1; width: 8vw; max-width: 50px; @@ -30,12 +32,12 @@ export const Loader = styled.div` width: 100%; height: 100%; animation: ${animation} 1s ease-in-out infinite; - border: 2px solid rgba(${yellowRGB}, .3); + border: 2px solid rgba(${yellowRGB}, 0.3); border-radius: 50%; border-top-color: ${yellow}; `; -export const Arrow = styled.div` +export const Arrow = styled.div<{ open?: boolean }>` ${fadeInOnOpen} position: absolute; right: 0; @@ -44,7 +46,7 @@ export const Arrow = styled.div` height: 8px; &:before { - content: ''; + content: ""; position: absolute; top: 0; left: 50%; @@ -57,7 +59,7 @@ export const Arrow = styled.div` } `; -export const Menu = styled.div` +export const Menu = styled.div<{ open?: boolean }>` ${fadeInOnOpen} position: absolute; right: 0; @@ -77,7 +79,7 @@ export const MenuItem = styled.button` cursor: pointer; `; -export const Username = styled.a` +export const Username = styled.a<{ open?: boolean }>` padding-right: 12px; font-size: 14px; text-decoration: none; diff --git a/components/ui/BackButton.jsx b/components/ui/BackButton.tsx similarity index 58% rename from components/ui/BackButton.jsx rename to components/ui/BackButton.tsx index 89ddb3e..3e6e09d 100644 --- a/components/ui/BackButton.jsx +++ b/components/ui/BackButton.tsx @@ -1,11 +1,9 @@ -import Router from 'next/router'; -import { ChevronLeft } from 'styled-icons/boxicons-regular/ChevronLeft'; -import { silver } from '../../lib/colors'; -import { Button } from './styles/BackButton.styles'; +import Router from "next/router"; +import { ChevronLeft } from "styled-icons/boxicons-regular"; +import { silver } from "../../lib/colors"; +import { Button } from "./styles/BackButton.styles"; -const getHostFromUrl = url => ( - (/\/\/([^/]+)\//i.exec(url) || [])[1] -); +const getHostFromUrl = url => (/\/\/([^/]+)\//i.exec(url) || [])[1]; const hasExternalReferrer = () => { if (!document.referrer) return true; @@ -14,7 +12,7 @@ const hasExternalReferrer = () => { const handleClick = () => { if (hasExternalReferrer()) { - Router.push('/'); + Router.push("/"); } else { Router.back(); } diff --git a/components/ui/Checkbox.jsx b/components/ui/Checkbox.jsx deleted file mode 100644 index 84c785a..0000000 --- a/components/ui/Checkbox.jsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -import { Input, Label, Wrapper } from './styles/Checkbox.styles'; - -class Checkbox extends React.Component { - constructor(props) { - super(props); - this.handleCheck = this.handleCheck.bind(this); - } - - shouldComponentUpdate(nextProps) { - // eslint-disable-next-line react/destructuring-assignment - return ['checked', 'disabled'].some(prop => this.props[prop] !== nextProps[prop]); - } - - handleCheck(event) { - const { onChange } = this.props; - onChange(event.target.checked); - } - - render() { - const { - name, className, checked, disabled, children, - } = this.props; - const id = `checkbox-${name}`; - - return ( - - - - - ); - } -} - -Checkbox.propTypes = { - checked: PropTypes.bool, - className: PropTypes.string, - name: PropTypes.string.isRequired, - children: PropTypes.any, - onChange: PropTypes.func, - disabled: PropTypes.bool, -}; - -Checkbox.defaultProps = { - checked: false, - className: null, - children: null, - onChange: () => {}, - disabled: false, -}; - -export default Checkbox; diff --git a/components/ui/Checkbox.tsx b/components/ui/Checkbox.tsx new file mode 100644 index 0000000..1e25e86 --- /dev/null +++ b/components/ui/Checkbox.tsx @@ -0,0 +1,59 @@ +import React from "react"; + +import { Input, Label, Wrapper } from "./styles/Checkbox.styles"; + +interface CheckboxProps { + checked?: boolean; + className?: string | null; + name: string; + children?: React.ReactNode; + onChange?: (checked: boolean) => void; + disabled?: boolean; +} + +class Checkbox extends React.Component { + label: any; + + constructor(props: CheckboxProps) { + super(props); + this.handleCheck = this.handleCheck.bind(this); + } + + shouldComponentUpdate(nextProps: CheckboxProps) { + // eslint-disable-next-line react/destructuring-assignment + return ["checked", "disabled"].some(prop => this.props[prop] !== nextProps[prop]); + } + + handleCheck(event) { + const { onChange = () => {} } = this.props; + onChange(event.target.checked); + } + + render() { + const { name, className = null, checked = false, disabled = false, children = null } = this.props; + const id = `checkbox-${name}`; + + return ( + + + + + ); + } +} + +export default Checkbox; diff --git a/components/ui/LegalLinks.jsx b/components/ui/LegalLinks.jsx deleted file mode 100644 index e14cbc7..0000000 --- a/components/ui/LegalLinks.jsx +++ /dev/null @@ -1,15 +0,0 @@ -import NextLink from 'next/link'; -import { Link, Links } from './styles/LegalLinks.styles'; - -const LegalLinks = () => ( - - - Privacy - - - Legal - - -); - -export default LegalLinks; diff --git a/components/ui/LegalLinks.tsx b/components/ui/LegalLinks.tsx new file mode 100644 index 0000000..21fb5b2 --- /dev/null +++ b/components/ui/LegalLinks.tsx @@ -0,0 +1,15 @@ +import NextLink from "next/link"; +import { Link, Links } from "./styles/LegalLinks.styles"; + +const LegalLinks = () => ( + + + Privacy + + + Legal + + +); + +export default LegalLinks; diff --git a/components/ui/LoginButton.jsx b/components/ui/LoginButton.tsx similarity index 63% rename from components/ui/LoginButton.jsx rename to components/ui/LoginButton.tsx index 4d4e56c..26e337f 100644 --- a/components/ui/LoginButton.jsx +++ b/components/ui/LoginButton.tsx @@ -1,5 +1,5 @@ -import LastfmIcon from '../icons/LastfmIcon'; -import { Caption, Wrapper } from './styles/LoginButton.styles'; +import LastfmIcon from "../icons/LastfmIcon"; +import { Caption, Wrapper } from "./styles/LoginButton.styles"; const LoginButton = props => ( diff --git a/components/ui/styles/BackButton.styles.js b/components/ui/styles/BackButton.styles.ts similarity index 68% rename from components/ui/styles/BackButton.styles.js rename to components/ui/styles/BackButton.styles.ts index c9c7688..0dbbe45 100644 --- a/components/ui/styles/BackButton.styles.js +++ b/components/ui/styles/BackButton.styles.ts @@ -1,6 +1,6 @@ -import styled from 'styled-components'; -import { silver } from '../../../lib/colors'; -import { buttonReset } from '../../../styles/mixins'; +import styled from "styled-components"; +import { silver } from "../../../lib/colors"; +import { buttonReset } from "../../../styles/mixins"; // eslint-disable-next-line import/prefer-default-export export const Button = styled.button` @@ -14,7 +14,7 @@ export const Button = styled.button` padding: 5px; overflow: hidden; border-bottom: 1px solid ${silver}; - background: rgba(0, 0, 0, .6); + background: rgba(0, 0, 0, 0.6); box-shadow: 0 0 3px 2px black; backdrop-filter: blur(5px); font-size: 16px; diff --git a/components/ui/styles/Checkbox.styles.js b/components/ui/styles/Checkbox.styles.ts similarity index 81% rename from components/ui/styles/Checkbox.styles.js rename to components/ui/styles/Checkbox.styles.ts index 4106535..6d8db60 100644 --- a/components/ui/styles/Checkbox.styles.js +++ b/components/ui/styles/Checkbox.styles.ts @@ -1,6 +1,6 @@ // Source: https://github.com/iceteabottle/css-checkbox -import styled, { keyframes } from 'styled-components'; -import { dark, silver, yellow } from '../../../lib/colors'; +import styled, { keyframes } from "styled-components"; +import { dark, silver, yellow } from "../../../lib/colors"; // custom checkbox/radios const inputHeight = 30; @@ -27,7 +27,7 @@ export const Label = styled.label` display: inline-flex; position: relative; align-items: center; - height: ${inputHeight + (2 * inputBorderWidth)}px; + height: ${inputHeight + 2 * inputBorderWidth}px; padding: 0 6px 0 42px; cursor: pointer; user-select: none; @@ -38,15 +38,15 @@ export const Label = styled.label` width: ${inputWidth}px; height: ${inputHeight}px; margin-top: ${-(inputHeight / 2 + inputBorderWidth)}px; - transition: .2s ease; + transition: 0.2s ease; transition-property: background-color, border-color; border: ${inputBorderWidth}px solid ${borderColor}; border-radius: 50%; - background: rgba(255, 255, 255, .1); + background: rgba(255, 255, 255, 0.1); text-align: center; } - &:after{ + &:after { top: 50%; left: 9px; width: 11px; @@ -54,7 +54,7 @@ export const Label = styled.label` margin-top: 0; transform: translateY(-5px) rotate(-45deg) scale(0); transform-origin: 50%; - transition: transform .2s ease-out; + transition: transform 0.2s ease-out; border-width: 0 0 3px 3px; border-style: solid; border-color: ${dark}; @@ -63,8 +63,8 @@ export const Label = styled.label` } &:before, - &:after{ - content: ''; + &:after { + content: ""; position: absolute; box-sizing: content-box; } @@ -91,13 +91,13 @@ export const Input = styled.input` &:checked { & + ${Label} { &:after { - content: ''; + content: ""; transform: translateY(-5px) rotate(-45deg) scale(1); - transition: transform .2s ease-out; + transition: transform 0.2s ease-out; border-color: ${dark}; } &:before { - animation: ${borderscale1} .2s ease-in; + animation: ${borderscale1} 0.2s ease-in; border-color: ${checkboxColor}; background: ${checkboxColor}; } diff --git a/components/ui/styles/LegalLinks.styles.js b/components/ui/styles/LegalLinks.styles.ts similarity index 81% rename from components/ui/styles/LegalLinks.styles.js rename to components/ui/styles/LegalLinks.styles.ts index 0d2c55f..6bdb821 100644 --- a/components/ui/styles/LegalLinks.styles.js +++ b/components/ui/styles/LegalLinks.styles.ts @@ -1,4 +1,4 @@ -import styled from 'styled-components'; +import styled from "styled-components"; export const Links = styled.div` display: flex; diff --git a/components/ui/styles/LoginButton.styles.js b/components/ui/styles/LoginButton.styles.ts similarity index 77% rename from components/ui/styles/LoginButton.styles.js rename to components/ui/styles/LoginButton.styles.ts index 0e77c7b..7da10a7 100644 --- a/components/ui/styles/LoginButton.styles.js +++ b/components/ui/styles/LoginButton.styles.ts @@ -1,12 +1,12 @@ -import styled from 'styled-components'; -import { lastFm, lastFmDark } from '../../../lib/colors'; +import styled from "styled-components"; +import { lastFm, lastFmDark } from "../../../lib/colors"; export const Wrapper = styled.a` display: inline-flex; flex-direction: column; align-items: flex-start; padding: 10px 25px; - transition: .25s; + transition: 0.25s; transition-property: box-shadow, transform; border: 1px solid ${lastFmDark}; border-radius: 5px; @@ -18,7 +18,7 @@ export const Wrapper = styled.a` &:active { transform: translateY(3px); - box-shadow: none + box-shadow: none; } `; diff --git a/config/express.js b/config/express.js deleted file mode 100644 index 15477fa..0000000 --- a/config/express.js +++ /dev/null @@ -1,35 +0,0 @@ -const morgan = require('morgan'); -const cookieParser = require('cookie-parser'); -const bodyParser = require('body-parser'); -const compression = require('compression'); -const session = require('express-session'); -const redis = require('redis'); -const helmet = require('helmet'); -const RedisStore = require('connect-redis')(session); - -module.exports = function expressConfig(app, passport, dev = false) { - if (dev) app.use(morgan('dev')); - app.use(cookieParser()); - app.use(bodyParser.json()); - app.use(compression()); - app.use(helmet()); - - const redisClient = redis.createClient(process.env.REDISCLOUD_URL); - redisClient.unref(); - redisClient.on('error', console.log); - - app.use(session({ - store: new RedisStore({ client: redisClient }), - secret: process.env.SESSION_SECRET, - resave: false, - saveUninitialized: true, - rolling: true, - name: 'sessionId', - cookie: { - // domain: "codescrobble.com", TODO: enable for production - maxAge: 2592000000, - }, - })); // session secret - app.use(passport.initialize()); - app.use(passport.session()); // persistent login sessions -}; diff --git a/config/passport.js b/config/passport.js deleted file mode 100644 index 1a2ae5b..0000000 --- a/config/passport.js +++ /dev/null @@ -1,51 +0,0 @@ -const LastFMStrategy = require('passport-lastfm'); -const dig = require('object-dig'); - -const LastFM = require('../app/lastfm'); -const User = require('../app/models/user'); - -// expose this function to our app using module.exports -module.exports = function passportConfig(passport) { - passport.serializeUser((user, done) => { - done(null, user.id); - }); - - passport.deserializeUser((id, done) => { - User.findById(id, (err, user) => { - done(err, user); - }); - }); - - passport.use( - new LastFMStrategy( - { - api_key: process.env.LASTFM_KEY, - secret: process.env.LASTFM_SECRET, - }, - - ((req, { name, key }, done) => { - // eslint-disable-next-line consistent-return - User.findOne({ name }, async (userErr, user) => { - if (userErr) return done(userErr); - - let currentUser = user; - if (!currentUser) currentUser = new User(); - - currentUser.name = name; - currentUser.key = key; - - const userData = await LastFM.getUserData(name, key); - currentUser.url = userData.url; - currentUser.image = dig(userData, 'image', 1, '#text'); - currentUser.imageLarge = dig(userData, 'image', 2, '#text'); - currentUser.imageXLarge = dig(userData, 'image', 3, '#text'); - - currentUser.save((saveErr) => { - if (saveErr) throw saveErr; - return done(null, currentUser); - }); - }); - }), - ), - ); -}; diff --git a/config/setupTests.js b/config/setupTests.js deleted file mode 100644 index 0b726a9..0000000 --- a/config/setupTests.js +++ /dev/null @@ -1,8 +0,0 @@ -// setup file -import { configure } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import fetchMock from 'jest-fetch-mock'; - -configure({ adapter: new Adapter() }); - -global.fetch = fetchMock; diff --git a/config/setupTests.ts b/config/setupTests.ts new file mode 100644 index 0000000..4ab8db6 --- /dev/null +++ b/config/setupTests.ts @@ -0,0 +1,4 @@ +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); diff --git a/docker-compose.yml b/docker-compose.yml index ec30b5b..6879004 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,6 @@ services: - MONGODB_URI=mongodb://mongodb:27017/codescrobble - REDISCLOUD_URL=redis://redis:6379 - NODE_ENV=development - - NODE_OPTIONS=--openssl-legacy-provider restart: always build: context: . diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..cefb3d4 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,58 @@ +const { FlatCompat } = require("@eslint/eslintrc"); +const js = require("@eslint/js"); +const prettierConfig = require("eslint-config-prettier/flat"); +const vitestModule = require("@vitest/eslint-plugin"); + +const vitest = vitestModule.default ?? vitestModule; + +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, +}); + +module.exports = [ + { + ignores: [ + ".next/**", + "node_modules/**", + ".storybook/**", + "coverage/**", + "dist/**", + "build/**", + "out/**", + "public/static/**", + "test-results/**", + ], + }, + js.configs.recommended, + ...compat.extends( + "next/core-web-vitals", + "next/typescript", + "plugin:jsx-a11y/recommended", + "plugin:import/recommended", + ), + { + files: ["**/*.{spec,test}.{js,jsx,ts,tsx}"], + plugins: { + vitest, + }, + languageOptions: { + globals: { + ...vitest.environments.env.globals, + }, + }, + rules: { + ...vitest.configs.recommended.rules, + "vitest/no-importing-vitest-globals": "error", + }, + }, + { + rules: { + "@typescript-eslint/no-empty-object-type": "warn", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-require-imports": "off", + "import/no-unresolved": "off", + }, + }, + prettierConfig, +]; diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 1434e3b..0000000 --- a/jest.config.js +++ /dev/null @@ -1,20 +0,0 @@ -module.exports = { - testPathIgnorePatterns: [ - '/.next/', - '/node_modules/', - ], - transform: { - '\\.jsx?$': 'babel-jest', - }, - transformIgnorePatterns: [ - '/node_modules/', - ], - setupFilesAfterEnv: ['config/setupTests.js'], - collectCoverageFrom: [ - 'app/**/*.{js,jsx}', - 'components/**/*.{js,jsx}', - 'lib/**/*.{js,jsx}', - '!lib/colors.js', - '!lib/polyfills.js', - ], -}; diff --git a/lib/analytics.js b/lib/analytics.js deleted file mode 100644 index 2e3889b..0000000 --- a/lib/analytics.js +++ /dev/null @@ -1,16 +0,0 @@ -export const ANALYTICS_ID = 'UA-135908212-1'; - -export const trackEvent = (action, category, label, value) => { - window.ga('send', 'event', category, action, label, value); -}; - -export const autotrackParams = (category, action, label, value) => { - if ((typeof category === 'undefined' || category === null) || (typeof action === 'undefined' || action === null)) return {}; - return { - 'data-event-category': category, - 'data-event-action': action, - 'data-event-label': label, - 'data-event-value': value, - 'data-on': 'click,auxclick,contextmenu', - }; -}; diff --git a/lib/analytics.ts b/lib/analytics.ts new file mode 100644 index 0000000..577c477 --- /dev/null +++ b/lib/analytics.ts @@ -0,0 +1,17 @@ +export const ANALYTICS_ID = "UA-135908212-1"; + +export const trackEvent = (action: string, category: string, label?: string, value?: string) => { + (window as any).ga("send", "event", category, action, label, value); +}; + +export const autotrackParams = (category: string, action: string, label?: string, value?: string) => { + if (typeof category === "undefined" || category === null || typeof action === "undefined" || action === null) + return {}; + return { + "data-event-category": category, + "data-event-action": action, + "data-event-label": label, + "data-event-value": value, + "data-on": "click,auxclick,contextmenu", + }; +}; diff --git a/lib/colors.js b/lib/colors.js deleted file mode 100644 index a156ea3..0000000 --- a/lib/colors.js +++ /dev/null @@ -1,8 +0,0 @@ -export const yellow = '#feda6a'; -export const silver = '#d4d4dc'; -export const grey = '#393f4d'; -export const dark = '#1d1e22'; -export const lastFm = '#d51007'; -export const lastFmDark = '#d51007'; - -export const yellowRGB = '254, 218, 106'; diff --git a/lib/colors.ts b/lib/colors.ts new file mode 100644 index 0000000..66e8055 --- /dev/null +++ b/lib/colors.ts @@ -0,0 +1,8 @@ +export const yellow = "#feda6a"; +export const silver = "#d4d4dc"; +export const grey = "#393f4d"; +export const dark = "#1d1e22"; +export const lastFm = "#d51007"; +export const lastFmDark = "#d51007"; + +export const yellowRGB = "254, 218, 106"; diff --git a/lib/durationFormat.js b/lib/durationFormat.ts similarity index 60% rename from lib/durationFormat.js rename to lib/durationFormat.ts index d49252e..b40fa2f 100644 --- a/lib/durationFormat.js +++ b/lib/durationFormat.ts @@ -1,13 +1,13 @@ export default function durationFormat(duration) { - if (duration <= 0) return ''; + if (duration <= 0) return ""; const hrs = Math.floor(duration / 3600); const mins = Math.floor((duration % 3600) / 60); const secs = Math.floor(duration % 60); - let ret = ''; - if (hrs > 0) ret += `${hrs}:${mins < 10 ? '0' : ''}`; - ret += `${mins}:${secs < 10 ? '0' : ''}`; + let ret = ""; + if (hrs > 0) ret += `${hrs}:${mins < 10 ? "0" : ""}`; + ret += `${mins}:${secs < 10 ? "0" : ""}`; ret += `${secs}`; return ret; } diff --git a/lib/initNProgress.js b/lib/initNProgress.ts similarity index 52% rename from lib/initNProgress.js rename to lib/initNProgress.ts index bead7be..f27193a 100644 --- a/lib/initNProgress.js +++ b/lib/initNProgress.ts @@ -1,5 +1,5 @@ -import Router from 'next/router'; -import NProgress from 'nprogress'; +import Router from "next/router"; +import NProgress from "nprogress"; let progressTimeout = null; @@ -11,10 +11,10 @@ const stopProgress = () => { export default () => { NProgress.configure({ showSpinner: false }); - Router.events.on('routeChangeStart', () => { + Router.events.on("routeChangeStart", () => { progressTimeout = setTimeout(NProgress.start, 100); }); - Router.events.on('routeChangeComplete', stopProgress); - Router.events.on('routeChangeError', stopProgress); + Router.events.on("routeChangeComplete", stopProgress); + Router.events.on("routeChangeError", stopProgress); }; diff --git a/lib/mongodb.ts b/lib/mongodb.ts new file mode 100644 index 0000000..aefd61e --- /dev/null +++ b/lib/mongodb.ts @@ -0,0 +1,9 @@ +import mongoose from "mongoose"; + +let isConnected = false; + +export async function connectToDatabase(): Promise { + if (isConnected) return; + await mongoose.connect(process.env.MONGODB_URI as string); + isConnected = true; +} diff --git a/lib/offline.js b/lib/offline.js deleted file mode 100644 index 6f90c33..0000000 --- a/lib/offline.js +++ /dev/null @@ -1,8 +0,0 @@ -/* eslint-disable no-console */ - -if (typeof window !== 'undefined' && 'serviceWorker' in navigator) { - navigator.serviceWorker - .register('/service-worker.js') - .then(() => { console.log('Service worker registered'); }) - .catch((e) => { console.error('Error during worker registration:', e); }); -} diff --git a/lib/offline.ts b/lib/offline.ts new file mode 100644 index 0000000..88f8a48 --- /dev/null +++ b/lib/offline.ts @@ -0,0 +1,12 @@ +/* eslint-disable no-console */ + +if (typeof window !== "undefined" && "serviceWorker" in navigator) { + navigator.serviceWorker + .register("/service-worker.js") + .then(() => { + console.log("Service worker registered"); + }) + .catch(e => { + console.error("Error during worker registration:", e); + }); +} diff --git a/lib/polyfills.js b/lib/polyfills.js deleted file mode 100644 index ca2a547..0000000 --- a/lib/polyfills.js +++ /dev/null @@ -1 +0,0 @@ -import 'intersection-observer'; diff --git a/lib/polyfills.ts b/lib/polyfills.ts new file mode 100644 index 0000000..7761c6c --- /dev/null +++ b/lib/polyfills.ts @@ -0,0 +1 @@ +// Polyfills file — intersection-observer is now native in all modern browsers diff --git a/lib/session.ts b/lib/session.ts new file mode 100644 index 0000000..a7687ee --- /dev/null +++ b/lib/session.ts @@ -0,0 +1,27 @@ +import { getIronSession, IronSession, SessionOptions } from "iron-session"; +import type { IncomingMessage, ServerResponse } from "http"; + +export interface SessionData { + userId?: string; + user?: { + id: string; + name: string; + url: string; + image: string; + imageLarge: string; + imageXLarge: string; + }; +} + +export const sessionOptions: SessionOptions = { + password: process.env.SESSION_SECRET as string, + cookieName: "code-scrobble-session", + cookieOptions: { + secure: process.env.NODE_ENV === "production", + maxAge: 2592000, // 30 days in seconds + }, +}; + +export function getSession(req: IncomingMessage, res: ServerResponse): Promise> { + return getIronSession(req, res, sessionOptions); +} diff --git a/lib/targetBlank.js b/lib/targetBlank.js deleted file mode 100644 index 15447e1..0000000 --- a/lib/targetBlank.js +++ /dev/null @@ -1,4 +0,0 @@ -export default { - target: '_blank', - rel: 'noopener noreferrer', -}; diff --git a/lib/targetBlank.ts b/lib/targetBlank.ts new file mode 100644 index 0000000..5b3e9fe --- /dev/null +++ b/lib/targetBlank.ts @@ -0,0 +1,4 @@ +export default { + target: "_blank", + rel: "noopener noreferrer", +}; diff --git a/lib/withAuth.ts b/lib/withAuth.ts new file mode 100644 index 0000000..925abea --- /dev/null +++ b/lib/withAuth.ts @@ -0,0 +1,22 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { connectToDatabase } from "./mongodb"; +import { getSession } from "./session"; +import User from "../app/models/user"; + +export async function requireUser(req: NextApiRequest, res: NextApiResponse): Promise { + await connectToDatabase(); + const session = await getSession(req, res); + + if (!session.userId) { + res.status(401).json({ error: "Unauthorized" }); + return null; + } + + const user = await User.findById(session.userId); + if (!user) { + res.status(401).json({ error: "Unauthorized" }); + return null; + } + + return user; +} diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..87a0136 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +const PUBLIC_PATHS = ["/login", "/legal", "/privacy", "/api/auth/"]; + +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + // Skip API routes, Next.js internals, and static assets + if ( + pathname.startsWith("/api/") || + pathname.startsWith("/_next/") || + pathname.startsWith("/static/") || + pathname.includes(".") + ) { + return NextResponse.next(); + } + + // Skip public pages + if (PUBLIC_PATHS.some(path => pathname.startsWith(path))) { + return NextResponse.next(); + } + + // Redirect to login if session cookie is absent + const sessionCookie = request.cookies.get("code-scrobble-session"); + if (!sessionCookie) { + return NextResponse.redirect(new URL("/login", request.url)); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], +}; diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..725dd6f --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/next.config.js b/next.config.js index 3024955..d240877 100644 --- a/next.config.js +++ b/next.config.js @@ -1,54 +1,11 @@ -/* eslint-disable no-param-reassign */ -const path = require('path'); -const SWPrecacheWebpackPlugin = require('sw-precache-webpack-plugin'); -const withBundleAnalyzer = require('@next/bundle-analyzer')({ - enabled: process.env.BUNDLE_ANALYZE === 'true', +const withBundleAnalyzer = require("@next/bundle-analyzer")({ + enabled: process.env.BUNDLE_ANALYZE === "true", }); -module.exports = withBundleAnalyzer({ - useFileSystemPublicRoutes: false, +/** @type {import('next').NextConfig} */ +const nextConfig = { poweredByHeader: false, + reactStrictMode: true, +}; - webpack: (config, { dev, isServer, buildId }) => { - if (!dev) { - config.plugins.push( - new SWPrecacheWebpackPlugin({ - cacheId: 'codescrobble', - filepath: path.resolve('./public/static/service-worker.js'), - minify: false, - navigateFallback: "/", - mergeStaticsConfig: false, - staticFileGlobs: [ - '.next/bundles/**/*.js', - '.next/static/**/*.{js,css,jpg,jpeg,png,svg,gif}', - ], - staticFileGlobsIgnorePatterns: [/_.*\.js$/, /\.map/], - stripPrefixMulti: { - '.next/bundles/pages/': `/_next/${buildId}/page/`, - '.next/static/': '/_next/static/', - }, - runtimeCaching: [ - { handler: 'fastest', urlPattern: /[.](jpe?g|png|svg|gif|ico)/ }, - { handler: 'networkFirst', urlPattern: /[.](js|css)/ }, - { handler: 'networkFirst', urlPattern: /\/detected\// }, - { handler: 'networkFirst', urlPattern: /\/session/ }, - { handler: 'networkFirst', urlPattern: /\/login/ }, - { handler: 'networkFirst', urlPattern: '/' }, - ], - verbose: true, - }), - ); - - if (!isServer) { - const originalEntry = config.entry; - config.entry = async () => { - const entries = await originalEntry(); - entries['main.js'].push(path.resolve('./lib/offline')); - entries['main.js'].unshift(path.resolve('./lib/polyfills.js')); - return entries; - }; - } - } - return config; - }, -}); +module.exports = withBundleAnalyzer(nextConfig); diff --git a/package.json b/package.json index a2c9dc0..941301f 100644 --- a/package.json +++ b/package.json @@ -13,90 +13,95 @@ }, "homepage": "https://www.codescrobble.com/", "scripts": { - "dev": "node server.js", + "dev": "next dev", "build": "next build", - "start": "NODE_ENV=production node server.js", - "test": "jest", - "test:watch": "yarn test --watch", - "test:coverage": "yarn test --coverage", - "lint": "eslint './**/*.{js,jsx}'", - "lint:css": "stylelint './**/*.{js,jsx}'", + "start": "next start", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "lint:css": "stylelint './**/*.styles.ts'", + "format": "prettier . --write", + "format:check": "prettier . --check", + "prepare": "husky", "analyze": "BUNDLE_ANALYZE=true yarn build" }, "dependencies": { - "@next/bundle-analyzer": "^9.2.0", - "babel-plugin-transform-class-properties": "^6.24.1", - "body-parser": "^1.19.0", - "compression": "^1.7.4", - "connect-redis": "^4.0.3", - "cookie-parser": "^1.4.4", - "disconnect": "^1.2.1", - "dotenv": "^8.2.0", - "express": "^4.17.1", - "express-session": "^1.17.0", - "helmet": "^3.21.2", - "intersection-observer": "^0.7.0", + "@next/bundle-analyzer": "^14.2.0", + "@redux-devtools/extension": "^3.3.0", + "disconnect": "^1.2.2", + "dotenv": "^16.4.7", + "iron-session": "^8.0.4", "lastfmapi": "^0.1.1", - "lodash": "^4.17.15", - "mongoose": "^5.8.9", - "morgan": "^1.9.1", - "next": "^9.2.0", - "next-redux-wrapper": "^4.0.1", + "lodash": "^4.17.21", + "mongoose": "^8.0.0", + "next": "^14.2.0", + "next-redux-wrapper": "^8.1.0", "nprogress": "^0.2.0", - "object-dig": "^0.1.3", - "passport": "^0.4.1", - "passport-lastfm": "dpuscher/passport-lastfm", - "prop-types": "^15.6.2", "quagga": "^0.12.1", - "react": "^16.12.0", - "react-dom": "^16.12.0", - "react-icons": "^3.8.0", - "react-is": "^16.12.0", - "react-lazy": "^1.1.0", - "react-redux": "^7.1.3", - "react-timeago": "^4.4.0", - "redis": "^2.8.0", - "redux": "^4.0.5", - "redux-devtools-extension": "^2.13.8", - "redux-thunk": "^2.3.0", - "styled-components": "^5.0.0", - "styled-icons": "^9.2.0", - "sw-precache-webpack-plugin": "^0.11.5" + "react": "^18.0.0", + "react-dom": "^18.0.0", + "react-icons": "^4.12.0", + "react-redux": "^9.0.0", + "react-timeago": "^8.3.0", + "redis": "^5.0.0", + "redux": "^5.0.0", + "redux-thunk": "^3.0.0", + "styled-components": "^6.0.0", + "styled-icons": "^10.47.1" }, "devDependencies": { - "@babel/core": "^7.8.3", + "@babel/core": "^7.26.0", + "@babel/runtime-corejs2": "^7.26.0", + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.18.0", "@lavamoat/allow-scripts": "^3.4.3", "@lavamoat/preinstall-always-fail": "^2.1.1", - "babel-eslint": "^10.0.3", - "babel-jest": "^24.9.0", - "enzyme": "^3.11.0", - "enzyme-adapter-react-16": "^1.15.2", - "eslint": "6.8.0", - "eslint-config-airbnb": "18.0.1", - "eslint-plugin-import": "^2.20.0", - "eslint-plugin-jest": "^23.6.0", - "eslint-plugin-jsx-a11y": "^6.2.3", - "eslint-plugin-react": "^7.18.0", - "jest": "^24.9.0", - "jest-fetch-mock": "^3.0.1", - "react-addons-test-utils": "^15.6.2", - "react-test-renderer": "^16.12.0", + "@testing-library/dom": "^10.0.0", + "@testing-library/react": "^16.0.0", + "@types/node": "^22.10.7", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.4.1", + "@vitest/coverage-v8": "^2.1.8", + "@vitest/eslint-plugin": "^1.3.25", + "babel-plugin-styled-components": "^2.1.4", + "eslint": "^8.57.0", + "eslint-config-next": "^14.2.0", + "eslint-config-prettier": "10.1.8", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsx-a11y": "^6.10.2", + "happy-dom": "^15.11.7", + "husky": "^9.0.0", + "lint-staged": "^16.2.4", + "postcss-styled-syntax": "^0.7.1", + "prettier": "^3.8.1", + "react-test-renderer": "^18.0.0", "redis-mock": "^0.47.0", "redux-mock-store": "^1.5.4", - "stylelint": "^12.0.0", - "stylelint-config-property-sort-order-smacss": "^5.2.0", - "stylelint-config-recommended": "^3.0.0", - "stylelint-config-styled-components": "^0.1.1", - "stylelint-processor-styled-components": "^1.9.0" + "stylelint": "^16.12.0", + "stylelint-config-recommended": "^14.0.1", + "typescript": "^5.7.3", + "vite": "^6.1.0", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^2.1.8" }, "packageManager": "yarn@4.12.0", "lavamoat": { "allowScripts": { "@lavamoat/preinstall-always-fail": false, - "@next/bundle-analyzer>webpack-bundle-analyzer>ejs": false, - "babel-jest>@jest/transform>jest-haste-map>fsevents": false, - "eslint-plugin-jsx-a11y>axobject-query>@babel/runtime-corejs3>core-js-pure": false, - "next>@babel/runtime-corejs2>core-js": false + "@babel/runtime-corejs2>core-js": false, + "eslint-config-next>eslint-import-resolver-typescript>unrs-resolver": false } + }, + "lint-staged": { + "*.{js,jsx,ts,tsx}": [ + "prettier --write", + "eslint --fix" + ], + "*.{json,md,yaml,yml,css,scss}": [ + "prettier --write" + ] } } diff --git a/pages/_app.js b/pages/_app.js deleted file mode 100644 index a49235f..0000000 --- a/pages/_app.js +++ /dev/null @@ -1,30 +0,0 @@ -import App from 'next/app'; -import Head from 'next/head'; -import React from 'react'; -import withRedux from 'next-redux-wrapper'; -import { Provider } from 'react-redux'; -import BaseStyles from '../components/layout/BaseStyles'; -import NProgressStyles from '../styles/nprogress.styles'; -import initNProgress from '../lib/initNProgress'; -import initializeStore from '../client/reduxStore'; - -initNProgress(); - -class MyApp extends App { - render() { - const { Component, pageProps, store } = this.props; - - return ( - - - - - CodeScrobble ► Easily scrobble VINYL and CD to Last.fm - - - - ); - } -} - -export default withRedux(initializeStore)(MyApp); diff --git a/pages/_app.tsx b/pages/_app.tsx new file mode 100644 index 0000000..9d4209a --- /dev/null +++ b/pages/_app.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import Head from "next/head"; +import { Provider } from "react-redux"; +import BaseStyles from "../components/layout/BaseStyles"; +import NProgressStyles from "../styles/nprogress.styles"; +import initNProgress from "../lib/initNProgress"; +import { wrapper } from "../client/reduxStore"; + +initNProgress(); + +function MyApp({ Component, ...rest }) { + const { store, props } = wrapper.useWrappedStore(rest); + + return ( + + + + + CodeScrobble ► Easily scrobble VINYL and CD to Last.fm + + + + + ); +} + +export default MyApp; diff --git a/pages/_document.js b/pages/_document.js deleted file mode 100644 index 6c0adb9..0000000 --- a/pages/_document.js +++ /dev/null @@ -1,77 +0,0 @@ -import Document, { Html, Head, Main, NextScript } from 'next/document'; -import { ServerStyleSheet } from 'styled-components'; -import { ANALYTICS_ID } from '../lib/analytics'; - -const isSafari = userAgent => ( - /Version\/([0-9._]+).*Safari/.test(userAgent) -); - -export default class MyDocument extends Document { - static async getInitialProps(ctx) { - const sheet = new ServerStyleSheet(); - const originalRenderPage = ctx.renderPage; - - try { - ctx.renderPage = () => originalRenderPage({ - enhanceApp: App => props => sheet.collectStyles(), - }); - - const initialProps = await Document.getInitialProps(ctx); - return { - ...initialProps, - showManifest: !isSafari(ctx.req.headers['user-agent']), - styles: <>{initialProps.styles}{sheet.getStyleElement()}, - }; - } finally { - sheet.seal(); - } - } - - render() { - return ( - - - - - {/* eslint-disable-next-line react/no-danger */} -