diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 0000000..fd54d60
--- /dev/null
+++ b/.eslintignore
@@ -0,0 +1,7 @@
+*.json
+*.yml
+*.sqlite3
+*.md
+**/public
+LICENSE
+Dockerfile
\ No newline at end of file
diff --git a/.eslintrc.yml b/.eslintrc.yml
new file mode 100644
index 0000000..9911bd8
--- /dev/null
+++ b/.eslintrc.yml
@@ -0,0 +1,15 @@
+env:
+ browser: true
+ es2021: true
+ jest: true
+extends:
+ - plugin:react/recommended
+ - airbnb
+overrides: []
+parserOptions:
+ ecmaVersion: latest
+ sourceType: module
+plugins:
+ - react
+ - jest
+rules: { 'indent': ['error', 4, { 'SwitchCase': 1 }], 'comma-dangle': 'off' }
diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml
index aff98fb..0b596b3 100644
--- a/.github/workflows/test-build.yml
+++ b/.github/workflows/test-build.yml
@@ -10,7 +10,7 @@ on:
workflow_dispatch:
jobs:
- test:
+ test-build:
environment: test
runs-on: ubuntu-latest
@@ -28,3 +28,17 @@ jobs:
run: npm ci
- name: Test build
run: npm run test
+ run-eslint:
+ environment: test
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v2
+ - name: Use Node.js 16.x
+ uses: actions/setup-node@v2
+ with:
+ node-version: 16.x
+ - name: Install dependencies
+ run: npm ci
+ - name: Test formmating
+ run: npm run eslint:all
diff --git a/.gitignore b/.gitignore
index 49a3626..efc721b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,17 +1,9 @@
-
-# Created by https://www.toptal.com/developers/gitignore/api/node,react,git,visualstudiocode
-# Edit at https://www.toptal.com/developers/gitignore?templates=node,react,git,visualstudiocode
-
-**/dist.tar.gz
-
-### Express ###
-**/public/
-
-### TODO ###
-TODO
-
-### Sqlite ###
+server/**/public
*.sqlite3
+*.sqlite3-journal
+
+# Created by https://www.toptal.com/developers/gitignore/api/node,react,visualstudiocode,git
+# Edit at https://www.toptal.com/developers/gitignore?templates=node,react,visualstudiocode,git
### Git ###
# Created by git for backups. To disable backups in Git:
@@ -200,6 +192,4 @@ sketch
.history
.ionide
-# Support for Project snippet scope
-
-# End of https://www.toptal.com/developers/gitignore/api/node,react,git,visualstudiocode
+# End of https://www.toptal.com/developers/gitignore/api/node,react,visualstudiocode,git
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..521a9f7
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+legacy-peer-deps=true
diff --git a/client/.storybook/main.js b/client/.storybook/main.js
new file mode 100644
index 0000000..1839745
--- /dev/null
+++ b/client/.storybook/main.js
@@ -0,0 +1,13 @@
+module.exports = {
+ stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
+ addons: [
+ '@storybook/addon-links',
+ '@storybook/addon-essentials',
+ '@storybook/addon-interactions',
+ '@storybook/preset-create-react-app',
+ ],
+ framework: '@storybook/react',
+ core: {
+ builder: '@storybook/builder-webpack5',
+ },
+};
diff --git a/client/.storybook/preview.js b/client/.storybook/preview.js
new file mode 100644
index 0000000..056c20e
--- /dev/null
+++ b/client/.storybook/preview.js
@@ -0,0 +1,100 @@
+import { useMemo } from 'react';
+import { MemoryRouter } from 'react-router-dom';
+
+import { CssBaseline } from '@mui/material';
+import { ThemeProvider } from '@mui/material/styles';
+import '@storybook/addon-console';
+import { initialize, mswDecorator } from 'msw-storybook-addon';
+import { cookieDecorator } from 'storybook-addon-cookie';
+
+import { darkTheme, lightTheme } from '../src/themes';
+
+export const parameters = {
+ actions: { argTypesRegex: '^on[A-Z].*' },
+ controls: {
+ matchers: {
+ color: /(background|color)$/i,
+ date: /Date$/,
+ },
+ },
+ options: {
+ storySort: {
+ method: 'alphabetical',
+ },
+ },
+ layout: 'fullscreen',
+ viewport: {
+ viewports: {
+ laptop: {
+ name: 'Laptop',
+ styles: {
+ width: '1024px',
+ height: '576px',
+ },
+ type: 'desktop',
+ },
+ tablet: {
+ name: 'Tablet',
+ styles: {
+ width: '768px',
+ height: '1024px',
+ },
+ type: 'Tablet',
+ },
+ mobile: {
+ name: 'Mobile',
+ styles: {
+ width: '360px',
+ height: '740px',
+ },
+ type: 'mobile',
+ },
+ },
+ },
+};
+
+export const globalTypes = {
+ theme: {
+ name: 'Theme',
+ title: 'Theme',
+ defaultValue: 'light',
+ toolbar: {
+ icon: 'paintbrush',
+ dynamicTitle: true,
+ items: [
+ { value: 'light', title: 'Light Mode', left: '🌞' },
+ { value: 'dark', title: 'Dark Mode', left: '🌙' },
+ ],
+ },
+ },
+};
+
+const THEMES = {
+ light: lightTheme,
+ dark: darkTheme,
+};
+
+export const withMuiTheme = (Story, context) => {
+ const { theme: themeKey } = context.globals;
+
+ const theme = useMemo(() => THEMES[themeKey] || THEMES['light'], [themeKey]);
+
+ return (
+
+
+
+
+ );
+};
+
+export const withBrowserRouter = (Story) => {
+ return (
+
+
+
+ );
+};
+
+initialize();
+
+export const decorators = [mswDecorator, cookieDecorator, withBrowserRouter, withMuiTheme];
diff --git a/client/package.json b/client/package.json
index 170ebb2..412168f 100644
--- a/client/package.json
+++ b/client/package.json
@@ -26,12 +26,24 @@
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
- "eject": "react-scripts eject"
+ "eject": "react-scripts eject",
+ "storybook": "start-storybook -p 6006 -s public",
+ "build-storybook": "build-storybook -s public"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
+ ],
+ "overrides": [
+ {
+ "files": [
+ "**/*.stories.*"
+ ],
+ "rules": {
+ "import/no-anonymous-default-export": "off"
+ }
+ }
]
},
"browserslist": {
@@ -45,5 +57,27 @@
"last 1 firefox version",
"last 1 safari version"
]
+ },
+ "devDependencies": {
+ "@storybook/addon-actions": "^6.5.15",
+ "@storybook/addon-console": "^1.2.3",
+ "@storybook/addon-essentials": "^6.5.15",
+ "@storybook/addon-interactions": "^6.5.15",
+ "@storybook/addon-links": "^6.5.15",
+ "@storybook/builder-webpack5": "^6.5.15",
+ "@storybook/manager-webpack5": "^6.5.15",
+ "@storybook/node-logger": "^6.5.15",
+ "@storybook/preset-create-react-app": "^4.1.2",
+ "@storybook/react": "^6.5.15",
+ "@storybook/testing-library": "^0.0.13",
+ "babel-plugin-named-exports-order": "^0.0.2",
+ "msw": "^0.49.2",
+ "msw-storybook-addon": "^1.6.3",
+ "prop-types": "^15.8.1",
+ "storybook-addon-cookie": "^1.0.6",
+ "webpack": "^5.75.0"
+ },
+ "msw": {
+ "workerDirectory": "public"
}
}
diff --git a/client/public/mockServiceWorker.js b/client/public/mockServiceWorker.js
new file mode 100644
index 0000000..70f0a2b
--- /dev/null
+++ b/client/public/mockServiceWorker.js
@@ -0,0 +1,303 @@
+/* eslint-disable */
+/* tslint:disable */
+
+/**
+ * Mock Service Worker (0.49.2).
+ * @see https://github.com/mswjs/msw
+ * - Please do NOT modify this file.
+ * - Please do NOT serve this file on production.
+ */
+
+const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70'
+const activeClientIds = new Set()
+
+self.addEventListener('install', function () {
+ self.skipWaiting()
+})
+
+self.addEventListener('activate', function (event) {
+ event.waitUntil(self.clients.claim())
+})
+
+self.addEventListener('message', async function (event) {
+ const clientId = event.source.id
+
+ if (!clientId || !self.clients) {
+ return
+ }
+
+ const client = await self.clients.get(clientId)
+
+ if (!client) {
+ return
+ }
+
+ const allClients = await self.clients.matchAll({
+ type: 'window',
+ })
+
+ switch (event.data) {
+ case 'KEEPALIVE_REQUEST': {
+ sendToClient(client, {
+ type: 'KEEPALIVE_RESPONSE',
+ })
+ break
+ }
+
+ case 'INTEGRITY_CHECK_REQUEST': {
+ sendToClient(client, {
+ type: 'INTEGRITY_CHECK_RESPONSE',
+ payload: INTEGRITY_CHECKSUM,
+ })
+ break
+ }
+
+ case 'MOCK_ACTIVATE': {
+ activeClientIds.add(clientId)
+
+ sendToClient(client, {
+ type: 'MOCKING_ENABLED',
+ payload: true,
+ })
+ break
+ }
+
+ case 'MOCK_DEACTIVATE': {
+ activeClientIds.delete(clientId)
+ break
+ }
+
+ case 'CLIENT_CLOSED': {
+ activeClientIds.delete(clientId)
+
+ const remainingClients = allClients.filter((client) => {
+ return client.id !== clientId
+ })
+
+ // Unregister itself when there are no more clients
+ if (remainingClients.length === 0) {
+ self.registration.unregister()
+ }
+
+ break
+ }
+ }
+})
+
+self.addEventListener('fetch', function (event) {
+ const { request } = event
+ const accept = request.headers.get('accept') || ''
+
+ // Bypass server-sent events.
+ if (accept.includes('text/event-stream')) {
+ return
+ }
+
+ // Bypass navigation requests.
+ if (request.mode === 'navigate') {
+ return
+ }
+
+ // Opening the DevTools triggers the "only-if-cached" request
+ // that cannot be handled by the worker. Bypass such requests.
+ if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
+ return
+ }
+
+ // Bypass all requests when there are no active clients.
+ // Prevents the self-unregistered worked from handling requests
+ // after it's been deleted (still remains active until the next reload).
+ if (activeClientIds.size === 0) {
+ return
+ }
+
+ // Generate unique request ID.
+ const requestId = Math.random().toString(16).slice(2)
+
+ event.respondWith(
+ handleRequest(event, requestId).catch((error) => {
+ if (error.name === 'NetworkError') {
+ console.warn(
+ '[MSW] Successfully emulated a network error for the "%s %s" request.',
+ request.method,
+ request.url,
+ )
+ return
+ }
+
+ // At this point, any exception indicates an issue with the original request/response.
+ console.error(
+ `\
+[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,
+ request.method,
+ request.url,
+ `${error.name}: ${error.message}`,
+ )
+ }),
+ )
+})
+
+async function handleRequest(event, requestId) {
+ const client = await resolveMainClient(event)
+ const response = await getResponse(event, client, requestId)
+
+ // Send back the response clone for the "response:*" life-cycle events.
+ // Ensure MSW is active and ready to handle the message, otherwise
+ // this message will pend indefinitely.
+ if (client && activeClientIds.has(client.id)) {
+ ;(async function () {
+ const clonedResponse = response.clone()
+ sendToClient(client, {
+ type: 'RESPONSE',
+ payload: {
+ requestId,
+ type: clonedResponse.type,
+ ok: clonedResponse.ok,
+ status: clonedResponse.status,
+ statusText: clonedResponse.statusText,
+ body:
+ clonedResponse.body === null ? null : await clonedResponse.text(),
+ headers: Object.fromEntries(clonedResponse.headers.entries()),
+ redirected: clonedResponse.redirected,
+ },
+ })
+ })()
+ }
+
+ return response
+}
+
+// Resolve the main client for the given event.
+// Client that issues a request doesn't necessarily equal the client
+// that registered the worker. It's with the latter the worker should
+// communicate with during the response resolving phase.
+async function resolveMainClient(event) {
+ const client = await self.clients.get(event.clientId)
+
+ if (client?.frameType === 'top-level') {
+ return client
+ }
+
+ const allClients = await self.clients.matchAll({
+ type: 'window',
+ })
+
+ return allClients
+ .filter((client) => {
+ // Get only those clients that are currently visible.
+ return client.visibilityState === 'visible'
+ })
+ .find((client) => {
+ // Find the client ID that's recorded in the
+ // set of clients that have registered the worker.
+ return activeClientIds.has(client.id)
+ })
+}
+
+async function getResponse(event, client, requestId) {
+ const { request } = event
+ const clonedRequest = request.clone()
+
+ function passthrough() {
+ // Clone the request because it might've been already used
+ // (i.e. its body has been read and sent to the client).
+ const headers = Object.fromEntries(clonedRequest.headers.entries())
+
+ // Remove MSW-specific request headers so the bypassed requests
+ // comply with the server's CORS preflight check.
+ // Operate with the headers as an object because request "Headers"
+ // are immutable.
+ delete headers['x-msw-bypass']
+
+ return fetch(clonedRequest, { headers })
+ }
+
+ // Bypass mocking when the client is not active.
+ if (!client) {
+ return passthrough()
+ }
+
+ // Bypass initial page load requests (i.e. static assets).
+ // The absence of the immediate/parent client in the map of the active clients
+ // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
+ // and is not ready to handle requests.
+ if (!activeClientIds.has(client.id)) {
+ return passthrough()
+ }
+
+ // Bypass requests with the explicit bypass header.
+ // Such requests can be issued by "ctx.fetch()".
+ if (request.headers.get('x-msw-bypass') === 'true') {
+ return passthrough()
+ }
+
+ // Notify the client that a request has been intercepted.
+ const clientMessage = await sendToClient(client, {
+ type: 'REQUEST',
+ payload: {
+ id: requestId,
+ url: request.url,
+ method: request.method,
+ headers: Object.fromEntries(request.headers.entries()),
+ cache: request.cache,
+ mode: request.mode,
+ credentials: request.credentials,
+ destination: request.destination,
+ integrity: request.integrity,
+ redirect: request.redirect,
+ referrer: request.referrer,
+ referrerPolicy: request.referrerPolicy,
+ body: await request.text(),
+ bodyUsed: request.bodyUsed,
+ keepalive: request.keepalive,
+ },
+ })
+
+ switch (clientMessage.type) {
+ case 'MOCK_RESPONSE': {
+ return respondWithMock(clientMessage.data)
+ }
+
+ case 'MOCK_NOT_FOUND': {
+ return passthrough()
+ }
+
+ case 'NETWORK_ERROR': {
+ const { name, message } = clientMessage.data
+ const networkError = new Error(message)
+ networkError.name = name
+
+ // Rejecting a "respondWith" promise emulates a network error.
+ throw networkError
+ }
+ }
+
+ return passthrough()
+}
+
+function sendToClient(client, message) {
+ return new Promise((resolve, reject) => {
+ const channel = new MessageChannel()
+
+ channel.port1.onmessage = (event) => {
+ if (event.data && event.data.error) {
+ return reject(event.data.error)
+ }
+
+ resolve(event.data)
+ }
+
+ client.postMessage(message, [channel.port2])
+ })
+}
+
+function sleep(timeMs) {
+ return new Promise((resolve) => {
+ setTimeout(resolve, timeMs)
+ })
+}
+
+async function respondWithMock(response) {
+ await sleep(response.delay)
+ return new Response(response.body, response)
+}
diff --git a/client/src/components/EditUserForm.jsx b/client/src/components/EditUserForm.jsx
index 9881b4a..71a10de 100644
--- a/client/src/components/EditUserForm.jsx
+++ b/client/src/components/EditUserForm.jsx
@@ -1,16 +1,14 @@
import React from 'react';
import { Link } from 'react-router-dom';
-import { Box, Typography } from '@mui/material';
import { Form } from '.';
-import { RecordMp3 } from '../controllers';
import { FormSubmit, FormTextField } from './StyledElements';
function EditUserForm({ form, error, handleSubmit, handleChange }) {
const linkStyle = { textDecoration: 'none', color: 'inherit' };
return (
-
-
);
}
diff --git a/client/src/components/MobileNavbar.jsx b/client/src/components/MobileNavbar.jsx
index ba230d0..4645877 100644
--- a/client/src/components/MobileNavbar.jsx
+++ b/client/src/components/MobileNavbar.jsx
@@ -3,8 +3,6 @@ import DarkModeIcon from '@mui/icons-material/DarkMode';
import LightModeIcon from '@mui/icons-material/LightMode';
import LoginIcon from '@mui/icons-material/Login';
import LogoutIcon from '@mui/icons-material/Logout';
-import MailIcon from '@mui/icons-material/Mail';
-import SearchIcon from '@mui/icons-material/Search';
import MenuIcon from '@mui/icons-material/Menu';
import {
AppBar,
@@ -25,7 +23,7 @@ import { Link } from 'react-router-dom';
export default function MobileNavbar({ logout, userData, open, setOpen, mode, toggleMode }) {
function ControlButtons() {
- return userData.username ? (
+ return userData?.email ? (
diff --git a/client/src/components/Navbar.jsx b/client/src/components/Navbar.jsx
index c201808..3c1713f 100644
--- a/client/src/components/Navbar.jsx
+++ b/client/src/components/Navbar.jsx
@@ -6,7 +6,7 @@ import { Link } from 'react-router-dom';
export default function Navbar({ logout, userData, mode, toggleMode }) {
function NavbarControls() {
- return userData.email ? (
+ return userData?.email ? (
<>