diff --git a/.babelrc b/.babelrc index cc7b03d..9189172 100644 --- a/.babelrc +++ b/.babelrc @@ -6,7 +6,6 @@ ["styled-components", { "displayName": false, "ssr": true - }], - "transform-class-properties" + }] ] } diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e959f7b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +*Dockerfile* +*docker-compose* +node_modules +.next +.env +.env.* +data +coverage 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 new file mode 100644 index 0000000..0bd7373 --- /dev/null +++ b/.github/workflows/build-production.yaml @@ -0,0 +1,43 @@ +name: Build and Deploy (Production) + +on: + push: + branches: + - production + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: ghcr.io/dpuscher/code-scrobble:production + + - name: Trigger Dokploy redeploy + run: | + curl -s -o /dev/null -w "%{http_code}" \ + -X POST "${{ secrets.DOKPLOY_PRODUCTION_DEPLOY_WEBHOOK }}" diff --git a/.github/workflows/build-staging.yaml b/.github/workflows/build-staging.yaml new file mode 100644 index 0000000..9466448 --- /dev/null +++ b/.github/workflows/build-staging.yaml @@ -0,0 +1,43 @@ +name: Build and Deploy (Staging) + +on: + push: + branches: + - staging + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: ghcr.io/dpuscher/code-scrobble:staging + + - name: Trigger Dokploy redeploy + run: | + curl -s -o /dev/null -w "%{http_code}" \ + -X POST "${{ secrets.DOKPLOY_STAGING_DEPLOY_WEBHOOK }}" diff --git a/.gitignore b/.gitignore index d4d8fb2..9a0ddd1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,15 @@ bundles/ coverage log/ node_modules/ -static/service-worker.js +public/static/service-worker.js yarn-error.log + +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions +data +.mcp.json diff --git a/.nvmrc b/.nvmrc index 70324da..53d1c14 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v10.15.1 +v22 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/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..5414b48 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1,5 @@ +enableScripts: false + +nodeLinker: node-modules + +npmMinimalAgeGate: 4320 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a252b18 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +FROM node:22-alpine AS base +RUN corepack enable + +FROM base AS deps +WORKDIR /app +RUN apk add --no-cache git +COPY package.json yarn.lock .yarnrc.yml ./ +RUN yarn install --immutable && yarn allow-scripts run + +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN yarn build + +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +COPY --from=deps /app/node_modules ./node_modules +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/public ./public +COPY --from=builder /app/app ./app +COPY --from=builder /app/lib ./lib +COPY package.json ./ + +USER nextjs + +EXPOSE 3000 + +CMD ["node_modules/.bin/next", "start"] 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..5086d14 --- /dev/null +++ b/app/__mocks__/redis.ts @@ -0,0 +1,25 @@ +/* eslint-disable no-underscore-dangle */ + +const store = new Map(); + +const mockGet = jest.fn(async (key) => store.get(key) || null); +const mockSet = jest.fn(async (key, value) => { + store.set(key, value); + return 'OK'; +}); +const mockConnect = jest.fn(async () => {}); +const mockOn = jest.fn(); + +const createClient = jest.fn(() => ({ + get: mockGet, + set: mockSet, + connect: mockConnect, + on: mockOn, +})); + +module.exports = { + createClient, + _get: mockGet, + _set: mockSet, + _reset: () => store.clear(), +}; diff --git a/app/cache.js b/app/cache.js index 664b0a5..122b4b2 100644 --- a/app/cache.js +++ b/app/cache.js @@ -1,24 +1,20 @@ -const redis = require('redis'); -const { promisify } = require('util'); +const { createClient } = require('redis'); -const client = redis.createClient(process.env.REDISCLOUD_URL); - -const getAsync = promisify(client.get).bind(client); -const setAsync = promisify(client.set).bind(client); +const client = createClient({ url: process.env.REDISCLOUD_URL }); +client.connect().catch(console.error); +client.on('error', console.error); module.exports = { set: (key, value, ttl = 86400) => ( - setAsync(key, JSON.stringify(value), 'EX', ttl) + client.set(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(); - } - }) - ), + get: async (key) => { + const value = await client.get(key); + if (value) { + return JSON.parse(value); + } + // eslint-disable-next-line prefer-promise-reject-errors + return Promise.reject(); + }, }; diff --git a/app/discogs.js b/app/discogs.js index 21bce40..d387b79 100644 --- a/app/discogs.js +++ b/app/discogs.js @@ -1,5 +1,4 @@ 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'); @@ -101,13 +100,13 @@ module.exports = { new Promise((resolve, reject) => { Database.getRelease(id, (err, data) => { if (err || !data) { - reject(); + reject(err || new Error('No data returned from Discogs')); } else { resolve({ id, artist: data.artists.map(a => a.name).join(', '), title: data.title, - image: dig(data, 'images', 0, 'uri'), + image: data?.images?.[0]?.uri, url: data.uri, year: data.year, tracks: normalizeTracklist(data.tracklist) 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 index a86332c..05deae1 100644 --- a/app/models/release.js +++ b/app/models/release.js @@ -66,7 +66,14 @@ releaseSchema.statics.firstOrCreate = async function firstOrCreate(param) { const id = param.id || await Discogs.barcode(param.barcode); if (!id) return null; - return this.createFromDiscogs(id, param.barcode); + try { + return await this.createFromDiscogs(id, param.barcode); + } catch (err) { + if (err.code === 11000) { + return this.findOne(param).exec(); + } + throw err; + } } // Data is older than one week @@ -77,4 +84,4 @@ releaseSchema.statics.firstOrCreate = async function firstOrCreate(param) { return release; }; -module.exports = mongoose.model('Release', releaseSchema); +module.exports = mongoose.models.Release || mongoose.model('Release', releaseSchema); diff --git a/app/models/user.js b/app/models/user.js index d82039a..463bac0 100644 --- a/app/models/user.js +++ b/app/models/user.js @@ -30,4 +30,4 @@ userSchema.methods.isInstantScrobble = function isInstantScrobble(id) { return (this.instantScrobbles || []).includes(String(id)); }; -module.exports = mongoose.model('User', userSchema); +module.exports = mongoose.models.User || mongoose.model('User', userSchema); 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 d4d2ba2..0000000 --- a/app/routes/auth.js +++ /dev/null @@ -1,14 +0,0 @@ -const passport = require('passport'); -const Router = require('express').Router(); - -Router.get('/lastfm', passport.authenticate('lastfm')); - -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.ts similarity index 89% rename from app/spec/cache.spec.js rename to app/spec/cache.spec.ts index e015a6a..466c464 100644 --- a/app/spec/cache.spec.js +++ b/app/spec/cache.spec.ts @@ -22,7 +22,7 @@ describe('cache', () => { 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'); + expect(redis._set.mock.calls[0][2]).toEqual({ EX: 86400 }); }); it('passes given ttl to redis store', async () => { @@ -32,7 +32,7 @@ describe('cache', () => { await Cache.set(key, value, ttl); - expect(redis._set.mock.calls[0][3]).toBe(ttl); + expect(redis._set.mock.calls[0][2]).toEqual({ EX: ttl }); }); it('uses 24 hours as default ttl', async () => { @@ -41,7 +41,7 @@ describe('cache', () => { await Cache.set(key, value); - expect(redis._set.mock.calls[0][3]).toBe(24 * 60 * 60); + expect(redis._set.mock.calls[0][2]).toEqual({ EX: 24 * 60 * 60 }); }); }); diff --git a/client/reduxStore.js b/client/reduxStore.ts similarity index 61% rename from client/reduxStore.js rename to client/reduxStore.ts index 7ef9772..d09fd96 100644 --- a/client/reduxStore.js +++ b/client/reduxStore.ts @@ -1,6 +1,7 @@ -import thunkMiddleware from 'redux-thunk'; +import { thunk as thunkMiddleware } from 'redux-thunk'; import { combineReducers, createStore, applyMiddleware } from 'redux'; -import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction'; +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'; @@ -16,10 +17,10 @@ const reducer = combineReducers({ query: queryReducer, }); -export default function initializeStore(initialState = {}) { - return createStore( - reducer, - initialState, - composeWithDevTools(applyMiddleware(thunkMiddleware)), - ); -} +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.tsx similarity index 97% rename from components/assets/Logo.jsx rename to components/assets/Logo.tsx index 611b308..2368ff8 100644 --- a/components/assets/Logo.jsx +++ b/components/assets/Logo.tsx @@ -1,6 +1,4 @@ -import PropTypes from 'prop-types'; - -const Logo = ({ className, ...props }) => ( +const Logo = ({ className = undefined, ...props }: { className?: string; [key: string]: any }) => ( CodeScrobble @@ -11,12 +9,4 @@ const Logo = ({ className, ...props }) => ( ); -Logo.propTypes = { - className: PropTypes.string, -}; - -Logo.defaultProps = { - className: undefined, -}; - export default Logo; diff --git a/components/assets/LogoSmall.jsx b/components/assets/LogoSmall.tsx similarity index 95% rename from components/assets/LogoSmall.jsx rename to components/assets/LogoSmall.tsx index 5971c3b..7b8ae2e 100644 --- a/components/assets/LogoSmall.jsx +++ b/components/assets/LogoSmall.tsx @@ -1,18 +1,8 @@ -import PropTypes from 'prop-types'; - -const Logo = ({ className, ...props }) => ( +const Logo = ({ className = undefined, ...props }: { className?: string; [key: string]: any }) => ( CodeScrobble ); -Logo.propTypes = { - className: PropTypes.string, -}; - -Logo.defaultProps = { - className: undefined, -}; - 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..8490401 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.tsx similarity index 84% rename from components/icons/LastfmIcon.jsx rename to components/icons/LastfmIcon.tsx index 1f24eb5..b44f0d6 100644 --- a/components/icons/LastfmIcon.jsx +++ b/components/icons/LastfmIcon.tsx @@ -1,20 +1,14 @@ -import PropTypes from 'prop-types'; +interface LastfmIconProps { + className?: string; + color?: string; + [key: string]: any; +} -const LastfmIcon = ({ color, ...props }) => ( +const LastfmIcon = ({ color = '#fff', ...props }: LastfmIconProps) => ( Last.fm ); -LastfmIcon.propTypes = { - className: PropTypes.string, - color: PropTypes.string, -}; - -LastfmIcon.defaultProps = { - className: undefined, - color: '#fff', -}; - 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..26c8648 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..e798c4a 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 100% rename from components/layout/BaseStyles.js rename to components/layout/BaseStyles.ts diff --git a/components/layout/CircleLayout.jsx b/components/layout/CircleLayout.tsx similarity index 74% rename from components/layout/CircleLayout.jsx rename to components/layout/CircleLayout.tsx index f85664d..b907d33 100644 --- a/components/layout/CircleLayout.jsx +++ b/components/layout/CircleLayout.tsx @@ -1,5 +1,5 @@ +import React from 'react'; 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, @@ -15,7 +15,13 @@ const ScrollLock = createGlobalStyle` } `; -const CircleLayout = ({ children, header, footer }) => ( +interface CircleLayoutProps { + children?: React.ReactNode; + header?: React.ReactNode; + footer?: React.ReactNode; +} + +const CircleLayout = ({ children = null, header = null, footer = null }: CircleLayoutProps) => (
@@ -25,7 +31,7 @@ const CircleLayout = ({ children, header, footer }) => ( - + {header} @@ -43,16 +49,4 @@ const CircleLayout = ({ children, header, 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/Loading.jsx b/components/layout/Loading.tsx similarity index 100% rename from components/layout/Loading.jsx rename to components/layout/Loading.tsx diff --git a/components/layout/Spinner.jsx b/components/layout/Spinner.tsx similarity index 89% rename from components/layout/Spinner.jsx rename to components/layout/Spinner.tsx index 5d191b0..ff026be 100644 --- a/components/layout/Spinner.jsx +++ b/components/layout/Spinner.tsx @@ -7,7 +7,7 @@ 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; diff --git a/components/layout/styles/Error.styles.js b/components/layout/styles/Error.styles.ts similarity index 100% rename from components/layout/styles/Error.styles.js rename to components/layout/styles/Error.styles.ts diff --git a/components/layout/styles/Loading.styles.js b/components/layout/styles/Loading.styles.ts similarity index 100% rename from components/layout/styles/Loading.styles.js rename to components/layout/styles/Loading.styles.ts diff --git a/components/profile/ProfileAutoScrobbleItem.jsx b/components/profile/ProfileAutoScrobbleItem.tsx similarity index 62% rename from components/profile/ProfileAutoScrobbleItem.jsx rename to components/profile/ProfileAutoScrobbleItem.tsx index c8c5ac0..3a35482 100644 --- a/components/profile/ProfileAutoScrobbleItem.jsx +++ b/components/profile/ProfileAutoScrobbleItem.tsx @@ -1,12 +1,20 @@ 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 { TrashAlt as DeleteIcon } from 'styled-icons/fa-regular'; import { deleteAutoScrobble } from './actions/autoScrobbleActions'; import { DeleteButton, ListCaption, ListItem } from '../../styles/profile.styles'; -class ProfileAutoScrobbleItem extends React.PureComponent { +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); @@ -14,7 +22,7 @@ class ProfileAutoScrobbleItem extends React.PureComponent { render() { const { - id, artist, title, year, isDeleting, + id, artist, title, year, isDeleting = false, } = this.props; return ( @@ -30,20 +38,6 @@ class ProfileAutoScrobbleItem extends React.PureComponent { } } -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) ); diff --git a/components/profile/ProfileAutoScrobbles.jsx b/components/profile/ProfileAutoScrobbles.tsx similarity index 80% rename from components/profile/ProfileAutoScrobbles.jsx rename to components/profile/ProfileAutoScrobbles.tsx index c807ee7..e1f025d 100644 --- a/components/profile/ProfileAutoScrobbles.jsx +++ b/components/profile/ProfileAutoScrobbles.tsx @@ -1,6 +1,5 @@ import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; import React from 'react'; import { fetchAutoScrobbles } from './actions/autoScrobbleActions'; import { @@ -9,14 +8,21 @@ import { import ProfileAutoScrobbleItem from './ProfileAutoScrobbleItem'; import Spinner from '../layout/Spinner'; -class ProfileAutoScrobbles extends React.PureComponent { +interface ProfileAutoScrobblesProps { + data?: any[]; + loading?: boolean; + deleting?: any[]; + fetchAutoScrobbles: () => void; +} + +class ProfileAutoScrobbles extends React.PureComponent { componentDidMount() { this.props.fetchAutoScrobbles(); } render() { const { - data, loading, deleting, + data = [], loading = true, deleting = [], } = this.props; return ( <> @@ -47,19 +53,6 @@ class ProfileAutoScrobbles extends React.PureComponent { } } -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, diff --git a/components/profile/ProfileHistory.jsx b/components/profile/ProfileHistory.tsx similarity index 78% rename from components/profile/ProfileHistory.jsx rename to components/profile/ProfileHistory.tsx index dcbe3c2..857ea6d 100644 --- a/components/profile/ProfileHistory.jsx +++ b/components/profile/ProfileHistory.tsx @@ -1,6 +1,5 @@ import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; import React from 'react'; import { fetchHistory } from './actions/historyActions'; import { @@ -9,13 +8,19 @@ import { import ProfileHistoryItem from './ProfileHistoryItem'; import Spinner from '../layout/Spinner'; -class ProfileHistorys extends React.PureComponent { +interface ProfileHistoryProps { + history?: any[]; + loading?: boolean; + fetchHistory: () => void; +} + +class ProfileHistory extends React.PureComponent { componentDidMount() { this.props.fetchHistory(); } render() { - const { history, loading } = this.props; + const { history = [], loading = true } = this.props; return ( <>

History

@@ -43,17 +48,6 @@ class ProfileHistorys extends React.PureComponent { } } -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, @@ -66,4 +60,4 @@ const mapDispatchToProps = dispatch => ( export default connect( mapStateToProps, mapDispatchToProps, -)(ProfileHistorys); +)(ProfileHistory); diff --git a/components/profile/ProfileHistoryItem.jsx b/components/profile/ProfileHistoryItem.tsx similarity index 52% rename from components/profile/ProfileHistoryItem.jsx rename to components/profile/ProfileHistoryItem.tsx index 8500066..4983271 100644 --- a/components/profile/ProfileHistoryItem.jsx +++ b/components/profile/ProfileHistoryItem.tsx @@ -1,20 +1,29 @@ - -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 { +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, + id, artist, title, year, barcode, discogsId, time, isDeleting = false, } = this.props; const barcodeParam = barcode || `id:${discogsId}`; return ( - + {`${artist} - ${title}`} @@ -28,21 +37,4 @@ class ProfileHistoryItem extends React.PureComponent { } } -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/actions/autoScrobbleActionCreators.js b/components/profile/actions/autoScrobbleActionCreators.ts similarity index 100% rename from components/profile/actions/autoScrobbleActionCreators.js rename to components/profile/actions/autoScrobbleActionCreators.ts diff --git a/components/profile/actions/autoScrobbleActions.js b/components/profile/actions/autoScrobbleActions.ts similarity index 81% rename from components/profile/actions/autoScrobbleActions.js rename to components/profile/actions/autoScrobbleActions.ts index 9d94129..96e33b5 100644 --- a/components/profile/actions/autoScrobbleActions.js +++ b/components/profile/actions/autoScrobbleActions.ts @@ -7,7 +7,9 @@ export const fetchAutoScrobbles = () => ( async (dispatch) => { try { dispatch(setLoadingState(true)); - const data = await fetch('/api/user/autoscrobbles', { credentials: 'include' }).then(r => r.json()); + 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)); diff --git a/components/profile/actions/historyActionCreators.js b/components/profile/actions/historyActionCreators.ts similarity index 100% rename from components/profile/actions/historyActionCreators.js rename to components/profile/actions/historyActionCreators.ts diff --git a/components/profile/actions/historyActions.js b/components/profile/actions/historyActions.ts similarity index 67% rename from components/profile/actions/historyActions.js rename to components/profile/actions/historyActions.ts index 742fba6..6acaa76 100644 --- a/components/profile/actions/historyActions.js +++ b/components/profile/actions/historyActions.ts @@ -5,7 +5,9 @@ export const fetchHistory = () => ( async (dispatch) => { try { dispatch(setLoadingState(true)); - const data = await fetch('/api/user/history', { credentials: 'include' }).then(r => r.json()); + 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)); diff --git a/components/profile/actions/spec/autoScrobbleActionCreators.spec.js b/components/profile/actions/spec/autoScrobbleActionCreators.spec.ts similarity index 100% rename from components/profile/actions/spec/autoScrobbleActionCreators.spec.js rename to components/profile/actions/spec/autoScrobbleActionCreators.spec.ts diff --git a/components/profile/actions/spec/autoScrobbleActions.spec.js b/components/profile/actions/spec/autoScrobbleActions.spec.ts similarity index 99% rename from components/profile/actions/spec/autoScrobbleActions.spec.js rename to components/profile/actions/spec/autoScrobbleActions.spec.ts index 2da5366..0ad5577 100644 --- a/components/profile/actions/spec/autoScrobbleActions.spec.js +++ b/components/profile/actions/spec/autoScrobbleActions.spec.ts @@ -1,5 +1,5 @@ import configureMockStore from 'redux-mock-store'; -import thunk from 'redux-thunk'; +import { thunk } from "redux-thunk";; import * as actionCreators from '../autoScrobbleActionCreators'; import { diff --git a/components/profile/actions/spec/historyActionCreators.spec.js b/components/profile/actions/spec/historyActionCreators.spec.ts similarity index 100% rename from components/profile/actions/spec/historyActionCreators.spec.js rename to components/profile/actions/spec/historyActionCreators.spec.ts diff --git a/components/profile/actions/spec/historyActions.spec.js b/components/profile/actions/spec/historyActions.spec.ts similarity index 98% rename from components/profile/actions/spec/historyActions.spec.js rename to components/profile/actions/spec/historyActions.spec.ts index 4af32e3..a8bbb5a 100644 --- a/components/profile/actions/spec/historyActions.spec.js +++ b/components/profile/actions/spec/historyActions.spec.ts @@ -1,5 +1,5 @@ import configureMockStore from 'redux-mock-store'; -import thunk from 'redux-thunk'; +import { thunk } from "redux-thunk";; import * as actionCreators from '../historyActionCreators'; import { fetchHistory } from '../historyActions'; diff --git a/components/profile/constants/autoScrobbleConstants.js b/components/profile/constants/autoScrobbleConstants.ts similarity index 100% rename from components/profile/constants/autoScrobbleConstants.js rename to components/profile/constants/autoScrobbleConstants.ts diff --git a/components/profile/constants/historyConstants.js b/components/profile/constants/historyConstants.ts similarity index 100% rename from components/profile/constants/historyConstants.js rename to components/profile/constants/historyConstants.ts diff --git a/components/profile/reducers/autoScrobbleReducer.js b/components/profile/reducers/autoScrobbleReducer.ts similarity index 86% rename from components/profile/reducers/autoScrobbleReducer.js rename to components/profile/reducers/autoScrobbleReducer.ts index 218a8c1..397670a 100644 --- a/components/profile/reducers/autoScrobbleReducer.js +++ b/components/profile/reducers/autoScrobbleReducer.ts @@ -10,7 +10,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 +45,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 89% rename from components/profile/reducers/historyReducer.js rename to components/profile/reducers/historyReducer.ts index 3a6b226..b6912e8 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'; -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 100% rename from components/profile/reducers/spec/autoScrobbleReducer.spec.js rename to components/profile/reducers/spec/autoScrobbleReducer.spec.ts diff --git a/components/profile/reducers/spec/historyReducer.spec.js b/components/profile/reducers/spec/historyReducer.spec.ts similarity index 100% rename from components/profile/reducers/spec/historyReducer.spec.js rename to components/profile/reducers/spec/historyReducer.spec.ts diff --git a/components/query/QueryRelease.jsx b/components/query/QueryRelease.tsx similarity index 83% rename from components/query/QueryRelease.jsx rename to components/query/QueryRelease.tsx index 7cbb2da..40c67a8 100644 --- a/components/query/QueryRelease.jsx +++ b/components/query/QueryRelease.tsx @@ -1,6 +1,5 @@ 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'; @@ -18,8 +17,17 @@ import { ThumbnailWrapper, Title, Wrapper, } from './styles/QueryRelease.styles'; -class QueryRelease extends React.Component { - inputRef = React.createRef(); +interface QueryReleaseProps { + results?: any[]; + query?: string; + loading?: boolean; + queryRelease: () => void; + resetResults: () => void; + setQuery: (query?: string) => void; +} + +class QueryRelease extends React.Component { + inputRef = React.createRef(); state = { open: false, @@ -55,7 +63,7 @@ class QueryRelease extends React.Component { render() { const { open, searched } = this.state; - const { loading, results, query } = this.props; + const { loading = false, results = [], query = '' } = this.props; let content = ; if (loading) { @@ -66,10 +74,10 @@ class QueryRelease extends React.Component { {results.map(({ id, title, thumb, country, year, format = [], }) => ( - + - - + + @@ -88,7 +96,7 @@ class QueryRelease extends React.Component { } else if (searched) { content = ( <FallbackWrapper css="text-align:center"> - <NoResultsIcon css="margin-bottom:20px" /> + <NoResultsIcon {...{ css: 'margin-bottom:20px' } as any} /> No results were found <br /> for your query @@ -125,21 +133,6 @@ class QueryRelease extends React.Component { } } -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, }); diff --git a/components/query/actions/queryActionCreators.js b/components/query/actions/queryActionCreators.ts similarity index 100% rename from components/query/actions/queryActionCreators.js rename to components/query/actions/queryActionCreators.ts diff --git a/components/query/actions/queryActions.js b/components/query/actions/queryActions.ts similarity index 100% rename from components/query/actions/queryActions.js rename to components/query/actions/queryActions.ts diff --git a/components/query/constants/queryConstants.js b/components/query/constants/queryConstants.ts similarity index 100% rename from components/query/constants/queryConstants.js rename to components/query/constants/queryConstants.ts diff --git a/components/query/reducers/queryReducer.js b/components/query/reducers/queryReducer.ts similarity index 91% rename from components/query/reducers/queryReducer.js rename to components/query/reducers/queryReducer.ts index bb8d008..82fb66f 100644 --- a/components/query/reducers/queryReducer.js +++ b/components/query/reducers/queryReducer.ts @@ -9,7 +9,7 @@ const initialState = { 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 97% rename from components/query/styles/QueryRelease.styles.js rename to components/query/styles/QueryRelease.styles.ts index 7d025a7..ce022ab 100644 --- a/components/query/styles/QueryRelease.styles.js +++ b/components/query/styles/QueryRelease.styles.ts @@ -1,6 +1,5 @@ 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'; @@ -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 87% rename from components/release/ReleaseInfo.jsx rename to components/release/ReleaseInfo.tsx index 93e5623..6ffe00a 100644 --- a/components/release/ReleaseInfo.jsx +++ b/components/release/ReleaseInfo.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React from 'react'; import { MdClose } from 'react-icons/md'; // TODO: replace import durationFormat from '../../lib/durationFormat'; @@ -10,7 +9,26 @@ import { 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<ReleaseInfoProps, { open: boolean }> { state = { open: false, } @@ -26,7 +44,7 @@ class ReleaseInfo extends React.Component { const { release: { image, title, year, artist, tracks, url, - }, + } = {} as Release, } = this.props; return ( <Wrapper> @@ -73,12 +91,4 @@ class ReleaseInfo extends React.Component { } } -ReleaseInfo.propTypes = { - release: PropTypes.object, -}; - -ReleaseInfo.defaultProps = { - release: {}, -}; - export default ReleaseInfo; diff --git a/components/release/SearchRelease.jsx b/components/release/SearchRelease.tsx similarity index 78% rename from components/release/SearchRelease.jsx rename to components/release/SearchRelease.tsx index 0461d43..e391743 100644 --- a/components/release/SearchRelease.jsx +++ b/components/release/SearchRelease.tsx @@ -1,6 +1,5 @@ import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; import React from 'react'; import { FaLastfm } from 'react-icons/fa'; import { MdClose } from 'react-icons/md'; @@ -10,12 +9,22 @@ import SearchReleaseError from './SearchReleaseError'; import { Button, Poster, PosterContent } from './styles/SearchRelease.styles'; import { autotrackParams } from '../../lib/analytics'; -class SearchRelease extends React.Component { +interface SearchReleaseProps { + code: string; + onScrobble: () => void; + onCancel: () => void; + fetchRelease: (code: string) => void; + error?: any; + loading?: boolean; + data?: any; +} + +class SearchRelease extends React.Component<SearchReleaseProps, {}> { componentDidMount() { this.props.fetchRelease(this.props.code); } - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps: SearchReleaseProps) { const { data } = this.props; if (prevProps.data !== data && data.instantScrobble) { this.props.onScrobble(); @@ -24,7 +33,7 @@ class SearchRelease extends React.Component { render() { const { - code, error, loading, data, onCancel, onScrobble, + code, error = null, loading = true, data = {}, onCancel, onScrobble, } = this.props; return ( <> @@ -57,22 +66,6 @@ class SearchRelease extends React.Component { } } -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], }); @@ -84,4 +77,4 @@ const mapDispatchToProps = dispatch => ( export default connect( mapStateToProps, mapDispatchToProps, -)(SearchRelease); +)(SearchRelease) as any; diff --git a/components/release/SearchReleaseError.jsx b/components/release/SearchReleaseError.tsx similarity index 72% rename from components/release/SearchReleaseError.jsx rename to components/release/SearchReleaseError.tsx index 8460ebf..57e1647 100644 --- a/components/release/SearchReleaseError.jsx +++ b/components/release/SearchReleaseError.tsx @@ -1,11 +1,15 @@ -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 }) => ( +interface SearchReleaseErrorProps { + code: string; + onRetry: () => void; +} + +const SearchReleaseError = ({ code, onRetry }: SearchReleaseErrorProps) => ( <FlexContent> <ErrorIcon color={yellow} /> <b>No release found<br />{code}</b> @@ -16,9 +20,4 @@ const SearchReleaseError = ({ code, onRetry }) => ( </FlexContent> ); -SearchReleaseError.propTypes = { - code: PropTypes.string.isRequired, - onRetry: PropTypes.func.isRequired, -}; - export default SearchReleaseError; diff --git a/components/release/actions/releaseActionCreators.js b/components/release/actions/releaseActionCreators.ts similarity index 100% rename from components/release/actions/releaseActionCreators.js rename to components/release/actions/releaseActionCreators.ts diff --git a/components/release/actions/releaseActions.js b/components/release/actions/releaseActions.ts similarity index 79% rename from components/release/actions/releaseActions.js rename to components/release/actions/releaseActions.ts index d0a5876..abf4203 100644 --- a/components/release/actions/releaseActions.js +++ b/components/release/actions/releaseActions.ts @@ -8,7 +8,9 @@ export const fetchRelease = code => ( dispatch(setErrorState(code, null)); dispatch(setLoadingState(code, true)); - const data = await fetch(`/api/barcode/${code}`, { credentials: 'include' }).then(r => r.json()); + 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)); diff --git a/components/release/constants/releaseConstants.js b/components/release/constants/releaseConstants.ts similarity index 100% rename from components/release/constants/releaseConstants.js rename to components/release/constants/releaseConstants.ts diff --git a/components/release/reducers/releaseReducer.js b/components/release/reducers/releaseReducer.ts similarity index 91% rename from components/release/reducers/releaseReducer.js rename to components/release/reducers/releaseReducer.ts index dcae5d1..ab02b84 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'; -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 100% rename from components/release/styles/ReleaseInfo.styles.js rename to components/release/styles/ReleaseInfo.styles.ts diff --git a/components/release/styles/SearchRelease.styles.js b/components/release/styles/SearchRelease.styles.ts similarity index 93% rename from components/release/styles/SearchRelease.styles.js rename to components/release/styles/SearchRelease.styles.ts index 76135b0..638b0f4 100644 --- a/components/release/styles/SearchRelease.styles.js +++ b/components/release/styles/SearchRelease.styles.ts @@ -1,6 +1,6 @@ import styled from 'styled-components'; -export const Poster = styled.div` +export const Poster = styled.div<{ image?: string }>` display: flex; position: absolute; top: 0; diff --git a/components/scanner/Scanner.jsx b/components/scanner/Scanner.tsx similarity index 88% rename from components/scanner/Scanner.jsx rename to components/scanner/Scanner.tsx index 8dbf47a..ecb26ee 100644 --- a/components/scanner/Scanner.jsx +++ b/components/scanner/Scanner.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React from 'react'; import Quagga from 'quagga'; import { yellow } from '../../lib/colors'; @@ -7,8 +6,12 @@ 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) { +interface ScannerProps { + onDetected: (result: any) => void; +} + +class Scanner extends React.Component<ScannerProps, { loading: boolean; videoError: boolean }> { + constructor(props: ScannerProps) { super(props); this.state = { loading: true, @@ -85,14 +88,10 @@ class Scanner extends React.Component { </FlexContent> )} {/* eslint-disable-next-line jsx-a11y/media-has-caption */} - <Camera id="camera" visible={ready}><video playsInline autoPlay /></Camera> + <Camera id="camera" $visible={ready}><video playsInline autoPlay /></Camera> </> ); } } -Scanner.propTypes = { - onDetected: PropTypes.func.isRequired, -}; - export default Scanner; diff --git a/components/scanner/styles/Scanner.styles.js b/components/scanner/styles/Scanner.styles.ts similarity index 71% rename from components/scanner/styles/Scanner.styles.js rename to components/scanner/styles/Scanner.styles.ts index f6e5bd4..d8412c3 100644 --- a/components/scanner/styles/Scanner.styles.js +++ b/components/scanner/styles/Scanner.styles.ts @@ -1,8 +1,8 @@ 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%; diff --git a/components/scrobble/Scrobble.jsx b/components/scrobble/Scrobble.tsx similarity index 82% rename from components/scrobble/Scrobble.jsx rename to components/scrobble/Scrobble.tsx index b0deedd..c7a1401 100644 --- a/components/scrobble/Scrobble.jsx +++ b/components/scrobble/Scrobble.tsx @@ -1,9 +1,14 @@ -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 { +interface ScrobbleProps { + release: { id: string; image?: string; [key: string]: any }; + autoScrobble: boolean; + onScrobbled: () => void; +} + +class Scrobble extends React.Component<ScrobbleProps, { loadingError: boolean }> { state = { loadingError: false, }; @@ -45,10 +50,4 @@ class Scrobble extends React.Component { } } -Scrobble.propTypes = { - release: PropTypes.object.isRequired, - autoScrobble: PropTypes.bool.isRequired, - onScrobbled: PropTypes.func.isRequired, -}; - export default Scrobble; diff --git a/components/scrobble/ScrobbleError.jsx b/components/scrobble/ScrobbleError.tsx similarity index 78% rename from components/scrobble/ScrobbleError.jsx rename to components/scrobble/ScrobbleError.tsx index b3a84fb..6965b89 100644 --- a/components/scrobble/ScrobbleError.jsx +++ b/components/scrobble/ScrobbleError.tsx @@ -1,11 +1,14 @@ -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 }) => ( +interface ScrobbleErrorProps { + onRetry: () => void; +} + +const ScrobbleError = ({ onRetry }: ScrobbleErrorProps) => ( <FlexContent> <ErrorIcon color={yellow} /> <b>An error occured while sending data to Last.fm</b> @@ -16,8 +19,4 @@ const ScrobbleError = ({ onRetry }) => ( </FlexContent> ); -ScrobbleError.propTypes = { - onRetry: PropTypes.func.isRequired, -}; - export default ScrobbleError; diff --git a/components/scrobble/styles/Scrobble.styles.js b/components/scrobble/styles/Scrobble.styles.ts similarity index 91% rename from components/scrobble/styles/Scrobble.styles.js rename to components/scrobble/styles/Scrobble.styles.ts index 042989a..c24e741 100644 --- a/components/scrobble/styles/Scrobble.styles.js +++ b/components/scrobble/styles/Scrobble.styles.ts @@ -6,7 +6,7 @@ export const Loading = styled(Spinner)` height: 100%; `; -export const LoadingWrapper = styled.div` +export const LoadingWrapper = styled.div<{ image?: string }>` display: flex; position: absolute; align-items: center; diff --git a/components/session/Session.jsx b/components/session/Session.tsx similarity index 79% rename from components/session/Session.jsx rename to components/session/Session.tsx index 65bdff7..6bd3eba 100644 --- a/components/session/Session.jsx +++ b/components/session/Session.tsx @@ -1,9 +1,9 @@ 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 Router from 'next/router'; import { fetchSessionIfNeeded } from './actions/sessionActions'; import { Arrow, Image, ImageAndUser, Loader, Menu, MenuItem, Username, @@ -11,8 +11,14 @@ import { import targetBlank from '../../lib/targetBlank'; import { autotrackParams } from '../../lib/analytics'; -class Session extends React.Component { - overlayRef = React.createRef(); +interface SessionProps { + session?: any; + error?: any; + fetchSessionIfNeeded: () => void; +} + +class Session extends React.Component<SessionProps, { open: boolean }> { + overlayRef = React.createRef<HTMLDivElement>(); state = { open: false, @@ -23,6 +29,12 @@ class Session extends React.Component { document.addEventListener('click', this.handleClickOutside); } + componentDidUpdate(prevProps: SessionProps) { + if (!prevProps.error && this.props.error) { + Router.push('/login'); + } + } + componentWillUnmount() { document.removeEventListener('click', this.handleClickOutside); } @@ -41,7 +53,7 @@ class Session extends React.Component { } render() { - const { session, error } = this.props; + const { session = {}, error = null } = this.props; const { open } = this.state; if (error) return null; @@ -64,10 +76,10 @@ class Session extends React.Component { </ImageAndUser> <Arrow open={open} /> <Menu open={open}> - <Link href="/profile" passHref> + <Link href="/profile" passHref legacyBehavior> <MenuItem {...autotrackParams('Session', 'Profile')}>Profile</MenuItem> </Link> - <Link href="/logout" passHref> + <Link href="/api/auth/logout" passHref legacyBehavior> <MenuItem {...autotrackParams('Session', 'Logout')}>Logout</MenuItem> </Link> </Menu> @@ -76,18 +88,6 @@ class Session extends React.Component { } } -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, diff --git a/components/session/actions/sessionActionCreators.js b/components/session/actions/sessionActionCreators.ts similarity index 100% rename from components/session/actions/sessionActionCreators.js rename to components/session/actions/sessionActionCreators.ts diff --git a/components/session/actions/sessionActions.js b/components/session/actions/sessionActions.ts similarity index 67% rename from components/session/actions/sessionActions.js rename to components/session/actions/sessionActions.ts index d87d9a4..ac51c06 100644 --- a/components/session/actions/sessionActions.js +++ b/components/session/actions/sessionActions.ts @@ -6,8 +6,13 @@ export const fetchSession = () => ( async (dispatch) => { try { dispatch(setLoadingState(true)); - const data = await fetch('/api/session', { credentials: 'include' }).then(r => r.json()); - dispatch(receivedSession(data)); + 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)); } diff --git a/components/session/constants/sessionConstants.js b/components/session/constants/sessionConstants.ts similarity index 100% rename from components/session/constants/sessionConstants.js rename to components/session/constants/sessionConstants.ts diff --git a/components/session/reducers/sessionReducer.js b/components/session/reducers/sessionReducer.ts similarity index 89% rename from components/session/reducers/sessionReducer.js rename to components/session/reducers/sessionReducer.ts index 29dcbc5..5eae3c8 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'; -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 86% rename from components/session/styles/Session.styles.js rename to components/session/styles/Session.styles.ts index cda8a96..99e8fe3 100644 --- a/components/session/styles/Session.styles.js +++ b/components/session/styles/Session.styles.ts @@ -3,7 +3,7 @@ import { dark, yellow, yellowRGB } from '../../../lib/colors'; import { animation } from '../../layout/Spinner'; import { buttonReset } from '../../../styles/mixins'; -const fadeInOnOpen = css` +const fadeInOnOpen = css<{ open?: boolean }>` transition: opacity .3s; opacity: 0; pointer-events:none; @@ -13,7 +13,7 @@ const fadeInOnOpen = css` `} `; -export const Image = styled.div` +export const Image = styled.div<{ image?: string }>` z-index: 1; width: 8vw; max-width: 50px; @@ -35,7 +35,7 @@ export const Loader = styled.div` border-top-color: ${yellow}; `; -export const Arrow = styled.div` +export const Arrow = styled.div<{ open?: boolean }>` ${fadeInOnOpen} position: absolute; right: 0; @@ -57,7 +57,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 +77,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 89% rename from components/ui/BackButton.jsx rename to components/ui/BackButton.tsx index 89ddb3e..9b77546 100644 --- a/components/ui/BackButton.jsx +++ b/components/ui/BackButton.tsx @@ -1,5 +1,5 @@ import Router from 'next/router'; -import { ChevronLeft } from 'styled-icons/boxicons-regular/ChevronLeft'; +import { ChevronLeft } from 'styled-icons/boxicons-regular'; import { silver } from '../../lib/colors'; import { Button } from './styles/BackButton.styles'; diff --git a/components/ui/Checkbox.jsx b/components/ui/Checkbox.tsx similarity index 63% rename from components/ui/Checkbox.jsx rename to components/ui/Checkbox.tsx index 84c785a..d590675 100644 --- a/components/ui/Checkbox.jsx +++ b/components/ui/Checkbox.tsx @@ -1,27 +1,37 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { Input, Label, Wrapper } from './styles/Checkbox.styles'; -class Checkbox extends React.Component { - constructor(props) { +interface CheckboxProps { + checked?: boolean; + className?: string | null; + name: string; + children?: React.ReactNode; + onChange?: (checked: boolean) => void; + disabled?: boolean; +} + +class Checkbox extends React.Component<CheckboxProps, {}> { + label: any; + + constructor(props: CheckboxProps) { super(props); this.handleCheck = this.handleCheck.bind(this); } - shouldComponentUpdate(nextProps) { + 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; + const { onChange = () => {} } = this.props; onChange(event.target.checked); } render() { const { - name, className, checked, disabled, children, + name, className = null, checked = false, disabled = false, children = null, } = this.props; const id = `checkbox-${name}`; @@ -48,21 +58,4 @@ class Checkbox extends React.Component { } } -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/LegalLinks.jsx b/components/ui/LegalLinks.tsx similarity index 70% rename from components/ui/LegalLinks.jsx rename to components/ui/LegalLinks.tsx index e14cbc7..58ab6fe 100644 --- a/components/ui/LegalLinks.jsx +++ b/components/ui/LegalLinks.tsx @@ -3,10 +3,10 @@ import { Link, Links } from './styles/LegalLinks.styles'; const LegalLinks = () => ( <Links> - <NextLink href="/privacy" passHref> + <NextLink href="/privacy" passHref legacyBehavior> <Link>Privacy</Link> </NextLink> - <NextLink href="/legal" passHref> + <NextLink href="/legal" passHref legacyBehavior> <Link>Legal</Link> </NextLink> </Links> diff --git a/components/ui/LoginButton.jsx b/components/ui/LoginButton.tsx similarity index 100% rename from components/ui/LoginButton.jsx rename to components/ui/LoginButton.tsx diff --git a/components/ui/styles/BackButton.styles.js b/components/ui/styles/BackButton.styles.ts similarity index 100% rename from components/ui/styles/BackButton.styles.js rename to components/ui/styles/BackButton.styles.ts diff --git a/components/ui/styles/Checkbox.styles.js b/components/ui/styles/Checkbox.styles.ts similarity index 100% rename from components/ui/styles/Checkbox.styles.js rename to components/ui/styles/Checkbox.styles.ts diff --git a/components/ui/styles/LegalLinks.styles.js b/components/ui/styles/LegalLinks.styles.ts similarity index 100% rename from components/ui/styles/LegalLinks.styles.js rename to components/ui/styles/LegalLinks.styles.ts diff --git a/components/ui/styles/LoginButton.styles.js b/components/ui/styles/LoginButton.styles.ts similarity index 100% rename from components/ui/styles/LoginButton.styles.js rename to components/ui/styles/LoginButton.styles.ts diff --git a/config/express.js b/config/express.js deleted file mode 100644 index c80dd4c..0000000 --- a/config/express.js +++ /dev/null @@ -1,32 +0,0 @@ -const flash = require('connect-flash'); -const morgan = require('morgan'); -const cookieParser = require('cookie-parser'); -const bodyParser = require('body-parser'); -const compression = require('compression'); -const session = require('express-session'); -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()); - - app.use(session({ - store: new RedisStore({ url: process.env.REDISCLOUD_URL }), - 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 - app.use(flash()); // use connect-flash for flash messages stored in session -}; diff --git a/config/passport.js b/config/passport.js deleted file mode 100644 index 43090c5..0000000 --- a/config/passport.js +++ /dev/null @@ -1,52 +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, - callbackURL: `${process.env.SERVER_URL}/auth/lastfm/callback`, - }, - - ((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..e1284da --- /dev/null +++ b/config/setupTests.ts @@ -0,0 +1,3 @@ +import fetchMock from 'jest-fetch-mock'; + +global.fetch = fetchMock as any; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6879004 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,48 @@ +services: + app: + container_name: app + env_file: .env + environment: + - MONGODB_URI=mongodb://mongodb:27017/codescrobble + - REDISCLOUD_URL=redis://redis:6379 + - NODE_ENV=development + restart: always + build: + context: . + target: deps + ports: + - 3000:3000 + volumes: + - .:/app + - /app/node_modules + depends_on: + mongodb: + condition: service_healthy + redis: + condition: service_healthy + command: ["sh", "-c", "yarn install --immutable && yarn dev"] + mongodb: + container_name: mongodb + image: mongo:7 + volumes: + - ./data/mongodb:/data/db + ports: + - "27017:27017" + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + redis: + container_name: redis + image: redis:7-alpine + volumes: + - ./data/redis:/data + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..295e748 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,50 @@ +const { FlatCompat } = require('@eslint/eslintrc'); +const babelParser = require('@babel/eslint-parser'); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +module.exports = [ + { + ignores: ['.next/**', 'node_modules/**', 'public/**'], + }, + ...compat.extends( + 'eslint:recommended', + 'plugin:react/recommended', + 'plugin:jest/recommended', + 'airbnb', + ), + { + languageOptions: { + parser: babelParser, + parserOptions: { + requireConfigFile: false, + babelOptions: { + presets: ['next/babel'], + }, + }, + globals: { + browser: true, + es6: true, + node: true, + }, + }, + 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', '.ts', '.tsx'] }], + 'react/jsx-one-expression-per-line': 'off', + 'react/react-in-jsx-scope': 'off', + }, + }, +]; diff --git a/jest.config.js b/jest.config.js index 1434e3b..af79000 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,19 +1,20 @@ module.exports = { + testEnvironment: 'jsdom', testPathIgnorePatterns: [ '<rootDir>/.next/', '<rootDir>/node_modules/', ], transform: { - '\\.jsx?$': 'babel-jest', + '\\.(js|jsx|ts|tsx)$': 'babel-jest', }, transformIgnorePatterns: [ '<rootDir>/node_modules/', ], - setupFilesAfterEnv: ['<rootDir>config/setupTests.js'], + setupFilesAfterEnv: ['<rootDir>/config/setupTests.ts'], collectCoverageFrom: [ - 'app/**/*.{js,jsx}', - 'components/**/*.{js,jsx}', - 'lib/**/*.{js,jsx}', + 'app/**/*.{js,jsx,ts,tsx}', + 'components/**/*.{js,jsx,ts,tsx}', + 'lib/**/*.{js,jsx,ts,tsx}', '!lib/colors.js', '!lib/polyfills.js', ], diff --git a/lib/analytics.js b/lib/analytics.ts similarity index 57% rename from lib/analytics.js rename to lib/analytics.ts index 2e3889b..ac2a525 100644 --- a/lib/analytics.js +++ b/lib/analytics.ts @@ -1,10 +1,10 @@ export const ANALYTICS_ID = 'UA-135908212-1'; -export const trackEvent = (action, category, label, value) => { - window.ga('send', 'event', category, action, label, value); +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, 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, diff --git a/lib/colors.js b/lib/colors.ts similarity index 100% rename from lib/colors.js rename to lib/colors.ts diff --git a/lib/durationFormat.js b/lib/durationFormat.ts similarity index 100% rename from lib/durationFormat.js rename to lib/durationFormat.ts diff --git a/lib/initNProgress.js b/lib/initNProgress.ts similarity index 100% rename from lib/initNProgress.js rename to lib/initNProgress.ts diff --git a/lib/mongodb.ts b/lib/mongodb.ts new file mode 100644 index 0000000..d8139cb --- /dev/null +++ b/lib/mongodb.ts @@ -0,0 +1,9 @@ +import mongoose from 'mongoose'; + +let isConnected = false; + +export async function connectToDatabase(): Promise<void> { + if (isConnected) return; + await mongoose.connect(process.env.MONGODB_URI as string); + isConnected = true; +} diff --git a/lib/offline.js b/lib/offline.ts similarity index 100% rename from lib/offline.js rename to lib/offline.ts 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..5080ea4 --- /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<IronSession<SessionData>> { + return getIronSession<SessionData>(req, res, sessionOptions); +} diff --git a/lib/targetBlank.js b/lib/targetBlank.ts similarity index 100% rename from lib/targetBlank.js rename to lib/targetBlank.ts diff --git a/lib/withAuth.ts b/lib/withAuth.ts new file mode 100644 index 0000000..c67f9b5 --- /dev/null +++ b/lib/withAuth.ts @@ -0,0 +1,22 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { connectToDatabase } from './mongodb'; +import { getSession } from './session'; +const User = require('../app/models/user'); + +export async function requireUser(req: NextApiRequest, res: NextApiResponse): Promise<any | null> { + 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..6998440 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +const PUBLIC_PATHS = ['/login', '/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 @@ +/// <reference types="next" /> +/// <reference types="next/image-types/global" /> +/// <reference types="next/navigation-types/compat/navigation" /> + +// 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 141e2ee..39d91cc 100644 --- a/next.config.js +++ b/next.config.js @@ -1,67 +1,11 @@ -/* eslint-disable no-param-reassign */ -const path = require('path'); -const SWPrecacheWebpackPlugin = require('sw-precache-webpack-plugin'); -const CleanWebpackPlugin = require('clean-webpack-plugin'); -const withBundleAnalyzer = require('@zeit/next-bundle-analyzer'); +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, +}; - analyzeServer: ['server', 'both'].includes(process.env.BUNDLE_ANALYZE), - analyzeBrowser: ['browser', 'both'].includes(process.env.BUNDLE_ANALYZE), - bundleAnalyzerConfig: { - server: { - analyzerMode: 'static', - reportFilename: '../bundles/server.html', - }, - browser: { - analyzerMode: 'static', - reportFilename: '../bundles/client.html', - }, - }, - - webpack: (config, { dev, isServer, buildId }) => { - if (!dev) { - config.plugins.push( - new CleanWebpackPlugin(), - new SWPrecacheWebpackPlugin({ - cacheId: 'codescrobble', - filepath: path.resolve('./static/service-worker.js'), - minify: false, - navigateFallback: process.env.SERVER_URL, - 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 f06013b..3f11c7e 100644 --- a/package.json +++ b/package.json @@ -13,80 +13,80 @@ }, "homepage": "https://www.codescrobble.com/", "scripts": { - "dev": "node server.js", + "dev": "next dev", "build": "next build", - "start": "NODE_ENV=production node server.js", + "start": "next start", "test": "jest", "test:watch": "yarn test --watch", "test:coverage": "yarn test --coverage", - "lint": "eslint './**/*.{js,jsx}'", - "lint:css": "stylelint './**/*.{js,jsx}'", - "analyze": "BUNDLE_ANALYZE=browser yarn build" + "lint": "eslint './**/*.{js,jsx,ts,tsx}'", + "lint:css": "stylelint './**/*.{js,jsx,ts,tsx}'", + "analyze": "BUNDLE_ANALYZE=true yarn build" }, "dependencies": { - "@zeit/next-bundle-analyzer": "^0.1.2", - "babel-plugin-transform-class-properties": "^6.24.1", - "body-parser": "^1.19.0", - "clean-webpack-plugin": "^2.0.1", - "compression": "^1.7.4", - "connect-flash": "^0.1.1", - "connect-redis": "^3.4.1", - "cookie-parser": "^1.4.4", - "disconnect": "^1.2.1", - "dotenv": "^7.0.0", - "express": "^4.16.4", - "express-session": "^1.16.1", - "helmet": "^3.15.1", - "intersection-observer": "^0.6.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.11", - "mongoose": "^5.5.4", - "morgan": "^1.9.1", - "next": "^8.1.0", - "next-redux-wrapper": "^3.0.0-alpha.2", + "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.0", - "passport-lastfm": "^1.0.1", - "prop-types": "^15.6.2", "quagga": "^0.12.1", - "react": "^16.8.6", - "react-css-loaders": "^0.0.5", - "react-dom": "^16.8.6", - "react-icons": "^3.6.1", - "react-lazy": "^1.1.0-beta.0", - "react-redux": "^7.0.3", - "react-timeago": "^4.4.0", - "redis": "^2.8.0", - "redux": "^4.0.1", - "redux-devtools-extension": "^2.13.8", - "redux-thunk": "^2.3.0", - "styled-components": "^4.2.0", - "styled-icons": "^7.11.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.4.4", - "babel-eslint": "^10.0.1", - "babel-jest": "^24.7.1", - "enzyme": "^3.9.0", - "enzyme-adapter-react-16": "^1.12.1", - "eslint": "5.16.0", - "eslint-config-airbnb": "17.1.0", - "eslint-plugin-import": "^2.17.2", - "eslint-plugin-jest": "^22.5.1", - "eslint-plugin-jsx-a11y": "^6.1.1", - "eslint-plugin-react": "^7.11.0", - "jest": "^24.7.1", - "jest-fetch-mock": "^2.1.2", - "react-addons-test-utils": "^15.6.2", - "react-test-renderer": "^16.8.6", - "redis-mock": "^0.43.0", - "redux-mock-store": "^1.5.3", - "stylelint": "^10.0.1", - "stylelint-config-property-sort-order-smacss": "^4.0.2", - "stylelint-config-recommended": "^2.2.0", - "stylelint-config-styled-components": "^0.1.1", - "stylelint-processor-styled-components": "^1.6.0" + "@babel/core": "^7.26.0", + "@babel/eslint-parser": "^7.25.9", + "@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", + "@testing-library/dom": "^10.0.0", + "@testing-library/react": "^16.0.0", + "@types/jest": "^30.0.0", + "@types/node": "^22.10.7", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "babel-jest": "^29.7.0", + "babel-plugin-styled-components": "^2.1.4", + "eslint": "^9.18.0", + "eslint-config-airbnb": "^19.0.4", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jest": "^28.11.0", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-react": "^7.37.4", + "eslint-plugin-react-hooks": "^5.1.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "jest-fetch-mock": "^3.0.1", + "react-test-renderer": "^18.0.0", + "redis-mock": "^0.47.0", + "redux-mock-store": "^1.5.4", + "stylelint": "^16.12.0", + "stylelint-config-recommended": "^14.0.1", + "typescript": "^5.7.3" + }, + "packageManager": "yarn@4.12.0", + "lavamoat": { + "allowScripts": { + "@lavamoat/preinstall-always-fail": false, + "babel-jest>@jest/transform>jest-haste-map>fsevents": false, + "jest-environment-jsdom>jest-util>jest-util>fsevents": false, + "@babel/runtime-corejs2>core-js": false + } } } diff --git a/pages/_app.js b/pages/_app.js deleted file mode 100644 index faae383..0000000 --- a/pages/_app.js +++ /dev/null @@ -1,33 +0,0 @@ -import App, { Container } from 'next/app'; -import Head from 'next/head'; -import React from 'react'; -import withRedux from 'next-redux-wrapper'; -import { Provider } from 'react-redux'; -import initNProgress from '../lib/initNProgress'; -import initializeStore from '../client/reduxStore'; - -initNProgress(); - -class MyApp extends App { - static async getInitialProps({ Component, ctx }) { - const pageProps = Component.getInitialProps ? await Component.getInitialProps(ctx) : {}; - return { pageProps }; - } - - render() { - const { Component, pageProps, store } = this.props; - - return ( - <Container> - <Provider store={store}> - <Head> - <title>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..7c9b398 --- /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.tsx similarity index 89% rename from pages/_document.js rename to pages/_document.tsx index 4f0bfb3..46e0cc4 100644 --- a/pages/_document.js +++ b/pages/_document.tsx @@ -1,7 +1,5 @@ -import Document, { Head, Main, NextScript } from 'next/document'; +import Document, { Html, Head, Main, NextScript } from 'next/document'; import { ServerStyleSheet } from 'styled-components'; -import BaseStyles from '../components/layout/BaseStyles'; -import NProgressStyles from '../styles/nprogress.styles'; import { ANALYTICS_ID } from '../lib/analytics'; const isSafari = userAgent => ( @@ -21,7 +19,7 @@ export default class MyDocument extends Document { const initialProps = await Document.getInitialProps(ctx); return { ...initialProps, - showManifest: !isSafari(ctx.req.headers['user-agent']), + showManifest: ctx.req ? !isSafari(ctx.req.headers['user-agent']) : true, styles: <>{initialProps.styles}{sheet.getStyleElement()}, }; } finally { @@ -31,7 +29,7 @@ export default class MyDocument extends Document { render() { return ( - + @@ -51,8 +49,7 @@ export default class MyDocument extends Document { - - {this.props.showManifest && ( + {(this.props as any).showManifest && ( )} @@ -67,15 +64,13 @@ export default class MyDocument extends Document { - - - +