├── .gitignore
├── GhostUI
├── ClientApp
│ ├── package.json
│ ├── public
│ │ ├── favicon.ico
│ │ ├── index.html
│ │ ├── logo192.png
│ │ ├── logo512.png
│ │ ├── manifest.json
│ │ └── robots.txt
│ ├── src
│ │ ├── App.tsx
│ │ ├── Layout.tsx
│ │ ├── api
│ │ │ ├── auth.service.ts
│ │ │ ├── base.service.ts
│ │ │ ├── index.ts
│ │ │ ├── sample.service.ts
│ │ │ └── signalr.service.ts
│ │ ├── assets
│ │ │ ├── image
│ │ │ │ ├── BulmaLogo.svg
│ │ │ │ ├── ReactCore.svg
│ │ │ │ └── based-ghost-main.png
│ │ │ └── style
│ │ │ │ └── scss
│ │ │ │ ├── base
│ │ │ │ ├── generic.scss
│ │ │ │ ├── transition.scss
│ │ │ │ └── variables.scss
│ │ │ │ ├── components
│ │ │ │ ├── navbar.scss
│ │ │ │ └── tool-tip.scss
│ │ │ │ └── site.scss
│ │ ├── components
│ │ │ ├── Authenticator.tsx
│ │ │ ├── Checkbox.tsx
│ │ │ ├── Footer.tsx
│ │ │ ├── Navbar.tsx
│ │ │ ├── Settings.tsx
│ │ │ ├── Spinner.tsx
│ │ │ └── index.ts
│ │ ├── config
│ │ │ ├── constants.ts
│ │ │ ├── fa.config.ts
│ │ │ ├── index.ts
│ │ │ ├── routes.config.ts
│ │ │ └── toastify.config.ts
│ │ ├── containers
│ │ │ ├── Dashboard
│ │ │ │ └── index.tsx
│ │ │ ├── FetchData
│ │ │ │ ├── ForecastTable.tsx
│ │ │ │ ├── Pagination.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── Form
│ │ │ │ ├── CheckboxFormGroup.tsx
│ │ │ │ ├── CounterFormGroup.tsx
│ │ │ │ ├── SelectFormGroup.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── Login
│ │ │ │ ├── LoginControls.tsx
│ │ │ │ ├── PasswordInput.tsx
│ │ │ │ ├── UserNameInput.tsx
│ │ │ │ └── index.tsx
│ │ │ └── index.ts
│ │ ├── hooks
│ │ │ ├── index.ts
│ │ │ ├── useCSSTransitionProps.ts
│ │ │ ├── useIsLoggedIn.ts
│ │ │ ├── useOnClickOutside.ts
│ │ │ └── useTextInput.ts
│ │ ├── index.tsx
│ │ ├── react-app-env.d.ts
│ │ ├── reportWebVitals.ts
│ │ ├── service-worker.ts
│ │ ├── serviceWorkerRegistration.ts
│ │ ├── store
│ │ │ ├── authSlice.ts
│ │ │ ├── configureStore.ts
│ │ │ ├── formSlice.ts
│ │ │ ├── hooks.ts
│ │ │ ├── index.ts
│ │ │ └── weatherSlice.ts
│ │ └── utils
│ │ │ ├── classNames.ts
│ │ │ ├── index.ts
│ │ │ └── isArrayWithLength.ts
│ └── tsconfig.json
├── Controllers
│ ├── AuthController.cs
│ └── SampleDataController.cs
├── Extensions
│ ├── ExceptionHandlerExtensions.cs
│ ├── HealthCheckBuilderExtensions.cs
│ └── ServiceCollectionExtensions.cs
├── GhostUI.csproj
├── HealthChecks
│ └── GCInfo
│ │ ├── GCInfoHealthCheck.cs
│ │ ├── GCInfoOptions.cs
│ │ └── IGCInfoOptions.cs
├── Hubs
│ ├── IUsersHub.cs
│ └── UsersHub.cs
├── Models
│ ├── AuthUser.cs
│ ├── Credentials.cs
│ ├── ExceptionDetails.cs
│ ├── IAuthUser.cs
│ ├── ICredentials.cs
│ ├── IWeatherForecast.cs
│ └── WeatherForecast.cs
├── Pages
│ ├── Error.cshtml
│ ├── Error.cshtml.cs
│ └── _ViewImports.cshtml
├── Program.cs
├── Properties
│ └── launchSettings.json
├── appsettings.Development.json
├── appsettings.json
├── nswag.json
└── openapi.json
├── LICENSE
├── README.md
├── demo
└── react_dot_net_52530-2021.gif
└── solution.sln
/.gitignore:
--------------------------------------------------------------------------------
1 | # Editor directories and files
2 | .idea
3 | *.suo
4 | *.user
5 | *.userosscache
6 | *.sln
7 | *.sln.docstates
8 | *.ntvs*
9 | *.njsproj
10 | .vscode
11 |
12 | # File generated by NuGet package AspNetCore.HealthChecks.UI
13 | healthchecksdb
14 |
15 | # User-specific files (MonoDevelop/Xamarin Studio)
16 | *.userprefs
17 |
18 | # Build results
19 | [Dd]ebug/
20 | [Dd]ebugPublic/
21 | [Rr]elease/
22 | [Rr]eleases/
23 | x64/
24 | x86/
25 | bld/
26 | [Bb]in/
27 | [Oo]bj/
28 | [Ll]og/
29 |
30 | # Visual Studio 2015 cache/options directory
31 | .vs/
32 | /wwwroot/dist/
33 | /ClientApp/dist/
34 | /GhostUI/wwwroot/dist
35 | /GhostUI/ClientApp/dist
36 |
37 | # MSTest test Results
38 | [Tt]est[Rr]esult*/
39 | [Bb]uild[Ll]og.*
40 |
41 | # NUNIT
42 | *.VisualState.xml
43 | TestResult.xml
44 |
45 | # Build Results of an ATL Project
46 | [Dd]ebugPS/
47 | [Rr]eleasePS/
48 | dlldata.c
49 |
50 | # DNX
51 | project.lock.json
52 | project.fragment.lock.json
53 | artifacts/
54 |
55 | *_i.c
56 | *_p.c
57 | *_i.h
58 | *.ilk
59 | *.meta
60 | *.obj
61 | *.pch
62 | *.pdb
63 | *.pgc
64 | *.pgd
65 | *.rsp
66 | *.sbr
67 | *.tlb
68 | *.tli
69 | *.tlh
70 | *.tmp
71 | *.tmp_proj
72 | *.log
73 | *.vspscc
74 | *.vssscc
75 | .builds
76 | *.pidb
77 | *.svclog
78 | *.scc
79 |
80 | # Chutzpah Test files
81 | _Chutzpah*
82 |
83 | # Visual C++ cache files
84 | ipch/
85 | *.aps
86 | *.ncb
87 | *.opendb
88 | *.opensdf
89 | *.sdf
90 | *.cachefile
91 | *.VC.db
92 | *.VC.VC.opendb
93 |
94 | # Visual Studio profiler
95 | *.psess
96 | *.vsp
97 | *.vspx
98 | *.sap
99 |
100 | # TFS 2012 Local Workspace
101 | $tf/
102 |
103 | # Guidance Automation Toolkit
104 | *.gpState
105 |
106 | # ReSharper is a .NET coding add-in
107 | _ReSharper*/
108 | *.[Rr]e[Ss]harper
109 | *.DotSettings.user
110 |
111 | # JustCode is a .NET coding add-in
112 | .JustCode
113 |
114 | # TeamCity is a build add-in
115 | _TeamCity*
116 |
117 | # DotCover is a Code Coverage Tool
118 | *.dotCover
119 |
120 | # NCrunch
121 | _NCrunch_*
122 | .*crunch*.local.xml
123 | nCrunchTemp_*
124 |
125 | # MightyMoose
126 | *.mm.*
127 | AutoTest.Net/
128 |
129 | # Web workbench (sass)
130 | .sass-cache/
131 |
132 | # Installshield output folder
133 | [Ee]xpress/
134 |
135 | # DocProject is a documentation generator add-in
136 | DocProject/buildhelp/
137 | DocProject/Help/*.HxT
138 | DocProject/Help/*.HxC
139 | DocProject/Help/*.hhc
140 | DocProject/Help/*.hhk
141 | DocProject/Help/*.hhp
142 | DocProject/Help/Html2
143 | DocProject/Help/html
144 |
145 | # Click-Once directory
146 | publish/
147 |
148 | # Publish Web Output
149 | *.[Pp]ublish.xml
150 | *.azurePubxml
151 | # TODO: Comment the next line if you want to checkin your web deploy settings
152 | # but database connection strings (with potential passwords) will be unencrypted
153 | #*.pubxml
154 | *.publishproj
155 |
156 | # Microsoft Azure Web App publish settings. Comment the next line if you want to
157 | # checkin your Azure Web App publish settings, but sensitive information contained
158 | # in these scripts will be unencrypted
159 | PublishScripts/
160 |
161 | # NuGet Packages
162 | *.nupkg
163 | # The packages folder can be ignored because of Package Restore
164 | **/packages/*
165 | # except build/, which is used as an MSBuild target.
166 | !**/packages/build/
167 | # Uncomment if necessary however generally it will be regenerated when needed
168 | #!**/packages/repositories.config
169 | # NuGet v3's project.json files produces more ignoreable files
170 | *.nuget.props
171 | *.nuget.targets
172 |
173 | # Microsoft Azure Build Output
174 | csx/
175 | *.build.csdef
176 |
177 | # Microsoft Azure Emulator
178 | ecf/
179 | rcf/
180 |
181 | # Windows Store app package directories and files
182 | AppPackages/
183 | BundleArtifacts/
184 | Package.StoreAssociation.xml
185 | _pkginfo.txt
186 |
187 | # Visual Studio cache files
188 | # files ending in .cache can be ignored
189 | *.[Cc]ache
190 | # but keep track of directories ending in .cache
191 | !*.[Cc]ache/
192 |
193 | # Others
194 | ClientBin/
195 | ~$*
196 | *~
197 | *.dbmdl
198 | *.dbproj.schemaview
199 | *.jfm
200 | *.pfx
201 | *.publishsettings
202 | node_modules/
203 | orleans.codegen.cs
204 | .DS_Store
205 | package-lock.json
206 |
207 | # Since there are multiple workflows, uncomment next line to ignore bower_components
208 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
209 | #bower_components/
210 |
211 | # RIA/Silverlight projects
212 | Generated_Code/
213 |
214 | # Backup & report files from converting an old project file
215 | # to a newer Visual Studio version. Backup files are not needed,
216 | # because we have git ;-)
217 | _UpgradeReport_Files/
218 | Backup*/
219 | UpgradeLog*.XML
220 | UpgradeLog*.htm
221 |
222 | # SQL Server files
223 | *.mdf
224 | *.ldf
225 |
226 | # Business Intelligence projects
227 | *.rdl.data
228 | *.bim.layout
229 | *.bim_*.settings
230 |
231 | # Microsoft Fakes
232 | FakesAssemblies/
233 |
234 | # GhostDoc plugin setting file
235 | *.GhostDoc.xml
236 |
237 | # Node.js Tools for Visual Studio
238 | .ntvs_analysis.dat
239 |
240 | # Visual Studio 6 build log
241 | *.plg
242 |
243 | # Visual Studio 6 workspace options file
244 | *.opt
245 |
246 | # Visual Studio LightSwitch build output
247 | **/*.HTMLClient/GeneratedArtifacts
248 | **/*.DesktopClient/GeneratedArtifacts
249 | **/*.DesktopClient/ModelManifest.xml
250 | **/*.Server/GeneratedArtifacts
251 | **/*.Server/ModelManifest.xml
252 | _Pvt_Extensions
253 |
254 | # Paket dependency manager
255 | .paket/paket.exe
256 | paket-files/
257 |
258 | # FAKE - F# Make
259 | .fake/
260 |
261 | # JetBrains Rider
262 | .idea/
263 | *.sln.iml
264 |
265 | # CodeRush
266 | .cr/
267 |
268 | # Python Tools for Visual Studio (PTVS)
269 | __pycache__/
270 | *.pyc
271 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "aspnet-core-react-redux-playground-template",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@fortawesome/fontawesome-svg-core": "^6.3.0",
7 | "@fortawesome/free-brands-svg-icons": "^6.3.0",
8 | "@fortawesome/free-solid-svg-icons": "^6.3.0",
9 | "@fortawesome/react-fontawesome": "^0.2.0",
10 | "@microsoft/signalr": "^7.0.4",
11 | "@reduxjs/toolkit": "^1.9.3",
12 | "axios": "^1.3.4",
13 | "bulma": "^0.9.4",
14 | "history": "^5.3.0",
15 | "react": "^18.2.0",
16 | "react-dom": "^18.2.0",
17 | "react-functional-select": "^5.0.0",
18 | "react-redux": "^8.0.5",
19 | "react-router-dom": "^6.9.0",
20 | "react-scripts": "^5.0.1",
21 | "react-toastify": "^9.1.2",
22 | "react-transition-group": "^4.4.5",
23 | "react-window": "^1.8.8",
24 | "redux": "^4.2.1",
25 | "styled-components": "^5.3.9",
26 | "web-vitals": "^3.3.0",
27 | "workbox-background-sync": "^6.5.4",
28 | "workbox-broadcast-update": "^6.5.4",
29 | "workbox-cacheable-response": "^6.5.4",
30 | "workbox-core": "^6.5.4",
31 | "workbox-expiration": "^6.5.4",
32 | "workbox-google-analytics": "^6.5.4",
33 | "workbox-navigation-preload": "^6.5.4",
34 | "workbox-precaching": "^6.5.4",
35 | "workbox-range-requests": "^6.5.4",
36 | "workbox-routing": "^6.5.4",
37 | "workbox-strategies": "^6.5.4",
38 | "workbox-streams": "^6.5.4"
39 | },
40 | "scripts": {
41 | "start": "react-scripts start",
42 | "build": "react-scripts build",
43 | "test": "react-scripts test",
44 | "eject": "react-scripts eject"
45 | },
46 | "eslintConfig": {
47 | "extends": "react-app"
48 | },
49 | "browserslist": {
50 | "production": [
51 | ">0.2%",
52 | "not dead",
53 | "not IE 11",
54 | "not op_mini all"
55 | ],
56 | "development": [
57 | "last 1 chrome version",
58 | "last 1 firefox version",
59 | "last 1 safari version"
60 | ]
61 | },
62 | "devDependencies": {
63 | "@types/history": "^4.7.11",
64 | "@types/jest": "^29.5.0",
65 | "@types/node": "^18.15.8",
66 | "@types/react": "^18.0.29",
67 | "@types/react-dom": "^18.0.11",
68 | "@types/react-router": "^5.1.20",
69 | "@types/react-router-dom": "^5.3.3",
70 | "@types/react-transition-group": "^4.4.5",
71 | "@types/styled-components": "^5.1.26",
72 | "@types/webpack-env": "^1.18.0",
73 | "sass": "^1.60.0",
74 | "typescript": "^5.0.2"
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/based-ghost/aspnet-core-react-redux-playground-template/443cfc3188dedcd7701d6c66d4b91df20359b487/GhostUI/ClientApp/public/favicon.ico
--------------------------------------------------------------------------------
/GhostUI/ClientApp/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
14 |
15 |
24 | GhostUI
25 |
26 |
27 | You need to enable JavaScript to run this app.
28 |
29 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/based-ghost/aspnet-core-react-redux-playground-template/443cfc3188dedcd7701d6c66d4b91df20359b487/GhostUI/ClientApp/public/logo192.png
--------------------------------------------------------------------------------
/GhostUI/ClientApp/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/based-ghost/aspnet-core-react-redux-playground-template/443cfc3188dedcd7701d6c66d4b91df20359b487/GhostUI/ClientApp/public/logo512.png
--------------------------------------------------------------------------------
/GhostUI/ClientApp/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "aspnet-core-react-redux-playground-template",
3 | "short_name": "aspnet-core-react-template",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "background_color": "#000000",
24 | "theme_color": "#209cee"
25 | }
26 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/App.tsx:
--------------------------------------------------------------------------------
1 | import Layout from './Layout';
2 | import { Routes as routes } from './config';
3 | import type { FunctionComponent } from 'react';
4 | import { useCSSTransitionProps } from './hooks';
5 | import { useLocation, Route, Routes } from 'react-router-dom';
6 | import { CSSTransition, SwitchTransition } from 'react-transition-group';
7 |
8 | const App: FunctionComponent = () => {
9 | const location = useLocation();
10 | const cssProps = useCSSTransitionProps();
11 |
12 | return (
13 |
14 |
15 |
16 |
17 | {routes.map(({ path, Component }) => (
18 | }
22 | />
23 | ))}
24 |
25 |
26 |
27 |
28 | );
29 | };
30 |
31 | export default App;
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/Layout.tsx:
--------------------------------------------------------------------------------
1 | import { Footer, Navbar, Settings } from './components';
2 | import { Fragment, type FunctionComponent, type PropsWithChildren } from 'react';
3 |
4 | const Layout: FunctionComponent = ({ children }) => (
5 |
6 |
7 |
8 | {children}
9 |
10 |
11 | );
12 |
13 | export default Layout;
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/api/auth.service.ts:
--------------------------------------------------------------------------------
1 | import { BaseService } from './base.service';
2 | import type { AxiosResponse } from 'axios';
3 | import type { AuthUser, Credentials } from '../store/authSlice';
4 |
5 | /**
6 | * Auth API abstraction layer communication via Axios (typescript singleton pattern)
7 | */
8 | class AuthService extends BaseService {
9 | private static _authService: AuthService;
10 | private static _controller: string = 'Auth';
11 |
12 | private constructor(name: string) {
13 | super(name);
14 | }
15 |
16 | public static get Instance(): AuthService {
17 | return this._authService || (this._authService = new this(this._controller));
18 | }
19 |
20 | public async logoutAsync(): Promise {
21 | return await this.$http.post('Logout');
22 | }
23 |
24 | public async loginAsync(credentials: Credentials): Promise {
25 | const { data } = await this.$http.post('Login', credentials);
26 | return data;
27 | }
28 | }
29 |
30 | export const AuthApi = AuthService.Instance;
31 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/api/base.service.ts:
--------------------------------------------------------------------------------
1 | import axios, { type AxiosInstance } from 'axios';
2 |
3 | /**
4 | * Service API base class - configures default settings/error handling for inheriting class
5 | */
6 | export abstract class BaseService {
7 | protected readonly $http: AxiosInstance;
8 |
9 | protected constructor(controller: string, timeout: number = 50000) {
10 | this.$http = axios.create({
11 | timeout,
12 | baseURL: `http://localhost:52530/api/${controller}/`
13 | });
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/api/index.ts:
--------------------------------------------------------------------------------
1 | export { AuthApi } from './auth.service';
2 | export { SampleApi } from './sample.service';
3 | export { SignalRApi } from './signalr.service';
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/api/sample.service.ts:
--------------------------------------------------------------------------------
1 | import { BaseService } from './base.service';
2 | import type { WeatherForecast } from '../store/weatherSlice';
3 |
4 | /**
5 | * SampleData API abstraction layer communication via Axios (typescript singleton pattern)
6 | */
7 | class SampleService extends BaseService {
8 | private static _sampleService: SampleService;
9 | private static _controller: string = 'SampleData';
10 |
11 | private constructor(name: string) {
12 | super(name);
13 | }
14 |
15 | public static get Instance(): SampleService {
16 | return this._sampleService || (this._sampleService = new this(this._controller));
17 | }
18 |
19 | public async getForecastsAsync(startDateIndex: number): Promise {
20 | const url = `WeatherForecasts?startDateIndex=${startDateIndex}`;
21 | const { data } = await this.$http.get(url);
22 | return data;
23 | }
24 | }
25 |
26 | export const SampleApi = SampleService.Instance;
27 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/api/signalr.service.ts:
--------------------------------------------------------------------------------
1 | import { toast } from 'react-toastify';
2 | import {
3 | LogLevel,
4 | HubConnection,
5 | HubConnectionState,
6 | HubConnectionBuilder
7 | } from '@microsoft/signalr';
8 |
9 | const SIGNALR_CONFIG = {
10 | messageDelay: 3000,
11 | baseUrl: '/hubs/users',
12 | toastIcon: 'info-circle',
13 | events: {
14 | login: 'UserLogin',
15 | logout: 'UserLogout',
16 | closeConnections: 'CloseAllConnections'
17 | }
18 | };
19 |
20 | /**
21 | * SignalR API abstraction layer communication.
22 | * Configures/manages hub connections (typescript singleton pattern).
23 | */
24 | class SignalRService {
25 | private static _signalRService: SignalRService;
26 | private _hubConnection: HubConnection | undefined;
27 |
28 | private constructor() {
29 | this.createConnection();
30 | this.registerOnServerEvents();
31 | }
32 |
33 | public static get Instance(): SignalRService {
34 | return this._signalRService || (this._signalRService = new this());
35 | }
36 |
37 | get connectionState(): HubConnectionState {
38 | return this._hubConnection?.state ?? HubConnectionState.Disconnected;
39 | }
40 |
41 | public async startConnection(): Promise {
42 | try {
43 | await this._hubConnection?.start();
44 | console.assert(this.connectionState === HubConnectionState.Connected);
45 | } catch (e) {
46 | console.assert(this.connectionState === HubConnectionState.Disconnected);
47 | console.error(e);
48 | setTimeout(() => this.startConnection(), 5000);
49 | }
50 | }
51 |
52 | private createConnection(): void {
53 | this._hubConnection = new HubConnectionBuilder()
54 | .withUrl(SIGNALR_CONFIG.baseUrl)
55 | .withAutomaticReconnect()
56 | .configureLogging(LogLevel.Information)
57 | .build();
58 | }
59 |
60 | private hubToastMessage(
61 | message: string,
62 | delay: number = SIGNALR_CONFIG.messageDelay
63 | ): void {
64 | setTimeout(() => toast.info(message), delay);
65 | }
66 |
67 | private registerOnServerEvents(): void {
68 | this._hubConnection?.on(SIGNALR_CONFIG.events.login, () => {
69 | this.hubToastMessage('A user has logged in (SignalR)');
70 | });
71 |
72 | this._hubConnection?.on(SIGNALR_CONFIG.events.logout, () => {
73 | this.hubToastMessage('A user has logged out (SignalR)');
74 | });
75 |
76 | this._hubConnection?.on(
77 | SIGNALR_CONFIG.events.closeConnections,
78 | async (reason: string) => {
79 | try {
80 | await this._hubConnection?.stop();
81 | this.hubToastMessage(`All hub connections closed (SignalR) - ${reason}`);
82 | } catch (e) {
83 | console.error(e);
84 | }
85 | }
86 | );
87 | }
88 | }
89 |
90 | export const SignalRApi = SignalRService.Instance;
91 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/assets/image/BulmaLogo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/assets/image/ReactCore.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/assets/image/based-ghost-main.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/based-ghost/aspnet-core-react-redux-playground-template/443cfc3188dedcd7701d6c66d4b91df20359b487/GhostUI/ClientApp/src/assets/image/based-ghost-main.png
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/assets/style/scss/base/generic.scss:
--------------------------------------------------------------------------------
1 | /* REACT-TOASTIFY NPM PACKAGE CSS OVERRIDES */
2 | .Toastify__toast {
3 | border-radius: $toastify-toast-border-radius;
4 | }
5 |
6 | .Toastify__toast-theme--colored.Toastify__toast--error {
7 | background-color: $toastify-color-error;
8 | }
9 |
10 | /* OVERRIDES & GENERAL STYLES */
11 | html,
12 | body {
13 | background-color: $color-body-bg;
14 | }
15 |
16 | hr {
17 | height: 1.5px;
18 | background-color: rgba(0, 0, 0, 0.1);
19 | }
20 |
21 | .subtitle {
22 | color: $color-subtitle;
23 | }
24 |
25 | .title:not(.is-spaced) + .subtitle.is-5 {
26 | margin-top: -1rem;
27 | }
28 |
29 | .box {
30 | padding: 1.75rem;
31 | border-radius: 0.25rem;
32 | border-color: rgba(0,0,0,.05);
33 | box-shadow: 0 2px 7px 0 rgba(0,0,0,.08), 0 5px 20px 0 rgba(0,0,0,.06);
34 |
35 | &.login-box {
36 | padding: 1.5rem;
37 | }
38 |
39 | &.container-box {
40 | min-height: 446px;
41 | }
42 | }
43 |
44 | #login-img {
45 | opacity: 0.9;
46 | padding-bottom: 0.75rem;
47 | }
48 |
49 | .section {
50 | min-height: 60rem;
51 | }
52 |
53 | .section-login {
54 | padding: 1rem 1.5rem;
55 | }
56 |
57 | code {
58 | padding: 0.275em 0.45em;
59 | font-size: 0.9em;
60 | word-break: break-word;
61 | border-radius: 3px;
62 | color: $color-code;
63 | background-color: #ebedf0;
64 | font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
65 |
66 | &.form-value {
67 | margin-left: 5px;
68 | }
69 | }
70 |
71 | .hero.is-dark {
72 | background-color: $color-hero-is-dark;
73 |
74 | .hero-body {
75 | padding: 1.25rem;
76 |
77 | img {
78 | user-select: none;
79 | }
80 | }
81 |
82 | .title {
83 | color: $color-blue-highlight;
84 | }
85 | }
86 |
87 | .form-columns {
88 | h5 {
89 | margin-bottom: 3rem !important;
90 | }
91 |
92 | .title:not(.is-spaced) + .subtitle {
93 | margin-top: -1rem;
94 | }
95 |
96 | .form-control-group {
97 | width: 95%;
98 | min-height: 60px;
99 | }
100 | }
101 |
102 | .is-horizontal-center {
103 | align-items: center;
104 | justify-content: center;
105 | }
106 |
107 | .button {
108 | font-weight: 600;
109 | }
110 |
111 | .remember-me-field {
112 | label {
113 | margin-left: auto;
114 | margin-right: auto;
115 | }
116 |
117 | span {
118 | font-size: 1.02em;
119 | }
120 | }
121 |
122 | .incrementer-buttons {
123 | align-items: initial;
124 | margin-bottom: .75rem !important;
125 |
126 | > .button {
127 | margin-bottom: 0;
128 | min-width: 6.25rem;
129 |
130 | svg {
131 | margin: auto;
132 | font-size: 1.4em;
133 | }
134 |
135 | &:not(:last-child) {
136 | margin-right: 0.75rem !important;
137 | }
138 | }
139 | }
140 |
141 | .dashboard-wrapper {
142 | min-height: 60rem;
143 | padding-bottom: 3rem;
144 |
145 | .card-content {
146 | padding: 1rem;
147 |
148 | .title {
149 | margin-bottom: 1.25rem;
150 | }
151 |
152 | .content li + li {
153 | margin-top: 0.75em;
154 | }
155 |
156 | .dashboard-info {
157 | padding: 1.25em !important;
158 | }
159 | }
160 |
161 | hr {
162 | width: 55%;
163 | margin: 0 auto 0.45rem;
164 | }
165 | }
166 |
167 | .dashboard-link {
168 | color: $color-code;
169 | font-weight: 700;
170 | padding: 0.15em 0.35em 0.15em;
171 | margin-right: 0.25em;
172 | transition: background-color 0.2s ease-out, border-bottom-color 0.2s ease-out;
173 |
174 | &.react {
175 | background-color: rgba(32, 156, 238, 0.16);
176 | border-bottom: 1px solid rgba(32, 156, 238, 0.25);
177 |
178 | &:hover {
179 | background-color: rgba(32, 156, 238, 0.3);
180 | border-bottom-color: rgba(32, 156, 238, 1);
181 | }
182 | }
183 |
184 | &.redux {
185 | background-color: rgba(118, 74, 188, 0.16);
186 | border-bottom: 1px solid rgba(118, 74, 188, 0.25);
187 |
188 | &:hover {
189 | background-color: rgba(118, 74, 188, 0.3);
190 | border-bottom-color: rgba(118, 74, 188, 1);
191 | }
192 | }
193 |
194 | &.bulma {
195 | background-color: rgba(0, 196, 167, 0.16);
196 | border-bottom: 1px solid rgba(0, 196, 167, 0.25);
197 |
198 | &:hover {
199 | background-color: rgba(0, 196, 167, 0.3);
200 | border-bottom-color: rgba(0, 196, 167, 1);
201 | }
202 | }
203 |
204 | &.aspcore {
205 | background-color: rgba(118, 74, 188, 0.16);
206 | border-bottom: 1px solid rgba(118, 74, 188, 0.25);
207 |
208 | &:hover {
209 | background-color: rgba(118, 74, 188, 0.3);
210 | border-bottom-color: rgba(118, 74, 188, 1);
211 | }
212 | }
213 |
214 | &.sass {
215 | background-color: rgba(198, 83, 140, 0.16);
216 | border-bottom: 1px solid rgba(198, 83, 140, 0.25);
217 |
218 | &:hover {
219 | background-color: rgba(198, 83, 140, 0.3);
220 | border-bottom-color: rgba(198, 83, 140, 1);
221 | }
222 | }
223 |
224 | &.typescript {
225 | background-color: rgba(41, 78, 128, 0.16);
226 | border-bottom: 1px solid rgba(41, 78, 128, 0.25);
227 |
228 | &:hover {
229 | background-color: rgba(41, 78, 128, 0.3);
230 | border-bottom-color: rgba(41, 78, 128, 1);
231 | }
232 | }
233 | }
234 |
235 | .pagination-group {
236 | > a {
237 | width: 8em;
238 |
239 | &:nth-child(1) {
240 | margin-left: auto;
241 | margin-right: 0.75rem !important;
242 | }
243 |
244 | &:nth-child(2) {
245 | margin-right: auto;
246 | }
247 |
248 | svg {
249 | font-size: 1.65em;
250 | }
251 | }
252 | }
253 |
254 | .icon-clickable {
255 | pointer-events: visible !important;
256 | cursor: pointer;
257 |
258 | &:hover {
259 | color: #363636 !important;
260 | opacity: 0.7;
261 | }
262 | }
263 |
264 | .table.is-fullwidth {
265 | @media all and (max-width: 449px) {
266 | font-size: 0.8rem;
267 | }
268 | }
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/assets/style/scss/base/transition.scss:
--------------------------------------------------------------------------------
1 | // START: fade transition
2 | .fade-enter {
3 | opacity: 0;
4 | }
5 |
6 | .fade-exit {
7 | opacity: 1;
8 | }
9 |
10 | .fade-enter-active {
11 | opacity: 1;
12 | transition: opacity 250ms ease;
13 | }
14 |
15 | .fade-exit-active {
16 | opacity: 0;
17 | transition: opacity 250ms ease;
18 | }
19 | // END: fade transition
20 |
21 | // START: page-slide transitions
22 | .page-slide-right-enter {
23 | opacity: 0;
24 | transform: translate3d(150px, 0, 0);
25 | }
26 |
27 | .page-slide-left-enter {
28 | opacity: 0;
29 | transform: translate3d(-150px, 0, 0);
30 | }
31 |
32 | .page-slide-right-enter-active,
33 | .page-slide-left-enter-active {
34 | opacity: 1;
35 | transform: translate3d(0, 0, 0);
36 | transition: opacity 350ms cubic-bezier(0.4, 0, 0, 1.5),
37 | transform 350ms cubic-bezier(0.4, 0, 0, 1.5);
38 | }
39 |
40 | .page-slide-right-exit,
41 | .page-slide-left-exit {
42 | opacity: 1;
43 | }
44 |
45 | .page-slide-right-exit-active,
46 | .page-slide-left-exit-active {
47 | opacity: 0;
48 | transition: opacity 250ms ease;
49 | }
50 | // END: page-slide transitions
51 |
52 | // START: react-toastify custom transitions
53 | .custom__toast__animate__bounceIn {
54 | animation: toast_bounceIn 1s both;
55 | }
56 |
57 | .custom__toast__animate__bounceOut {
58 | animation: toast_bounceOut 0.85s both;
59 | }
60 |
61 | @keyframes toast_bounceOut {
62 | 20% {
63 | transform: scale3d(0.9, 0.9, 0.9);
64 | } 50%,
65 | 55% {
66 | opacity: 1;
67 | transform: scale3d(1.1, 1.1, 1.1);
68 | } to {
69 | opacity: 0;
70 | transform: scale3d(0.3, 0.3, 0.3);
71 | }
72 | }
73 |
74 | @keyframes toast_bounceIn {
75 | from,
76 | 20%,
77 | 40%,
78 | 60%,
79 | 80%,
80 | to {
81 | animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
82 | }
83 |
84 | 0% {
85 | opacity: 0;
86 | transform: scale3d(0.3, 0.3, 0.3);
87 | } 20% {
88 | transform: scale3d(1.1, 1.1, 1.1);
89 | } 40% {
90 | transform: scale3d(0.9, 0.9, 0.9);
91 | } 60% {
92 | opacity: 1;
93 | transform: scale3d(1.03, 1.03, 1.03);
94 | } 80% {
95 | transform: scale3d(0.97, 0.97, 0.97);
96 | } to {
97 | opacity: 1;
98 | transform: scale3d(1, 1, 1);
99 | }
100 | }
101 | // END: react-toastify custom transitions
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/assets/style/scss/base/variables.scss:
--------------------------------------------------------------------------------
1 | /* BULMA COLOR OVERRIDES */
2 | $red: #e93e60;
3 | $cyan: #09d3ac;
4 | $blue: $cyan;
5 |
6 | /* COLOR */
7 | $color-body-bg: #f7f7f7;
8 | $color-nav-bar: #33363b;
9 | $color-hero-is-dark: #1f2227;
10 | $color-blue-highlight: $cyan;
11 |
12 | $color-subtitle: #646464;
13 | $color-code: #363636;
14 |
15 | /* REACT-TOASTIFY OVERRIDES */
16 | $toastify-color-error: $red;
17 | $toastify-toast-border-radius: 3px;
18 |
19 | /* MIXINS */
20 | @mixin renderTabletNavView {
21 | @media all and (max-width: 950px) and (min-width: 600px) {
22 | @content;
23 | }
24 | }
25 |
26 | @mixin renderMobileNavView {
27 | @media all and (max-width: 599px) {
28 | @content;
29 | }
30 | }
31 |
32 | @mixin removeNavBarPadding {
33 | @media all and (max-width: 1099px) {
34 | @content;
35 | }
36 | }
37 |
38 | @mixin reduceNavBarPadding {
39 | @media all and (max-width: 1472px) and (min-width: 1100px) {
40 | @content;
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/assets/style/scss/components/navbar.scss:
--------------------------------------------------------------------------------
1 | .navbar {
2 | width: 100%;
3 | height: 66px;
4 | background-color: $color-nav-bar;
5 | padding-left: 7rem;
6 | padding-right: 7rem;
7 |
8 | @include reduceNavBarPadding {
9 | padding-left: 4rem;
10 | padding-right: 4rem;
11 | }
12 |
13 | @include removeNavBarPadding {
14 | padding-left: 1rem;
15 | padding-right: 1rem;
16 | }
17 |
18 | .navbar-wrapper {
19 | width: 100%;
20 | height: 100%;
21 | margin: auto;
22 | display: flex;
23 | animation-delay: 0.25s;
24 | animation: fadeInNavbar 0.25s both ease;
25 |
26 | .brand-wrapper {
27 | display: flex;
28 | align-items: center;
29 | width: 46%;
30 | margin: auto;
31 |
32 | @include renderMobileNavView {
33 | margin-right: 0.25rem;
34 | }
35 | }
36 |
37 | .navbar-routes {
38 | font-size: 1.25rem;
39 | height: 100%;
40 | display: flex;
41 | justify-content: flex-end;
42 | margin: auto;
43 | width: 54%;
44 | align-items: center;
45 |
46 | .navbar-item {
47 | color: white;
48 | font-weight: 600;
49 | background-color: transparent;
50 | transition: color 0.2s ease-out, border-bottom-color 0.2s ease-out;
51 | border-bottom: 2.5px solid transparent;
52 | border-top: 2.5px solid transparent;
53 | display: flex;
54 | overflow-x: auto;
55 | overflow-y: hidden;
56 | height: 100%;
57 |
58 | @include renderMobileNavView {
59 | font-size: 0.95rem;
60 | padding: 0.75rem 0.2rem 0.75rem 0.2rem;
61 | }
62 |
63 | &:not(:first-child) {
64 | margin-left: 1.25rem;
65 |
66 | @include renderMobileNavView {
67 | margin-left: 0;
68 | }
69 | }
70 |
71 | &:hover {
72 | color: $color-blue-highlight;
73 | background-color: transparent;
74 | }
75 |
76 | &.is-active {
77 | color: $color-blue-highlight !important;
78 | border-bottom-color: $color-blue-highlight !important;
79 | }
80 | }
81 | }
82 | }
83 | }
84 |
85 | @keyframes fadeInNavbar {
86 | from {
87 | opacity: 0;
88 | } to {
89 | opacity: 1;
90 | }
91 | }
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/assets/style/scss/components/tool-tip.scss:
--------------------------------------------------------------------------------
1 | [data-tooltip] {
2 | position: relative;
3 | z-index: 2;
4 | cursor: pointer;
5 |
6 | &:before,
7 | &:after {
8 | visibility: hidden;
9 | -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)";
10 | filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=0);
11 | opacity: 0;
12 | pointer-events: none;
13 | }
14 |
15 | &:before {
16 | position: absolute;
17 | bottom: 77%;
18 | left: 50%;
19 | margin-bottom: 5px;
20 | margin-left: -80px;
21 | padding: 7px;
22 | width: 145px;
23 | border-radius: 4px;
24 | background-color: #000;
25 | background-color: hsla(0, 0%, 20%, 0.9);
26 | color: white;
27 | content: attr(data-tooltip);
28 | text-align: center;
29 | font-size: 13px;
30 | line-height: 1.5;
31 | }
32 |
33 | &:after {
34 | position: absolute;
35 | bottom: 77%;
36 | left: 50%;
37 | margin-left: -5px;
38 | width: 0;
39 | border-top: 5px solid #000;
40 | border-top: 5px solid hsla(0, 0%, 20%, 0.9);
41 | border-right: 5px solid transparent;
42 | border-left: 5px solid transparent;
43 | content: " ";
44 | font-size: 0;
45 | line-height: 0;
46 | }
47 |
48 | &:hover {
49 | &:before,
50 | &:after {
51 | visibility: visible;
52 | -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=100)";
53 | filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=100);
54 | opacity: 1;
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/assets/style/scss/site.scss:
--------------------------------------------------------------------------------
1 | /* overrides for bulma & react-toastify defaults */
2 | @import 'base/variables';
3 |
4 | /* node_modules imports */
5 | @import 'bulma/bulma';
6 | @import 'react-toastify/dist/ReactToastify.css';
7 |
8 | /* local styles */
9 | @import 'base/generic', 'base/transition';
10 | @import 'components/navbar', 'components/tool-tip';
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/components/Authenticator.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, memo } from 'react';
2 | import { AuthStatusEnum } from '../store/authSlice';
3 | import styled, { keyframes } from 'styled-components';
4 |
5 | type AuthenticatorProps = Readonly<{
6 | delay?: number;
7 | authStatus: AuthStatusEnum;
8 | handleOnFail: (...args: any[]) => any;
9 | handleOnSuccess: (...args: any[]) => any;
10 | }>;
11 |
12 | const CHILD_DIV_COUNT = 9;
13 |
14 | const ROTATE_KEYFRAMES = keyframes`
15 | 100% {
16 | transform: rotate(360deg);
17 | }
18 | `;
19 |
20 | const getChildDivBorderColor = (authStatus: AuthStatusEnum): string => {
21 | switch (authStatus) {
22 | case AuthStatusEnum.FAIL: return '#e93e60';
23 | case AuthStatusEnum.SUCCESS: return '#09d3ac';
24 | default: return 'rgba(9, 30, 66, 0.35)';
25 | }
26 | };
27 |
28 | const getChildDivCSS = (): string => {
29 | const childDivTemplate = (idx: number): string => `
30 | &:nth-child(${idx + 1}) {
31 | height: calc(96px / 9 + ${idx} * 96px / 9);
32 | width: calc(96px / 9 + ${idx} * 96px / 9);
33 | animation-delay: calc(50ms * ${idx + 1});
34 | }
35 | `;
36 |
37 | return [...Array(CHILD_DIV_COUNT).keys()]
38 | .map((key) => childDivTemplate(key))
39 | .join('');
40 | };
41 |
42 | const AuthenticatorWrapper = styled.div>`
43 | width: 100px;
44 | height: 100px;
45 | padding: 2px;
46 | overflow: hidden;
47 | position: relative;
48 | box-sizing: border-box;
49 | margin: 1.25em auto auto auto;
50 |
51 | > div {
52 | top: 0;
53 | left: 0;
54 | right: 0;
55 | bottom: 0;
56 | margin: auto;
57 | position: absolute;
58 | border-radius: 50%;
59 | box-sizing: border-box;
60 | border: 2px solid transparent;
61 | border-top-color: ${({ authStatus }) => getChildDivBorderColor(authStatus)};
62 | animation: ${ROTATE_KEYFRAMES} 1500ms cubic-bezier(0.68, -0.75, 0.265, 1.75) infinite forwards;
63 |
64 | ${getChildDivCSS()}
65 | }
66 | `;
67 |
68 | const Authenticator = memo(({
69 | authStatus,
70 | handleOnFail,
71 | handleOnSuccess,
72 | delay = 1500
73 | }) => {
74 | useEffect(() => {
75 | const authHandler = setTimeout(() => {
76 | switch (authStatus) {
77 | case AuthStatusEnum.FAIL: return handleOnFail();
78 | case AuthStatusEnum.SUCCESS: return handleOnSuccess();
79 | default: return;
80 | }
81 | }, delay);
82 |
83 | return () => {
84 | clearTimeout(authHandler);
85 | }
86 | }, [authStatus, delay, handleOnFail, handleOnSuccess]);
87 |
88 | if (!authStatus || authStatus === AuthStatusEnum.NONE) {
89 | return null;
90 | }
91 |
92 | return (
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 | );
105 | });
106 |
107 | Authenticator.displayName = 'Authenticator';
108 |
109 | export default Authenticator;
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/components/Checkbox.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { forwardRef, type Ref } from 'react';
3 |
4 | type CheckboxProps = Readonly<{
5 | id?: string;
6 | name?: string;
7 | label?: string;
8 | checked: boolean;
9 | disabled?: boolean;
10 | readOnly?: boolean;
11 | onCheck: (checked: boolean) => void;
12 | }>;
13 |
14 | const Label = styled.span`
15 | padding-left: 1.5rem;
16 | `;
17 |
18 | const CheckboxWrapper = styled.label`
19 | display: inline-flex;
20 | user-select: none;
21 | position: relative;
22 | `;
23 |
24 | const Input = styled.input`
25 | top: 0.25rem;
26 | z-index: 3;
27 | opacity: 0;
28 | width: 1rem;
29 | height: 1rem;
30 | cursor: pointer;
31 | position: absolute;
32 |
33 | :checked ~ i {
34 | border-color: rgba(9, 211, 172, 0.6);
35 |
36 | :after,
37 | :before {
38 | opacity: 1;
39 | transition: height 0.33s ease;
40 | }
41 |
42 | :after {
43 | height: 0.5rem;
44 | }
45 |
46 | :before {
47 | height: 1.2rem;
48 | transition-delay: 0.1s;
49 | }
50 | }
51 | `;
52 |
53 | const CheckIcon = styled.i`
54 | top: 0.25rem;
55 | z-index: 0;
56 | width: 1rem;
57 | height: 1rem;
58 | color: #ced4da;
59 | position: absolute;
60 | box-sizing: border-box;
61 | border-radius: 2px;
62 | background-color: transparent;
63 | border: 1.5px solid currentColor;
64 | transition: border-color 0.33s ease;
65 |
66 | :after,
67 | :before {
68 | height: 0;
69 | opacity: 0;
70 | content: "";
71 | width: 0.2rem;
72 | display: block;
73 | position: absolute;
74 | border-radius: 0.25rem;
75 | background-color: #09d3ac;
76 | transform-origin: left top;
77 | transition: opacity 0.33s ease, height 0s linear 0.33s;
78 | }
79 |
80 | :after {
81 | left: 0;
82 | top: 0.3rem;
83 | transform: rotate(-45deg);
84 | }
85 |
86 | :before {
87 | top: 0.65rem;
88 | left: 0.38rem;
89 | transform: rotate(-135deg);
90 | }
91 | `;
92 |
93 | const Checkbox = forwardRef((
94 | {
95 | id,
96 | name,
97 | label,
98 | onCheck,
99 | checked,
100 | disabled,
101 | readOnly
102 | },
103 | ref: Ref
104 | ) => (
105 |
106 | onCheck(e.target.checked)}
115 | />
116 |
117 | {label && {label} }
118 |
119 | ));
120 |
121 | Checkbox.displayName = 'Checkbox';
122 |
123 | export default Checkbox;
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import type { FunctionComponent } from 'react';
3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
4 |
5 | const BrandContent = styled.div`
6 | text-align: center;
7 | word-spacing: 0.05rem;
8 | `;
9 |
10 | const StyledFooter = styled.footer`
11 | color: #fff;
12 | width: 100%;
13 | margin: auto;
14 | display: block;
15 | font-size: 1.15rem;
16 | padding: 3rem 1.5rem;
17 | background-color: #33363b;
18 |
19 | @media all and (max-width: 769px) {
20 | font-size: 1rem;
21 | }
22 | `;
23 |
24 | const FooterButtons = styled.div`
25 | display: flex;
26 | flex-wrap: wrap;
27 | margin-bottom: 0rem;
28 | align-items: center;
29 | justify-content: flex-start;
30 |
31 | > a {
32 | :first-child {
33 | margin-left: auto !important;
34 | }
35 |
36 | :last-child {
37 | margin-right: auto !important;
38 | }
39 | }
40 | `;
41 |
42 | const FooterLink = styled.a`
43 | color: #fff;
44 | margin-bottom: 0;
45 | font-size: 1.25rem;
46 | padding: 0 0.5em 0.75rem;
47 | border-color: transparent;
48 | margin-right: 0 !important;
49 | background-color: transparent;
50 | transition: color 0.2s ease-out;
51 |
52 | &:hover {
53 | color: #09d3ac;
54 | }
55 |
56 | .icon {
57 | align-items: baseline;
58 | }
59 | `;
60 |
61 | const Footer: FunctionComponent = () => (
62 |
63 |
64 |
70 |
71 |
72 |
76 |
77 |
78 |
82 |
83 |
84 |
85 |
86 | {`Copyright © ${new Date().getFullYear()} based-ghost LLC`}
87 |
88 |
89 | );
90 |
91 | export default Footer;
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/components/Navbar.tsx:
--------------------------------------------------------------------------------
1 | import { useIsLoggedIn } from '../hooks';
2 | import { Routes as routes } from '../config';
3 | import type { FunctionComponent } from 'react';
4 | import { NavLink, generatePath } from 'react-router-dom';
5 | import { ReactComponent as BulmaLogoSVG } from '../assets/image/BulmaLogo.svg';
6 |
7 | const Navbar: FunctionComponent = () => {
8 | const isLoggedIn = useIsLoggedIn();
9 |
10 | return (
11 |
16 |
17 |
18 |
24 |
25 |
26 | {isLoggedIn &&
27 | routes
28 | .filter(({ showInNav }) => showInNav)
29 | .map(({ path, name, params }) => (
30 | 'navbar-item' + (isActive ? ' is-active' : '')}
34 | >
35 | {name}
36 |
37 | ))}
38 |
39 |
40 |
41 | );
42 | };
43 |
44 | export default Navbar;
45 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/components/Settings.tsx:
--------------------------------------------------------------------------------
1 | import { AuthApi } from '../api';
2 | import { useAppDispatch } from '../store';
3 | import { useNavigate } from 'react-router-dom';
4 | import { resetState } from '../store/authSlice';
5 | import styled, { keyframes } from 'styled-components';
6 | import { useIsLoggedIn, useOnClickOutside } from '../hooks';
7 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
8 | import { Routes, NUGET_URL_CONFIG, LINK_ATTRIBUTES } from '../config';
9 | import { useRef, useState, useCallback, type FunctionComponent } from 'react';
10 |
11 | const CLICK_OUTSIDE_EVENTS = ['click', 'touchend'];
12 |
13 | const FADE_IN_KEYFRAMES = keyframes`
14 | from {
15 | opacity: 0;
16 | } to {
17 | opacity: 1;
18 | }
19 | `;
20 |
21 | const CogIcon = styled(FontAwesomeIcon)`
22 | color: #fff;
23 | padding: 10px;
24 | font-size: 1.75em;
25 | `;
26 |
27 | const SettingsLink = styled.a`
28 | border: 0;
29 | outline: 0;
30 | cursor: pointer;
31 | padding: 0.25rem;
32 | background: transparent;
33 | `;
34 |
35 | const SettingsMenuLink = styled.a`
36 | width: 100%;
37 | color: #555;
38 | font-size: 1rem;
39 | z-index: initial;
40 | text-align: left;
41 | line-height: 1.5;
42 | position: relative;
43 | white-space: nowrap;
44 | display: inline-block;
45 | padding: 0.375rem 1rem;
46 | pointer-events: visible;
47 |
48 | svg {
49 | opacity: 0.8;
50 | margin: 0 0.3rem;
51 | }
52 | `;
53 |
54 | const SettingsMenuTitle = styled.li`
55 | color: #7f888f;
56 | font-size: 18px;
57 | font-weight: 600;
58 | margin-left: auto;
59 | line-height: 35px;
60 | margin-right: auto;
61 | text-align: center;
62 | padding-bottom: 3px;
63 | margin-bottom: 0.5rem;
64 | text-transform: uppercase;
65 | border-bottom: 1px solid rgba(0, 0, 0, 0.1);
66 | `;
67 |
68 | const SettingsMenu = styled.ul`
69 | top: 0;
70 | opacity: 1;
71 | left: auto;
72 | right: 69px;
73 | width: 11rem;
74 | z-index: 1000;
75 | display: block;
76 | padding: 5px 0;
77 | min-width: 11rem;
78 | user-select: none;
79 | position: absolute;
80 | background-color: #fff;
81 | border-radius: 0.25rem;
82 | box-shadow: 0 2px 7px 0 rgba(0, 0, 0, 0.08), 0 5px 20px 0 rgba(0, 0, 0, 0.06);
83 |
84 | :before,
85 | :after {
86 | top: 22px;
87 | content: '';
88 | width: 17px;
89 | position: absolute;
90 | display: inline-block;
91 | transform: translateY(-50%);
92 | border-top: 16px solid transparent;
93 | border-bottom: 16px solid transparent;
94 | }
95 |
96 | :before {
97 | left: auto;
98 | right: -16px;
99 | margin-left: auto;
100 | border-left: 16px solid #dbdbdb;
101 | }
102 |
103 | :after {
104 | right: -15px;
105 | border-left: 16px solid #fff;
106 | }
107 |
108 | > li:not(:first-of-type) {
109 | transition: background-color .2s ease-out;
110 |
111 | :hover {
112 | background-color: #f5f5f5;
113 | }
114 | }
115 | `;
116 |
117 | const StyledSettings = styled.div<{ isMenuOpen: boolean }>`
118 | right: 0;
119 | top: 120px;
120 | width: 65px;
121 | z-index: 1031;
122 | position: fixed;
123 | text-align: center;
124 | animation-delay: 0.25s;
125 | border-radius: 8px 0 0 8px;
126 | transition: background 0.15s ease-in;
127 | animation: ${FADE_IN_KEYFRAMES} 0.25s both ease;
128 | background: ${({ isMenuOpen }) => `rgba(0, 0, 0, ${isMenuOpen ? 0.6 : 0.45})`};
129 |
130 | :hover {
131 | background: rgba(0, 0, 0, 0.6);
132 | }
133 | `;
134 |
135 | const Settings: FunctionComponent = () => {
136 | const isLoggedIn = useIsLoggedIn();
137 | const settingsLinkRef = useRef(null);
138 | const [isMenuOpen, setisMenuOpen] = useState(false);
139 |
140 | // Deps list has "isMenuOpen" to limit extraneous setStates causing rerenders on every outside click
141 | const onMenuClickOutside = useCallback(() => {
142 | isMenuOpen && setisMenuOpen(false);
143 | }, [isMenuOpen]);
144 |
145 | useOnClickOutside(
146 | settingsLinkRef,
147 | onMenuClickOutside,
148 | CLICK_OUTSIDE_EVENTS
149 | );
150 |
151 | // react-redux hooks state/actions
152 | const navigate = useNavigate();
153 | const dispatch = useAppDispatch();
154 |
155 | if (!isLoggedIn) {
156 | return null;
157 | }
158 |
159 | const {
160 | path: loginPath,
161 | icon: loginIcon,
162 | name: loginName
163 | } = Routes.find((x) => x.path === '/')!;
164 |
165 | const handleLogout = async () => {
166 | try {
167 | await AuthApi.logoutAsync();
168 | dispatch(resetState());
169 | navigate(loginPath);
170 | } catch (e) {
171 | console.error(e);
172 | }
173 | };
174 |
175 | return (
176 |
177 | setisMenuOpen((prevIsMenuOpen) => !prevIsMenuOpen)}
181 | >
182 |
183 |
184 | {isMenuOpen && (
185 |
186 |
187 | Settings
188 |
189 |
190 |
194 | Health Checks
195 |
196 |
197 |
198 |
202 | Swagger API
203 |
204 |
205 |
206 |
210 | {` ${loginName}`}
211 |
212 |
213 |
214 | )}
215 |
216 | );
217 | };
218 |
219 | export default Settings;
220 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/components/Spinner.tsx:
--------------------------------------------------------------------------------
1 | import type { FunctionComponent } from 'react';
2 | import styled, { keyframes } from 'styled-components';
3 |
4 | type SpinnerProps = Readonly<{
5 | isLoading: boolean;
6 | }>;
7 |
8 | const SPIN_KEYFRAMES = keyframes`
9 | 0% {
10 | transform: rotate(0deg);
11 | } 100% {
12 | transform: rotate(360deg);
13 | }
14 | `;
15 |
16 | const StyledSpinner = styled.div`
17 | top: 50%;
18 | left: 48%;
19 | z-index: 9999;
20 | width: 4.75em;
21 | height: 4.75em;
22 | position: absolute;
23 | display: ${({ isLoading }) => isLoading ? 'inline-block' : 'none'};
24 |
25 | > div {
26 | width: 4.75em;
27 | height: 4.75em;
28 | position: absolute;
29 | border-radius: 50%;
30 | border: 0.35em solid;
31 | display: inline-block;
32 | box-sizing: border-box;
33 | border-color: #09d3ac transparent transparent transparent;
34 | animation: ${SPIN_KEYFRAMES} 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
35 |
36 | :nth-child(1) {
37 | animation-delay: -0.45s;
38 | }
39 |
40 | :nth-child(2) {
41 | animation-delay: -0.3s;
42 | }
43 |
44 | :nth-child(3) {
45 | animation-delay: -0.15s;
46 | }
47 | }
48 | `;
49 |
50 | const Spinner: FunctionComponent = ({ isLoading }) => (
51 |
52 |
53 |
54 |
55 |
56 |
57 | );
58 |
59 | export default Spinner;
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/components/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Footer } from './Footer';
2 | export { default as Navbar } from './Navbar';
3 | export { default as Spinner } from './Spinner';
4 | export { default as Checkbox } from './Checkbox';
5 | export { default as Settings } from './Settings';
6 | export { default as Authenticator } from './Authenticator';
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/config/constants.ts:
--------------------------------------------------------------------------------
1 | import type { AnchorHTMLAttributes } from 'react';
2 | import type { Theme } from 'react-functional-select';
3 | import type { SelectOption } from '../store/formSlice';
4 |
5 | /**
6 | * react-functional-select 'themeConfig' property
7 | */
8 | export const THEME_CONFIG: Theme = {
9 | color: {
10 | primary: '#09d3ac'
11 | },
12 | control: {
13 | boxShadowColor: 'rgba(9, 211, 172, 0.25)',
14 | focusedBorderColor: 'rgba(9, 211, 172, 0.75)'
15 | },
16 | menu: {
17 | option: {
18 | selectedColor: '#fff',
19 | selectedBgColor: '#09d3ac',
20 | focusedBgColor: 'rgba(9, 211, 172, 0.225)'
21 | }
22 | }
23 | };
24 |
25 | /**
26 | * Select control test data
27 | */
28 | export const DROPDOWN_TEST_DATA: SelectOption[] = [
29 | { value: 1, label: 'Option 1' },
30 | { value: 2, label: 'Option 2' },
31 | { value: 3, label: 'Option 3' },
32 | { value: 4, label: 'Option 4' },
33 | { value: 5, label: 'Option 5' }
34 | ];
35 |
36 | /**
37 | * HealthChecks/Swagger response path config
38 | */
39 | export const NUGET_URL_CONFIG = {
40 | HealthUi: 'http://localhost:52530/healthchecks-ui',
41 | HealthJson: 'http://localhost:52530/healthchecks-json',
42 | SwaggerDocs: 'http://localhost:52530/swagger'
43 | };
44 |
45 | /**
46 | * HTML attributes to spread on anchor elements in Settings.tsx component
47 | */
48 | export const LINK_ATTRIBUTES: AnchorHTMLAttributes = {
49 | role: 'button',
50 | target: '_blank',
51 | rel: 'noopener noreferrer'
52 | };
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/config/fa.config.ts:
--------------------------------------------------------------------------------
1 | import { library } from '@fortawesome/fontawesome-svg-core';
2 | import {
3 | faAngleDoubleLeft,
4 | faAngleDoubleRight,
5 | faCog,
6 | faExclamationTriangle,
7 | faEye,
8 | faEyeSlash,
9 | faFile,
10 | faHeart,
11 | faInfoCircle,
12 | faLock,
13 | faMinus,
14 | faPlus,
15 | faSignInAlt,
16 | faSignOutAlt,
17 | faUser
18 | } from '@fortawesome/free-solid-svg-icons';
19 | import {
20 | faGithub,
21 | faEtsy,
22 | faTwitter
23 | } from '@fortawesome/free-brands-svg-icons';
24 |
25 | export default function registerIcons(): void {
26 | library.add(
27 | faAngleDoubleLeft,
28 | faAngleDoubleRight,
29 | faCog,
30 | faExclamationTriangle,
31 | faEye,
32 | faEyeSlash,
33 | faFile,
34 | faHeart,
35 | faInfoCircle,
36 | faLock,
37 | faMinus,
38 | faPlus,
39 | faSignInAlt,
40 | faSignOutAlt,
41 | faUser,
42 | faGithub,
43 | faEtsy,
44 | faTwitter
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/config/index.ts:
--------------------------------------------------------------------------------
1 | export * from './constants';
2 | export * from './routes.config';
3 | export * from './toastify.config';
4 |
5 | export { default as registerIcons } from './fa.config';
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/config/routes.config.ts:
--------------------------------------------------------------------------------
1 | import type { ComponentType } from 'react';
2 | import type { Params } from 'react-router-dom';
3 | import { Login, Dashboard, FetchData, Form } from '../containers';
4 | import type { IconProp } from '@fortawesome/fontawesome-svg-core';
5 |
6 | export const TRANSITION_DEFAULT = {
7 | classNames: 'fade',
8 | timeout: { enter: 250, exit: 250 }
9 | };
10 |
11 | export type RouteComponent = ComponentType;
12 | export type Transition = typeof TRANSITION_DEFAULT;
13 |
14 | export type Route = Readonly<{
15 | name: string;
16 | path: string;
17 | icon?: IconProp;
18 | showInNav?: boolean;
19 | transition: Transition;
20 | Component: RouteComponent;
21 | params?: Readonly>;
22 | }>;
23 |
24 | export const Routes: Route[] = [
25 | {
26 | path: '/',
27 | icon: 'sign-out-alt',
28 | name: 'Logout',
29 | Component: Login,
30 | transition: TRANSITION_DEFAULT
31 | },
32 | {
33 | path: '/form',
34 | showInNav: true,
35 | name: 'Form',
36 | Component: Form,
37 | transition: {
38 | classNames: 'page-slide-left',
39 | timeout: { enter: 350, exit: 250 }
40 | }
41 | },
42 | {
43 | showInNav: true,
44 | path: '/home',
45 | name: 'Home',
46 | Component: Dashboard,
47 | transition: TRANSITION_DEFAULT
48 | },
49 | {
50 | showInNav: true,
51 | name: 'Fetch',
52 | path: '/fetch/:startDateIndex',
53 | Component: FetchData,
54 | transition: {
55 | classNames: 'page-slide-right',
56 | timeout: { enter: 350, exit: 250 }
57 | },
58 | params: {
59 | startDateIndex: '0'
60 | }
61 | }
62 | ];
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/config/toastify.config.ts:
--------------------------------------------------------------------------------
1 | import { cssTransition, type ToastContainerProps } from 'react-toastify';
2 |
3 | const transition = cssTransition({
4 | enter: 'custom__toast__animate__bounceIn',
5 | exit: 'custom__toast__animate__bounceOut'
6 | });
7 |
8 | const toastifyProps: ToastContainerProps = {
9 | transition,
10 | autoClose: 1500,
11 | draggable: false,
12 | newestOnTop: true,
13 | theme: 'colored',
14 | position: 'top-center'
15 | };
16 |
17 | export {
18 | toastifyProps
19 | };
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/containers/Dashboard/index.tsx:
--------------------------------------------------------------------------------
1 | import type { FunctionComponent } from 'react';
2 | import { ReactComponent as ReactCoreSVG } from '../../assets/image/ReactCore.svg';
3 |
4 | const Dashboard: FunctionComponent = () => (
5 |
6 |
18 |
19 |
20 |
21 |
22 |
Technology Stack
23 |
24 |
25 |
26 |
27 |
28 |
29 |
35 | React
36 |
37 | is an open-source JavaScript library that makes no
38 | assumptions about the rest of your technology stack. It
39 | allows you to build encapsulated components that mange
40 | their own state using JavaScript, instead of templates.
41 |
42 |
43 |
49 | Redux
50 |
51 | centralizes your application"s state and logic and helps
52 | you write applications that behave consistently and are
53 | easy to test.
54 |
55 |
56 |
62 | Bulma
63 |
64 | is open source CSS framework based on Flexbox (with no
65 | JQuery dependency).
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
80 | ASP.NET Core
81 |
82 | is an open source web framework for building modern web
83 | apps and services with .NET. Creates websites based on
84 | HTML5, CSS, and JavaScript that are simple, fast, and can
85 | scale to millions of users.
86 |
87 |
88 |
94 | SASS
95 |
96 | is a CSS pre-processor extension to help provide more
97 | flexibility & maintainability to your style-sheets.
98 |
99 |
100 |
106 | TypeScript
107 |
108 | is a typed superset of JavaScript that compiles to plain
109 | JavaScript
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 | );
121 |
122 | export default Dashboard;
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/containers/FetchData/ForecastTable.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import type { WeatherState } from '../../store/weatherSlice';
3 |
4 | type ForecastTableProps = Pick;
5 |
6 | const ForecastTable = memo(({ forecasts }) => (
7 |
8 |
9 |
10 | Date
11 | Temp. (C)
12 | Temp. (F)
13 | Summary
14 |
15 |
16 |
17 | {forecasts.map((f) => (
18 |
19 | {f.dateFormatted}
20 | {f.temperatureC}
21 | {f.temperatureF}
22 | {f.summary}
23 |
24 | ))}
25 |
26 |
27 | ));
28 |
29 | ForecastTable.displayName = 'ForecastTable';
30 |
31 | export default ForecastTable;
32 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/containers/FetchData/Pagination.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { Link } from 'react-router-dom';
3 | import type { WeatherState } from '../../store/weatherSlice';
4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
5 |
6 | type PaginationProps = Pick;
7 |
8 | const Pagination = memo(({ startDateIndex = 0 }) => (
9 |
10 |
14 |
15 |
16 |
20 |
21 |
22 |
23 | ));
24 |
25 | Pagination.displayName = 'Pagination';
26 |
27 | export default Pagination;
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/containers/FetchData/index.tsx:
--------------------------------------------------------------------------------
1 | import Pagination from './Pagination';
2 | import { Spinner } from '../../components';
3 | import ForecastTable from './ForecastTable';
4 | import { useParams } from 'react-router-dom';
5 | import { useEffect, type FunctionComponent } from 'react';
6 | import { useAppSelector, useAppDispatch } from '../../store';
7 | import { getForecastsAsync, type WeatherForecast } from '../../store/weatherSlice';
8 |
9 | const FetchData: FunctionComponent = () => {
10 | const dispatch = useAppDispatch();
11 | const { startDateIndex: startDateIndexDefault = '0' } = useParams();
12 | const intNextStartDateIndex = parseInt(startDateIndexDefault, 10);
13 | const isLoading = useAppSelector((state) => state.weather.isLoading);
14 | const forecasts = useAppSelector((state) => state.weather.forecasts);
15 | const startDateIndex = useAppSelector((state) => state.weather.startDateIndex);
16 |
17 | useEffect(() => {
18 | if (startDateIndex !== intNextStartDateIndex) {
19 | dispatch(getForecastsAsync(intNextStartDateIndex));
20 | }
21 | }, [dispatch, startDateIndex, intNextStartDateIndex]);
22 |
23 | return (
24 |
25 |
26 |
27 | Fetch Data
28 |
29 |
30 |
31 | Weather forecast
32 |
33 |
34 | This component demonstrates fetching data from the server and working with URL parameters.
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | );
43 | };
44 |
45 | export default FetchData;
46 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/containers/Form/CheckboxFormGroup.tsx:
--------------------------------------------------------------------------------
1 | import { Checkbox } from '../../components';
2 | import { setChecked } from '../../store/formSlice';
3 | import { useCallback, type FunctionComponent } from 'react';
4 | import { useAppSelector, useAppDispatch } from '../../store';
5 |
6 | const CheckboxFormGroup: FunctionComponent = () => {
7 | const dispatch = useAppDispatch();
8 | const checked = useAppSelector((state) => state.form.checked);
9 |
10 | const handleOnCheck = useCallback((checked: boolean) => {
11 | dispatch(setChecked(checked));
12 | }, [dispatch]);
13 |
14 | return (
15 |
16 |
Checkbox
17 |
Toggle the checkbox
18 |
19 |
23 |
24 |
25 | Value: {checked.toString()}
26 |
27 |
28 | );
29 | };
30 |
31 | export default CheckboxFormGroup;
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/containers/Form/CounterFormGroup.tsx:
--------------------------------------------------------------------------------
1 | import type { FunctionComponent } from 'react';
2 | import { increment, decrement } from '../../store/formSlice';
3 | import { useAppSelector, useAppDispatch } from '../../store';
4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
5 |
6 | const CounterFormGroup: FunctionComponent = () => {
7 | const dispatch = useAppDispatch();
8 | const count = useAppSelector((state) => state.form.count);
9 |
10 | return (
11 |
12 |
Counter
13 |
Use buttons to increment/decrement
14 |
15 | dispatch(decrement())}
18 | >
19 |
20 |
21 | dispatch(increment())}
24 | >
25 |
26 |
27 |
28 |
29 | Value: {count}
30 |
31 |
32 | );
33 | };
34 |
35 | export default CounterFormGroup;
36 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/containers/Form/SelectFormGroup.tsx:
--------------------------------------------------------------------------------
1 | import { Select } from 'react-functional-select';
2 | import { useCallback, type FunctionComponent } from 'react';
3 | import { useAppSelector, useAppDispatch } from '../../store';
4 | import { THEME_CONFIG, DROPDOWN_TEST_DATA } from '../../config';
5 | import { selectOption, type SelectOption } from '../../store/formSlice';
6 |
7 | const SelectFormGroup: FunctionComponent = () => {
8 | const dispatch = useAppDispatch();
9 | const selectedOption = useAppSelector((state) => state.form.selectedOption);
10 |
11 | const onOptionChange = useCallback((option: SelectOption) => {
12 | dispatch(selectOption(option));
13 | }, [dispatch]);
14 |
15 | return (
16 |
17 |
Dropdown
18 |
Select options from the dropdown
19 |
20 |
26 |
27 |
28 | Value: {selectedOption?.label}
29 |
30 |
31 | );
32 | };
33 |
34 | export default SelectFormGroup;
35 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/containers/Form/index.tsx:
--------------------------------------------------------------------------------
1 | import type { FunctionComponent } from 'react';
2 | import SelectFormGroup from './SelectFormGroup';
3 | import CounterFormGroup from './CounterFormGroup';
4 | import CheckboxFormGroup from './CheckboxFormGroup';
5 |
6 | const Form: FunctionComponent = () => (
7 |
8 |
9 |
Form Controls
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | );
20 |
21 | export default Form;
22 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/containers/Login/LoginControls.tsx:
--------------------------------------------------------------------------------
1 | import { memo, Fragment } from 'react';
2 | import { Checkbox } from '../../components';
3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
4 |
5 | type LoginControlsProps = Readonly<{
6 | rememberMe: boolean;
7 | handleRememberMeCheck: (checked: boolean) => void;
8 | }>;
9 |
10 | const LoginControls = memo(({
11 | rememberMe,
12 | handleRememberMeCheck
13 | }) => (
14 |
15 |
16 |
21 |
22 |
26 | Login
27 |
28 |
29 |
30 |
31 |
32 | ));
33 |
34 | LoginControls.displayName = 'LoginControls';
35 |
36 | export default LoginControls;
37 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/containers/Login/PasswordInput.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { classNames } from '../../utils';
3 | import { useTextInput } from '../../hooks';
4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
5 |
6 | type PasswordInputProps = Readonly<{
7 | showPassword: boolean;
8 | isInputInvalid: boolean;
9 | toggleShowPassword: () => void;
10 | textInput: ReturnType;
11 | }>;
12 |
13 | const PasswordInput = memo(({
14 | textInput,
15 | showPassword,
16 | isInputInvalid,
17 | toggleShowPassword
18 | }) => {
19 | const { hasValue, bindToInput } = textInput;
20 |
21 | const className = classNames([
22 | 'input',
23 | 'is-medium',
24 | (isInputInvalid && !hasValue) && 'is-danger'
25 | ]);
26 |
27 | return (
28 |
29 |
30 |
36 |
37 |
38 |
39 |
44 |
45 |
46 |
47 |
48 | );
49 | });
50 |
51 | PasswordInput.displayName = 'PasswordInput';
52 |
53 | export default PasswordInput;
54 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/containers/Login/UserNameInput.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { classNames } from '../../utils';
3 | import { useTextInput } from '../../hooks';
4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
5 |
6 | type UserNameInputProps = Readonly<{
7 | isInputInvalid: boolean;
8 | textInput: ReturnType;
9 | }>;
10 |
11 | const UserNameInput = memo(({
12 | textInput,
13 | isInputInvalid
14 | }) => {
15 | const { hasValue, bindToInput } = textInput;
16 |
17 | const className = classNames([
18 | 'input',
19 | 'is-medium',
20 | (isInputInvalid && !hasValue) && 'is-danger'
21 | ]);
22 |
23 | return (
24 |
25 |
26 |
32 |
33 |
34 |
35 |
36 |
37 | );
38 | });
39 |
40 | UserNameInput.displayName = 'UserNameInput';
41 |
42 | export default UserNameInput;
43 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/containers/Login/index.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useState, useRef, type FormEvent, type FunctionComponent } from 'react';
2 | import { Routes } from '../../config';
3 | import { useTextInput } from '../../hooks';
4 | import LoginControls from './LoginControls';
5 | import UserNameInput from './UserNameInput';
6 | import PasswordInput from './PasswordInput';
7 | import { useNavigate } from 'react-router-dom';
8 | import { toast, type Id } from 'react-toastify';
9 | import { Authenticator } from '../../components';
10 | import { useAppDispatch, useAppSelector } from '../../store';
11 | import BasedGhostLogoPNG from '../../assets/image/based-ghost-main.png';
12 | import { loginAsync, setAuthStatus, resetState, AuthStatusEnum, type Credentials } from '../../store/authSlice';
13 |
14 | const Login: FunctionComponent = () => {
15 | const toastIdRef = useRef('');
16 | const [rememberMe, setRememberMe] = useState(false);
17 | const [showPassword, setShowPassword] = useState(false);
18 | const [isInputInvalid, setIsInputInvalid] = useState(false);
19 |
20 | const userNameInput = useTextInput('');
21 | const passwordInput = useTextInput('', showPassword ? 'text' : 'password');
22 |
23 | // react-redux hooks state/actions
24 | const navigate = useNavigate();
25 | const dispatch = useAppDispatch();
26 | const status = useAppSelector((state) => state.auth.status);
27 |
28 | const dispatchAuthStatus = useCallback((status: AuthStatusEnum): void => {
29 | dispatch(setAuthStatus(status));
30 | }, [dispatch]);
31 |
32 | const onFailedAuth = useCallback((): void => {
33 | dispatchAuthStatus(AuthStatusEnum.NONE);
34 | dispatch(resetState());
35 | }, [dispatch, dispatchAuthStatus]);
36 |
37 | const onSuccessfulAuth = useCallback((): void => {
38 | const homePath = Routes.find((x) => x.name === 'Home')?.path ?? '/';
39 | navigate(homePath);
40 | }, [navigate]);
41 |
42 | const onRememberMeCheck = useCallback((checked: boolean): void => setRememberMe(checked), []);
43 | const onToggleShowPassword = useCallback((): void => setShowPassword((prevShow: boolean) => !prevShow), []);
44 |
45 | const handleLogin = (e: FormEvent): void => {
46 | e.preventDefault();
47 | if (status === AuthStatusEnum.PROCESS) {
48 | return;
49 | }
50 |
51 | if (!userNameInput.hasValue || !passwordInput.hasValue) {
52 | // Run invalidInputs error and display toast notification (if one is not already active)
53 | setIsInputInvalid(true);
54 | if (!toast.isActive(toastIdRef.current)) {
55 | toastIdRef.current = toast.error('Enter user name/password');
56 | }
57 | } else {
58 | // Clear any toast notifications and prepare state for Login request stub / run login request stub
59 | toast.dismiss();
60 | setIsInputInvalid(false);
61 | dispatchAuthStatus(AuthStatusEnum.PROCESS);
62 |
63 | setTimeout(() => {
64 | const credentials: Credentials = {
65 | rememberMe,
66 | userName: userNameInput.value,
67 | password: passwordInput.value,
68 | };
69 |
70 | dispatch(loginAsync(credentials));
71 | }, 2000);
72 | }
73 | };
74 |
75 | return (
76 |
77 |
78 |
79 |
Login
80 |
Please login to proceed
81 |
82 |
89 |
105 |
110 |
111 |
112 |
113 |
114 | );
115 | };
116 |
117 | export default Login;
118 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/containers/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Form } from './Form';
2 | export { default as Login } from './Login';
3 | export { default as Dashboard } from './Dashboard';
4 | export { default as FetchData } from './FetchData';
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export { default as useTextInput } from './useTextInput';
2 | export { default as useIsLoggedIn } from './useIsLoggedIn';
3 | export { default as useOnClickOutside } from './useOnClickOutside';
4 | export { default as useCSSTransitionProps } from './useCSSTransitionProps';
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/hooks/useCSSTransitionProps.ts:
--------------------------------------------------------------------------------
1 | import { useLocation } from 'react-router-dom';
2 | import { Routes, TRANSITION_DEFAULT, type Transition } from '../config';
3 |
4 | export type CSSTransitionProps = Transition & { readonly key: string };
5 |
6 | const useCSSTransitionProps = (): CSSTransitionProps => {
7 | const { pathname } = useLocation();
8 | const key = pathname.split('/', 2).join('/');
9 | const route = Routes.find((r) => r.path.startsWith(key));
10 | const transition = route?.transition ?? TRANSITION_DEFAULT;
11 |
12 | return { key, ...transition };
13 | };
14 |
15 | export default useCSSTransitionProps;
16 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/hooks/useIsLoggedIn.ts:
--------------------------------------------------------------------------------
1 | import { useAppSelector } from '../store';
2 | import { useLocation } from 'react-router-dom';
3 |
4 | const useIsLoggedIn = (): boolean => {
5 | const { pathname } = useLocation();
6 | const isAuthenticated = useAppSelector((state) => state.auth.isAuthenticated);
7 | return isAuthenticated && (pathname !== '/');
8 | };
9 |
10 | export default useIsLoggedIn;
11 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/hooks/useOnClickOutside.ts:
--------------------------------------------------------------------------------
1 | import { useRef, useEffect, type RefObject } from 'react';
2 |
3 | // Events to addEventListener for if 'events' param not specified
4 | const DEFAULT_EVENTS = ['mousedown', 'touchstart'];
5 |
6 | const useOnClickOutside = (
7 | ref: RefObject,
8 | callback: (...args: any[]) => any,
9 | events: string[] = DEFAULT_EVENTS
10 | ): void => {
11 | const callbackRef = useRef(callback);
12 |
13 | useEffect(() => {
14 | callbackRef.current = callback;
15 | });
16 |
17 | useEffect(() => {
18 | const onClickHandler = (e: Event) => {
19 | if (!ref.current?.contains(e.target as Node)) {
20 | callbackRef.current(e);
21 | }
22 | };
23 |
24 | events.forEach((e) => document.addEventListener(e, onClickHandler));
25 |
26 | return () => {
27 | events.forEach((e) => document.removeEventListener(e, onClickHandler));
28 | };
29 | }, [ref, events]);
30 | };
31 |
32 | export default useOnClickOutside;
33 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/hooks/useTextInput.ts:
--------------------------------------------------------------------------------
1 | import { useState, useCallback, useMemo, type ChangeEvent } from 'react';
2 |
3 | type InputType = 'text' | 'password';
4 |
5 | const useTextInput = (
6 | initial: string = '',
7 | type: InputType = 'text'
8 | ) => {
9 | const [value, setValue] = useState(initial);
10 | const clear = useCallback(() => setValue(''), []);
11 | const onChange = useCallback((e: ChangeEvent) => setValue(e.currentTarget.value), []);
12 |
13 | return useMemo(() => ({
14 | value,
15 | clear,
16 | hasValue: !!value?.trim(),
17 | bindToInput: {
18 | type,
19 | value,
20 | onChange
21 | }
22 | }), [value, type, onChange, clear]);
23 | };
24 |
25 | export default useTextInput;
26 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/index.tsx:
--------------------------------------------------------------------------------
1 | import { Provider } from 'react-redux';
2 | import { createRoot } from 'react-dom/client';
3 | import { BrowserRouter } from 'react-router-dom';
4 | import { useEffect, StrictMode, Fragment } from 'react';
5 | import App from './App';
6 | import './assets/style/scss/site.scss';
7 | import { store } from './store';
8 | import { ToastContainer } from 'react-toastify';
9 | import reportWebVitals from './reportWebVitals';
10 | import { SignalRApi } from './api/signalr.service';
11 | import { toastifyProps, registerIcons } from './config';
12 | import * as serviceWorkerRegistration from './serviceWorkerRegistration';
13 |
14 | registerIcons();
15 |
16 | const container = document.getElementById('root');
17 | const root = createRoot(container as HTMLElement);
18 |
19 | function AppRenderer() {
20 | useEffect(() => {
21 | setTimeout(() => {
22 | SignalRApi.startConnection();
23 | }, 250);
24 | }, []);
25 |
26 | return (
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | );
38 | }
39 |
40 | root.render( );
41 |
42 | // If you want your app to work offline and load faster, you can change
43 | // unregister() to register() below. Note this comes with some pitfalls.
44 | // Learn more about service workers: https://cra.link/PWA
45 | serviceWorkerRegistration.unregister();
46 |
47 | // If you want to start measuring performance in your app, pass a function
48 | // to log results (for example: reportWebVitals(console.log))
49 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
50 | reportWebVitals();
51 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/reportWebVitals.ts:
--------------------------------------------------------------------------------
1 | import type { ReportHandler } from 'web-vitals';
2 |
3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => {
4 | if (onPerfEntry && onPerfEntry instanceof Function) {
5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
6 | getCLS(onPerfEntry);
7 | getFID(onPerfEntry);
8 | getFCP(onPerfEntry);
9 | getLCP(onPerfEntry);
10 | getTTFB(onPerfEntry);
11 | });
12 | }
13 | };
14 |
15 | export default reportWebVitals;
16 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/service-worker.ts:
--------------------------------------------------------------------------------
1 | ///
2 | /* eslint-disable no-restricted-globals */
3 |
4 | // This service worker can be customized!
5 | // See https://developers.google.com/web/tools/workbox/modules
6 | // for the list of available Workbox modules, or add any other
7 | // code you'd like.
8 | // You can also remove this file if you'd prefer not to use a
9 | // service worker, and the Workbox build step will be skipped.
10 |
11 | import { clientsClaim } from 'workbox-core';
12 | import { ExpirationPlugin } from 'workbox-expiration';
13 | import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
14 | import { registerRoute } from 'workbox-routing';
15 | import { StaleWhileRevalidate } from 'workbox-strategies';
16 |
17 | declare const self: ServiceWorkerGlobalScope;
18 |
19 | clientsClaim();
20 |
21 | // Precache all of the assets generated by your build process.
22 | // Their URLs are injected into the manifest variable below.
23 | // This variable must be present somewhere in your service worker file,
24 | // even if you decide not to use precaching. See https://cra.link/PWA
25 | precacheAndRoute(self.__WB_MANIFEST);
26 |
27 | // Set up App Shell-style routing, so that all navigation requests
28 | // are fulfilled with your index.html shell. Learn more at
29 | // https://developers.google.com/web/fundamentals/architecture/app-shell
30 | const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$');
31 | registerRoute(
32 | // Return false to exempt requests from being fulfilled by index.html.
33 | ({ request, url }: { request: Request; url: URL }) => {
34 | // If this isn't a navigation, skip.
35 | if (request.mode !== 'navigate') {
36 | return false;
37 | }
38 |
39 | // If this is a URL that starts with /_, skip.
40 | if (url.pathname.startsWith('/_')) {
41 | return false;
42 | }
43 |
44 | // If this looks like a URL for a resource, because it contains
45 | // a file extension, skip.
46 | if (url.pathname.match(fileExtensionRegexp)) {
47 | return false;
48 | }
49 |
50 | // Return true to signal that we want to use the handler.
51 | return true;
52 | },
53 | createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')
54 | );
55 |
56 | // An example runtime caching route for requests that aren't handled by the
57 | // precache, in this case same-origin .png requests like those from in public/
58 | registerRoute(
59 | // Add in any other file extensions or routing criteria as needed.
60 | ({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'),
61 | // Customize this strategy as needed, e.g., by changing to CacheFirst.
62 | new StaleWhileRevalidate({
63 | cacheName: 'images',
64 | plugins: [
65 | // Ensure that once this runtime cache reaches a maximum size the
66 | // least-recently used images are removed.
67 | new ExpirationPlugin({ maxEntries: 50 }),
68 | ],
69 | })
70 | );
71 |
72 | // This allows the web app to trigger skipWaiting via
73 | // registration.waiting.postMessage({type: 'SKIP_WAITING'})
74 | self.addEventListener('message', (event) => {
75 | if (event.data && event.data.type === 'SKIP_WAITING') {
76 | self.skipWaiting();
77 | }
78 | });
79 |
80 | // Any other custom service worker logic can go here.
81 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/serviceWorkerRegistration.ts:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read https://cra.link/PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === 'localhost' ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === '[::1]' ||
17 | // 127.0.0.0/8 are considered localhost for IPv4.
18 | window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)
19 | );
20 |
21 | type Config = {
22 | onSuccess?: (registration: ServiceWorkerRegistration) => void;
23 | onUpdate?: (registration: ServiceWorkerRegistration) => void;
24 | };
25 |
26 | export function register(config?: Config) {
27 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
28 | // The URL constructor is available in all browsers that support SW.
29 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
30 | if (publicUrl.origin !== window.location.origin) {
31 | // Our service worker won't work if PUBLIC_URL is on a different origin
32 | // from what our page is served on. This might happen if a CDN is used to
33 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
34 | return;
35 | }
36 |
37 | window.addEventListener('load', () => {
38 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
39 |
40 | if (isLocalhost) {
41 | // This is running on localhost. Let's check if a service worker still exists or not.
42 | checkValidServiceWorker(swUrl, config);
43 |
44 | // Add some additional logging to localhost, pointing developers to the
45 | // service worker/PWA documentation.
46 | navigator.serviceWorker.ready.then(() => {
47 | console.log(
48 | 'This web app is being served cache-first by a service ' +
49 | 'worker. To learn more, visit https://cra.link/PWA'
50 | );
51 | });
52 | } else {
53 | // Is not localhost. Just register service worker
54 | registerValidSW(swUrl, config);
55 | }
56 | });
57 | }
58 | }
59 |
60 | function registerValidSW(swUrl: string, config?: Config) {
61 | navigator.serviceWorker
62 | .register(swUrl)
63 | .then((registration) => {
64 | registration.onupdatefound = () => {
65 | const installingWorker = registration.installing;
66 | if (installingWorker == null) {
67 | return;
68 | }
69 | installingWorker.onstatechange = () => {
70 | if (installingWorker.state === 'installed') {
71 | if (navigator.serviceWorker.controller) {
72 | // At this point, the updated precached content has been fetched,
73 | // but the previous service worker will still serve the older
74 | // content until all client tabs are closed.
75 | console.log(
76 | 'New content is available and will be used when all ' +
77 | 'tabs for this page are closed. See https://cra.link/PWA.'
78 | );
79 |
80 | // Execute callback
81 | if (config && config.onUpdate) {
82 | config.onUpdate(registration);
83 | }
84 | } else {
85 | // At this point, everything has been precached.
86 | // It's the perfect time to display a
87 | // "Content is cached for offline use." message.
88 | console.log('Content is cached for offline use.');
89 |
90 | // Execute callback
91 | if (config && config.onSuccess) {
92 | config.onSuccess(registration);
93 | }
94 | }
95 | }
96 | };
97 | };
98 | })
99 | .catch((error) => {
100 | console.error('Error during service worker registration:', error);
101 | });
102 | }
103 |
104 | function checkValidServiceWorker(swUrl: string, config?: Config) {
105 | // Check if the service worker can be found. If it can't reload the page.
106 | fetch(swUrl, {
107 | headers: { 'Service-Worker': 'script' },
108 | })
109 | .then((response) => {
110 | // Ensure service worker exists, and that we really are getting a JS file.
111 | const contentType = response.headers.get('content-type');
112 | if (
113 | response.status === 404 ||
114 | (contentType != null && contentType.indexOf('javascript') === -1)
115 | ) {
116 | // No service worker found. Probably a different app. Reload the page.
117 | navigator.serviceWorker.ready.then((registration) => {
118 | registration.unregister().then(() => {
119 | window.location.reload();
120 | });
121 | });
122 | } else {
123 | // Service worker found. Proceed as normal.
124 | registerValidSW(swUrl, config);
125 | }
126 | })
127 | .catch(() => {
128 | console.log('No internet connection found. App is running in offline mode.');
129 | });
130 | }
131 |
132 | export function unregister() {
133 | if ('serviceWorker' in navigator) {
134 | navigator.serviceWorker.ready
135 | .then((registration) => {
136 | registration.unregister();
137 | })
138 | .catch((error) => {
139 | console.error(error.message);
140 | });
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/store/authSlice.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-redeclare */
2 | import { AuthApi } from 'src/api';
3 | import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit';
4 |
5 | export const AuthStatusEnum = {
6 | FAIL: 'fail',
7 | NONE: 'none',
8 | PROCESS: 'process',
9 | SUCCESS: 'success'
10 | } as const;
11 |
12 | export type AuthStatusEnum = typeof AuthStatusEnum[keyof typeof AuthStatusEnum];
13 |
14 | export type Credentials = {
15 | userName?: string;
16 | password?: string;
17 | rememberMe?: boolean;
18 | };
19 |
20 | export type AuthUser = {
21 | token?: string;
22 | userName?: string;
23 | status: AuthStatusEnum;
24 | };
25 |
26 | export type AuthState = AuthUser & { isAuthenticated: boolean; };
27 |
28 | const initialState: AuthState = {
29 | token: '',
30 | userName: '',
31 | isAuthenticated: false,
32 | status: AuthStatusEnum.NONE
33 | };
34 |
35 | const replaceState = (
36 | state: AuthState,
37 | { status, token, userName, isAuthenticated }: AuthState
38 | ) => {
39 | state.token = token;
40 | state.status = status;
41 | state.userName = userName;
42 | state.isAuthenticated = isAuthenticated;
43 | };
44 |
45 | export const authSlice = createSlice({
46 | name: 'auth',
47 | initialState,
48 | reducers: {
49 | setAuthStatus: (state, action: PayloadAction) => {
50 | state.status = action.payload;
51 | },
52 | setUserLogin: (state, action: PayloadAction) => {
53 | replaceState(state, action.payload);
54 | },
55 | resetState: (state) => {
56 | replaceState(state, initialState);
57 | }
58 | }
59 | });
60 |
61 | export const loginAsync = createAsyncThunk(
62 | 'auth/loginAsync',
63 | async (credentials: Credentials, { dispatch }) => {
64 | try {
65 | const authUser = await AuthApi.loginAsync(credentials);
66 | const payload = { ...authUser, isAuthenticated: true };
67 | dispatch(setUserLogin(payload));
68 | } catch (e) {
69 | dispatch(setAuthStatus(AuthStatusEnum.FAIL));
70 | }
71 | }
72 | );
73 |
74 | export const { setAuthStatus, setUserLogin, resetState } = authSlice.actions;
75 |
76 | export default authSlice.reducer;
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/store/configureStore.ts:
--------------------------------------------------------------------------------
1 | import authReducer from './authSlice';
2 | import formReducer from './formSlice';
3 | import weatherReducer from './weatherSlice';
4 | import { configureStore } from '@reduxjs/toolkit'
5 |
6 | export const store = configureStore({
7 | reducer: {
8 | auth: authReducer,
9 | form: formReducer,
10 | weather: weatherReducer
11 | }
12 | });
13 |
14 | // Infer the `RootState` and `AppDispatch` types from the store itself
15 | export type RootState = ReturnType;
16 |
17 | // Inferred type: {auth: AuthState, form: FormState, weather: WeatherState}
18 | export type AppDispatch = typeof store.dispatch;
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/store/formSlice.ts:
--------------------------------------------------------------------------------
1 | import { DROPDOWN_TEST_DATA } from '../config';
2 | import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
3 |
4 | export type SelectOption = Readonly<{
5 | value: number;
6 | label: string;
7 | }>;
8 |
9 | export type FormState = Readonly<{
10 | count: number;
11 | checked: boolean;
12 | selectedOption: SelectOption;
13 | }>;
14 |
15 | const initialState: FormState = {
16 | count: 0,
17 | checked: false,
18 | selectedOption: DROPDOWN_TEST_DATA[0]
19 | };
20 |
21 | export const formSlice = createSlice({
22 | name: 'form',
23 | initialState,
24 | reducers: {
25 | increment: (state) => {
26 | state.count += 1;
27 | },
28 | decrement: (state) => {
29 | state.count -= 1;
30 | },
31 | setChecked: (state, action: PayloadAction) => {
32 | state.checked = action.payload;
33 | },
34 | selectOption: (state, action: PayloadAction) => {
35 | state.selectedOption = action.payload;
36 | },
37 | },
38 | });
39 |
40 | export const { increment, decrement, setChecked, selectOption } = formSlice.actions;
41 |
42 | export default formSlice.reducer;
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/store/hooks.ts:
--------------------------------------------------------------------------------
1 | import type { RootState, AppDispatch } from './configureStore';
2 | import { useDispatch, useSelector, type TypedUseSelectorHook } from 'react-redux';
3 |
4 | // Use throughout your app instead of plain `useDispatch` and `useSelector`
5 | export const useAppDispatch = () => useDispatch();
6 | export const useAppSelector: TypedUseSelectorHook = useSelector;
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/store/index.ts:
--------------------------------------------------------------------------------
1 | export { useAppDispatch, useAppSelector } from './hooks';
2 | export { store, type RootState, type AppDispatch } from './configureStore';
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/store/weatherSlice.ts:
--------------------------------------------------------------------------------
1 | import { SampleApi } from 'src/api';
2 | import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit';
3 |
4 | export type WeatherForecast = Readonly<{
5 | id: number;
6 | summary: string;
7 | temperatureC: number;
8 | temperatureF: number;
9 | dateFormatted: string;
10 | }>;
11 |
12 | export type WeatherState = Readonly<{
13 | isLoading: boolean;
14 | startDateIndex: number;
15 | forecasts: WeatherForecast[];
16 | }>;
17 |
18 | export type ReceiveForecastsPayload = Pick;
19 |
20 | const initialState: WeatherState = {
21 | forecasts: [],
22 | isLoading: false,
23 | startDateIndex: 5
24 | };
25 |
26 | export const weatherSlice = createSlice({
27 | name: 'weather',
28 | initialState,
29 | reducers: {
30 | requestForecasts: (state, action: PayloadAction) => {
31 | state.isLoading = true;
32 | state.startDateIndex = action.payload;
33 | },
34 | receiveForecasts: (state, action: PayloadAction) => {
35 | const { forecasts, startDateIndex } = action.payload;
36 | if (startDateIndex === state.startDateIndex) {
37 | // Only accept the incoming data if it matches the most recent request.
38 | // This ensures we correctly handle out-of-order responses.
39 | state.isLoading = false;
40 | state.forecasts = forecasts;
41 | state.startDateIndex = startDateIndex;
42 | }
43 | }
44 | }
45 | });
46 |
47 | export const getForecastsAsync = createAsyncThunk(
48 | 'weather/getForecastsAsync',
49 | async (startDateIndex: number, { dispatch, getState }) => {
50 | // If param startDateIndex === state.startDateIndex, do not perform action
51 | const { startDateIndex: stateIdx } = (getState as () => WeatherState)();
52 | if (startDateIndex === stateIdx) {
53 | return;
54 | }
55 |
56 | // Dispatch request to intialize loading phase
57 | dispatch(requestForecasts(startDateIndex));
58 |
59 | // Build http request and success handler in Promise wrapper / complete processing
60 | try {
61 | const forecasts = await SampleApi.getForecastsAsync(startDateIndex);
62 | const payload = { forecasts, startDateIndex };
63 | dispatch(receiveForecasts(payload));
64 | } catch (e) {
65 | console.error(e);
66 | }
67 | }
68 | );
69 |
70 | export const { requestForecasts, receiveForecasts } = weatherSlice.actions;
71 |
72 | export default weatherSlice.reducer;
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/utils/classNames.ts:
--------------------------------------------------------------------------------
1 | export const classNames = (cls: any[]): string => cls.filter(Boolean).join(' ');
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export { classNames } from './classNames';
2 | export { isArrayWithLength } from './isArrayWithLength';
--------------------------------------------------------------------------------
/GhostUI/ClientApp/src/utils/isArrayWithLength.ts:
--------------------------------------------------------------------------------
1 | export const isArrayWithLength = (val: unknown): boolean => Array.isArray(val) && !!val.length;
--------------------------------------------------------------------------------
/GhostUI/ClientApp/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "target": "esnext",
5 | "module": "esnext",
6 | "jsx": "react-jsx",
7 | "moduleResolution": "node",
8 | "ignoreDeprecations": "5.0",
9 | "strict": true,
10 | "noEmit": true,
11 | "sourceMap": true,
12 | "skipLibCheck": true,
13 | "importHelpers": true,
14 | "isolatedModules": true,
15 | "esModuleInterop": true,
16 | "resolveJsonModule": true,
17 | "preserveValueImports": true,
18 | "noFallthroughCasesInSwitch": true,
19 | "allowSyntheticDefaultImports": true,
20 | "forceConsistentCasingInFileNames": true,
21 | "lib": [
22 | "dom",
23 | "dom.iterable",
24 | "esnext"
25 | ]
26 | },
27 | "include": [
28 | "src"
29 | ],
30 | "exclude": [
31 | "node_modules"
32 | ]
33 | }
34 |
--------------------------------------------------------------------------------
/GhostUI/Controllers/AuthController.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using GhostUI.Hubs;
3 | using GhostUI.Models;
4 | using System.Threading.Tasks;
5 | using Microsoft.AspNetCore.Mvc;
6 | using Microsoft.AspNetCore.Http;
7 | using Microsoft.AspNetCore.SignalR;
8 |
9 | namespace GhostUI.Controllers
10 | {
11 | [ApiController]
12 | [Route("api/[controller]/[action]")]
13 | public class AuthController : ControllerBase
14 | {
15 | private readonly IHubContext _hubContext;
16 |
17 | public AuthController(IHubContext usersHub)
18 | {
19 | _hubContext = usersHub;
20 | }
21 |
22 | [HttpPost]
23 | [ProducesResponseType(typeof(AuthUser), StatusCodes.Status200OK)]
24 | public async Task Login([FromBody]Credentials request)
25 | {
26 | await _hubContext.Clients.All.SendAsync("UserLogin");
27 |
28 | var token = Guid.NewGuid().ToString();
29 | var authUser = new AuthUser("success", token, request?.UserName ?? "");
30 |
31 | return Ok(authUser);
32 | }
33 |
34 | [HttpPost]
35 | [ProducesResponseType(StatusCodes.Status200OK)]
36 | public async Task Logout()
37 | {
38 | await _hubContext.Clients.All.SendAsync("UserLogout");
39 | return Ok();
40 | }
41 | }
42 | }
--------------------------------------------------------------------------------
/GhostUI/Controllers/SampleDataController.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Linq;
3 | using GhostUI.Models;
4 | using Microsoft.AspNetCore.Mvc;
5 | using System.Collections.Generic;
6 | using System.Collections.Immutable;
7 |
8 | namespace GhostUI.Controllers
9 | {
10 | [ApiController]
11 | [Route("api/[controller]/[action]")]
12 | public class SampleDataController : ControllerBase
13 | {
14 | public static readonly ImmutableArray Summaries = ImmutableArray.Create(new[]
15 | {
16 | "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
17 | });
18 |
19 | [HttpGet]
20 | public IEnumerable WeatherForecasts(int startDateIndex)
21 | {
22 | var rng = new Random();
23 |
24 | return Enumerable.Range(1, 5).Select(index => new WeatherForecast
25 | {
26 | TemperatureC = rng.Next(-20, 55),
27 | Summary = Summaries[rng.Next(Summaries.Length)],
28 | DateFormatted = DateTime.Now.AddDays(index + startDateIndex).ToString("d")
29 | })
30 | .ToArray();
31 | }
32 | }
33 | }
--------------------------------------------------------------------------------
/GhostUI/Extensions/ExceptionHandlerExtensions.cs:
--------------------------------------------------------------------------------
1 | using System.Net;
2 | using GhostUI.Models;
3 | using Microsoft.AspNetCore.Http;
4 | using Microsoft.AspNetCore.Hosting;
5 | using Microsoft.Extensions.Hosting;
6 | using Microsoft.AspNetCore.Builder;
7 | using Microsoft.AspNetCore.Diagnostics;
8 |
9 | namespace GhostUI.Extensions
10 | {
11 | public static class ExceptionHandlerExtensions
12 | {
13 | public static IApplicationBuilder UseCustomExceptionHandler(this IApplicationBuilder app)
14 | {
15 | app.UseExceptionHandler(builder =>
16 | {
17 | builder.Run(async context =>
18 | {
19 | var error = context.Features.Get();
20 | var exDetails = new ExceptionDetails((int)HttpStatusCode.InternalServerError, error?.Error.Message ?? "");
21 |
22 | context.Response.ContentType = "application/json";
23 | context.Response.StatusCode = exDetails.StatusCode;
24 | context.Response.Headers.Add("Access-Control-Allow-Origin", "*");
25 | context.Response.Headers.Add("Application-Error", exDetails.Message);
26 | context.Response.Headers.Add("Access-Control-Expose-Headers", "Application-Error");
27 |
28 | await context.Response.WriteAsync(exDetails.ToString());
29 | });
30 | });
31 |
32 | return app;
33 | }
34 | }
35 | }
--------------------------------------------------------------------------------
/GhostUI/Extensions/HealthCheckBuilderExtensions.cs:
--------------------------------------------------------------------------------
1 | using System.Linq;
2 | using GhostUI.HealthChecks;
3 | using System.Collections.Generic;
4 | using Microsoft.Extensions.DependencyInjection;
5 | using Microsoft.Extensions.Diagnostics.HealthChecks;
6 |
7 | namespace GhostUI.Extensions
8 | {
9 | public static class HealthChecksBuilderExtensions
10 | {
11 | public static IHealthChecksBuilder AddGCInfoCheck(
12 | this IHealthChecksBuilder builder,
13 | string name,
14 | HealthStatus? failureStatus = null,
15 | IEnumerable? tags = null,
16 | long? thresholdInBytes = null)
17 | {
18 | builder.AddCheck(
19 | name,
20 | failureStatus ?? HealthStatus.Degraded,
21 | tags ?? Enumerable.Empty());
22 |
23 | if (thresholdInBytes.HasValue)
24 | builder.Services.Configure(name, options => options.Threshold = thresholdInBytes.Value);
25 |
26 | return builder;
27 | }
28 | }
29 | }
--------------------------------------------------------------------------------
/GhostUI/Extensions/ServiceCollectionExtensions.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.IO.Compression;
3 | using Microsoft.AspNetCore.Builder;
4 | using Microsoft.Extensions.Configuration;
5 | using Microsoft.Extensions.DependencyInjection;
6 | using Microsoft.AspNetCore.ResponseCompression;
7 |
8 | namespace GhostUI.Extensions
9 | {
10 | public static class ServiceCollectionExtensions
11 | {
12 | public static IServiceCollection AddCorsConfig(this IServiceCollection services, string name)
13 | {
14 | services.AddCors(c => c.AddPolicy(name,
15 | options => options.AllowAnyOrigin()
16 | .AllowAnyHeader()
17 | .AllowAnyMethod()));
18 |
19 | return services;
20 | }
21 |
22 | public static IServiceCollection AddResponseCompressionConfig(
23 | this IServiceCollection services,
24 | IConfiguration config,
25 | CompressionLevel compressionLvl = CompressionLevel.Fastest)
26 | {
27 | var enableForHttps = config.GetValue("Compression:EnableForHttps");
28 | var gzipMimeTypes = config.GetSection("Compression:MimeTypes").Get();
29 |
30 | services.AddResponseCompression(options => {
31 | options.Providers.Add();
32 | options.Providers.Add();
33 | options.EnableForHttps = enableForHttps;
34 | options.MimeTypes = gzipMimeTypes ?? Array.Empty();
35 | });
36 |
37 | services.Configure(options => options.Level = compressionLvl);
38 | services.Configure(options => options.Level = compressionLvl);
39 |
40 | return services;
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/GhostUI/GhostUI.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net7.0
5 | enable
6 | true
7 | Latest
8 | false
9 | ClientApp\
10 | $(DefaultItemExcludes);$(SpaRoot)node_modules\**
11 | GhostUI
12 | GhostUI
13 | False
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | all
30 | runtime; build; native; contentfiles; analyzers; buildtransitive
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 | %(DistFiles.Identity)
64 | PreserveNewest
65 |
66 |
67 |
68 |
69 |
70 |
--------------------------------------------------------------------------------
/GhostUI/HealthChecks/GCInfo/GCInfoHealthCheck.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Threading;
3 | using System.Threading.Tasks;
4 | using System.Collections.Generic;
5 | using Microsoft.Extensions.Options;
6 | using Microsoft.Extensions.Diagnostics.HealthChecks;
7 |
8 | namespace GhostUI.HealthChecks
9 | {
10 | public class GCInfoHealthCheck : IHealthCheck
11 | {
12 | private readonly IOptionsMonitor _options;
13 |
14 | public GCInfoHealthCheck(IOptionsMonitor options)
15 | {
16 | _options = options;
17 | }
18 |
19 | public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
20 | {
21 | // This example will report degraded status if the application is using more than the configured amount of memory (1gb by default).
22 | // Additionally, we include some GC info in the reported diagnostics.
23 | var options = _options.Get(context.Registration.Name);
24 | var allocated = GC.GetTotalMemory(forceFullCollection: false);
25 |
26 | var data = new Dictionary
27 | {
28 | { "Allocated", allocated },
29 | { "Gen0Collections", GC.CollectionCount(0) },
30 | { "Gen1Collections", GC.CollectionCount(1) },
31 | { "Gen2Collections", GC.CollectionCount(2) }
32 | };
33 |
34 | // Report failure if the allocated memory is >= the threshold.
35 | // Using context.Registration.FailureStatus means that the application developer can configure how they want failures to appear.
36 | var status = allocated >= options.Threshold
37 | ? context.Registration.FailureStatus
38 | : HealthStatus.Healthy;
39 |
40 | return Task.FromResult(new HealthCheckResult(
41 | status,
42 | description: "reports degraded status if allocated bytes >= 1gb",
43 | data: data));
44 | }
45 | }
46 | }
--------------------------------------------------------------------------------
/GhostUI/HealthChecks/GCInfo/GCInfoOptions.cs:
--------------------------------------------------------------------------------
1 | namespace GhostUI.HealthChecks
2 | {
3 | public class GCInfoOptions : IGCInfoOptions
4 | {
5 | public long Threshold { get; set; } = 1024L * 1024L * 1024L;
6 | }
7 | }
--------------------------------------------------------------------------------
/GhostUI/HealthChecks/GCInfo/IGCInfoOptions.cs:
--------------------------------------------------------------------------------
1 | namespace GhostUI.HealthChecks
2 | {
3 | public interface IGCInfoOptions
4 | {
5 | long Threshold { get; set; }
6 | }
7 | }
--------------------------------------------------------------------------------
/GhostUI/Hubs/IUsersHub.cs:
--------------------------------------------------------------------------------
1 | using System.Threading.Tasks;
2 |
3 | namespace GhostUI.Hubs
4 | {
5 | public interface IUsersHub
6 | {
7 | Task UserLogin();
8 | Task UserLogout();
9 | Task CloseAllConnections(string reason);
10 | }
11 | }
--------------------------------------------------------------------------------
/GhostUI/Hubs/UsersHub.cs:
--------------------------------------------------------------------------------
1 | using System.Threading.Tasks;
2 | using Microsoft.AspNetCore.SignalR;
3 |
4 | namespace GhostUI.Hubs
5 | {
6 | public class UsersHub : Hub
7 | {
8 | public async Task UserLogin() => await Clients.All.UserLogin();
9 | public async Task UserLogout() => await Clients.All.UserLogout();
10 | public async Task CloseAllConnections(string reason) => await Clients.All.CloseAllConnections(reason);
11 | }
12 | }
--------------------------------------------------------------------------------
/GhostUI/Models/AuthUser.cs:
--------------------------------------------------------------------------------
1 | namespace GhostUI.Models
2 | {
3 | public class AuthUser : IAuthUser
4 | {
5 | public string Status { get; }
6 | public string Token { get; }
7 | public string UserName { get; }
8 |
9 | public AuthUser(string status, string token, string userName)
10 | {
11 | Status = status;
12 | Token = token;
13 | UserName = userName;
14 | }
15 | }
16 | }
--------------------------------------------------------------------------------
/GhostUI/Models/Credentials.cs:
--------------------------------------------------------------------------------
1 | namespace GhostUI.Models
2 | {
3 | public class Credentials : ICredentials
4 | {
5 | public string? UserName { get; set; }
6 | public string? Password { get; set; }
7 | public bool RememberMe { get; set; }
8 | }
9 | }
--------------------------------------------------------------------------------
/GhostUI/Models/ExceptionDetails.cs:
--------------------------------------------------------------------------------
1 | using System.Text.Json;
2 |
3 | namespace GhostUI.Models
4 | {
5 | public class ExceptionDetails
6 | {
7 | public readonly int StatusCode;
8 | public readonly string Message;
9 |
10 | public ExceptionDetails(int statusCode, string message)
11 | {
12 | StatusCode = statusCode;
13 | Message = message ?? "No error message found in exception.";
14 | }
15 |
16 | public override string ToString() => JsonSerializer.Serialize(this);
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/GhostUI/Models/IAuthUser.cs:
--------------------------------------------------------------------------------
1 | namespace GhostUI.Models
2 | {
3 | public interface IAuthUser
4 | {
5 | string Status { get; }
6 | string Token { get; }
7 | string UserName { get; }
8 | }
9 | }
--------------------------------------------------------------------------------
/GhostUI/Models/ICredentials.cs:
--------------------------------------------------------------------------------
1 | namespace GhostUI.Models
2 | {
3 | public interface ICredentials
4 | {
5 | string? UserName { get; set; }
6 | string? Password { get; set; }
7 | bool RememberMe { get; set; }
8 | }
9 | }
--------------------------------------------------------------------------------
/GhostUI/Models/IWeatherForecast.cs:
--------------------------------------------------------------------------------
1 | namespace GhostUI.Models
2 | {
3 | public interface IWeatherForecast
4 | {
5 | int Id { get; }
6 | int TemperatureF { get; }
7 | int TemperatureC { get; set; }
8 | string? DateFormatted { get; set; }
9 | string? Summary { get; set; }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/GhostUI/Models/WeatherForecast.cs:
--------------------------------------------------------------------------------
1 | using System;
2 |
3 | namespace GhostUI.Models
4 | {
5 | public class WeatherForecast : IWeatherForecast
6 | {
7 | public int TemperatureC { get; set; }
8 | public string? DateFormatted { get; set; }
9 | public string? Summary { get; set; }
10 |
11 | public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
12 |
13 | public int Id => Convert.ToInt32(DateFormatted?.Replace("/", ""));
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/GhostUI/Pages/Error.cshtml:
--------------------------------------------------------------------------------
1 | @page
2 | @model ErrorModel
3 | @{
4 | ViewData["Title"] = "Error";
5 | }
6 |
7 | Error.
8 | An error occurred while processing your request.
9 |
10 | @if (Model.ShowRequestId)
11 | {
12 |
13 | Request ID: @Model.RequestId
14 |
15 | }
16 |
17 | Development Mode
18 |
19 | Swapping to the Development environment displays detailed information about the error that occurred.
20 |
21 |
22 | The Development environment shouldn't be enabled for deployed applications.
23 | It can result in displaying sensitive information from exceptions to end users.
24 | For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development
25 | and restarting the app.
26 |
27 |
--------------------------------------------------------------------------------
/GhostUI/Pages/Error.cshtml.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics;
2 | using Microsoft.AspNetCore.Mvc;
3 | using Microsoft.AspNetCore.Mvc.RazorPages;
4 |
5 | namespace GhostUI.Pages
6 | {
7 | [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
8 | public class ErrorModel : PageModel
9 | {
10 | public string? RequestId { get; set; }
11 |
12 | public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
13 |
14 | public void OnGet()
15 | {
16 | RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/GhostUI/Pages/_ViewImports.cshtml:
--------------------------------------------------------------------------------
1 | @namespace GhostUI.Pages
2 | @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
--------------------------------------------------------------------------------
/GhostUI/Program.cs:
--------------------------------------------------------------------------------
1 | using GhostUI.Hubs;
2 | using GhostUI.Extensions;
3 | using HealthChecks.UI.Client;
4 | using Microsoft.AspNetCore.Hosting;
5 | using Microsoft.Extensions.Hosting;
6 | using Microsoft.AspNetCore.Builder;
7 | using Microsoft.Extensions.DependencyInjection;
8 | using Microsoft.AspNetCore.Diagnostics.HealthChecks;
9 | using Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer;
10 |
11 | var spaSrcPath = "ClientApp";
12 | var corsPolicyName = "AllowAll";
13 | var builder = WebApplication.CreateBuilder(args);
14 |
15 | // Custom healthcheck example
16 | builder.Services.AddHealthChecks()
17 | .AddGCInfoCheck("GCInfo");
18 |
19 | // Write healthcheck custom results to healthchecks-ui (use InMemory for the DB - AspNetCore.HealthChecks.UI.InMemory.Storage nuget package)
20 | builder.Services.AddHealthChecksUI()
21 | .AddInMemoryStorage();
22 |
23 | builder.Services.AddCorsConfig(corsPolicyName);
24 | builder.Services.AddControllers();
25 | builder.Services.AddSignalR();
26 |
27 | // Add Brotli/Gzip response compression (prod only)
28 | builder.Services.AddResponseCompressionConfig(builder.Configuration);
29 |
30 | // Config change in asp.net core 3.0+ - 'Async' suffix in action names get stripped by default - so, to access them by full name with 'Async' part - opt out of this feature.
31 | builder.Services.AddMvc(opt => opt.SuppressAsyncSuffixInActionNames = false);
32 |
33 | // In production, the React files will be served from this directory
34 | builder.Services.AddSpaStaticFiles(opt => opt.RootPath = $"{spaSrcPath}/dist");
35 |
36 | // Register the Swagger services (using OpenApi 3.0)
37 | builder.Services.AddOpenApiDocument(settings =>
38 | {
39 | settings.Version = "v1";
40 | settings.Title = "GhostUI API";
41 | settings.Description = "Detailed Description of API";
42 | });
43 |
44 | var app = builder.Build();
45 |
46 | // If development, enable Hot Module Replacement
47 | // If production, enable Brotli/Gzip response compression & strict transport security headers
48 | if (app.Environment.IsDevelopment())
49 | {
50 | app.UseDeveloperExceptionPage();
51 | }
52 | else
53 | {
54 | app.UseResponseCompression();
55 | app.UseExceptionHandler("/Error");
56 | app.UseHsts();
57 | }
58 |
59 | app.UseCustomExceptionHandler();
60 | app.UseCors(corsPolicyName);
61 |
62 | // Show/write HealthReport data from healthchecks (AspNetCore.HealthChecks.UI.Client nuget package)
63 | app.UseHealthChecksUI();
64 | app.UseHealthChecks("/healthchecks-json", new HealthCheckOptions()
65 | {
66 | Predicate = _ => true,
67 | ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
68 | });
69 |
70 | // Register the Swagger generator and the Swagger UI middlewares
71 | // NSwage.MsBuild + adding automation config in GhostUI.csproj makes this part of the build step (updates to API will be handled automatically)
72 | app.UseOpenApi();
73 | app.UseSwaggerUi3();
74 | app.UseHttpsRedirection();
75 | app.UseRouting();
76 |
77 | // Map controllers / SignalR hubs
78 | app.UseEndpoints(endpoints =>
79 | {
80 | endpoints.MapControllers();
81 | endpoints.MapHub("/hubs/users");
82 | });
83 |
84 | // Killing .NET debug session does not kill spawned Node.js process (have to manually kill)
85 | app.UseSpa(spa =>
86 | {
87 | spa.Options.SourcePath = spaSrcPath;
88 |
89 | if (app.Environment.IsDevelopment())
90 | spa.UseReactDevelopmentServer(npmScript: "start");
91 | });
92 |
93 | app.Run();
--------------------------------------------------------------------------------
/GhostUI/Properties/launchSettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "iisSettings": {
3 | "windowsAuthentication": false,
4 | "anonymousAuthentication": true,
5 | "iisExpress": {
6 | "applicationUrl": "http://localhost:52530/",
7 | "sslPort": 0
8 | }
9 | },
10 | "profiles": {
11 | "IIS Express": {
12 | "commandName": "IISExpress",
13 | "launchBrowser": true,
14 | "environmentVariables": {
15 | "ASPNETCORE_ENVIRONMENT": "Development"
16 | }
17 | },
18 | "GhostUI": {
19 | "commandName": "Project",
20 | "launchBrowser": true,
21 | "environmentVariables": {
22 | "ASPNETCORE_ENVIRONMENT": "Development"
23 | },
24 | "applicationUrl": "https://localhost:5001;http://localhost:52530"
25 | }
26 | }
27 | }
--------------------------------------------------------------------------------
/GhostUI/appsettings.Development.json:
--------------------------------------------------------------------------------
1 | {
2 | "Logging": {
3 | "LogLevel": {
4 | "Default": "Information",
5 | "System": "Debug",
6 | "Microsoft": "Error"
7 | }
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/GhostUI/appsettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "Logging": {
3 | "LogLevel": {
4 | "Default": "Information",
5 | "System": "Debug",
6 | "Microsoft": "Error"
7 | }
8 | },
9 | "Compression": {
10 | "EnableForHttps": true,
11 | "MimeTypes": [
12 | "text/css",
13 | "text/xml",
14 | "text/html",
15 | "text/plain",
16 | "application/xml",
17 | "application/javascript"
18 | ]
19 | },
20 | "HealthChecksUI": {
21 | "HealthChecks": [
22 | {
23 | "Name": "HTTP-Api-Basic and UI",
24 | "Uri": "http://localhost:52530/healthchecks-json"
25 | }
26 | ],
27 | "Webhooks": [],
28 | "EvaluationTimeOnSeconds": 10,
29 | "MinimumSecondsBetweenFailureNotifications": 60
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/GhostUI/nswag.json:
--------------------------------------------------------------------------------
1 | {
2 | "runtime": "Net70",
3 | "defaultVariables": null,
4 | "documentGenerator": {
5 | "aspNetCoreToOpenApi": {
6 | "project": "GhostUI.csproj",
7 | "msBuildProjectExtensionsPath": null,
8 | "configuration": null,
9 | "runtime": null,
10 | "targetFramework": null,
11 | "noBuild": true,
12 | "verbose": true,
13 | "workingDirectory": null,
14 | "requireParametersWithoutDefault": false,
15 | "apiGroupNames": null,
16 | "defaultPropertyNameHandling": "Default",
17 | "defaultReferenceTypeNullHandling": "Null",
18 | "defaultDictionaryValueReferenceTypeNullHandling": "NotNull",
19 | "defaultResponseReferenceTypeNullHandling": "NotNull",
20 | "defaultEnumHandling": "Integer",
21 | "flattenInheritanceHierarchy": false,
22 | "generateKnownTypes": true,
23 | "generateEnumMappingDescription": false,
24 | "generateXmlObjects": false,
25 | "generateAbstractProperties": false,
26 | "generateAbstractSchemas": true,
27 | "ignoreObsoleteProperties": false,
28 | "allowReferencesWithProperties": false,
29 | "excludedTypeNames": [],
30 | "serviceHost": null,
31 | "serviceBasePath": null,
32 | "serviceSchemes": [],
33 | "infoTitle": "My Title",
34 | "infoDescription": null,
35 | "infoVersion": "1.0.0",
36 | "documentTemplate": null,
37 | "documentProcessorTypes": [],
38 | "operationProcessorTypes": [],
39 | "typeNameGeneratorType": null,
40 | "schemaNameGeneratorType": null,
41 | "contractResolverType": null,
42 | "serializerSettingsType": null,
43 | "useDocumentProvider": true,
44 | "documentName": "v1",
45 | "aspNetCoreEnvironment": null,
46 | "createWebHostBuilderMethod": null,
47 | "startupType": null,
48 | "allowNullableBodyParameters": true,
49 | "output": "openapi.json",
50 | "outputType": "Swagger2",
51 | "assemblyPaths": [],
52 | "assemblyConfig": null,
53 | "referencePaths": [],
54 | "useNuGetCache": false
55 | }
56 | },
57 | "codeGenerators": {}
58 | }
--------------------------------------------------------------------------------
/GhostUI/openapi.json:
--------------------------------------------------------------------------------
1 | {
2 | "x-generator": "NSwag v13.18.2.0 (NJsonSchema v10.8.0.0 (Newtonsoft.Json v13.0.0.0))",
3 | "openapi": "3.0.0",
4 | "info": {
5 | "title": "GhostUI API",
6 | "description": "Detailed Description of API",
7 | "version": "v1"
8 | },
9 | "paths": {
10 | "/api/Auth/Login": {
11 | "post": {
12 | "tags": [
13 | "Auth"
14 | ],
15 | "operationId": "Auth_Login",
16 | "requestBody": {
17 | "x-name": "request",
18 | "content": {
19 | "application/json": {
20 | "schema": {
21 | "$ref": "#/components/schemas/Credentials"
22 | }
23 | }
24 | },
25 | "required": true,
26 | "x-position": 1
27 | },
28 | "responses": {
29 | "200": {
30 | "description": "",
31 | "content": {
32 | "application/json": {
33 | "schema": {
34 | "$ref": "#/components/schemas/AuthUser"
35 | }
36 | }
37 | }
38 | }
39 | }
40 | }
41 | },
42 | "/api/Auth/Logout": {
43 | "post": {
44 | "tags": [
45 | "Auth"
46 | ],
47 | "operationId": "Auth_Logout",
48 | "responses": {
49 | "200": {
50 | "description": ""
51 | }
52 | }
53 | }
54 | },
55 | "/api/SampleData/WeatherForecasts": {
56 | "get": {
57 | "tags": [
58 | "SampleData"
59 | ],
60 | "operationId": "SampleData_WeatherForecasts",
61 | "parameters": [
62 | {
63 | "name": "startDateIndex",
64 | "in": "query",
65 | "schema": {
66 | "type": "integer",
67 | "format": "int32"
68 | },
69 | "x-position": 1
70 | }
71 | ],
72 | "responses": {
73 | "200": {
74 | "description": "",
75 | "content": {
76 | "application/json": {
77 | "schema": {
78 | "type": "array",
79 | "items": {
80 | "$ref": "#/components/schemas/WeatherForecast"
81 | }
82 | }
83 | }
84 | }
85 | }
86 | }
87 | }
88 | }
89 | },
90 | "components": {
91 | "schemas": {
92 | "AuthUser": {
93 | "type": "object",
94 | "additionalProperties": false,
95 | "properties": {
96 | "status": {
97 | "type": "string"
98 | },
99 | "token": {
100 | "type": "string"
101 | },
102 | "userName": {
103 | "type": "string"
104 | }
105 | }
106 | },
107 | "Credentials": {
108 | "type": "object",
109 | "additionalProperties": false,
110 | "properties": {
111 | "userName": {
112 | "type": "string",
113 | "nullable": true
114 | },
115 | "password": {
116 | "type": "string",
117 | "nullable": true
118 | },
119 | "rememberMe": {
120 | "type": "boolean"
121 | }
122 | }
123 | },
124 | "WeatherForecast": {
125 | "type": "object",
126 | "additionalProperties": false,
127 | "properties": {
128 | "temperatureC": {
129 | "type": "integer",
130 | "format": "int32"
131 | },
132 | "dateFormatted": {
133 | "type": "string",
134 | "nullable": true
135 | },
136 | "summary": {
137 | "type": "string",
138 | "nullable": true
139 | },
140 | "temperatureF": {
141 | "type": "integer",
142 | "format": "int32"
143 | },
144 | "id": {
145 | "type": "integer",
146 | "format": "int32"
147 | }
148 | }
149 | }
150 | }
151 | }
152 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Matthew Areddia
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ASP.NET Core 7.0 + React + Redux + TypeScript + Hot Module Replacement (HMR)
2 | This template is a SPA application built using ASP.NET Core 7.0 as the REST API server and React/Redux/TypeScript as the web client (Bulma + SASS + styled-components for UI styling). You can find a similar version using Vue + Vuex (and associated libraries) here: [aspnet-core-vue-vuex-playground-template](https://github.com/based-ghost/aspnet-core-vue-vuex-playground-template)
3 |
4 |
5 | 
6 |
7 |
8 | ## General Overview
9 | This template is vaguley based on the original React + Redux .NET Core SPA template that was offered around the time of .NET Core 2.0 release (the existing template is a much more simplified version of what was offered in the past and the structure is quite different as well). Using that as a base, this template greatly extends the functionality provided and also uses the latest versions of all referenced libraries/packages. Keep in mind that I use this project (or others like it) as a testing ground for varying libraries/packages and it is not meant to act as a stand-alone final solution - it is more of POC. For example, the login & logout processes are stubbed to simulate the actual process (no real authentication is happening, however, it is something I plan to add to this project in the near future). I plan on keeping this up to date, and the listed technology stack may be subject to change.
10 |
11 | > Note: All components are written using `FunctionComponents` & `React Hooks`. Legacy class components are not used.
12 |
13 | ## Technology Stack Overview
14 | - **Server**
15 | - ASP.NET Core 7.0
16 | - SignalR
17 | - HealthChecks + [AspNetCore.HealthChecks.UI package](https://github.com/xabaril/AspNetCore.Diagnostics.HealthChecks) - this provides a nicely formatted UI for viewing the results of the HealthCheck modules in use and is accessed on ```/health-ui``` (also, provide an option for viewing the raw JSON data that the UI package prettifies for you at ```/healthchecks-json```). Access this view in the application via the floating settings cog on right screen by clicking the "Health Checks" link.
18 | - API Documentation using Swagger UI - using package [NSwag.AspNetCore](http://NSwag.org) to prettify the specification output and display at ```/swagger/*``` & [NSwag.MSBuild](http://NSwag.org) to handle automatic updates - so that when the project builds, the NSwag CLI will run and generate an updated API specification. Access this view in the application via the floating settings cog on right screen by clicking the "Swagger API" link.
19 | - Brotli/Gzip response compression (production build)
20 | - **Client**
21 | - [`React`](https://reactjs.org/)
22 | - [`Redux`](https://redux.js.org/)
23 | - [`TypeScript`](https://www.typescriptlang.org/)
24 | - [`Webpack`](https://github.com/webpack/webpack) for bundling of application assets and HMR (Hot Module Replacement)
25 | - [`Bulma CSS`](https://bulma.io/) + [`SASS`](https://github.com/sass/sass) + Font Awesome 5 (using fontawesome-svg-core)
26 | - [`styled-components`](https://www.styled-components.com/) - CSS-in-JS via template literals - Examples in this project: `Checkbox.tsx`, `Spinner.tsx`, `Authenticator.tsx`, `Settings.tsx`, and `Footer.tsx` are written using `styled-components`.
27 | - [`react-functional-select`](https://github.com/based-ghost/react-functional-select) - A micro-sized & micro-optimized select component for ReactJS. Inspired by [`react-select`](https://github.com/JedWatson/react-select) and built for ultimate performance - leverages [`react-window`](https://github.com/bvaughn/react-window) to virtualize long options data and `styled-components` to handle styling via CSS-in-JS. Note: I am the author of this package.
28 | - [`Axios`](https://github.com/axios/axios) for REST endpoint requests
29 | - [`react-toastify`](https://github.com/fkhadra/react-toastify) - a highly configurable toast notification library - comes hooked up to display login error & SignalR hub push notifications examples.
30 | - react-router/react-router-dom - route transitions handled using [`react-transition-group`](https://github.com/reactjs/react-transition-group)
31 | - Custom, reusable Dropdown & Checkbox components that provide full functionality w/ state management (without a JQuery dependency).
32 | - Two different loader components (spinner & authentication animation w/ callback for success/fail)
33 |
34 | ## Setup
35 | - [Node.js version >= 14](https://nodejs.org/en/download/)
36 | - [`.NET 7.0 SDK`](https://dotnet.microsoft.com/download/dotnet/7.0)
37 | - Clone the repository and run ```npm install``` from the root of the ```ClientApp``` directory to properly install all node packages/dependencies.
38 | - Opening the solution in VisualStudio should automatically trigger nuget package and other dependencies to be restored (assuming latest version of VisualStudio and installation of .NET Core version mentioned aboved). If issues are encountered, attempting to refresh the dependencies or executing a ```rebuild solution``` should resolve them.
39 | - A solution.sln file is added to act as an entry point to open the application in Visual Studio. Visual Studio 2022 and up.
40 | - GhostUI/GhostUI.csproj acts as the entry point to open the application in Visual Studio Code.
41 |
--------------------------------------------------------------------------------
/demo/react_dot_net_52530-2021.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/based-ghost/aspnet-core-react-redux-playground-template/443cfc3188dedcd7701d6c66d4b91df20359b487/demo/react_dot_net_52530-2021.gif
--------------------------------------------------------------------------------
/solution.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | # Visual Studio Version 17
4 | VisualStudioVersion = 17.0.32014.148
5 | MinimumVisualStudioVersion = 10.0.40219.1
6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GhostUI", "GhostUI\GhostUI.csproj", "{07B28075-ED26-412D-9CB6-3CD29C2AC86F}"
7 | EndProject
8 | Global
9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
10 | Debug|Any CPU = Debug|Any CPU
11 | Release|Any CPU = Release|Any CPU
12 | EndGlobalSection
13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
14 | {07B28075-ED26-412D-9CB6-3CD29C2AC86F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
15 | {07B28075-ED26-412D-9CB6-3CD29C2AC86F}.Debug|Any CPU.Build.0 = Debug|Any CPU
16 | {07B28075-ED26-412D-9CB6-3CD29C2AC86F}.Release|Any CPU.ActiveCfg = Release|Any CPU
17 | {07B28075-ED26-412D-9CB6-3CD29C2AC86F}.Release|Any CPU.Build.0 = Release|Any CPU
18 | EndGlobalSection
19 | GlobalSection(SolutionProperties) = preSolution
20 | HideSolutionNode = FALSE
21 | EndGlobalSection
22 | GlobalSection(ExtensibilityGlobals) = postSolution
23 | SolutionGuid = {D460BDE0-0DDC-4449-8606-B39723D467BB}
24 | EndGlobalSection
25 | EndGlobal
26 |
--------------------------------------------------------------------------------