init
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(rm -rf server/.template.config)",
|
||||
"Bash(rm -f server/CleanArcTemplate.nuspec)",
|
||||
"Bash(rm -f server/.github/workflows/package.yml)",
|
||||
"Bash(rmdir server/.github/workflows)",
|
||||
"Bash(rmdir server/.github)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# AGENTS.md — Balinyaar
|
||||
|
||||
Guidance for AI coding agents working in this repository. Read this first, then the project-specific `AGENTS.md` for whichever side you are editing.
|
||||
|
||||
## What this repository is
|
||||
|
||||
Balinyaar is a full-stack application split into two independent projects:
|
||||
|
||||
| Path | Project | Stack | Detail |
|
||||
| -------------------------- | -------------- | ----------------------------------------------------------- | ------------------------------- |
|
||||
| [`client/`](client/) | Web frontend | Next.js (App Router), React, TypeScript, Material UI (MUI) | [client/AGENTS.md](client/AGENTS.md) |
|
||||
| [`server/`](server/) | Backend API | ASP.NET Core (.NET 10), Clean Architecture, CQRS, EF Core | [server/AGENTS.md](server/AGENTS.md) |
|
||||
|
||||
The two communicate over HTTP/JSON (and optionally gRPC). The client reads the API base URL from `NEXT_PUBLIC_API_URL`; the server runs on `https://localhost:5002` by default.
|
||||
|
||||
There is **no root-level build** — each project is built and run on its own. `client/` and `server/` are not part of the same package/solution.
|
||||
|
||||
## Working agreements
|
||||
|
||||
- **Stay within one project per change** unless the task explicitly spans both. A frontend change rarely needs server files and vice-versa.
|
||||
- **Match the surrounding style.** Each project has its own conventions (see its `AGENTS.md`). Mirror the existing patterns rather than introducing new ones.
|
||||
- **Run the project's own checks** before declaring work done:
|
||||
- client: `npm run lint` and `npm run type` (and `npm run test:ci` if tests are touched)
|
||||
- server: `dotnet build` and `dotnet test`
|
||||
- **Do not reintroduce template/starter scaffolding.** This repo was derived from two open-source starters; their branding, demo/showcase pages, NuGet template packaging, and `_TITLE_`/`_DESCRIPTION_` placeholders have been intentionally removed. Don't add them back.
|
||||
- **Secrets**: never commit real connection strings, keys, or tokens. Use `.env` (client) and `appsettings.*.json` / user-secrets (server).
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Frontend
|
||||
cd client && npm install && npm run dev # http://localhost:3000
|
||||
|
||||
# Backend
|
||||
cd server && dotnet run --project src/API/CleanArc.Web.Api/CleanArc.Web.Api.csproj # https://localhost:5002/swagger
|
||||
```
|
||||
|
||||
## Naming note
|
||||
|
||||
The server's C# namespaces and solution file still use the `CleanArc*` prefix (e.g. `CleanArc.Web.Api`, `CleanArcTemplate.sln`). This is the internal project naming and is **not** template branding — do not mass-rename it unless explicitly asked, as it touches every file, the `.sln`, and EF migrations.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Environment similar to NODE_ENV.
|
||||
# Analytics and public resources are enabled only in "production"
|
||||
# How to use: set value to "production" to get fully functional application.
|
||||
NEXT_PUBLIC_ENV = development
|
||||
# NEXT_PUBLIC_ENV = preview
|
||||
# NEXT_PUBLIC_ENV = production
|
||||
|
||||
# Enables additional debug features, no additional debug information if the variable is not set
|
||||
# How to use: set value to "true" to get more debugging information, but don't do it on production.
|
||||
NEXT_PUBLIC_DEBUG = true
|
||||
|
||||
# Public URL of the application/website.
|
||||
# How to use: Do not set any value until you need custom domain for your application.
|
||||
# NEXT_PUBLIC_PUBLIC_URL = https://xxx.com
|
||||
# NEXT_PUBLIC_PUBLIC_URL = https://xxx.web.app
|
||||
NEXT_PUBLIC_PUBLIC_URL = http://localhost:3000
|
||||
|
||||
|
||||
# API/Backend basic URL (the CleanArc server)
|
||||
NEXT_PUBLIC_API_URL = https://localhost:5002
|
||||
# NEXT_PUBLIC_API_URL = https://dev-api.domain.com
|
||||
# NEXT_PUBLIC_API_URL = https://api.domain.com
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals",
|
||||
"rules": {
|
||||
"import/no-cycle": "error"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,4 @@
|
||||
.next
|
||||
node_modules
|
||||
out
|
||||
styles
|
||||
@@ -0,0 +1,13 @@
|
||||
module.exports = {
|
||||
printWidth: 120, // max 120 chars in line, code is easy to read
|
||||
useTabs: false, // use spaces instead of tabs
|
||||
tabWidth: 2, // "visual width" of of the "tab"
|
||||
trailingComma: 'es5', // add trailing commas in objects, arrays, etc.
|
||||
semi: true, // add ; when needed
|
||||
singleQuote: true, // '' for stings instead of ""
|
||||
bracketSpacing: true, // import { some } ... instead of import {some} ...
|
||||
arrowParens: 'always', // braces even for single param in arrow functions (a) => { }
|
||||
jsxSingleQuote: false, // "" for react props, like in html
|
||||
jsxBracketSameLine: false, // pretty JSX
|
||||
endOfLine: 'lf', // 'lf' for linux, 'crlf' for windows, we need to use 'lf' for git
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
# AGENTS.md — Balinyaar Web Client
|
||||
|
||||
Agent-oriented guide to the frontend. For human setup/run instructions see [README.md](README.md).
|
||||
|
||||
## Stack
|
||||
|
||||
- **Next.js** with the **App Router** (`src/app/`), statically exported (`output: 'export'` in `next.config.mjs` → builds to `out/`)
|
||||
- **React** + **TypeScript** (`strict` mode)
|
||||
- **Material UI (MUI)** for components and theming (Emotion under the hood)
|
||||
- **Jest** + **Testing Library** for unit tests
|
||||
- ESLint (`eslint-config-next`) + Prettier
|
||||
|
||||
## Commands
|
||||
|
||||
| Task | Command |
|
||||
| ------------ | ------------------ |
|
||||
| Dev server | `npm run dev` |
|
||||
| Build | `npm run build` |
|
||||
| Lint | `npm run lint` |
|
||||
| Type-check | `npm run type` |
|
||||
| Format | `npm run format` |
|
||||
| Test (watch) | `npm test` |
|
||||
| Test (CI) | `npm run test:ci` |
|
||||
|
||||
Always run `npm run lint` and `npm run type` after a change. Run `npm run test:ci` when you touch a component that has a `*.test.tsx`.
|
||||
|
||||
## Directory map
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ Routes (App Router). Each folder = a route; page.tsx = the page.
|
||||
│ ├── layout.tsx Root layout: store + theme providers, global <metadata>
|
||||
│ ├── page.tsx "/" entry (delegates to home)
|
||||
│ ├── home/ about/ Content pages
|
||||
│ ├── auth/login|signup Auth pages (LoginForm is currently a stub)
|
||||
│ └── me/ Authenticated user page
|
||||
├── components/
|
||||
│ ├── common/ Reusable App* primitives (see below) + ErrorBoundary
|
||||
│ └── UserInfo/ Logged-in user summary
|
||||
├── hooks/ useIsAuthenticated, useIsMobile, event hooks, useWindowSize
|
||||
├── layout/ PublicLayout / PrivateLayout + TopBar, SideBar, BottomBar, config
|
||||
├── store/ Global state: React context + reducer (AppStore, AppReducer)
|
||||
├── theme/ ThemeProvider, light/dark palettes, colors, MUI-for-Next bridge
|
||||
└── utils/ storage (local/session), navigation, environment, text, types
|
||||
```
|
||||
|
||||
## Conventions (follow these)
|
||||
|
||||
- **Imports**: use the `@/*` alias for anything under `src/` (e.g. `import { AppButton } from '@/components'`). The alias is defined in `tsconfig.json`.
|
||||
- **Barrel files**: most folders export through an `index.ts(x)`. Add new public exports there (e.g. a new common component is re-exported from `src/components/common/index.tsx`, which `src/components/index.tsx` re-exports).
|
||||
- **Common components** live in `src/components/common/<Name>/` as a folder containing `<Name>.tsx`, `index.tsx` (re-export), and an optional `<Name>.test.tsx`. Existing ones: `AppAlert`, `AppButton`, `AppIcon`, `AppIconButton`, `AppImage`, `AppLink`, `AppLoading`. Prefer reusing these over raw MUI where one exists.
|
||||
- **Icons**: reference icons by string name through `AppIcon`. The name→icon map is in `src/components/common/AppIcon/config.ts`; add new icons there (custom SVGs go in `AppIcon/icons/`).
|
||||
- **Navigation**: sidebar/bottom-bar items are arrays of `LinkToPage` defined inside `PublicLayout.tsx` / `PrivateLayout.tsx`. Add a route → add an entry there to surface it in the nav.
|
||||
- **Two layouts**: `PrivateLayout` (after auth) and `PublicLayout` (before auth); `CurrentLayout` picks between them based on auth state. The app name lives in the `TITLE_PRIVATE` / `TITLE_PUBLIC` constants in those files.
|
||||
- **Auth is a stub.** `src/hooks/auth.ts` and `src/app/auth/login/LoginForm.tsx` fake login by writing a placeholder token to session storage (`// TODO: AUTH:`). When implementing real auth, replace those spots and point requests at `NEXT_PUBLIC_API_URL` (the `server` project's JWT endpoints).
|
||||
- **Theming**: use the theme/palette via MUI's `sx` / `useTheme`; don't hardcode colors. Light/dark values are in `src/theme/light.ts` and `dark.ts`; dark-mode toggle flows through the store.
|
||||
- **Client vs server components**: files needing hooks/browser APIs start with `'use client';` (see `LoginForm.tsx`). Pages that only render markup can stay server components.
|
||||
|
||||
## Environment
|
||||
|
||||
Browser-exposed config comes from `NEXT_PUBLIC_*` variables (copy `.env.sample` → `.env`). The ones actually read in code: `NEXT_PUBLIC_ENV`, `NEXT_PUBLIC_DEBUG`, `NEXT_PUBLIC_PUBLIC_URL` (see `src/config.ts`) and `NEXT_PUBLIC_VERSION` (see `src/utils/environment.ts`). `NEXT_PUBLIC_API_URL` is the backend base URL.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Static export (`output: 'export'`) means **no server-side runtime** — no API routes, no SSR-only features, `images.unoptimized` is on.
|
||||
- Don't reintroduce the removed demo/showcase route (`src/app/dev`) or `Demo*` components — they were template content.
|
||||
@@ -0,0 +1,76 @@
|
||||
# Balinyaar — Web Client
|
||||
|
||||
Frontend for the Balinyaar application, built with **Next.js (App Router)**, **React**, **TypeScript**, and **Material UI (MUI)**.
|
||||
|
||||
It ships with a small library of reusable `App*` components, a theming system (light/dark), a lightweight global store, and public/private layouts wired for an authentication flow.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 18+ and npm
|
||||
- The backend API (see [`../server`](../server)) running for authenticated features
|
||||
|
||||
## Getting started
|
||||
|
||||
1. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Create your local environment file from the sample and adjust values:
|
||||
|
||||
```bash
|
||||
cp .env.sample .env
|
||||
```
|
||||
|
||||
Key variables (all `NEXT_PUBLIC_*` are exposed to the browser):
|
||||
|
||||
| Variable | Purpose |
|
||||
| ------------------------ | ---------------------------------------------------- |
|
||||
| `NEXT_PUBLIC_ENV` | `development` \| `preview` \| `production` |
|
||||
| `NEXT_PUBLIC_DEBUG` | `true` enables extra console logging |
|
||||
| `NEXT_PUBLIC_PUBLIC_URL` | Public URL of the site |
|
||||
| `NEXT_PUBLIC_API_URL` | Base URL of the backend API (the `server` project) |
|
||||
|
||||
3. Run the dev server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
## Available scripts
|
||||
|
||||
| Script | Description |
|
||||
| ------------------ | ------------------------------------------------------- |
|
||||
| `npm run dev` | Start the development server with hot reload |
|
||||
| `npm run build` | Production build (static export to `out/`) |
|
||||
| `npm run start` | Serve a production build |
|
||||
| `npm run lint` | Run ESLint (`eslint-config-next`) |
|
||||
| `npm run format` | Format the codebase with Prettier |
|
||||
| `npm test` | Run Jest in watch mode |
|
||||
| `npm run test:ci` | Run Jest once (CI) |
|
||||
| `npm run type` | Type-check with `tsc` |
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ Next.js App Router routes (home, about, auth, me) + root layout
|
||||
├── components/ Reusable UI: common App* components (AppButton, AppIcon, ...) + UserInfo
|
||||
├── hooks/ Custom hooks (auth, layout, events, window size)
|
||||
├── layout/ PublicLayout / PrivateLayout + TopBar, SideBar, BottomBar
|
||||
├── store/ Global app store (React context + reducer)
|
||||
├── theme/ MUI theme provider, light/dark palettes, colors
|
||||
└── utils/ Helpers (storage, navigation, env, text, types)
|
||||
```
|
||||
|
||||
The `@/*` import alias maps to `src/*` (see `tsconfig.json`).
|
||||
|
||||
> For a deeper, agent-oriented map of conventions and where to make changes, see [AGENTS.md](AGENTS.md).
|
||||
|
||||
## Notes
|
||||
|
||||
- `next.config.mjs` uses `output: 'export'`, producing a static site in `out/`.
|
||||
- Authentication in `src/hooks/auth.ts` and `src/app/auth/login/LoginForm.tsx` is currently a stub (look for `// TODO: AUTH:`); wire it to the backend's JWT endpoints when implementing real auth.
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Config } from 'jest';
|
||||
import nextJest from 'next/jest.js';
|
||||
|
||||
// const nextJest = require('next/jest');
|
||||
|
||||
const createJestConfig = nextJest({
|
||||
// Provide the path to your Next.js app to load next.config.* and .env files in your test environment
|
||||
dir: './',
|
||||
});
|
||||
|
||||
// Add any custom config to be passed to Jest
|
||||
const customJestConfig: Config = {
|
||||
coverageProvider: 'v8',
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
moduleNameMapper: {
|
||||
// Handle module aliases
|
||||
'^@/(.*)$': '<rootDir>/$1',
|
||||
},
|
||||
// testEnvironment: 'jest-environment-jsdom',
|
||||
testEnvironment: 'jsdom',
|
||||
// transform: {
|
||||
// '.+\\.(css|styl|less|sass|scss)$': 'jest-css-modules-transform',
|
||||
// },
|
||||
};
|
||||
|
||||
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
|
||||
module.exports = createJestConfig(customJestConfig);
|
||||
@@ -0,0 +1,9 @@
|
||||
// Optional: configure or set up a testing framework before each test.
|
||||
// If you delete this file, remove `setupFilesAfterEnv` from `jest.config.*`
|
||||
|
||||
// Used for __tests__/testing-library.js
|
||||
// Learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
// To get 'next/router' working with tests
|
||||
jest.mock('next/router', () => require('next-router-mock'));
|
||||
@@ -0,0 +1,16 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
const nextConfig = {
|
||||
output: 'export', // Use this if you want to create "static generated website" (SSG), result in "/out" folder
|
||||
trailingSlash: true,
|
||||
images: { unoptimized: true },
|
||||
|
||||
env: {
|
||||
// Add custom build-time env variables here, also check .env.* files
|
||||
},
|
||||
|
||||
reactStrictMode: true,
|
||||
// reactStrictMode: false,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "balinyaar-client",
|
||||
"version": "0.1.0",
|
||||
"description": "Balinyaar web application",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"format": "prettier ./ --write",
|
||||
"lint": "next lint",
|
||||
"start": "next start",
|
||||
"test": "jest --watch",
|
||||
"test:ci": "jest --ci",
|
||||
"type": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/cache": "latest",
|
||||
"@emotion/react": "latest",
|
||||
"@emotion/server": "latest",
|
||||
"@emotion/styled": "latest",
|
||||
"@mui/icons-material": "latest",
|
||||
"@mui/material": "latest",
|
||||
"@mui/material-nextjs": "latest",
|
||||
"clsx": "latest",
|
||||
"copy-to-clipboard": "latest",
|
||||
"next": "latest",
|
||||
"react": "latest",
|
||||
"react-dom": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "latest",
|
||||
"@testing-library/react": "latest",
|
||||
"@testing-library/user-event": "latest",
|
||||
"@types/jest": "latest",
|
||||
"@types/node": "latest",
|
||||
"@types/react": "latest",
|
||||
"@types/react-dom": "latest",
|
||||
"eslint": "latest",
|
||||
"eslint-config-next": "latest",
|
||||
"jest": "latest",
|
||||
"jest-environment-jsdom": "latest",
|
||||
"next-router-mock": "latest",
|
||||
"prettier": "latest",
|
||||
"ts-node": "latest",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 479 B |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 923 B |
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,2 @@
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#D99E82" d="M35.222 33.598c-.647-2.101-1.705-6.059-2.325-7.566-.501-1.216-.969-2.438-1.544-3.014-.575-.575-1.553-.53-2.143.058 0 0-2.469 1.675-3.354 2.783-1.108.882-2.785 3.357-2.785 3.357-.59.59-.635 1.567-.06 2.143.576.575 1.798 1.043 3.015 1.544 1.506.62 5.465 1.676 7.566 2.325.359.11 1.74-1.271 1.63-1.63z"/><path fill="#EA596E" d="M13.643 5.308c1.151 1.151 1.151 3.016 0 4.167l-4.167 4.168c-1.151 1.15-3.018 1.15-4.167 0L1.141 9.475c-1.15-1.151-1.15-3.016 0-4.167l4.167-4.167c1.15-1.151 3.016-1.151 4.167 0l4.168 4.167z"/><path fill="#FFCC4D" d="M31.353 23.018l-4.17 4.17-4.163 4.165L7.392 15.726l8.335-8.334 15.626 15.626z"/><path fill="#292F33" d="M32.078 34.763s2.709 1.489 3.441.757c.732-.732-.765-3.435-.765-3.435s-2.566.048-2.676 2.678z"/><path fill="#CCD6DD" d="M2.183 10.517l8.335-8.335 5.208 5.209-8.334 8.335z"/><path fill="#99AAB5" d="M3.225 11.558l8.334-8.334 1.042 1.042L4.267 12.6zm2.083 2.086l8.335-8.335 1.042 1.042-8.335 8.334z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,5 @@
|
||||
User-agent: *
|
||||
Disallow: /private/
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "Balinyaar",
|
||||
"short_name": "Balinyaar",
|
||||
"description": "Balinyaar web application",
|
||||
"start_url": ".",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico?v=1.0",
|
||||
"sizes": "48x48 32x32 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{ "src": "img/favicon/16x16.png?v=1.0", "sizes": "16x16", "type": "image/png" },
|
||||
{ "src": "img/favicon/32x32.png?v=1.0", "sizes": "32x32", "type": "image/png" },
|
||||
{ "src": "img/favicon/180x180.png?v=1.0", "sizes": "180x180", "type": "image/png" },
|
||||
{ "src": "img/favicon/192x192.png?v=1.0", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "img/favicon/512x512.png?v=1.0", "sizes": "512x512", "type": "image/png" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import { NextPage } from 'next';
|
||||
|
||||
/**
|
||||
* Renders About Application page
|
||||
* @page About
|
||||
*/
|
||||
const AboutPage: NextPage = () => {
|
||||
return (
|
||||
<Stack spacing={2} padding={2}>
|
||||
<Stack>
|
||||
<Typography variant="h3">About application</Typography>
|
||||
<Typography variant="body1">Balinyaar is a Next.js (App Router) application built with Material UI.</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutPage;
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
import { Stack } from '@mui/material';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AppButton } from '@/components';
|
||||
import { useAppStore } from '@/store';
|
||||
import { useEventLogout } from '@/hooks';
|
||||
import { sessionStorageSet } from '@/utils';
|
||||
|
||||
/**
|
||||
* Renders login form for user to authenticate
|
||||
* @component LoginForm
|
||||
*/
|
||||
const LoginForm = () => {
|
||||
const router = useRouter();
|
||||
const [, dispatch] = useAppStore();
|
||||
const onLogout = useEventLogout();
|
||||
|
||||
const onLogin = () => {
|
||||
// TODO: AUTH: Sample of access token store, replace next line in real application
|
||||
sessionStorageSet('access_token', 'TODO:_save-real-access-token-here');
|
||||
|
||||
dispatch({ type: 'LOG_IN' });
|
||||
router.replace('/'); // Redirect to home page without ability to go back
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack alignItems="center" spacing={2} padding={2}>
|
||||
<Stack>Put form controls or add social login buttons here...</Stack>
|
||||
|
||||
<Stack direction="row">
|
||||
<AppButton color="success" onClick={onLogin}>
|
||||
Emulate User Login
|
||||
</AppButton>
|
||||
<AppButton color="warning" onClick={onLogout}>
|
||||
Logout User
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginForm;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Metadata, NextPage } from 'next';
|
||||
import LoginForm from './LoginForm';
|
||||
|
||||
/**
|
||||
* User Login page
|
||||
* @page Login
|
||||
*/
|
||||
const LoginPage: NextPage = () => {
|
||||
return (
|
||||
<>
|
||||
<LoginForm />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Login - Balinyaar',
|
||||
description: 'Balinyaar web application',
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* Redirects to default Auth page
|
||||
* @page Auth
|
||||
* @redirect /auth
|
||||
*/
|
||||
const AuthPage = () => {
|
||||
redirect('/auth/login');
|
||||
// return <div>Auth Page</div>;
|
||||
};
|
||||
|
||||
export default AuthPage;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Metadata } from 'next';
|
||||
import LoginPage from '../login/page';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Signup - Balinyaar',
|
||||
description: 'Balinyaar web application',
|
||||
};
|
||||
|
||||
export default LoginPage; // Reuses the Login page for now
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,18 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
/* required for sticky elements: HeaderMobile, and so on */
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Metadata, NextPage } from 'next';
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Balinyaar',
|
||||
description: 'Balinyaar web application',
|
||||
};
|
||||
|
||||
/**
|
||||
* Main page of the Application
|
||||
* @page Home
|
||||
*/
|
||||
const Home: NextPage = () => {
|
||||
return (
|
||||
<Stack spacing={2} padding={2}>
|
||||
<Stack>
|
||||
<Typography variant="h3">Welcome to Balinyaar</Typography>
|
||||
<Typography variant="body1">This is the home page of the application.</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { FunctionComponent, PropsWithChildren } from 'react';
|
||||
import { Metadata, Viewport } from 'next';
|
||||
import { SimplePaletteColorOptions } from '@mui/material';
|
||||
import { AppStoreProvider } from '@/store';
|
||||
import defaultTheme, { ThemeProvider } from '@/theme';
|
||||
import CurrentLayout from '@/layout';
|
||||
import './globals.css';
|
||||
|
||||
const THEME_COLOR = (defaultTheme.palette?.primary as SimplePaletteColorOptions)?.main || '#FFFFFF';
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: THEME_COLOR,
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Balinyaar',
|
||||
description: 'Balinyaar web application',
|
||||
manifest: '/site.webmanifest',
|
||||
};
|
||||
|
||||
const RootLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<AppStoreProvider>
|
||||
<ThemeProvider>
|
||||
<CurrentLayout>{children}</CurrentLayout>
|
||||
</ThemeProvider>
|
||||
</AppStoreProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
};
|
||||
|
||||
export default RootLayout;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Stack } from '@mui/material';
|
||||
import { NextPage } from 'next';
|
||||
import { AppAlert, UserInfo } from '../../components';
|
||||
|
||||
/**
|
||||
* Renders User Profile Page
|
||||
* @page Me
|
||||
*/
|
||||
const MeAkaProfilePage: NextPage = () => {
|
||||
return (
|
||||
<Stack spacing={2} padding={2}>
|
||||
<AppAlert severity="warning">This page is under construction</AppAlert>
|
||||
<UserInfo showAvatar />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default MeAkaProfilePage;
|
||||
@@ -0,0 +1,3 @@
|
||||
import HomePage from './home/page';
|
||||
|
||||
export default HomePage;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Avatar, Stack, Typography } from '@mui/material';
|
||||
import { AppLink } from '../common';
|
||||
|
||||
interface UserInfoProps {
|
||||
className?: string;
|
||||
showAvatar?: boolean;
|
||||
user?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders User info with Avatar
|
||||
* @component UserInfo
|
||||
* @param {boolean} [showAvatar] - user's avatar picture is shown when true
|
||||
* @param {object} [user] - logged user data {name, email, avatar...}
|
||||
*/
|
||||
const UserInfo = ({ showAvatar = false, user, ...restOfProps }: UserInfoProps) => {
|
||||
const fullName = user?.name || [user?.nameFirst || '', user?.nameLast || ''].join(' ').trim();
|
||||
const srcAvatar = user?.avatar ? user?.avatar : undefined;
|
||||
const userPhoneOrEmail = user?.phone || (user?.email as string);
|
||||
|
||||
return (
|
||||
<Stack alignItems="center" minHeight="fit-content" marginBottom={2} {...restOfProps}>
|
||||
{showAvatar ? (
|
||||
<AppLink to="/me" underline="none">
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
fontSize: '3rem',
|
||||
}}
|
||||
alt={fullName || 'User Avatar'}
|
||||
src={srcAvatar}
|
||||
/>
|
||||
</AppLink>
|
||||
) : null}
|
||||
<Typography sx={{ mt: 1 }} variant="h6">
|
||||
{fullName || 'Current User'}
|
||||
</Typography>
|
||||
<Typography variant="body2">{userPhoneOrEmail || 'Loading...'}</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserInfo;
|
||||
@@ -0,0 +1,4 @@
|
||||
import UserInfo from './UserInfo';
|
||||
|
||||
export { UserInfo };
|
||||
export default UserInfo;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import AppAlert from './AppAlert';
|
||||
import { capitalize, randomText } from '@/utils';
|
||||
import { AlertProps } from '@mui/material';
|
||||
|
||||
const ComponentToTest = AppAlert;
|
||||
|
||||
/**
|
||||
* Tests for <AppAlert/> component
|
||||
*/
|
||||
describe('<AppAlert/> component', () => {
|
||||
it('renders itself', () => {
|
||||
const testId = randomText(8);
|
||||
render(<ComponentToTest data-testid={testId} />);
|
||||
const alert = screen.getByTestId(testId);
|
||||
expect(alert).toBeDefined();
|
||||
expect(alert).toHaveAttribute('role', 'alert');
|
||||
expect(alert).toHaveClass('MuiAlert-root');
|
||||
});
|
||||
|
||||
it('supports .severity property', () => {
|
||||
const SEVERITIES = ['error', 'info', 'success', 'warning'];
|
||||
for (const severity of SEVERITIES) {
|
||||
const testId = randomText(8);
|
||||
const severity = 'success';
|
||||
render(
|
||||
<ComponentToTest
|
||||
data-testid={testId}
|
||||
severity={severity}
|
||||
variant="filled" // Needed to verify exact MUI classes
|
||||
/>
|
||||
);
|
||||
const alert = screen.getByTestId(testId);
|
||||
expect(alert).toBeDefined();
|
||||
expect(alert).toHaveClass(`MuiAlert-filled${capitalize(severity)}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('supports .variant property', () => {
|
||||
const VARIANTS = ['filled', 'outlined', 'standard'];
|
||||
for (const variant of VARIANTS) {
|
||||
const testId = randomText(8);
|
||||
render(
|
||||
<ComponentToTest
|
||||
data-testid={testId}
|
||||
variant={variant as AlertProps['variant']}
|
||||
severity="warning" // Needed to verify exact MUI classes
|
||||
/>
|
||||
);
|
||||
const alert = screen.getByTestId(testId);
|
||||
expect(alert).toBeDefined();
|
||||
expect(alert).toHaveClass(`MuiAlert-${variant}Warning`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import MuiAlert, { AlertProps as MuiAlertProps } from '@mui/material/Alert';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { APP_ALERT_SEVERITY, APP_ALERT_VARIANT } from '../../config';
|
||||
|
||||
/**
|
||||
* Application styled Alert component
|
||||
* @component AppAlert
|
||||
*/
|
||||
const AppAlert: FunctionComponent<MuiAlertProps> = ({
|
||||
severity = APP_ALERT_SEVERITY,
|
||||
variant = APP_ALERT_VARIANT,
|
||||
onClose,
|
||||
...restOfProps
|
||||
}) => {
|
||||
return <MuiAlert severity={severity} sx={{ marginY: 1 }} variant={variant} onClose={onClose} {...restOfProps} />;
|
||||
};
|
||||
|
||||
export default AppAlert;
|
||||
@@ -0,0 +1,3 @@
|
||||
import AppAlert from './AppAlert';
|
||||
|
||||
export { AppAlert as default, AppAlert };
|
||||
@@ -0,0 +1,155 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
import AppButton, { AppButtonProps } from './AppButton';
|
||||
import DefaultIcon from '@mui/icons-material/MoreHoriz';
|
||||
import { randomText, capitalize } from '@/utils';
|
||||
|
||||
/**
|
||||
* AppButton wrapped with Theme Provider
|
||||
*/
|
||||
const ComponentToTest: FunctionComponent<AppButtonProps> = (props) => (
|
||||
<ThemeProvider>
|
||||
<AppButton {...props} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
/**
|
||||
* Test specific color for AppButton
|
||||
* @param {string} colorName - name of the color, one of ColorName type
|
||||
* @param {string} [expectedClassName] - optional value to be found in className (color "true" may use "success" class name)
|
||||
* @param {boolean} [ignoreClassName] - optional flag to ignore className (color "inherit" doesn't use any class name)
|
||||
*/
|
||||
function testButtonColor(colorName: string, ignoreClassName = false, expectedClassName = colorName) {
|
||||
it(`supports "${colorName}" color`, () => {
|
||||
const testId = randomText(8);
|
||||
let text = `${colorName} button`;
|
||||
render(
|
||||
<ComponentToTest
|
||||
color={colorName}
|
||||
data-testid={testId}
|
||||
variant="contained" // Required to get correct CSS class name
|
||||
>
|
||||
{text}
|
||||
</ComponentToTest>
|
||||
);
|
||||
|
||||
let button = screen.getByTestId(testId);
|
||||
expect(button).toBeDefined();
|
||||
// console.log('button.className:', button?.className);
|
||||
if (!ignoreClassName) {
|
||||
expect(button?.className?.includes('MuiButton-root')).toBeTruthy();
|
||||
expect(button?.className?.includes('MuiButton-contained')).toBeTruthy();
|
||||
expect(button?.className?.includes(`MuiButton-contained${capitalize(expectedClassName)}`)).toBeTruthy(); // Check for "MuiButton-contained[Primary| Secondary |...]" class
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('<AppButton/> component', () => {
|
||||
// beforeEach(() => {});
|
||||
|
||||
it('renders itself', () => {
|
||||
let text = 'sample button';
|
||||
const testId = randomText(8);
|
||||
render(<ComponentToTest data-testid={testId}>{text}</ComponentToTest>);
|
||||
const button = screen.getByTestId(testId);
|
||||
expect(button).toBeDefined();
|
||||
expect(button).toHaveAttribute('role', 'button');
|
||||
expect(button).toHaveAttribute('type', 'button'); // not "submit" or "input" by default
|
||||
});
|
||||
|
||||
it('has .margin style by default', () => {
|
||||
let text = 'button with default margin';
|
||||
const testId = randomText(8);
|
||||
render(<ComponentToTest data-testid={testId}>{text}</ComponentToTest>);
|
||||
const button = screen.getByTestId(testId);
|
||||
expect(button).toBeDefined();
|
||||
expect(button).toHaveStyle('margin: 8px'); // Actually it is theme.spacing(1) value
|
||||
});
|
||||
|
||||
it('supports .className property', () => {
|
||||
let text = 'button with specific class';
|
||||
let className = 'someClassName';
|
||||
const testId = randomText(8);
|
||||
render(
|
||||
<ComponentToTest data-testid={testId} className={className}>
|
||||
{text}
|
||||
</ComponentToTest>
|
||||
);
|
||||
const button = screen.getByTestId(testId);
|
||||
expect(button).toBeDefined();
|
||||
expect(button).toHaveClass(className);
|
||||
});
|
||||
|
||||
it('supports .label property', () => {
|
||||
let text = 'button with label';
|
||||
render(<ComponentToTest label={text} />);
|
||||
let span = screen.getByText(text);
|
||||
expect(span).toBeDefined();
|
||||
let button = span.closest('button'); // parent <button> element
|
||||
expect(button).toBeDefined();
|
||||
});
|
||||
|
||||
it('supports .text property', () => {
|
||||
let text = 'button with text';
|
||||
render(<ComponentToTest text={text} />);
|
||||
let span = screen.getByText(text);
|
||||
expect(span).toBeDefined();
|
||||
let button = span.closest('button'); // parent <button> element
|
||||
expect(button).toBeDefined();
|
||||
});
|
||||
|
||||
it('supports .startIcon property as <svg/>', () => {
|
||||
let text = 'button with start icon';
|
||||
render(<ComponentToTest text={text} startIcon={<DefaultIcon data-testid="startIcon" />} />);
|
||||
let button = screen.getByText(text);
|
||||
let icon = within(button).getByTestId('startIcon');
|
||||
expect(icon).toBeDefined();
|
||||
let span = icon.closest('span');
|
||||
expect(span).toHaveClass('MuiButton-startIcon');
|
||||
});
|
||||
|
||||
it('supports .endIcon property', () => {
|
||||
let text = 'button with end icon as <svg/>';
|
||||
render(<ComponentToTest text={text} endIcon={<DefaultIcon data-testid="endIcon" />} />);
|
||||
let button = screen.getByText(text);
|
||||
let icon = within(button).getByTestId('endIcon');
|
||||
expect(icon).toBeDefined();
|
||||
let span = icon.closest('span');
|
||||
expect(span).toHaveClass('MuiButton-endIcon');
|
||||
});
|
||||
|
||||
it('supports .startIcon property as string', () => {
|
||||
let text = 'button with start icon';
|
||||
render(<ComponentToTest text={text} startIcon="default" />);
|
||||
let button = screen.getByText(text);
|
||||
let icon = within(button).getByTestId('MoreHorizIcon'); //Note: this is valid only when "default" icon is <MoreHorizIcon />
|
||||
expect(icon).toBeDefined();
|
||||
let span = icon.closest('span');
|
||||
expect(span).toHaveClass('MuiButton-startIcon');
|
||||
});
|
||||
|
||||
it('supports .endIcon property', () => {
|
||||
let text = 'button with end icon as string';
|
||||
render(<ComponentToTest text={text} endIcon="default" />);
|
||||
let button = screen.getByText(text);
|
||||
let icon = within(button).getByTestId('MoreHorizIcon'); //Note: this is valid only when "default" icon is <MoreHorizIcon />
|
||||
expect(icon).toBeDefined();
|
||||
let span = icon.closest('span');
|
||||
expect(span).toHaveClass('MuiButton-endIcon');
|
||||
});
|
||||
|
||||
// MUI colors
|
||||
testButtonColor('inherit');
|
||||
testButtonColor('primary');
|
||||
testButtonColor('secondary');
|
||||
testButtonColor('error');
|
||||
testButtonColor('warning');
|
||||
testButtonColor('info');
|
||||
testButtonColor('success');
|
||||
|
||||
// Non-MUI colors
|
||||
testButtonColor('green', true);
|
||||
testButtonColor('#FF00FF', true);
|
||||
testButtonColor('rgba(255, 0, 0, 0.5)', true);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { ElementType, FunctionComponent, ReactNode, useMemo } from 'react';
|
||||
import Button, { ButtonProps } from '@mui/material/Button';
|
||||
import AppIcon from '../AppIcon';
|
||||
import AppLink from '../AppLink';
|
||||
import { APP_BUTTON_VARIANT } from '../../config';
|
||||
|
||||
const MUI_BUTTON_COLORS = ['inherit', 'primary', 'secondary', 'success', 'error', 'info', 'warning'];
|
||||
|
||||
const DEFAULT_SX_VALUES = {
|
||||
margin: 1, // By default the AppButton has theme.spacing(1) margin on all sides
|
||||
};
|
||||
|
||||
export interface AppButtonProps extends Omit<ButtonProps, 'color' | 'endIcon' | 'startIcon'> {
|
||||
color?: string; // Not only 'inherit' | 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning',
|
||||
endIcon?: string | ReactNode;
|
||||
label?: string; // Alternate to .text
|
||||
text?: string; // Alternate to .label
|
||||
startIcon?: string | ReactNode;
|
||||
// Missing props
|
||||
component?: ElementType; // Could be RouterLink, AppLink, <a>, etc.
|
||||
to?: string; // Link prop
|
||||
href?: string; // Link prop
|
||||
openInNewTab?: boolean; // Link prop
|
||||
underline?: 'none' | 'hover' | 'always'; // Link prop
|
||||
}
|
||||
|
||||
/**
|
||||
* Application styled Material UI Button with Box around to specify margins using props
|
||||
* @component AppButton
|
||||
* @param {string} [color] - when passing MUI value ('primary', 'secondary', and so on), it is color of the button body, otherwise it is color of text and icons
|
||||
* @param {string} [children] - content to render, overrides .label and .text props
|
||||
* @param {string | ReactNode} [endIcon] - name of AppIcon or ReactNode to show after the button label
|
||||
* @param {string} [href] - external link URI
|
||||
* @param {string} [label] - text to render, alternate to .text
|
||||
* @param {boolean} [openInNewTab] - link will be opened in new tab when true
|
||||
* @param {string | ReactNode} [startIcon] - name of AppIcon or ReactNode to show before the button label
|
||||
* @param {Array<func| object| bool> | func | object} [sx] - additional CSS styles to apply to the button
|
||||
* @param {string} [text] - text to render, alternate to .label
|
||||
* @param {string} [to] - internal link URI
|
||||
* @param {string} [underline] - controls underline style when button used as link, one of 'none', 'hover', or 'always'
|
||||
* @param {string} [variant] - MUI variant of the button, one of 'text', 'outlined', or 'contained'
|
||||
*/
|
||||
const AppButton: FunctionComponent<AppButtonProps> = ({
|
||||
children,
|
||||
color: propColor = 'inherit',
|
||||
component: propComponent,
|
||||
endIcon,
|
||||
label,
|
||||
startIcon,
|
||||
sx: propSx = DEFAULT_SX_VALUES,
|
||||
text,
|
||||
underline = 'none',
|
||||
variant = APP_BUTTON_VARIANT,
|
||||
...restOfProps
|
||||
}) => {
|
||||
const iconStart: ReactNode = useMemo(
|
||||
() => (!startIcon ? undefined : typeof startIcon === 'string' ? <AppIcon icon={String(startIcon)} /> : startIcon),
|
||||
[startIcon]
|
||||
);
|
||||
|
||||
const iconEnd: ReactNode = useMemo(
|
||||
() => (!endIcon ? undefined : typeof endIcon === 'string' ? <AppIcon icon={String(endIcon)} /> : endIcon),
|
||||
[endIcon]
|
||||
);
|
||||
|
||||
const isMuiColor = useMemo(() => MUI_BUTTON_COLORS.includes(propColor), [propColor]);
|
||||
|
||||
const componentToRender =
|
||||
!propComponent && (restOfProps?.href || restOfProps?.to) ? AppLink : propComponent ?? Button;
|
||||
|
||||
const colorToRender = isMuiColor ? (propColor as ButtonProps['color']) : 'inherit';
|
||||
const sxToRender = {
|
||||
...propSx,
|
||||
...(isMuiColor ? {} : { color: propColor }),
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
component={componentToRender}
|
||||
color={colorToRender}
|
||||
endIcon={iconEnd}
|
||||
startIcon={iconStart}
|
||||
sx={sxToRender}
|
||||
variant={variant}
|
||||
{...{ ...restOfProps, underline }}
|
||||
>
|
||||
{children || label || text}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppButton;
|
||||
@@ -0,0 +1,3 @@
|
||||
import AppButton from './AppButton';
|
||||
|
||||
export { AppButton as default, AppButton };
|
||||
@@ -0,0 +1,64 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import AppIcon from './AppIcon';
|
||||
import { APP_ICON_SIZE } from '../../config';
|
||||
import { randomColor, randomText } from '@/utils';
|
||||
import { ICONS } from './config';
|
||||
|
||||
const ComponentToTest = AppIcon;
|
||||
|
||||
/**
|
||||
* Tests for <AppIcon/> component
|
||||
*/
|
||||
describe('<AppIcon/> component', () => {
|
||||
it('renders itself', () => {
|
||||
const testId = randomText(8);
|
||||
render(<ComponentToTest data-testid={testId} />);
|
||||
const svg = screen.getByTestId(testId);
|
||||
expect(svg).toBeDefined();
|
||||
expect(svg).toHaveAttribute('data-icon', 'default');
|
||||
expect(svg).toHaveAttribute('size', String(APP_ICON_SIZE)); // default size
|
||||
expect(svg).toHaveAttribute('height', String(APP_ICON_SIZE)); // default size when .size is not set
|
||||
expect(svg).toHaveAttribute('width', String(APP_ICON_SIZE)); // default size when .size is not se
|
||||
});
|
||||
|
||||
it('supports .color property', () => {
|
||||
const testId = randomText(8);
|
||||
const color = randomColor(); // Note: 'rgb(255, 128, 0)' format is used by react-icons npm, so tests may fail
|
||||
render(<ComponentToTest data-testid={testId} color={color} />);
|
||||
const svg = screen.getByTestId(testId);
|
||||
expect(svg).toHaveAttribute('data-icon', 'default');
|
||||
// expect(svg).toHaveAttribute('color', color); // TODO: Looks like MUI Icons exclude .color property from <svg> rendering
|
||||
expect(svg).toHaveStyle(`color: ${color}`);
|
||||
expect(svg).toHaveAttribute('fill', 'currentColor'); // .fill must be 'currentColor' when .color property is set
|
||||
});
|
||||
|
||||
it('supports .icon property', () => {
|
||||
// Verify that all icons are supported
|
||||
for (const icon of Object.keys(ICONS)) {
|
||||
const testId = randomText(8);
|
||||
render(<ComponentToTest data-testid={testId} icon={icon} />);
|
||||
const svg = screen.getByTestId(testId);
|
||||
expect(svg).toBeDefined();
|
||||
expect(svg).toHaveAttribute('data-icon', icon.toLowerCase());
|
||||
}
|
||||
});
|
||||
|
||||
it('supports .size property', () => {
|
||||
const testId = randomText(8);
|
||||
const size = Math.floor(Math.random() * 128) + 1;
|
||||
render(<ComponentToTest data-testid={testId} size={size} />);
|
||||
const svg = screen.getByTestId(testId);
|
||||
expect(svg).toHaveAttribute('size', String(size));
|
||||
expect(svg).toHaveAttribute('height', String(size));
|
||||
expect(svg).toHaveAttribute('width', String(size));
|
||||
});
|
||||
|
||||
it('supports .title property', () => {
|
||||
const testId = randomText(8);
|
||||
const title = randomText(16);
|
||||
render(<ComponentToTest data-testid={testId} title={title} />);
|
||||
const svg = screen.getByTestId(testId);
|
||||
expect(svg).toBeDefined();
|
||||
expect(svg).toHaveAttribute('title', title);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ComponentType, FunctionComponent, SVGAttributes } from 'react';
|
||||
import { APP_ICON_SIZE } from '../../config';
|
||||
import { IconName, ICONS } from './config';
|
||||
|
||||
/**
|
||||
* Props of the AppIcon component, also can be used for SVG icons
|
||||
*/
|
||||
export interface Props extends SVGAttributes<SVGElement> {
|
||||
color?: string;
|
||||
icon?: IconName | string;
|
||||
size?: string | number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders SVG icon by given Icon name
|
||||
* @component AppIcon
|
||||
* @param {string} [color] - color of the icon as a CSS color value
|
||||
* @param {string} [icon] - name of the Icon to render
|
||||
* @param {string} [title] - title/hint to show when the cursor hovers the icon
|
||||
* @param {string | number} [size] - size of the icon, default is ICON_SIZE
|
||||
*/
|
||||
const AppIcon: FunctionComponent<Props> = ({
|
||||
color,
|
||||
icon = 'default',
|
||||
size = APP_ICON_SIZE,
|
||||
style,
|
||||
...restOfProps
|
||||
}) => {
|
||||
const iconName = (icon || 'default').trim().toLowerCase() as IconName;
|
||||
|
||||
let ComponentToRender: ComponentType = ICONS[iconName];
|
||||
if (!ComponentToRender) {
|
||||
console.warn(`AppIcon: icon "${iconName}" is not found!`);
|
||||
ComponentToRender = ICONS.default; // ICONS['default'];
|
||||
}
|
||||
|
||||
const propsToRender = {
|
||||
height: size,
|
||||
color,
|
||||
fill: color && 'currentColor',
|
||||
size,
|
||||
style: { ...style, color },
|
||||
width: size,
|
||||
...restOfProps,
|
||||
};
|
||||
|
||||
return <ComponentToRender data-icon={iconName} {...propsToRender} />;
|
||||
};
|
||||
|
||||
export default AppIcon;
|
||||
@@ -0,0 +1,56 @@
|
||||
// SVG assets
|
||||
import PencilIcon from './icons/PencilIcon';
|
||||
// MUI Icons
|
||||
import DefaultIcon from '@mui/icons-material/MoreHoriz';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import DayNightIcon from '@mui/icons-material/Brightness4';
|
||||
import NightIcon from '@mui/icons-material/Brightness3';
|
||||
import DayIcon from '@mui/icons-material/Brightness5';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import InfoIcon from '@mui/icons-material/Info';
|
||||
import HomeIcon from '@mui/icons-material/Home';
|
||||
import AccountCircle from '@mui/icons-material/AccountCircle';
|
||||
import PersonAddIcon from '@mui/icons-material/PersonAdd';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import ExitToAppIcon from '@mui/icons-material/ExitToApp';
|
||||
import NotificationsIcon from '@mui/icons-material/NotificationsOutlined';
|
||||
import DangerousIcon from '@mui/icons-material/Dangerous';
|
||||
|
||||
/**
|
||||
* List of all available Icon names
|
||||
*/
|
||||
export type IconName = keyof typeof ICONS;
|
||||
|
||||
/**
|
||||
* How to use:
|
||||
* 1. Import all required React, MUI or other SVG icons into this file.
|
||||
* 2. Add icons with "unique lowercase names" into ICONS object. Lowercase is a must!
|
||||
* 3. Use icons everywhere in the App by their names in <Icon icon="xxx" /> component
|
||||
* Important: properties of ICONS object MUST be lowercase!
|
||||
* Note: You can use camelCase or UPPERCASE in the <Icon icon="someIconByName" /> component
|
||||
*/
|
||||
export const ICONS /* Note: Setting type disables property autocomplete :( was - : Record<string, ComponentType> */ = {
|
||||
default: DefaultIcon,
|
||||
logo: PencilIcon,
|
||||
close: CloseIcon,
|
||||
menu: MenuIcon,
|
||||
settings: SettingsIcon,
|
||||
visibilityon: VisibilityIcon,
|
||||
visibilityoff: VisibilityOffIcon,
|
||||
daynight: DayNightIcon,
|
||||
night: NightIcon,
|
||||
day: DayIcon,
|
||||
search: SearchIcon,
|
||||
info: InfoIcon,
|
||||
home: HomeIcon,
|
||||
account: AccountCircle,
|
||||
signup: PersonAddIcon,
|
||||
login: PersonIcon,
|
||||
logout: ExitToAppIcon,
|
||||
notifications: NotificationsIcon,
|
||||
error: DangerousIcon,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { IconProps } from '../utils';
|
||||
|
||||
const CurrencyIcon: FunctionComponent<IconProps> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 36 36" {...props}>
|
||||
<path
|
||||
fill="#D99E82"
|
||||
d="M35.222 33.598c-.647-2.101-1.705-6.059-2.325-7.566-.501-1.216-.969-2.438-1.544-3.014-.575-.575-1.553-.53-2.143.058 0 0-2.469 1.675-3.354 2.783-1.108.882-2.785 3.357-2.785 3.357-.59.59-.635 1.567-.06 2.143.576.575 1.798 1.043 3.015 1.544 1.506.62 5.465 1.676 7.566 2.325.359.11 1.74-1.271 1.63-1.63z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA596E"
|
||||
d="M13.643 5.308c1.151 1.151 1.151 3.016 0 4.167l-4.167 4.168c-1.151 1.15-3.018 1.15-4.167 0L1.141 9.475c-1.15-1.151-1.15-3.016 0-4.167l4.167-4.167c1.15-1.151 3.016-1.151 4.167 0l4.168 4.167z"
|
||||
/>
|
||||
<path fill="#FFCC4D" d="M31.353 23.018l-4.17 4.17-4.163 4.165L7.392 15.726l8.335-8.334 15.626 15.626z" />
|
||||
<path
|
||||
fill="#292F33"
|
||||
d="M32.078 34.763s2.709 1.489 3.441.757c.732-.732-.765-3.435-.765-3.435s-2.566.048-2.676 2.678z"
|
||||
/>
|
||||
<path fill="#CCD6DD" d="M2.183 10.517l8.335-8.335 5.208 5.209-8.334 8.335z" />
|
||||
<path
|
||||
fill="#99AAB5"
|
||||
d="M3.225 11.558l8.334-8.334 1.042 1.042L4.267 12.6zm2.083 2.086l8.335-8.335 1.042 1.042-8.335 8.334z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurrencyIcon;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { IconProps } from '../utils';
|
||||
|
||||
const PencilIcon: FunctionComponent<IconProps> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 36 36" {...props}>
|
||||
<path
|
||||
fill="#D99E82"
|
||||
d="M35.222 33.598c-.647-2.101-1.705-6.059-2.325-7.566-.501-1.216-.969-2.438-1.544-3.014-.575-.575-1.553-.53-2.143.058 0 0-2.469 1.675-3.354 2.783-1.108.882-2.785 3.357-2.785 3.357-.59.59-.635 1.567-.06 2.143.576.575 1.798 1.043 3.015 1.544 1.506.62 5.465 1.676 7.566 2.325.359.11 1.74-1.271 1.63-1.63z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA596E"
|
||||
d="M13.643 5.308c1.151 1.151 1.151 3.016 0 4.167l-4.167 4.168c-1.151 1.15-3.018 1.15-4.167 0L1.141 9.475c-1.15-1.151-1.15-3.016 0-4.167l4.167-4.167c1.15-1.151 3.016-1.151 4.167 0l4.168 4.167z"
|
||||
/>
|
||||
<path fill="#FFCC4D" d="M31.353 23.018l-4.17 4.17-4.163 4.165L7.392 15.726l8.335-8.334 15.626 15.626z" />
|
||||
<path
|
||||
fill="#292F33"
|
||||
d="M32.078 34.763s2.709 1.489 3.441.757c.732-.732-.765-3.435-.765-3.435s-2.566.048-2.676 2.678z"
|
||||
/>
|
||||
<path fill="#CCD6DD" d="M2.183 10.517l8.335-8.335 5.208 5.209-8.334 8.335z" />
|
||||
<path
|
||||
fill="#99AAB5"
|
||||
d="M3.225 11.558l8.334-8.334 1.042 1.042L4.267 12.6zm2.083 2.086l8.335-8.335 1.042 1.042-8.335 8.334z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default PencilIcon;
|
||||
@@ -0,0 +1,125 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { IconProps } from '../utils';
|
||||
|
||||
const YellowPlaneIcon: FunctionComponent<IconProps> = (props) => {
|
||||
const styleOpacityAndEnableBackground = {
|
||||
opacity: 0.2,
|
||||
// enableBackground: 'new'
|
||||
};
|
||||
|
||||
return (
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" xmlSpace="preserve" {...props}>
|
||||
<path
|
||||
style={{ fill: '#FFCE00' }}
|
||||
d="M142.21,493.991c12.991,12.991,34.057,12.991,47.05,0c12.991-12.991,12.991-34.057,0-47.048
|
||||
L65.057,322.742c-12.993-12.992-34.059-12.992-47.05,0s-12.989,34.055,0,47.048L142.21,493.991z"
|
||||
/>
|
||||
<circle style={{ fill: '#7D868C' }} cx="386.857" cy="125.141" r="35.932" />
|
||||
<path
|
||||
style={styleOpacityAndEnableBackground}
|
||||
d="M391.846,120.154c-9.556-9.556-18.727-17.187-27.59-22.948
|
||||
c-0.969,0.785-1.908,1.627-2.807,2.527c-14.033,14.034-14.033,36.786,0,50.816c14.031,14.034,36.786,14.034,50.816,0
|
||||
c0.908-0.907,1.754-1.853,2.546-2.829C409.07,138.902,401.453,129.759,391.846,120.154z"
|
||||
/>
|
||||
<path
|
||||
style={styleOpacityAndEnableBackground}
|
||||
d="M75.949,333.636c-15.914,18.935-28.002,33.038-32.3,37.337
|
||||
c-4.221,4.221-7.777,8.86-10.672,13.785l94.264,94.266c4.925-2.894,9.565-6.449,13.789-10.672
|
||||
c4.298-4.301,18.399-16.384,37.334-32.303L75.949,333.636z"
|
||||
/>
|
||||
<path
|
||||
style={{ fill: '#333E48' }}
|
||||
d="M384.037,127.964c-30.663-30.663-63.439-47.604-94.099-16.941
|
||||
C254.182,146.782,79.164,363.201,57.52,384.842c-19.227,19.231-19.23,50.409,0,69.636c19.23,19.233,50.409,19.228,69.636,0
|
||||
c21.644-21.641,238.063-196.657,273.82-232.416C431.639,191.4,414.698,158.626,384.037,127.964z"
|
||||
/>
|
||||
<circle style={{ fill: '#7D868C' }} cx="218.905" cy="293.104" r="35.93" />
|
||||
<path
|
||||
style={styleOpacityAndEnableBackground}
|
||||
d="M278.708,123.019c-25.32,28.083-74.009,86.548-119.486,141.296
|
||||
l88.463,88.463c54.748-45.477,113.215-94.162,141.296-119.487L278.708,123.019z"
|
||||
/>
|
||||
<g>
|
||||
<path
|
||||
style={{ fill: '#FFCE00' }}
|
||||
d="M384.46,458.665c27.283,27.281,71.513,27.279,98.795-0.003c27.28-27.279,27.283-71.511,0-98.793
|
||||
L152.13,28.746c-27.283-27.283-71.513-27.283-98.793,0c-27.283,27.281-27.285,71.514-0.002,98.793L384.46,458.665z"
|
||||
/>
|
||||
<path
|
||||
style={{ fill: '#FFCE00' }}
|
||||
d="M84.341,435.945c-2.121,0-4.241-0.809-5.857-2.426c-3.236-3.235-3.236-8.48-0.002-11.716
|
||||
l50.812-50.814c3.236-3.236,8.483-3.235,11.716-0.001c3.236,3.235,3.236,8.48,0,11.714l-50.812,50.814
|
||||
C88.58,435.136,86.459,435.945,84.341,435.945z"
|
||||
/>
|
||||
</g>
|
||||
<rect
|
||||
x="174.379"
|
||||
y="88.222"
|
||||
transform="matrix(-0.7071 -0.7071 0.7071 -0.7071 200.0443 399.0237)"
|
||||
style={styleOpacityAndEnableBackground}
|
||||
width="16.568"
|
||||
height="139.719"
|
||||
/>
|
||||
<rect
|
||||
x="117.601"
|
||||
y="31.432"
|
||||
transform="matrix(-0.7071 -0.7071 0.7071 -0.7071 143.2742 261.929)"
|
||||
style={styleOpacityAndEnableBackground}
|
||||
width="16.568"
|
||||
height="139.719"
|
||||
/>
|
||||
<rect
|
||||
x="345.637"
|
||||
y="259.464"
|
||||
transform="matrix(-0.7071 -0.7071 0.7071 -0.7071 371.313 812.4501)"
|
||||
style={styleOpacityAndEnableBackground}
|
||||
width="16.568"
|
||||
height="139.719"
|
||||
/>
|
||||
<rect
|
||||
x="402.427"
|
||||
y="316.259"
|
||||
transform="matrix(-0.7071 -0.7071 0.7071 -0.7071 428.1006 949.5629)"
|
||||
style={styleOpacityAndEnableBackground}
|
||||
width="16.568"
|
||||
height="139.719"
|
||||
/>
|
||||
<path
|
||||
style={{ fill: '#1E252B' }}
|
||||
d="M489.114,354.011L383.944,248.841c10.747-9.425,18.276-16.308,22.89-20.921
|
||||
c16.43-16.43,22.038-34.95,16.673-55.044c-1.363-5.108-3.454-10.308-6.267-15.628c0.296-0.281,0.596-0.553,0.885-0.842
|
||||
c2.167-2.167,4.076-4.523,5.724-7.024l41.209,41.209c1.618,1.617,3.739,2.426,5.858,2.426c2.12,0,4.24-0.808,5.858-2.426
|
||||
c3.235-3.236,3.235-8.48,0-11.716l-46.323-46.323c0.406-2.427,0.626-4.902,0.626-7.411c0-11.811-4.599-22.914-12.95-31.265
|
||||
c-8.351-8.352-19.454-12.953-31.265-12.953c-2.511,0-4.987,0.22-7.415,0.627L333.126,35.23c-3.235-3.235-8.48-3.236-11.716-0.001
|
||||
c-3.236,3.235-3.236,8.48-0.001,11.714l41.209,41.21c-2.503,1.647-4.858,3.555-7.025,5.722c-0.288,0.288-0.562,0.59-0.843,0.886
|
||||
c-5.319-2.812-10.518-4.902-15.627-6.267c-20.099-5.369-38.615,0.243-55.044,16.673c-4.61,4.611-11.492,12.142-20.921,22.893
|
||||
L157.989,22.89C143.229,8.129,123.605,0,102.733,0S62.237,8.129,47.48,22.888c-14.76,14.76-22.89,34.383-22.89,55.254
|
||||
c-0.001,20.873,8.127,40.497,22.887,55.254l114.564,114.564c-6.337,7.624-12.648,15.223-18.86,22.703
|
||||
c-19.346,23.293-38.189,45.983-53.839,64.649l-18.428-18.429c-7.848-7.848-18.284-12.17-29.383-12.17s-21.534,4.322-29.382,12.172
|
||||
c-16.198,16.199-16.198,42.559,0,58.761l25.683,25.684c-6.704,20.045-2.103,43.073,13.83,59.004
|
||||
c10.865,10.867,25.311,16.85,40.677,16.85c6.339,0,12.515-1.034,18.354-2.994l25.658,25.658c8.102,8.101,18.74,12.15,29.381,12.15
|
||||
c10.64,0,21.284-4.051,29.384-12.15c16.201-16.201,16.201-42.562-0.001-58.763l-18.43-18.43
|
||||
c18.659-15.643,41.335-34.476,64.615-53.811c7.49-6.221,15.101-12.541,22.736-18.887l114.566,114.564
|
||||
c14.757,14.756,34.379,22.885,55.251,22.887c0.002,0,0.002,0,0.004,0c20.87,0,40.495-8.129,55.254-22.89
|
||||
c14.758-14.759,22.887-34.381,22.888-55.253C512.002,388.394,503.872,368.77,489.114,354.011z M386.86,97.491
|
||||
c7.385,0,14.328,2.876,19.55,8.099c5.222,5.222,8.097,12.165,8.097,19.55c0,6.56-2.273,12.766-6.438,17.731
|
||||
c-4.96-6.686-10.995-13.587-18.174-20.765c-7.179-7.179-14.08-13.215-20.767-18.176C374.093,99.765,380.301,97.491,386.86,97.491z
|
||||
M295.795,116.881c12.28-12.282,24.689-16.218,39.053-12.38c12.86,3.434,27.034,13.026,43.329,29.323
|
||||
c16.296,16.295,25.886,30.468,29.32,43.329c3.836,14.362-0.098,26.772-12.382,39.054c-4.413,4.414-12.113,11.434-22.915,20.895
|
||||
l-97.303-97.303C284.365,128.993,291.385,121.29,295.795,116.881z M23.866,363.932c-9.74-9.742-9.74-25.593,0-35.333
|
||||
c4.717-4.718,10.992-7.317,17.666-7.317c6.675,0,12.949,2.599,17.668,7.317l19.438,19.441C65.414,363.702,55.648,375,51.662,378.986
|
||||
c-2.168,2.168-4.12,4.47-5.868,6.876L23.866,363.932z M183.401,452.801c9.742,9.741,9.742,25.59,0.001,35.331
|
||||
c-9.742,9.741-25.594,9.742-35.336,0l-21.927-21.926c2.417-1.763,4.717-3.717,6.873-5.872c3.985-3.985,15.285-13.751,30.947-26.976
|
||||
L183.401,452.801z M230.718,356.101c-53.485,44.418-99.676,82.779-109.419,92.521c-7.735,7.735-18.02,11.995-28.96,11.996
|
||||
c-10.939,0-21.225-4.26-28.961-11.997c-15.968-15.966-15.968-41.949,0-57.92c9.744-9.743,48.118-55.949,92.55-109.452
|
||||
c5.891-7.094,11.87-14.293,17.879-21.523l78.468,78.468C245.033,344.21,237.822,350.2,230.718,356.101z M477.397,452.804
|
||||
c-11.631,11.632-27.093,18.038-43.539,18.038h-0.003c-16.447-0.001-31.909-6.406-43.538-18.034L59.192,121.681
|
||||
c-11.631-11.628-18.036-27.09-18.034-43.538c0-16.447,6.406-31.91,18.037-43.541c11.631-11.629,27.093-18.034,43.539-18.034
|
||||
c16.447,0,31.91,6.405,43.54,18.036l331.124,331.123c11.63,11.629,18.036,27.093,18.036,43.54
|
||||
C495.434,425.713,489.029,441.175,477.397,452.804z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default YellowPlaneIcon;
|
||||
@@ -0,0 +1,3 @@
|
||||
import AppIcon from './AppIcon';
|
||||
|
||||
export { AppIcon as default, AppIcon };
|
||||
@@ -0,0 +1,11 @@
|
||||
import { SVGAttributes } from 'react';
|
||||
|
||||
/**
|
||||
* Props to use with custom SVG icons, similar to AppIcon's Props
|
||||
*/
|
||||
export interface IconProps extends SVGAttributes<SVGElement> {
|
||||
color?: string;
|
||||
icon?: string;
|
||||
size?: string | number;
|
||||
title?: string;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import AppIconButton, { MUI_ICON_BUTTON_COLORS } from './AppIconButton';
|
||||
import { APP_ICON_SIZE } from '../../config';
|
||||
import { capitalize, randomColor, randomText } from '@/utils';
|
||||
import { ICONS } from '../AppIcon/config';
|
||||
|
||||
const ComponentToTest = AppIconButton;
|
||||
|
||||
function randomPropertyName(obj: object): string {
|
||||
const objectProperties = Object.keys(obj);
|
||||
const propertyName = objectProperties[Math.floor(Math.random() * objectProperties.length)];
|
||||
return propertyName;
|
||||
}
|
||||
|
||||
// function randomPropertyValue(obj: object): unknown {
|
||||
// const propertyName = randomPropertyName(obj);
|
||||
// return (obj as ObjectPropByName)[propertyName];
|
||||
// }
|
||||
|
||||
/**
|
||||
* Tests for <AppIconButton/> component
|
||||
*/
|
||||
describe('<AppIconButton/> component', () => {
|
||||
it('renders itself', () => {
|
||||
const testId = randomText(8);
|
||||
render(<ComponentToTest data-testid={testId} />);
|
||||
|
||||
// Button
|
||||
const button = screen.getByTestId(testId);
|
||||
expect(button).toBeDefined();
|
||||
expect(button).toHaveAttribute('role', 'button');
|
||||
expect(button).toHaveAttribute('type', 'button');
|
||||
|
||||
// Icon
|
||||
const svg = button.querySelector('svg');
|
||||
expect(svg).toBeDefined();
|
||||
expect(svg).toHaveAttribute('data-icon', 'default'); // default icon
|
||||
expect(svg).toHaveAttribute('size', String(APP_ICON_SIZE)); // default size
|
||||
expect(svg).toHaveAttribute('height', String(APP_ICON_SIZE)); // default size when .size is not set
|
||||
expect(svg).toHaveAttribute('width', String(APP_ICON_SIZE)); // default size when .size is not se
|
||||
});
|
||||
|
||||
it('supports .color property', () => {
|
||||
for (const color of [...MUI_ICON_BUTTON_COLORS, randomColor(), randomColor(), randomColor()]) {
|
||||
const testId = randomText(8);
|
||||
const icon = randomPropertyName(ICONS) as string;
|
||||
render(<ComponentToTest data-testid={testId} color={color} icon={icon} />);
|
||||
|
||||
// Button
|
||||
const button = screen.getByTestId(testId);
|
||||
expect(button).toBeDefined();
|
||||
|
||||
if (color == 'default') {
|
||||
return; // Nothing to test for default color
|
||||
}
|
||||
|
||||
if (MUI_ICON_BUTTON_COLORS.includes(color)) {
|
||||
expect(button).toHaveClass(`MuiIconButton-color${capitalize(color)}`);
|
||||
} else {
|
||||
expect(button).toHaveStyle({ color: color });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('supports .disable property', () => {
|
||||
const testId = randomText(8);
|
||||
const title = randomText(16);
|
||||
render(<ComponentToTest data-testid={testId} disabled />);
|
||||
|
||||
// Button
|
||||
const button = screen.getByTestId(testId);
|
||||
expect(button).toBeDefined();
|
||||
expect(button).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(button).toHaveClass('Mui-disabled');
|
||||
});
|
||||
|
||||
it('supports .icon property', () => {
|
||||
// Verify that all icons are supported
|
||||
for (const icon of Object.keys(ICONS)) {
|
||||
const testId = randomText(8);
|
||||
render(<ComponentToTest data-testid={testId} icon={icon} />);
|
||||
|
||||
// Button
|
||||
const button = screen.getByTestId(testId);
|
||||
expect(button).toBeDefined();
|
||||
|
||||
// Icon
|
||||
const svg = button.querySelector('svg');
|
||||
expect(button).toBeDefined();
|
||||
expect(svg).toHaveAttribute('data-icon', icon.toLowerCase());
|
||||
}
|
||||
});
|
||||
|
||||
it('supports .size property', () => {
|
||||
const sizes = ['small', 'medium', 'large'] as const; // as IconButtonProps['size'][];
|
||||
for (const size of sizes) {
|
||||
const testId = randomText(8);
|
||||
render(<ComponentToTest data-testid={testId} size={size} />);
|
||||
|
||||
// Button
|
||||
const button = screen.getByTestId(testId);
|
||||
expect(button).toBeDefined();
|
||||
expect(button).toHaveClass(`MuiIconButton-size${capitalize(size)}`); // MuiIconButton-sizeSmall | MuiIconButton-sizeMedium | MuiIconButton-sizeLarge
|
||||
}
|
||||
});
|
||||
|
||||
it('supports .title property', async () => {
|
||||
const testId = randomText(8);
|
||||
const title = randomText(16);
|
||||
render(<ComponentToTest data-testid={testId} title={title} />);
|
||||
|
||||
// Button
|
||||
const button = screen.getByTestId(testId);
|
||||
expect(button).toBeDefined();
|
||||
expect(button).toHaveAttribute('aria-label', title);
|
||||
|
||||
// Emulate mouseover event to show tooltip
|
||||
await fireEvent(button, new MouseEvent('mouseover', { bubbles: true }));
|
||||
|
||||
// Tooltip is rendered in a separate div, so we need to find it by role
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
expect(tooltip).toBeDefined();
|
||||
expect(tooltip).toHaveTextContent(title);
|
||||
expect(tooltip).toHaveClass('MuiTooltip-popper');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { ElementType, FunctionComponent, useMemo } from 'react';
|
||||
import { Tooltip, IconButton, IconButtonProps, TooltipProps } from '@mui/material';
|
||||
import AppIcon from '../AppIcon';
|
||||
import AppLink from '../AppLink';
|
||||
import { alpha } from '@mui/material';
|
||||
import { Props } from '../AppIcon/AppIcon';
|
||||
import { IconName } from '../AppIcon/config';
|
||||
|
||||
export const MUI_ICON_BUTTON_COLORS = [
|
||||
'inherit',
|
||||
'default',
|
||||
'primary',
|
||||
'secondary',
|
||||
'success',
|
||||
'error',
|
||||
'info',
|
||||
'warning',
|
||||
];
|
||||
|
||||
export interface AppIconButtonProps extends Omit<IconButtonProps, 'color'> {
|
||||
color?: string; // Not only 'inherit' | 'default' | 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning',
|
||||
icon?: IconName | string;
|
||||
iconProps?: Partial<Props>;
|
||||
// Missing props
|
||||
component?: ElementType; // Could be RouterLink, AppLink, <a>, etc.
|
||||
to?: string; // Link prop
|
||||
href?: string; // Link prop
|
||||
openInNewTab?: boolean; // Link prop
|
||||
tooltipProps?: Partial<TooltipProps>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders MUI IconButton with SVG image by given Icon name
|
||||
* @param {string} [color] - color of background and hover effect. Non MUI values is also accepted.
|
||||
* @param {boolean} [disabled] - the IconButton is not active when true, also the Tooltip is not rendered.
|
||||
* @param {string} [href] - external link URI
|
||||
* @param {string} [icon] - name of Icon to render inside the IconButton
|
||||
* @param {object} [iconProps] - additional props to pass into the AppIcon component
|
||||
* @param {boolean} [openInNewTab] - link will be opened in new tab when true
|
||||
* @param {string} [size] - size of the button: 'small', 'medium' or 'large'
|
||||
* @param {Array<func| object| bool> | func | object} [sx] - additional CSS styles to apply to the button
|
||||
* @param {string} [title] - when set, the IconButton is rendered inside Tooltip with this text
|
||||
* @param {string} [to] - internal link URI
|
||||
* @param {object} [tooltipProps] - additional props to pass into the Tooltip component
|
||||
*/
|
||||
const AppIconButton: FunctionComponent<AppIconButtonProps> = ({
|
||||
color = 'default',
|
||||
component,
|
||||
children,
|
||||
disabled,
|
||||
icon,
|
||||
iconProps,
|
||||
sx,
|
||||
title,
|
||||
tooltipProps,
|
||||
...restOfProps
|
||||
}) => {
|
||||
const componentToRender = !component && (restOfProps?.href || restOfProps?.to) ? AppLink : component ?? IconButton;
|
||||
|
||||
const isMuiColor = useMemo(() => MUI_ICON_BUTTON_COLORS.includes(color), [color]);
|
||||
|
||||
const iconButtonToRender = useMemo(() => {
|
||||
const colorToRender = isMuiColor ? (color as IconButtonProps['color']) : 'default';
|
||||
const sxToRender = {
|
||||
...sx,
|
||||
...(!isMuiColor && {
|
||||
color: color,
|
||||
':hover': {
|
||||
backgroundColor: alpha(color, 0.04),
|
||||
},
|
||||
}),
|
||||
};
|
||||
return (
|
||||
<IconButton
|
||||
component={componentToRender}
|
||||
color={colorToRender}
|
||||
disabled={disabled}
|
||||
sx={sxToRender}
|
||||
{...restOfProps}
|
||||
>
|
||||
<AppIcon icon={icon} {...iconProps} />
|
||||
{children}
|
||||
</IconButton>
|
||||
);
|
||||
}, [color, componentToRender, children, disabled, icon, isMuiColor, sx, iconProps, restOfProps]);
|
||||
|
||||
// When title is set, wrap the IconButton with Tooltip.
|
||||
// Note: when IconButton is disabled the Tooltip is not working, so we don't need it
|
||||
return title && !disabled ? (
|
||||
<Tooltip title={title} {...tooltipProps}>
|
||||
{iconButtonToRender}
|
||||
</Tooltip>
|
||||
) : (
|
||||
iconButtonToRender
|
||||
);
|
||||
};
|
||||
|
||||
export default AppIconButton;
|
||||
@@ -0,0 +1,3 @@
|
||||
import AppIconButton from './AppIconButton';
|
||||
|
||||
export { AppIconButton as default, AppIconButton };
|
||||
@@ -0,0 +1,55 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { randomText } from '@/utils';
|
||||
import AppImage from './AppImage';
|
||||
|
||||
const ComponentToTest = AppImage;
|
||||
|
||||
/**
|
||||
* Tests for <AppImage/> component
|
||||
*/
|
||||
describe('<AppImage/> component', () => {
|
||||
const src = 'https:/domain.com/image.jpg';
|
||||
|
||||
it('renders itself', () => {
|
||||
const testId = randomText(8);
|
||||
render(<ComponentToTest data-testid={testId} src={src} />);
|
||||
const image = screen.getByTestId(testId);
|
||||
expect(image).toBeDefined();
|
||||
expect(image).toHaveAttribute('src', src);
|
||||
expect(image).toHaveAttribute('alt', 'Image'); // Default prop value
|
||||
expect(image).toHaveAttribute('height', '256'); // Default prop value
|
||||
expect(image).toHaveAttribute('width', '256'); // Default prop value
|
||||
});
|
||||
|
||||
it('supports .width and .height props', () => {
|
||||
const testId = randomText(8);
|
||||
const height = 345;
|
||||
const width = 123;
|
||||
render(<ComponentToTest data-testid={testId} height={height} src={src} width={width} />);
|
||||
const image = screen.getByTestId(testId);
|
||||
expect(image).toBeDefined();
|
||||
expect(image).toHaveAttribute('height', String(height));
|
||||
expect(image).toHaveAttribute('width', String(width));
|
||||
});
|
||||
|
||||
it('supports .title property', () => {
|
||||
const testId = randomText(8);
|
||||
const title = randomText(16);
|
||||
render(<ComponentToTest data-testid={testId} src={src} title={title} />);
|
||||
const image = screen.getByTestId(testId);
|
||||
expect(image).toBeDefined();
|
||||
expect(image).toHaveAttribute('title', title);
|
||||
expect(image).toHaveAttribute('alt', title); // When title is provided, it is used as alt
|
||||
});
|
||||
|
||||
it('supports .alt property even when .title is provided', () => {
|
||||
const testId = randomText(8);
|
||||
const title = randomText(16);
|
||||
const alt = randomText(32);
|
||||
render(<ComponentToTest alt={alt} data-testid={testId} src={src} title={title} />);
|
||||
const image = screen.getByTestId(testId);
|
||||
expect(image).toBeDefined();
|
||||
expect(image).toHaveAttribute('alt', alt);
|
||||
expect(image).toHaveAttribute('title', title);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import NextImage, { ImageProps } from 'next/image';
|
||||
|
||||
interface AppImageProps extends Omit<ImageProps, 'alt'> {
|
||||
alt?: string; // Make property optional as it was before NextJs v13
|
||||
}
|
||||
|
||||
/**
|
||||
* Application wrapper around NextJS image with some default props
|
||||
* @component AppImage
|
||||
*/
|
||||
const AppImage: FunctionComponent<AppImageProps> = ({
|
||||
title, // Note: value has be destructed before usage as default value for other property
|
||||
alt = title ?? 'Image',
|
||||
height = 256,
|
||||
width = 256,
|
||||
...restOfProps
|
||||
}) => {
|
||||
// Uses custom loader + unoptimized="true" to avoid NextImage warning https://nextjs.org/docs/api-reference/next/image#unoptimized
|
||||
return <NextImage alt={alt} height={height} title={title} unoptimized={true} width={width} {...restOfProps} />;
|
||||
};
|
||||
|
||||
export default AppImage;
|
||||
@@ -0,0 +1,3 @@
|
||||
import AppImage from './AppImage';
|
||||
|
||||
export { AppImage as default, AppImage };
|
||||
@@ -0,0 +1,229 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import mockRouter from 'next-router-mock';
|
||||
/* IMPORTANT! To get 'next/router' working with tests, add into "jest.setup.js" file following:
|
||||
---
|
||||
jest.mock('next/router', () => require('next-router-mock'));
|
||||
---
|
||||
*/
|
||||
import AppLink from '.';
|
||||
import { capitalize, randomColor } from '@/utils';
|
||||
|
||||
jest.mock('next/navigation', () => {
|
||||
const result = {
|
||||
...require('next-router-mock'),
|
||||
// useSearchParams: () => jest.fn(),
|
||||
usePathname: () => {
|
||||
const router = mockRouter;
|
||||
return router.asPath;
|
||||
},
|
||||
};
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* AppLink wrapped with Mocked Router
|
||||
*/
|
||||
const ComponentToTest = AppLink;
|
||||
|
||||
/**
|
||||
* Tests for <AppLink/> component
|
||||
*/
|
||||
describe('<AppLink/> component', () => {
|
||||
it('renders itself', () => {
|
||||
const text = 'sample text';
|
||||
const url = 'https://example.com/';
|
||||
render(<ComponentToTest href={url}>{text}</ComponentToTest>);
|
||||
const link = screen.getByText(text);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).toHaveAttribute('href', url);
|
||||
expect(link).toHaveTextContent(text);
|
||||
});
|
||||
|
||||
it('supports external link', () => {
|
||||
const text = 'external link';
|
||||
const url = 'https://example.com/';
|
||||
render(<ComponentToTest href={url}>{text}</ComponentToTest>);
|
||||
const link = screen.getByText(text);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).toHaveAttribute('href', url);
|
||||
expect(link).toHaveTextContent(text);
|
||||
expect(link).toHaveAttribute('target', '_blank'); // Open external links in new Tab by default
|
||||
expect(link).toHaveAttribute('rel'); // For links opened in new Tab rel="noreferrer noopener" is required
|
||||
const rel = (link as any)?.rel;
|
||||
expect(rel.includes('noreferrer')).toBeTruthy(); // ref="noreferrer" check
|
||||
expect(rel.includes('noopener')).toBeTruthy(); // rel="noreferrer check
|
||||
});
|
||||
|
||||
it('supports internal link', () => {
|
||||
const text = 'internal link';
|
||||
const url = '/internal-link';
|
||||
render(<ComponentToTest to={url}>{text}</ComponentToTest>);
|
||||
const link = screen.getByText(text);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).toHaveAttribute('href', url);
|
||||
expect(link).toHaveTextContent(text);
|
||||
expect(link).not.toHaveAttribute('target');
|
||||
expect(link).not.toHaveAttribute('rel');
|
||||
});
|
||||
|
||||
it('supports .openInNewTab property', () => {
|
||||
// External link with openInNewTab={false}
|
||||
let text = 'external link in same tab';
|
||||
let url = 'https://example.com/';
|
||||
render(
|
||||
<ComponentToTest href={url} openInNewTab={false}>
|
||||
{text}
|
||||
</ComponentToTest>
|
||||
);
|
||||
let link = screen.getByText(text);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).toHaveAttribute('href', url);
|
||||
expect(link).toHaveTextContent(text);
|
||||
expect(link).not.toHaveAttribute('target');
|
||||
expect(link).not.toHaveAttribute('rel');
|
||||
|
||||
// Internal link with openInNewTab={true}
|
||||
text = 'internal link in new tab';
|
||||
url = '/internal-link-in-new-tab';
|
||||
render(
|
||||
<ComponentToTest to={url} openInNewTab>
|
||||
{text}
|
||||
</ComponentToTest>
|
||||
);
|
||||
link = screen.getByText(text);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).toHaveAttribute('href', url);
|
||||
expect(link).toHaveTextContent(text);
|
||||
expect(link).toHaveAttribute('target', '_blank'); // Open links in new Tab
|
||||
expect(link).toHaveAttribute('rel'); // For links opened in new Tab rel="noreferrer noopener" is required
|
||||
const rel = (link as any)?.rel;
|
||||
expect(rel.includes('noreferrer')).toBeTruthy(); // ref="noreferrer" check
|
||||
expect(rel.includes('noopener')).toBeTruthy(); // rel="noreferrer check
|
||||
});
|
||||
|
||||
it('supports .className property', () => {
|
||||
let text = 'internal link with specific class';
|
||||
let url = '/internal-link-with-class';
|
||||
let className = 'someClassName';
|
||||
render(
|
||||
<ComponentToTest to={url} className={className}>
|
||||
{text}
|
||||
</ComponentToTest>
|
||||
);
|
||||
let link = screen.getByText(text);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).toHaveClass(className);
|
||||
});
|
||||
|
||||
it('supports .activeClassName property in pair with .to property', () => {
|
||||
let link;
|
||||
let textActive = 'internal link with activeClassName';
|
||||
let textPassive = 'internal link without activeClassName';
|
||||
let url = '/internal-link';
|
||||
let activeClassName = 'someClassName';
|
||||
|
||||
// router.pathhname doesn't match .to prop
|
||||
mockRouter.push('not-' + url);
|
||||
render(
|
||||
<ComponentToTest to={url} activeClassName={activeClassName}>
|
||||
{textPassive}
|
||||
</ComponentToTest>
|
||||
);
|
||||
link = screen.getByText(textPassive);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).not.toHaveClass(activeClassName);
|
||||
|
||||
// router.pathhname matches .to prop
|
||||
mockRouter.push(url);
|
||||
render(
|
||||
<ComponentToTest to={url} activeClassName={activeClassName}>
|
||||
{textActive}
|
||||
</ComponentToTest>
|
||||
);
|
||||
link = screen.getByText(textActive);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).toHaveClass(activeClassName);
|
||||
});
|
||||
|
||||
it('supports .activeClassName property in pair with .href property', () => {
|
||||
let link;
|
||||
let textActive = 'external link with activeClassName';
|
||||
let textPassive = 'external link without activeClassName';
|
||||
let url = '/external-link.com';
|
||||
let activeClassName = 'someClassName';
|
||||
|
||||
// router.pathhname doesn't match .href prop
|
||||
mockRouter.push('not-' + url);
|
||||
render(
|
||||
<ComponentToTest href={url} activeClassName={activeClassName}>
|
||||
{textPassive}
|
||||
</ComponentToTest>
|
||||
);
|
||||
link = screen.getByText(textPassive);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).not.toHaveClass(activeClassName);
|
||||
|
||||
// router.pathhname matches .href prop
|
||||
mockRouter.push(url);
|
||||
render(
|
||||
<ComponentToTest href={url} activeClassName={activeClassName}>
|
||||
{textActive}
|
||||
</ComponentToTest>
|
||||
);
|
||||
link = screen.getByText(textActive);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).toHaveClass(activeClassName);
|
||||
});
|
||||
|
||||
it('supports .color property', () => {
|
||||
// Check several times with random colors
|
||||
for (let i = 1; i < 5; i++) {
|
||||
let text = `link #${i} with .color property`;
|
||||
let url = '/internal-link-with-color';
|
||||
let color = randomColor();
|
||||
render(
|
||||
<ComponentToTest to={url} color={color}>
|
||||
{text}
|
||||
</ComponentToTest>
|
||||
);
|
||||
let link = screen.getByText(text);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).toHaveStyle(`color: ${color}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('supports .underline property', () => {
|
||||
// Enumerate all possible values
|
||||
['hover', 'always', 'none'].forEach((underline) => {
|
||||
let text = `link with .underline == "${underline}"`;
|
||||
let url = '/internal-link-with-underline';
|
||||
render(
|
||||
<ComponentToTest to={url} underline={underline as any}>
|
||||
{text}
|
||||
</ComponentToTest>
|
||||
);
|
||||
let link = screen.getByText(text);
|
||||
expect(link).toBeDefined();
|
||||
underline === 'none'
|
||||
? expect(link).toHaveStyle('text-decoration: none')
|
||||
: expect(link).toHaveStyle('text-decoration: underline');
|
||||
// TODO: make "hover" test with "mouse moving"
|
||||
|
||||
expect(link).toHaveClass(`MuiLink-underline${capitalize(underline)}`);
|
||||
});
|
||||
});
|
||||
|
||||
it('supports .noLinkStyle property', () => {
|
||||
let text = 'internal link noLinkStyle';
|
||||
let url = '/internal-link-no-style';
|
||||
let noLinkStyle = true;
|
||||
render(
|
||||
<ComponentToTest to={url} noLinkStyle={noLinkStyle}>
|
||||
{text}
|
||||
</ComponentToTest>
|
||||
);
|
||||
let link = screen.getByText(text);
|
||||
expect(link).toBeDefined();
|
||||
expect(link).not.toHaveClass('MuiLink-root');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
// See: https://github.com/mui-org/material-ui/blob/6b18675c7e6204b77f4c469e113f62ee8be39178/examples/nextjs-with-typescript/src/Link.tsx
|
||||
/* eslint-disable jsx-a11y/anchor-has-content */
|
||||
import { AnchorHTMLAttributes, forwardRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import NextLink, { LinkProps as NextLinkProps } from 'next/link';
|
||||
import MuiLink, { LinkProps as MuiLinkProps } from '@mui/material/Link';
|
||||
import { APP_LINK_COLOR, APP_LINK_UNDERLINE } from '../../config';
|
||||
|
||||
export const EXTERNAL_LINK_PROPS = {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
};
|
||||
|
||||
/**
|
||||
* Props for NextLinkComposed component
|
||||
*/
|
||||
interface NextLinkComposedProps
|
||||
extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'>,
|
||||
Omit<NextLinkProps, 'href' | 'as' | 'onClick' | 'onMouseEnter'> {
|
||||
to: NextLinkProps['href'];
|
||||
linkAs?: NextLinkProps['as'];
|
||||
href?: NextLinkProps['href'];
|
||||
}
|
||||
|
||||
/**
|
||||
* NextJS composed link to use with Material UI
|
||||
* @NextLinkComposed NextLinkComposed
|
||||
*/
|
||||
const NextLinkComposed = forwardRef<HTMLAnchorElement, NextLinkComposedProps>(function NextLinkComposed(
|
||||
{ to, linkAs, href, replace, scroll, passHref, shallow, prefetch, ...restOfProps },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<NextLink
|
||||
legacyBehavior={true} // TODO: Remove when MUI become compatible with NextJs 13+
|
||||
href={to}
|
||||
prefetch={prefetch}
|
||||
as={linkAs}
|
||||
replace={replace}
|
||||
scroll={scroll}
|
||||
shallow={shallow}
|
||||
passHref={passHref}
|
||||
>
|
||||
<a ref={ref} {...restOfProps} />
|
||||
</NextLink>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Props for AppLinkForNext component
|
||||
*/
|
||||
export type AppLinkForNextProps = {
|
||||
activeClassName?: string;
|
||||
as?: NextLinkProps['as'];
|
||||
href?: string | NextLinkProps['href'];
|
||||
noLinkStyle?: boolean;
|
||||
to?: string | NextLinkProps['href'];
|
||||
openInNewTab?: boolean;
|
||||
} & Omit<NextLinkComposedProps, 'to' | 'linkAs' | 'href'> &
|
||||
Omit<MuiLinkProps, 'href'>;
|
||||
|
||||
/**
|
||||
* Material UI link for NextJS
|
||||
* A styled version of the Next.js Link component: https://nextjs.org/docs/#with-link
|
||||
* @component AppLinkForNext
|
||||
* @param {string} [activeClassName] - class name for active link, applied when the router.pathname matches .href or .to props
|
||||
* @param {string} [as] - passed to NextJS Link component in .as prop
|
||||
* @param {string} [className] - class name for <a> tag or NextJS Link component
|
||||
* @param {object|function} children - content to wrap with <a> tag
|
||||
* @param {string} [color] - color of the link
|
||||
* @param {boolean} [noLinkStyle] - when true, link will not have MUI styles
|
||||
* @param {string} [to] - internal link URI
|
||||
* @param {string} [href] - external link URI
|
||||
* @param {boolean} [openInNewTab] - link will be opened in new tab when true
|
||||
* @param {string} [underline] - controls "underline" style of the MUI link: 'hover' | 'always' | 'none'
|
||||
*/
|
||||
const AppLinkForNext = forwardRef<HTMLAnchorElement, AppLinkForNextProps>(function Link(props, ref) {
|
||||
const {
|
||||
activeClassName = 'active', // This class is applied to the Link component when the router.pathname matches the href/to prop
|
||||
as: linkAs,
|
||||
className: classNameProps,
|
||||
href,
|
||||
noLinkStyle,
|
||||
role, // Link don't have roles, so just exclude it from ...restOfProps
|
||||
color = APP_LINK_COLOR,
|
||||
underline = APP_LINK_UNDERLINE,
|
||||
to,
|
||||
sx,
|
||||
openInNewTab = Boolean(href), // Open external links in new Tab by default
|
||||
...restOfProps
|
||||
} = props;
|
||||
const currentPath = usePathname();
|
||||
const destination = to ?? href ?? '';
|
||||
const pathname = typeof destination === 'string' ? destination : destination.pathname;
|
||||
const className = clsx(classNameProps, {
|
||||
[activeClassName]: pathname == currentPath && activeClassName,
|
||||
});
|
||||
|
||||
const isExternal =
|
||||
typeof destination === 'string' && (destination.startsWith('http') || destination.startsWith('mailto:'));
|
||||
|
||||
const propsToRender = {
|
||||
color,
|
||||
underline, // 'hover' | 'always' | 'none'
|
||||
...(openInNewTab && EXTERNAL_LINK_PROPS),
|
||||
...restOfProps,
|
||||
};
|
||||
|
||||
if (isExternal) {
|
||||
if (noLinkStyle) {
|
||||
return <a className={className} href={destination as string} ref={ref as any} {...propsToRender} />;
|
||||
}
|
||||
|
||||
return <MuiLink className={className} href={destination as string} ref={ref} sx={sx} {...propsToRender} />;
|
||||
}
|
||||
|
||||
if (noLinkStyle) {
|
||||
return <NextLinkComposed className={className} ref={ref as any} to={destination} {...propsToRender} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MuiLink
|
||||
component={NextLinkComposed}
|
||||
linkAs={linkAs}
|
||||
className={className}
|
||||
ref={ref}
|
||||
to={destination}
|
||||
sx={sx}
|
||||
{...propsToRender}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default AppLinkForNext;
|
||||
@@ -0,0 +1,4 @@
|
||||
import AppLink, { AppLinkForNextProps as AppLinkProps } from './AppLinkNextNavigation';
|
||||
|
||||
export type { AppLinkProps };
|
||||
export { AppLink as default, AppLink };
|
||||
@@ -0,0 +1,36 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { CircularProgress, CircularProgressProps, LinearProgress, Stack, StackProps } from '@mui/material';
|
||||
import { APP_LOADING_COLOR, APP_LOADING_SIZE, APP_LOADING_TYPE } from '@/components/config';
|
||||
|
||||
interface Props extends StackProps {
|
||||
color?: CircularProgressProps['color'];
|
||||
size?: number | string;
|
||||
type?: 'circular' | 'linear';
|
||||
value?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders MI circular progress centered inside Stack
|
||||
* @component AppLoading
|
||||
* @prop {string} [size] - size of the progress component. Numbers means pixels, string can be '2.5rem'
|
||||
*/
|
||||
const AppLoading: FunctionComponent<Props> = ({
|
||||
color = APP_LOADING_COLOR,
|
||||
size = APP_LOADING_SIZE,
|
||||
type = APP_LOADING_TYPE,
|
||||
value,
|
||||
...restOfProps
|
||||
}) => {
|
||||
const alignItems = type === 'linear' ? undefined : 'center';
|
||||
return (
|
||||
<Stack my={2} alignItems={alignItems} {...restOfProps}>
|
||||
{type === 'linear' ? (
|
||||
<LinearProgress color={color} value={value} />
|
||||
) : (
|
||||
<CircularProgress color={color} size={size} value={value} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLoading;
|
||||
@@ -0,0 +1,4 @@
|
||||
import AppLoading from './AppLoading';
|
||||
|
||||
export { AppLoading };
|
||||
export default AppLoading;
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
errorInfo?: ErrorInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary wrapper to save Application parts from falling
|
||||
* @component ErrorBoundary
|
||||
* @param {string} [props.name] - name of the wrapped segment, "Error Boundary" by default
|
||||
*/
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
static defaultProps = {
|
||||
name: 'Error Boundary',
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
// The next render will show the Error UI
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
// Save information to help render Error UI
|
||||
this.setState({ error, errorInfo });
|
||||
// TODO: Add log error messages to an error reporting service here
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// Error UI rendering
|
||||
return (
|
||||
<div>
|
||||
<h2>{this.props.name} - Something went wrong</h2>
|
||||
<details style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{this.state?.error?.toString()}
|
||||
<br />
|
||||
{this.state?.errorInfo?.componentStack}
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Normal UI rendering
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -0,0 +1,10 @@
|
||||
import AppAlert from './AppAlert';
|
||||
import AppButton from './AppButton';
|
||||
import AppIcon from './AppIcon';
|
||||
import AppIconButton from './AppIconButton';
|
||||
import AppImage from './AppImage';
|
||||
import AppLink from './AppLink';
|
||||
import AppLoading from './AppLoading';
|
||||
import ErrorBoundary from './ErrorBoundary';
|
||||
|
||||
export { ErrorBoundary, AppAlert, AppButton, AppIcon, AppIconButton, AppImage, AppLink, AppLoading };
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Components configuration
|
||||
*/
|
||||
export const CONTENT_MAX_WIDTH = 800;
|
||||
export const CONTENT_MIN_WIDTH = 320; // CONTENT_MAX_WIDTH - Sidebar width
|
||||
|
||||
/**
|
||||
* AppAlert and AppSnackBarAlert components
|
||||
*/
|
||||
export const APP_ALERT_SEVERITY = 'error'; // 'error' | 'info'| 'success' | 'warning'
|
||||
export const APP_ALERT_VARIANT = 'filled'; // 'filled' | 'outlined' | 'standard'
|
||||
|
||||
/**
|
||||
* AppButton component
|
||||
*/
|
||||
export const APP_BUTTON_VARIANT = 'contained'; // | 'text' | 'outlined'
|
||||
export const APP_BUTTON_MARGIN = 1;
|
||||
|
||||
/**
|
||||
* AppIcon component
|
||||
*/
|
||||
export const APP_ICON_SIZE = 24;
|
||||
|
||||
/**
|
||||
* AppLink component
|
||||
*/
|
||||
export const APP_LINK_COLOR = 'textSecondary'; // 'primary' // 'secondary'
|
||||
export const APP_LINK_UNDERLINE = 'hover'; // 'always
|
||||
|
||||
/**
|
||||
* AppLoading component
|
||||
*/
|
||||
export const APP_LOADING_COLOR = 'primary'; // 'secondary'
|
||||
export const APP_LOADING_SIZE = '3rem'; // 40
|
||||
export const APP_LOADING_TYPE = 'circular'; // 'linear'; // 'circular'
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './common';
|
||||
|
||||
import UserInfo from './UserInfo';
|
||||
|
||||
export { UserInfo };
|
||||
@@ -0,0 +1,15 @@
|
||||
import { envRequired, getCurrentEnvironment } from '@/utils/environment';
|
||||
|
||||
export const IS_DEBUG = process.env.NEXT_PUBLIC_DEBUG === 'true'; // Enables logging, etc.
|
||||
|
||||
export const IS_PRODUCTION = getCurrentEnvironment() === 'production'; // Enables analytics, etc.
|
||||
|
||||
// export const PUBLIC_URL = envRequired(process.env.NEXT_PUBLIC_PUBLIC_URL); // Variant 1: .env variable is required
|
||||
export const PUBLIC_URL = process.env.NEXT_PUBLIC_PUBLIC_URL; // Variant 2: .env variable is optional
|
||||
|
||||
IS_DEBUG &&
|
||||
console.log('@/config', {
|
||||
IS_DEBUG,
|
||||
IS_PRODUCTION,
|
||||
PUBLIC_URL,
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useCallback } from 'react';
|
||||
import { sessionStorageGet, sessionStorageDelete } from '@/utils/sessionStorage';
|
||||
import { useAppStore } from '../store';
|
||||
|
||||
/**
|
||||
* Hook to detect is current user authenticated or not
|
||||
* @returns {boolean} true if user is authenticated, false otherwise
|
||||
*/
|
||||
export function useIsAuthenticated() {
|
||||
const [state] = useAppStore();
|
||||
let result = state.isAuthenticated;
|
||||
|
||||
// TODO: AUTH: replace next line with access token verification
|
||||
result = Boolean(sessionStorageGet('access_token', ''));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns event handler to Logout current user
|
||||
* @returns {function} calling this event logs out current user
|
||||
*/
|
||||
export function useEventLogout() {
|
||||
const [, dispatch] = useAppStore();
|
||||
|
||||
return useCallback(() => {
|
||||
// TODO: AUTH: replace next line with access token saving
|
||||
sessionStorageDelete('access_token');
|
||||
|
||||
dispatch({ type: 'LOG_OUT' });
|
||||
}, [dispatch]);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useAppStore } from '../store';
|
||||
|
||||
/**
|
||||
* Returns event handler to toggle Dark/Light modes
|
||||
* @returns {function} calling this event toggles dark/light mode
|
||||
*/
|
||||
export function useEventSwitchDarkMode() {
|
||||
const [state, dispatch] = useAppStore();
|
||||
|
||||
return useCallback(() => {
|
||||
dispatch({
|
||||
type: 'DARK_MODE',
|
||||
payload: !state.darkMode,
|
||||
});
|
||||
}, [state, dispatch]);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './auth';
|
||||
export * from './event';
|
||||
export * from './layout';
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import useWindowsSize from './useWindowSize';
|
||||
import { useMediaQuery, useTheme } from '@mui/material';
|
||||
import { IS_SERVER } from '@/utils';
|
||||
|
||||
export const MOBILE_SCREEN_MAX_WIDTH = 600; // Sync with https://mui.com/material-ui/customization/breakpoints/
|
||||
export const SERVER_SIDE_MOBILE_FIRST = true; // true - for mobile, false - for desktop
|
||||
|
||||
/**
|
||||
* Hook to detect onMobile vs. onDesktop using "resize" event listener
|
||||
* @returns {boolean} true when on onMobile, false when on onDesktop
|
||||
*/
|
||||
export function useIsMobileByWindowsResizing() {
|
||||
const theme = useTheme();
|
||||
const { width } = useWindowsSize();
|
||||
const onMobile = width <= theme.breakpoints?.values?.sm ?? MOBILE_SCREEN_MAX_WIDTH;
|
||||
return onMobile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to detect onMobile vs. onDesktop using Media Query
|
||||
* @returns {boolean} true when on onMobile, false when on onDesktop
|
||||
*/
|
||||
function useIsMobileByMediaQuery() {
|
||||
// const onMobile = useMediaQuery({ maxWidth: MOBILE_SCREEN_MAX_WIDTH });
|
||||
const theme = useTheme();
|
||||
const onMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
return onMobile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to detect onMobile vs. onDesktop with Next.js workaround
|
||||
* @returns {boolean} true when on onMobile, false when on onDesktop
|
||||
*/
|
||||
function useIsMobileForNextJs() {
|
||||
// const onMobile = useOnMobileByWindowsResizing();
|
||||
const onMobile = useIsMobileByMediaQuery();
|
||||
const [onMobileDelayed, setOnMobileDelayed] = useState(SERVER_SIDE_MOBILE_FIRST);
|
||||
|
||||
useEffect(() => {
|
||||
setOnMobileDelayed(onMobile); // Next.js don't allow to use useOnMobileXxx() directly, so we need to use this workaround
|
||||
}, [onMobile]);
|
||||
|
||||
return onMobileDelayed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to apply "onMobile" vs. "onDesktop" class to document.body depending on screen size.
|
||||
* Due to SSR/SSG we can not set 'app-layout onMobile' or 'app-layout onDesktop' on the server
|
||||
* If we modify className using JS, we will got Warning: Prop `className` did not match. Server: "app-layout" Client: "app-layout onDesktop"
|
||||
* So we have to apply document.body.class using the hook :)
|
||||
* Note: Use this hook one time only! In main App or Layout component
|
||||
*/
|
||||
function useMobileOrDesktopByChangingBodyClass() {
|
||||
// const onMobile = useOnMobileByWindowsResizing();
|
||||
const onMobile = useIsMobileByMediaQuery();
|
||||
|
||||
useEffect(() => {
|
||||
if (onMobile) {
|
||||
document.body.classList.remove('onDesktop');
|
||||
document.body.classList.add('onMobile');
|
||||
} else {
|
||||
document.body.classList.remove('onMobile');
|
||||
document.body.classList.add('onDesktop');
|
||||
}
|
||||
}, [onMobile]);
|
||||
}
|
||||
|
||||
/**
|
||||
* We need a "smart export wrappers", because we can not use hooks on the server side
|
||||
*/
|
||||
// export const useOnMobile = IS_SERVER ? () => SERVER_SIDE_IS_MOBILE_VALUE : useOnMobileByWindowsResizing;
|
||||
// export const useOnMobile = IS_SERVER ? () => SERVER_SIDE_IS_MOBILE_VALUE : useOnMobileByMediaQuery;
|
||||
export const useIsMobile = IS_SERVER ? () => SERVER_SIDE_MOBILE_FIRST : useIsMobileForNextJs;
|
||||
export const useBodyClassForMobileOrDesktop = IS_SERVER ? () => undefined : useMobileOrDesktopByChangingBodyClass;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
import { IS_SERVER } from '@/utils';
|
||||
|
||||
const MOBILE_WINDOWS_SIZE = { width: 720, height: 1280 };
|
||||
const DESKTOP_WINDOWS_SIZE = { width: 1920, height: 1080 };
|
||||
const DEFAULT_WINDOWS_SIZE = MOBILE_WINDOWS_SIZE ?? DESKTOP_WINDOWS_SIZE; // Mobile-First by default
|
||||
|
||||
type WindowSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to monitor Window (actually Browser) Size using "resize" event listener
|
||||
* @returns {WindowSize} current window size as {width, height} object
|
||||
*/
|
||||
const useWindowSize = (): WindowSize => {
|
||||
const [windowSize, setWindowSize] = useState<WindowSize>(DEFAULT_WINDOWS_SIZE);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
function handleResize() {
|
||||
setWindowSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
handleResize(); // Get initial/current window size
|
||||
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
return windowSize;
|
||||
};
|
||||
|
||||
/**
|
||||
* The hook will really work in Browser only, so or Server Side Rendering (SSR) we just return DEFAULT_WINDOWS_SIZE
|
||||
*/
|
||||
export default IS_SERVER ? () => DEFAULT_WINDOWS_SIZE : useWindowSize;
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
import React, { FunctionComponent, PropsWithChildren } from 'react';
|
||||
import { useIsAuthenticated } from '@/hooks';
|
||||
import PrivateLayout from './PrivateLayout';
|
||||
import PublicLayout from './PublicLayout';
|
||||
|
||||
/**
|
||||
* Returns the current Layout component depending on different circumstances.
|
||||
* @layout CurrentLayout
|
||||
*/
|
||||
const CurrentLayout: FunctionComponent<PropsWithChildren> = (props) => {
|
||||
return useIsAuthenticated() ? <PrivateLayout {...props} /> : <PublicLayout {...props} />;
|
||||
};
|
||||
|
||||
export default CurrentLayout;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { FunctionComponent, PropsWithChildren } from 'react';
|
||||
import { LinkToPage } from '@/utils';
|
||||
import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
|
||||
|
||||
const TITLE_PRIVATE = 'Balinyaar'; // Title for pages after authentication
|
||||
|
||||
/**
|
||||
* SideBar navigation items with links for Private Layout
|
||||
*/
|
||||
const SIDE_BAR_ITEMS: Array<LinkToPage> = [
|
||||
{
|
||||
title: 'Home',
|
||||
path: '/',
|
||||
icon: 'home',
|
||||
},
|
||||
{
|
||||
title: 'My Profile',
|
||||
path: '/me',
|
||||
icon: 'account',
|
||||
},
|
||||
{
|
||||
title: '404',
|
||||
path: '/wrong-url',
|
||||
icon: 'error',
|
||||
},
|
||||
{
|
||||
title: 'About',
|
||||
path: '/about',
|
||||
icon: 'info',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Renders "Private Layout" composition
|
||||
* @layout PrivateLayout
|
||||
*/
|
||||
const PrivateLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
const title = TITLE_PRIVATE;
|
||||
document.title = title; // Also Update Tab Title // TODO: Do we need this? Move it to useEffect()?
|
||||
|
||||
return (
|
||||
<TopBarAndSideBarLayout sidebarItems={SIDE_BAR_ITEMS} title={title} variant="sidebarPersistentOnDesktop">
|
||||
{children}
|
||||
{/* <Stack component="footer">Copyright © </Stack> */}
|
||||
</TopBarAndSideBarLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivateLayout;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { FunctionComponent, PropsWithChildren } from 'react';
|
||||
import { Stack } from '@mui/material';
|
||||
import { LinkToPage } from '@/utils';
|
||||
import { useIsMobile } from '@/hooks';
|
||||
import { BottomBar } from './components';
|
||||
import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
|
||||
import { BOTTOM_BAR_DESKTOP_VISIBLE } from './config';
|
||||
|
||||
const TITLE_PUBLIC = 'Unauthorized - Balinyaar'; // Title for pages without/before authentication
|
||||
|
||||
/**
|
||||
* SideBar navigation items with links for Public Layout
|
||||
*/
|
||||
const SIDE_BAR_ITEMS: Array<LinkToPage> = [
|
||||
{
|
||||
title: 'Log In',
|
||||
path: '/auth/login',
|
||||
icon: 'login',
|
||||
},
|
||||
{
|
||||
title: 'Sign Up',
|
||||
path: '/auth/signup',
|
||||
icon: 'signup',
|
||||
},
|
||||
{
|
||||
title: 'About',
|
||||
path: '/about',
|
||||
icon: 'info',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* BottomBar navigation items with links for Public Layout
|
||||
*/
|
||||
const BOTTOM_BAR_ITEMS: Array<LinkToPage> = [
|
||||
{
|
||||
title: 'Log In',
|
||||
path: '/auth/login',
|
||||
icon: 'login',
|
||||
},
|
||||
{
|
||||
title: 'Sign Up',
|
||||
path: '/auth/signup',
|
||||
icon: 'signup',
|
||||
},
|
||||
{
|
||||
title: 'About',
|
||||
path: '/about',
|
||||
icon: 'info',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Renders "Public Layout" composition
|
||||
* @layout PublicLayout
|
||||
*/
|
||||
const PublicLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
const onMobile = useIsMobile();
|
||||
const bottomBarVisible = onMobile || BOTTOM_BAR_DESKTOP_VISIBLE;
|
||||
|
||||
const title = TITLE_PUBLIC;
|
||||
document.title = title; // Also Update Tab Title // TODO: Do we need this? Move it to useEffect()?
|
||||
|
||||
return (
|
||||
<TopBarAndSideBarLayout sidebarItems={SIDE_BAR_ITEMS} title={title} variant="sidebarAlwaysTemporary">
|
||||
{children}
|
||||
<Stack component="footer">{bottomBarVisible && <BottomBar items={BOTTOM_BAR_ITEMS} />}</Stack>
|
||||
</TopBarAndSideBarLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default PublicLayout;
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useMemo, useState } from 'react';
|
||||
import { Stack, StackProps } from '@mui/material';
|
||||
import { IS_DEBUG } from '@/config';
|
||||
import { AppIconButton, ErrorBoundary } from '@/components';
|
||||
import { useAppStore } from '@/store';
|
||||
import { LinkToPage } from '@/utils';
|
||||
import { useEventSwitchDarkMode, useIsMobile } from '@/hooks';
|
||||
import { TopBar } from './components';
|
||||
import SideBar, { SideBarProps } from './components/SideBar';
|
||||
import {
|
||||
SIDE_BAR_DESKTOP_ANCHOR,
|
||||
SIDE_BAR_MOBILE_ANCHOR,
|
||||
SIDE_BAR_WIDTH,
|
||||
TOP_BAR_DESKTOP_HEIGHT,
|
||||
TOP_BAR_MOBILE_HEIGHT,
|
||||
} from './config';
|
||||
|
||||
interface Props extends StackProps {
|
||||
sidebarItems: Array<LinkToPage>;
|
||||
title: string;
|
||||
variant: 'sidebarAlwaysTemporary' | 'sidebarPersistentOnDesktop' | 'sidebarAlwaysPersistent';
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders "TopBar and SideBar" composition
|
||||
* @layout TopBarAndSideBarLayout
|
||||
*/
|
||||
const TopBarAndSideBarLayout: FunctionComponent<Props> = ({ children, sidebarItems, title, variant }) => {
|
||||
const [state] = useAppStore();
|
||||
const [sidebarVisible, setSidebarVisible] = useState(false); // TODO: Verify is default value is correct
|
||||
const onMobile = useIsMobile();
|
||||
const onSwitchDarkMode = useEventSwitchDarkMode();
|
||||
|
||||
const sidebarProps = useMemo((): Partial<SideBarProps> => {
|
||||
const anchor = onMobile ? SIDE_BAR_MOBILE_ANCHOR : SIDE_BAR_DESKTOP_ANCHOR;
|
||||
let open = sidebarVisible;
|
||||
let sidebarVariant: SideBarProps['variant'] = 'temporary';
|
||||
switch (variant) {
|
||||
case 'sidebarAlwaysTemporary':
|
||||
break;
|
||||
case 'sidebarPersistentOnDesktop':
|
||||
open = onMobile ? sidebarVisible : true;
|
||||
sidebarVariant = onMobile ? 'temporary' : 'persistent';
|
||||
break;
|
||||
case 'sidebarAlwaysPersistent':
|
||||
open = true;
|
||||
sidebarVariant = 'persistent';
|
||||
break;
|
||||
}
|
||||
return { anchor, open, variant: sidebarVariant };
|
||||
}, [onMobile, sidebarVisible, variant]);
|
||||
|
||||
const stackStyles = useMemo(
|
||||
() => ({
|
||||
minHeight: '100vh', // Full screen height
|
||||
paddingTop: onMobile ? TOP_BAR_MOBILE_HEIGHT : TOP_BAR_DESKTOP_HEIGHT,
|
||||
paddingLeft:
|
||||
sidebarProps.variant === 'persistent' && sidebarProps.open && sidebarProps?.anchor?.includes('left')
|
||||
? SIDE_BAR_WIDTH
|
||||
: undefined,
|
||||
paddingRight:
|
||||
sidebarProps.variant === 'persistent' && sidebarProps.open && sidebarProps?.anchor?.includes('right')
|
||||
? SIDE_BAR_WIDTH
|
||||
: undefined,
|
||||
}),
|
||||
[onMobile, sidebarProps]
|
||||
);
|
||||
|
||||
const onSideBarOpen = () => {
|
||||
if (!sidebarVisible) setSidebarVisible(true); // Don't re-render Layout when SideBar is already open
|
||||
};
|
||||
|
||||
const onSideBarClose = () => {
|
||||
if (sidebarVisible) setSidebarVisible(false); // Don't re-render Layout when SideBar is already closed
|
||||
};
|
||||
|
||||
const LogoButton = (
|
||||
<AppIconButton
|
||||
icon="logo"
|
||||
title={sidebarProps.open ? undefined : 'Open Sidebar'}
|
||||
to={sidebarProps.open ? '/' : undefined} // Navigate to Home only when SideBar is closed
|
||||
onClick={sidebarProps.open ? undefined : onSideBarOpen} // Open SideBar only when it's closed
|
||||
/>
|
||||
);
|
||||
|
||||
const DarkModeButton = (
|
||||
<AppIconButton
|
||||
icon={state.darkMode ? 'day' : 'night'} // Variant 1
|
||||
// icon="daynight" // Variant 2
|
||||
title={state.darkMode ? 'Switch to Light mode' : 'Switch to Dark mode'}
|
||||
onClick={onSwitchDarkMode}
|
||||
/>
|
||||
);
|
||||
|
||||
// Note: useMemo() is not needed for startNode, endNode. We need respect store.darkMode and so on.
|
||||
const { startNode, endNode } = sidebarProps?.anchor?.includes('left')
|
||||
? { startNode: LogoButton, endNode: DarkModeButton }
|
||||
: { startNode: DarkModeButton, endNode: LogoButton };
|
||||
|
||||
IS_DEBUG &&
|
||||
console.log('Render <TopbarAndSidebarLayout/>', {
|
||||
onMobile,
|
||||
darkMode: state.darkMode,
|
||||
sidebarProps,
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack sx={stackStyles}>
|
||||
<Stack component="header">
|
||||
<TopBar startNode={startNode} title={title} endNode={endNode} />
|
||||
<SideBar items={sidebarItems} onClose={onSideBarClose} {...sidebarProps} />
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
component="main"
|
||||
flexGrow={1} // Takes all possible space
|
||||
justifyContent="space-between" // Push children content (Footer, StatusBar, etc.) to the bottom
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
paddingTop={1}
|
||||
>
|
||||
<ErrorBoundary name="Content">{children}</ErrorBoundary>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopBarAndSideBarLayout;
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { BottomNavigation, BottomNavigationAction } from '@mui/material';
|
||||
import { LinkToPage } from '@/utils';
|
||||
import { AppIcon } from '@/components';
|
||||
|
||||
interface Props {
|
||||
items: Array<LinkToPage>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders horizontal Navigation Bar using MUI BottomNavigation component
|
||||
* @component BottomBar
|
||||
*/
|
||||
const BottomBar: FunctionComponent<Props> = ({ items }) => {
|
||||
const router = useRouter();
|
||||
|
||||
const onNavigationChange = useCallback(
|
||||
(_event: unknown, newValue: string) => {
|
||||
router.push(newValue);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
return (
|
||||
<BottomNavigation
|
||||
value={location.pathname} // Automatically highlights bottom navigation for current page
|
||||
showLabels // Always show labels on bottom navigation, otherwise label visible only for active page
|
||||
onChange={onNavigationChange}
|
||||
>
|
||||
{items.map(({ title, path, icon }) => (
|
||||
<BottomNavigationAction key={`${title}-${path}`} label={title} value={path} icon={<AppIcon icon={icon} />} />
|
||||
))}
|
||||
</BottomNavigation>
|
||||
);
|
||||
};
|
||||
|
||||
export default BottomBar;
|
||||
@@ -0,0 +1,97 @@
|
||||
import { FunctionComponent, useCallback, MouseEvent } from 'react';
|
||||
import { Stack, Divider, Drawer, DrawerProps, FormControlLabel, Switch, Tooltip } from '@mui/material';
|
||||
import { useAppStore } from '@/store';
|
||||
import { LinkToPage } from '@/utils';
|
||||
import { useEventLogout, useEventSwitchDarkMode, useIsAuthenticated, useIsMobile } from '@/hooks';
|
||||
import { AppIconButton, UserInfo } from '@/components';
|
||||
import { SIDE_BAR_WIDTH, TOP_BAR_DESKTOP_HEIGHT } from '../config';
|
||||
import SideBarNavList from './SideBarNavList';
|
||||
|
||||
export interface SideBarProps extends Pick<DrawerProps, 'anchor' | 'className' | 'open' | 'variant' | 'onClose'> {
|
||||
items: Array<LinkToPage>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders SideBar with Menu and User details
|
||||
* Actually for Authenticated users only, rendered in "Private Layout"
|
||||
* @component SideBar
|
||||
* @param {string} anchor - 'left' or 'right'
|
||||
* @param {boolean} open - the Drawer is visible when true
|
||||
* @param {string} variant - variant of the Drawer, one of 'permanent', 'persistent', 'temporary'
|
||||
* @param {function} onClose - called when the Drawer is closing
|
||||
*/
|
||||
const SideBar: FunctionComponent<SideBarProps> = ({ anchor, open, variant, items, onClose, ...restOfProps }) => {
|
||||
const [state] = useAppStore();
|
||||
// const isAuthenticated = state.isAuthenticated; // Variant 1
|
||||
const isAuthenticated = useIsAuthenticated(); // Variant 2
|
||||
const onMobile = useIsMobile();
|
||||
|
||||
const onSwitchDarkMode = useEventSwitchDarkMode();
|
||||
const onLogout = useEventLogout();
|
||||
|
||||
const handleAfterLinkClick = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (variant === 'temporary' && typeof onClose === 'function') {
|
||||
onClose(event, 'backdropClick');
|
||||
}
|
||||
},
|
||||
[variant, onClose]
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
anchor={anchor}
|
||||
open={open}
|
||||
variant={variant}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
width: SIDE_BAR_WIDTH,
|
||||
marginTop: onMobile ? 0 : variant === 'temporary' ? 0 : TOP_BAR_DESKTOP_HEIGHT,
|
||||
height: onMobile ? '100%' : variant === 'temporary' ? '100%' : `calc(100% - ${TOP_BAR_DESKTOP_HEIGHT})`,
|
||||
},
|
||||
}}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Stack
|
||||
sx={{
|
||||
height: '100%',
|
||||
padding: 2,
|
||||
}}
|
||||
{...restOfProps}
|
||||
onClick={handleAfterLinkClick}
|
||||
>
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<UserInfo showAvatar />
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
<SideBarNavList items={items} showIcons />
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-evenly',
|
||||
alignItems: 'center',
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
<Tooltip title={state.darkMode ? 'Switch to Light mode' : 'Switch to Dark mode'}>
|
||||
<FormControlLabel
|
||||
label={!state.darkMode ? 'Light mode' : 'Dark mode'}
|
||||
control={<Switch checked={state.darkMode} onChange={onSwitchDarkMode} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{isAuthenticated && <AppIconButton icon="logout" title="Logout Current User" onClick={onLogout} />}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SideBar;
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
import { FunctionComponent, MouseEventHandler } from 'react';
|
||||
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
|
||||
import { AppIcon, AppLink } from '@/components';
|
||||
import { LinkToPage } from '@/utils';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
interface Props extends LinkToPage {
|
||||
openInNewTab?: boolean;
|
||||
selected?: boolean;
|
||||
onClick?: MouseEventHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders Navigation Item for SideBar, detects current url and sets selected state if needed
|
||||
* @component SideBarNavItem
|
||||
*/
|
||||
const SideBarNavItem: FunctionComponent<Props> = ({
|
||||
openInNewTab,
|
||||
icon,
|
||||
path,
|
||||
selected: propSelected = false,
|
||||
subtitle,
|
||||
title,
|
||||
onClick,
|
||||
}) => {
|
||||
const pathname = usePathname();
|
||||
const selected = propSelected || (path && path.length > 1 && pathname.startsWith(path)) || false;
|
||||
|
||||
return (
|
||||
<ListItemButton
|
||||
component={AppLink}
|
||||
selected={selected}
|
||||
to={path}
|
||||
href="" // Hard reset for .href property, otherwise links are always opened in new tab :(
|
||||
openInNewTab={openInNewTab}
|
||||
onClick={onClick}
|
||||
>
|
||||
<ListItemIcon>{icon && <AppIcon icon={icon} />}</ListItemIcon>
|
||||
<ListItemText primary={title} secondary={subtitle} />
|
||||
</ListItemButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default SideBarNavItem;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { FunctionComponent, MouseEventHandler } from 'react';
|
||||
import List from '@mui/material/List';
|
||||
import { LinkToPage } from '@/utils';
|
||||
import SideBarNavItem from './SideBarNavItem';
|
||||
|
||||
interface Props {
|
||||
items: Array<LinkToPage>;
|
||||
showIcons?: boolean;
|
||||
onClick?: MouseEventHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders list of Navigation Items inside SideBar
|
||||
* @component SideBarNavList
|
||||
* @param {array} items - list of objects to render as navigation items
|
||||
* @param {boolean} [showIcons] - icons in navigation items are visible when true
|
||||
* @param {function} [onAfterLinkClick] - optional callback called when some navigation item was clicked
|
||||
*/
|
||||
const SideBarNavList: FunctionComponent<Props> = ({ items, showIcons, onClick, ...restOfProps }) => {
|
||||
return (
|
||||
<List component="nav" {...restOfProps}>
|
||||
{items.map(({ icon, path, title }) => (
|
||||
<SideBarNavItem
|
||||
key={`${title}-${path}`}
|
||||
icon={showIcons ? icon : undefined}
|
||||
path={path}
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export default SideBarNavList;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { FunctionComponent, ReactNode } from 'react';
|
||||
import { AppBar, Toolbar, Typography } from '@mui/material';
|
||||
|
||||
interface Props {
|
||||
endNode?: ReactNode;
|
||||
startNode?: ReactNode;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders TopBar composition
|
||||
* @component TopBar
|
||||
*/
|
||||
const TopBar: FunctionComponent<Props> = ({ endNode, startNode, title = '', ...restOfProps }) => {
|
||||
return (
|
||||
<AppBar
|
||||
component="div"
|
||||
sx={
|
||||
{
|
||||
// boxShadow: 'none', // Uncomment to hide shadow
|
||||
}
|
||||
}
|
||||
{...restOfProps}
|
||||
>
|
||||
<Toolbar disableGutters sx={{ paddingX: 1 }}>
|
||||
{startNode}
|
||||
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
marginX: 1,
|
||||
flexGrow: 1,
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
|
||||
{endNode}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopBar;
|
||||
@@ -0,0 +1,5 @@
|
||||
import BottomBar from './BottomBar';
|
||||
import SideBar from './SideBar';
|
||||
import TopBar from './TopBar';
|
||||
|
||||
export { BottomBar, SideBar, TopBar };
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Layout configuration
|
||||
*/
|
||||
|
||||
/**
|
||||
* SideBar configuration
|
||||
*/
|
||||
export const SIDE_BAR_MOBILE_ANCHOR = 'right'; // 'right';
|
||||
export const SIDE_BAR_DESKTOP_ANCHOR = 'left'; // 'right';
|
||||
export const SIDE_BAR_WIDTH = '240px';
|
||||
|
||||
/**
|
||||
* TopBar configuration
|
||||
*/
|
||||
export const TOP_BAR_MOBILE_HEIGHT = '56px';
|
||||
export const TOP_BAR_DESKTOP_HEIGHT = '64px';
|
||||
|
||||
/**
|
||||
* BottomBar configuration
|
||||
*/
|
||||
export const BOTTOM_BAR_DESKTOP_VISIBLE = false; // true;
|
||||
@@ -0,0 +1,6 @@
|
||||
import CurrentLayout from './CurrentLayout';
|
||||
import PrivateLayout from './PrivateLayout';
|
||||
import PublicLayout from './PublicLayout';
|
||||
|
||||
export { PublicLayout, PrivateLayout };
|
||||
export default CurrentLayout;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Reducer } from 'react';
|
||||
import { localStorageSet } from '../utils/localStorage';
|
||||
import { AppStoreState } from './config';
|
||||
|
||||
/**
|
||||
* Reducer for global AppStore using "Redux styled" actions
|
||||
* @function AppReducer
|
||||
* @param {object} state - current/default state
|
||||
* @param {string} action.type - unique name of the action
|
||||
* @param {string} action.action - alternate to action.type property, unique name of the action
|
||||
* @param {*} [action.payload] - optional data object or the function to get data object
|
||||
*/
|
||||
const AppReducer: Reducer<AppStoreState, any> = (state, action) => {
|
||||
// console.log('AppReducer() - action:', action);
|
||||
switch (action.type || action.action) {
|
||||
case 'CURRENT_USER':
|
||||
return {
|
||||
...state,
|
||||
currentUser: action?.currentUser || action?.payload,
|
||||
};
|
||||
case 'SIGN_UP':
|
||||
case 'LOG_IN':
|
||||
return {
|
||||
...state,
|
||||
isAuthenticated: true,
|
||||
};
|
||||
case 'LOG_OUT':
|
||||
return {
|
||||
...state,
|
||||
isAuthenticated: false,
|
||||
currentUser: undefined, // Also reset previous user data
|
||||
};
|
||||
case 'DARK_MODE': {
|
||||
const darkMode = action?.darkMode ?? action?.payload;
|
||||
localStorageSet('darkMode', darkMode);
|
||||
return {
|
||||
...state,
|
||||
darkMode,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default AppReducer;
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
import {
|
||||
createContext,
|
||||
useReducer,
|
||||
useContext,
|
||||
FunctionComponent,
|
||||
PropsWithChildren,
|
||||
Dispatch,
|
||||
ComponentType,
|
||||
} from 'react';
|
||||
// import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import AppReducer from './AppReducer';
|
||||
import { localStorageGet } from '../utils/localStorage';
|
||||
import { IS_SERVER } from '../utils/environment';
|
||||
import { APP_STORE_INITIAL_STATE, AppStoreState } from './config';
|
||||
|
||||
/**
|
||||
* Instance of React Context for global AppStore
|
||||
*/
|
||||
export type AppContextReturningType = [AppStoreState, Dispatch<any>];
|
||||
const AppContext = createContext<AppContextReturningType>([APP_STORE_INITIAL_STATE, () => null]);
|
||||
|
||||
/**
|
||||
* Main global Store as HOC with React Context API
|
||||
* @component AppStoreProvider
|
||||
* import {AppStoreProvider} from './store'
|
||||
* ...
|
||||
* <AppStoreProvider>
|
||||
* <App/>
|
||||
* </AppStoreProvider>
|
||||
*/
|
||||
const AppStoreProvider: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
// const prefersDarkMode = IS_SERVER ? false : useMediaQuery('(prefers-color-scheme: dark)'); // Note: Conditional hook is bad idea :(
|
||||
const prefersDarkMode = IS_SERVER ? false : window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const previousDarkMode = IS_SERVER ? false : Boolean(localStorageGet('darkMode', false));
|
||||
// const tokenExists = Boolean(loadToken());
|
||||
|
||||
const initialState: AppStoreState = {
|
||||
...APP_STORE_INITIAL_STATE,
|
||||
darkMode: previousDarkMode || prefersDarkMode,
|
||||
// isAuthenticated: tokenExists,
|
||||
};
|
||||
const value: AppContextReturningType = useReducer(AppReducer, initialState);
|
||||
|
||||
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to use the AppStore in functional components
|
||||
* @hook useAppStore
|
||||
* import {useAppStore} from './store'
|
||||
* ...
|
||||
* const [state, dispatch] = useAppStore();
|
||||
* OR
|
||||
* const [state] = useAppStore();
|
||||
*/
|
||||
const useAppStore = (): AppContextReturningType => useContext(AppContext);
|
||||
|
||||
/**
|
||||
* HOC to inject the ApStore to class component, also works for functional components
|
||||
* @hok withAppStore
|
||||
* import {withAppStore} from './store'
|
||||
* ...
|
||||
* class MyComponent
|
||||
*
|
||||
* render () {
|
||||
* const [state, dispatch] = this.props.appStore;
|
||||
* ...
|
||||
* }
|
||||
* ...
|
||||
* export default withAppStore(MyComponent)
|
||||
*/
|
||||
interface WithAppStoreProps {
|
||||
appStore: AppContextReturningType;
|
||||
}
|
||||
const withAppStore = (Component: ComponentType<WithAppStoreProps>): FunctionComponent =>
|
||||
function ComponentWithAppStore(props) {
|
||||
return <Component {...props} appStore={useAppStore()} />;
|
||||
};
|
||||
|
||||
export { AppStoreProvider, useAppStore, withAppStore };
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Data structure of the AppStore state
|
||||
*/
|
||||
export interface AppStoreState {
|
||||
darkMode: boolean;
|
||||
isAuthenticated: boolean;
|
||||
currentUser?: object | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial values for the AppStore state
|
||||
*/
|
||||
export const APP_STORE_INITIAL_STATE: AppStoreState = {
|
||||
darkMode: false, // Overridden by useMediaQuery('(prefers-color-scheme: dark)') in AppStore
|
||||
isAuthenticated: false, // Overridden in AppStore by checking auth token
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { AppStoreProvider, useAppStore, withAppStore } from './AppStore';
|
||||
|
||||
export { AppStoreProvider, useAppStore, withAppStore };
|
||||
@@ -0,0 +1,12 @@
|
||||
import { AppRouterCacheProvider } from '@mui/material-nextjs/v13-appRouter';
|
||||
import { FunctionComponent, PropsWithChildren } from 'react';
|
||||
|
||||
/**
|
||||
* Platform-specific ThemeProvider for Next.js
|
||||
* @component MuiThemeProviderForNextJs
|
||||
*/
|
||||
const MuiThemeProviderForNextJs: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
return <AppRouterCacheProvider>{children}</AppRouterCacheProvider>;
|
||||
};
|
||||
|
||||
export default MuiThemeProviderForNextJs;
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
import { FunctionComponent, PropsWithChildren, useEffect, useMemo, useState } from 'react';
|
||||
import { ThemeProvider as MuiThemeProvider, createTheme } from '@mui/material/styles';
|
||||
|
||||
import { useAppStore } from '../store';
|
||||
import DARK_THEME from './dark';
|
||||
import LIGHT_THEME from './light';
|
||||
import MuiThemeProviderForNextJs from './MuiThemeProviderForNextJs';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
|
||||
function getThemeByDarkMode(darkMode: boolean) {
|
||||
return darkMode ? createTheme(DARK_THEME) : createTheme(LIGHT_THEME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders composition of Emotion's CacheProvider + MUI's ThemeProvider to wrap content of entire App
|
||||
* The Light or Dark themes applied depending on global .darkMode state
|
||||
* @component AppThemeProvider
|
||||
*/
|
||||
const AppThemeProvider: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
const [state] = useAppStore();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const currentTheme = useMemo(
|
||||
() => getThemeByDarkMode(state.darkMode),
|
||||
[state.darkMode] // Observe AppStore and re-create the theme when .darkMode changes
|
||||
);
|
||||
|
||||
useEffect(() => setLoading(false), []); // Set .loading to false when the component is mounted
|
||||
|
||||
if (loading) return null; // Don't render anything until the component is mounted
|
||||
|
||||
return (
|
||||
<MuiThemeProviderForNextJs>
|
||||
<MuiThemeProvider theme={currentTheme}>
|
||||
<CssBaseline /* MUI Styles */ />
|
||||
{children}
|
||||
</MuiThemeProvider>
|
||||
</MuiThemeProviderForNextJs>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppThemeProvider;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { PaletteOptions, SimplePaletteColorOptions } from '@mui/material';
|
||||
|
||||
const COLOR_PRIMARY: SimplePaletteColorOptions = {
|
||||
main: '#64B5F6',
|
||||
contrastText: '#000000',
|
||||
// light: '#64B5F6',
|
||||
// dark: '#64B5F6',
|
||||
};
|
||||
|
||||
const COLOR_SECONDARY: SimplePaletteColorOptions = {
|
||||
main: '#EF9A9A',
|
||||
contrastText: '#000000',
|
||||
// light: '#EF9A9A',
|
||||
// dark: '#EF9A9A',
|
||||
};
|
||||
|
||||
/**
|
||||
* MUI colors set to use in theme.palette
|
||||
*/
|
||||
export const PALETTE_COLORS: Partial<PaletteOptions> = {
|
||||
primary: COLOR_PRIMARY,
|
||||
secondary: COLOR_SECONDARY,
|
||||
// error: COLOR_ERROR,
|
||||
// warning: COLOR_WARNING;
|
||||
// info: COLOR_INFO;
|
||||
// success: COLOR_SUCCESS;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ThemeOptions } from '@mui/material';
|
||||
import { PALETTE_COLORS } from './colors';
|
||||
|
||||
/**
|
||||
* MUI theme options for "Dark Mode"
|
||||
*/
|
||||
export const DARK_THEME: ThemeOptions = {
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
// background: {
|
||||
// paper: '#424242', // Gray 800 - Background of "Paper" based component
|
||||
// default: '#121212',
|
||||
// },
|
||||
...PALETTE_COLORS,
|
||||
},
|
||||
};
|
||||
|
||||
export default DARK_THEME;
|
||||
@@ -0,0 +1,10 @@
|
||||
import AppThemeProvider from './ThemeProvider';
|
||||
import DARK_THEME from './dark';
|
||||
import LIGHT_THEME from './light';
|
||||
|
||||
export {
|
||||
LIGHT_THEME as default, // Change to DARK_THEME if you want to use dark theme as default
|
||||
DARK_THEME,
|
||||
LIGHT_THEME,
|
||||
AppThemeProvider as ThemeProvider,
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ThemeOptions } from '@mui/material';
|
||||
import { PALETTE_COLORS } from './colors';
|
||||
|
||||
/**
|
||||
* MUI theme options for "Light Mode"
|
||||
*/
|
||||
export const LIGHT_THEME: ThemeOptions = {
|
||||
palette: {
|
||||
mode: 'light',
|
||||
// background: {
|
||||
// paper: '#f5f5f5', // Gray 100 - Background of "Paper" based component
|
||||
// default: '#FFFFFF',
|
||||
// },
|
||||
...PALETTE_COLORS,
|
||||
},
|
||||
};
|
||||
|
||||
export default LIGHT_THEME;
|
||||
@@ -0,0 +1,53 @@
|
||||
export const IS_SERVER = typeof window === 'undefined';
|
||||
export const IS_BROWSER = typeof window !== 'undefined' && typeof window?.document !== 'undefined';
|
||||
/* eslint-disable no-restricted-globals */
|
||||
export const IS_WEBWORKER =
|
||||
typeof self === 'object' && self.constructor && self.constructor.name === 'DedicatedWorkerGlobalScope';
|
||||
/* eslint-enable no-restricted-globals */
|
||||
|
||||
/**
|
||||
* Returns the value of the environment variable with the given name, raises an error if it is required and not set.
|
||||
* Note: My not work with Next.js on client-side code.
|
||||
* @param {string} name - The name of the environment variable to get: e.g. XXX_YYY_PUBLIC_URL
|
||||
* @param {boolean} [isRequired] - Whether the environment variable is required or not.
|
||||
* @param {string} [defaultValue] - The default value to return if the environment variable is not set.
|
||||
* @returns {string} The value of the environment variable with the given name.
|
||||
*/
|
||||
export function envGet(
|
||||
name: string,
|
||||
isRequired = false,
|
||||
defaultValue: string | undefined = undefined
|
||||
): string | undefined {
|
||||
let variable = process.env[name]; // Classic way
|
||||
// let variable = import.meta.env[name]; // Vite way
|
||||
|
||||
if (typeof variable === 'undefined') {
|
||||
if (isRequired) {
|
||||
throw new Error(`Missing process.env.${name} variable`);
|
||||
}
|
||||
variable = defaultValue;
|
||||
}
|
||||
return variable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies existence of environment variables, raises an error if it is required and not set.
|
||||
* @example const MY_VARIABLE = requireEnv(process.env.MY_VARIABLE);
|
||||
* @param {string} [passProcessDotEnvDotValueNameHere] - Pass a value of process.env.MY_VARIABLE here, not just a name!
|
||||
* @returns {string} The value of incoming parameter.
|
||||
* @throws Error "Missing .env variable!"
|
||||
*/
|
||||
export function envRequired(passProcessDotEnvDotValueNameHere: string | undefined): string {
|
||||
if (typeof passProcessDotEnvDotValueNameHere === 'undefined') {
|
||||
throw new Error('Missing .env variable!');
|
||||
}
|
||||
return passProcessDotEnvDotValueNameHere;
|
||||
}
|
||||
|
||||
export function getCurrentVersion(): string {
|
||||
return process.env.npm_package_version ?? process.env.NEXT_PUBLIC_VERSION ?? 'unknown';
|
||||
}
|
||||
|
||||
export function getCurrentEnvironment(): string {
|
||||
return process.env.NEXT_PUBLIC_ENV ?? process.env?.NODE_ENV ?? 'development';
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './environment';
|
||||
export * from './localStorage';
|
||||
export * from './navigation';
|
||||
export * from './sessionStorage';
|
||||
export * from './sleep';
|
||||
export * from './type';
|
||||
export * from './text';
|
||||
@@ -0,0 +1,56 @@
|
||||
import { IS_SERVER } from './environment';
|
||||
|
||||
/**
|
||||
* Smartly reads value from localStorage
|
||||
*/
|
||||
export function localStorageGet(name: string, defaultValue: any = ''): string {
|
||||
if (IS_SERVER) {
|
||||
return defaultValue; // We don't have access to localStorage on the server
|
||||
}
|
||||
|
||||
const valueFromStore = localStorage.getItem(name);
|
||||
if (valueFromStore === null) return defaultValue; // No value in store, return default one
|
||||
|
||||
try {
|
||||
const jsonParsed = JSON.parse(valueFromStore);
|
||||
if (['boolean', 'number', 'bigint', 'string', 'object'].includes(typeof jsonParsed)) {
|
||||
return jsonParsed; // We successfully parse JS value from the store
|
||||
}
|
||||
} catch (error) {}
|
||||
|
||||
return valueFromStore; // Return string value as it is
|
||||
}
|
||||
|
||||
/**
|
||||
* Smartly writes value into localStorage
|
||||
*/
|
||||
export function localStorageSet(name: string, value: any) {
|
||||
if (IS_SERVER) {
|
||||
return; // Do nothing on server side
|
||||
}
|
||||
if (typeof value === 'undefined') {
|
||||
return; // Do not store undefined values
|
||||
}
|
||||
let valueAsString: string;
|
||||
if (typeof value === 'object') {
|
||||
valueAsString = JSON.stringify(value);
|
||||
} else {
|
||||
valueAsString = String(value);
|
||||
}
|
||||
|
||||
localStorage.setItem(name, valueAsString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes value by name from localStorage, if specified name is empty entire localStorage is cleared.
|
||||
*/
|
||||
export function localStorageDelete(name: string) {
|
||||
if (IS_SERVER) {
|
||||
return; // Do nothing on server side
|
||||
}
|
||||
if (name) {
|
||||
localStorage.removeItem(name);
|
||||
} else {
|
||||
localStorage.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { IS_BROWSER } from './environment';
|
||||
|
||||
export const EXTERNAL_LINK_PROPS = {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
};
|
||||
|
||||
/**
|
||||
* Disables "Back" button for current page
|
||||
* Usage: Call function in useEffect( ,[]) or directly
|
||||
*/
|
||||
export function disableBackNavigation() {
|
||||
window.history.pushState(null, '', window.location.href);
|
||||
window.onpopstate = function () {
|
||||
window.history.go(1);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates to the specified URL with options
|
||||
*/
|
||||
export function navigateTo(url: string, replaceInsteadOfPush = false, optionalTitle = '') {
|
||||
if (replaceInsteadOfPush) {
|
||||
window.history.replaceState(null, optionalTitle, url);
|
||||
} else {
|
||||
window.history.pushState(null, optionalTitle, url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For smooth scrolling to the specified element with optional offset
|
||||
* @param {object} destinationElement - DOM element to scroll to
|
||||
* @param {number} [verticalOffset] - optional vertical offset
|
||||
* @param {object} [scrollingElement] - optional scrolling element, defaults to .window
|
||||
* @param {string} [behavior] - optional scroll behavior, defaults to 'smooth'
|
||||
*/
|
||||
export function scrollIntoViewAdjusted(
|
||||
destinationElement: Element | HTMLElement | null,
|
||||
verticalOffset = 0,
|
||||
scrollingElement?: Element | HTMLElement | null,
|
||||
behavior: 'auto' | 'instant' | 'smooth' | undefined = 'smooth'
|
||||
) {
|
||||
if (!IS_BROWSER || !destinationElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = destinationElement.getBoundingClientRect();
|
||||
if (!rect || typeof rect.top === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const top = rect.top - verticalOffset;
|
||||
const elementToScroll = scrollingElement ?? window;
|
||||
elementToScroll.scrollBy({ top, behavior });
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { IS_SERVER } from './environment';
|
||||
|
||||
/**
|
||||
* Smartly reads value from sessionStorage
|
||||
*/
|
||||
export function sessionStorageGet(name: string, defaultValue: any = ''): string {
|
||||
if (IS_SERVER) {
|
||||
return defaultValue; // We don't have access to sessionStorage on the server
|
||||
}
|
||||
|
||||
const valueFromStore = sessionStorage.getItem(name);
|
||||
if (valueFromStore === null) return defaultValue; // No value in store, return default one
|
||||
|
||||
try {
|
||||
const jsonParsed = JSON.parse(valueFromStore);
|
||||
if (['boolean', 'number', 'bigint', 'string', 'object'].includes(typeof jsonParsed)) {
|
||||
return jsonParsed; // We successfully parse JS value from the store
|
||||
}
|
||||
} catch (error) {}
|
||||
|
||||
return valueFromStore; // Return string value as it is
|
||||
}
|
||||
|
||||
/**
|
||||
* Smartly writes value into sessionStorage
|
||||
*/
|
||||
export function sessionStorageSet(name: string, value: any) {
|
||||
if (IS_SERVER) {
|
||||
return; // Do nothing on server side
|
||||
}
|
||||
if (typeof value === 'undefined') {
|
||||
return; // Do not store undefined values
|
||||
}
|
||||
let valueAsString: string;
|
||||
if (typeof value === 'object') {
|
||||
valueAsString = JSON.stringify(value);
|
||||
} else {
|
||||
valueAsString = String(value);
|
||||
}
|
||||
|
||||
sessionStorage.setItem(name, valueAsString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes value by name from sessionStorage, if specified name is empty entire sessionStorage is cleared.
|
||||
*/
|
||||
export function sessionStorageDelete(name: string) {
|
||||
if (IS_SERVER) {
|
||||
return; // Do nothing on server side
|
||||
}
|
||||
if (name) {
|
||||
sessionStorage.removeItem(name);
|
||||
} else {
|
||||
sessionStorage.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Delays code executions for specific amount of time. Must be called with await!
|
||||
* @param {number} interval - number of milliseconds to wait for
|
||||
*/
|
||||
export async function sleep(interval = 1000) {
|
||||
return new Promise((resolve) => setTimeout(resolve, interval));
|
||||
}
|
||||
|
||||
export default sleep;
|
||||
@@ -0,0 +1,49 @@
|
||||
export const CHARS_NUMERIC = '0123456789';
|
||||
export const CHARS_ALPHA_LOWER = 'abcdefghijklmnopqrstuvwxyz';
|
||||
export const CHARS_ALPHA_UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
export const CHARS_ALPHA_NUMERIC = CHARS_NUMERIC + CHARS_ALPHA_LOWER + CHARS_ALPHA_UPPER;
|
||||
|
||||
/**
|
||||
* Generate a random string of a given length using a given set of characters
|
||||
* @param {number} length - the length of the string to generate
|
||||
* @param {string} [allowedChars] - the set of characters to use in the string, defaults to all alphanumeric characters in upper and lower case + numbers
|
||||
* @returns {string} - the generated string
|
||||
*/
|
||||
export function randomText(length: number, allowedChars = CHARS_ALPHA_NUMERIC) {
|
||||
let result = '';
|
||||
const charLength = allowedChars.length;
|
||||
let counter = 0;
|
||||
while (counter < length) {
|
||||
result += allowedChars.charAt(Math.floor(Math.random() * charLength));
|
||||
counter += 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Compare two strings including null and undefined values
|
||||
* @param {string} a - the first string to compare
|
||||
* @param {string} b - the second string to compare
|
||||
* @returns {boolean} - true if the strings are the same or both null or undefined, false otherwise
|
||||
*/
|
||||
export function compareTexts(a: string | null | undefined, b: string | null | undefined) {
|
||||
if (a === undefined || a === null || a === '') {
|
||||
return b === undefined || b === null || b === '';
|
||||
}
|
||||
return a === b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capitalize the first letter of a string
|
||||
* @param {string} s - the string to capitalize
|
||||
* @returns {string} - the capitalized string
|
||||
*/
|
||||
export const capitalize = (s: string): string => s.charAt(0).toUpperCase() + s.substring(1);
|
||||
|
||||
/**
|
||||
* Generate a random color as #RRGGBB value
|
||||
* @returns {string} - the generated color
|
||||
*/
|
||||
export function randomColor() {
|
||||
const color = Math.floor(Math.random() * 16777215).toString(16);
|
||||
return '#' + color;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Helper to read object's properties as obj['name']
|
||||
export type ObjectPropByName = Record<string, any>;
|
||||
|
||||
/**
|
||||
* Data for "Page Link" in SideBar adn other UI elements
|
||||
*/
|
||||
export type LinkToPage = {
|
||||
icon?: string; // Icon name to use as <AppIcon icon={icon} />
|
||||
path?: string; // URL to navigate to
|
||||
title?: string; // Title or primary text to display
|
||||
subtitle?: string; // Sub-title or secondary text to display
|
||||
};
|
||||