Front-end panel #1
					 26 changed files with 5047 additions and 7 deletions
				
			
		
							
								
								
									
										13
									
								
								README.md
									
										
									
									
									
								
							
							
						
						
									
										13
									
								
								README.md
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -17,7 +17,7 @@
 | 
			
		|||
---
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
<p align="center">A simple yet somehow overdesigned guestbook system featuring a simple control panel <small>(which is a WIP so you'll have to make do with a db manager)</small></p>
 | 
			
		||||
<p align="center">A simple yet somehow overdesigned guestbook system featuring a simple control panel</p>
 | 
			
		||||
 | 
			
		||||
<p align="center"> This is phase I of the personal backscratchers project.</p>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -29,6 +29,8 @@
 | 
			
		|||
- [📑 Documentation ](#-documentation-)
 | 
			
		||||
- [🏁 Getting Started ](#-getting-started-)
 | 
			
		||||
- [🕸️ Prerequisites](#️-prerequisites)
 | 
			
		||||
  - [Backend:](#backend)
 | 
			
		||||
  - [Admin panel:](#admin-panel)
 | 
			
		||||
- [🚀 Deployment ](#-deployment-)
 | 
			
		||||
- [⛏️ Built Using ](#️-built-using-)
 | 
			
		||||
- [✍️ Authors ](#️-authors-)
 | 
			
		||||
| 
						 | 
				
			
			@ -49,6 +51,8 @@ These instructions will get you a copy of the project up and running on your loc
 | 
			
		|||
 | 
			
		||||
## 🕸️ Prerequisites
 | 
			
		||||
 | 
			
		||||
### Backend:
 | 
			
		||||
 | 
			
		||||
For running it locally:
 | 
			
		||||
- .NET 8.0
 | 
			
		||||
- A running instance of MongoDB
 | 
			
		||||
| 
						 | 
				
			
			@ -84,6 +88,12 @@ You will be able to see in `build/docker-compose.public.yml` that the applicatio
 | 
			
		|||
> [!TIP]
 | 
			
		||||
> For local usage of the backend, you can use `docker-compose.local.yml` and edit the fields you need.
 | 
			
		||||
 | 
			
		||||
### Admin panel:
 | 
			
		||||
 | 
			
		||||
The admin panel is a simple React/Vite app. For development, it should be enough to run `vite` in Guestbooky-admin's `src` folder.
 | 
			
		||||
 | 
			
		||||
In order to create a live version, adjust the **API_URL** path in `Guestbooky-admin/src/environment/constants.js`, and execute `vite build`.
 | 
			
		||||
 | 
			
		||||
## 🚀 Deployment <a name = "deployment"></a>
 | 
			
		||||
 | 
			
		||||
Use `docker-compose.public.yml` as a basis. it should create the image for you and start running.
 | 
			
		||||
| 
						 | 
				
			
			@ -92,6 +102,7 @@ Use `docker-compose.public.yml` as a basis. it should create the image for you a
 | 
			
		|||
 | 
			
		||||
- [MongoDB](https://www.mongodb.com/) - Database
 | 
			
		||||
- [.NET](https://dot.net/) - Backend
 | 
			
		||||
- [React](https://react.dev/)/[Vite](https://vite.dev) - Admin Panel
 | 
			
		||||
- [Cloudflare Turnstile](https://www.cloudflare.com/pt-br/products/turnstile/) - Captcha
 | 
			
		||||
 | 
			
		||||
## ✍️ Authors <a name = "authors"></a>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -43,3 +43,4 @@ ___
 | 
			
		|||
- There isn't much exception handling, except in the API layer. This is on purpose. Another thing that this project could really use, but is left as an exercise, is using a `Maybe<T>/Result<T>/ErrorOr<T>` type.
 | 
			
		||||
  - Since there is so little that can go wrong with *low-stakes CRUDding*, it is a reasonable trade-off to let the API layer catch and send an internal server error.
 | 
			
		||||
 | 
			
		||||
- By default, you need to choose between good Cookie-based authentication defaults or REST-friendly authentication via the `Authorization` header. Luckily you can support both with a few small additions - it made more sense to keep *RESTy* as the main method and stick Cookie support to its tail.
 | 
			
		||||
							
								
								
									
										24
									
								
								src/Guestbooky-admin/.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/Guestbooky-admin/.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,24 @@
 | 
			
		|||
# Logs
 | 
			
		||||
logs
 | 
			
		||||
*.log
 | 
			
		||||
npm-debug.log*
 | 
			
		||||
yarn-debug.log*
 | 
			
		||||
yarn-error.log*
 | 
			
		||||
pnpm-debug.log*
 | 
			
		||||
lerna-debug.log*
 | 
			
		||||
 | 
			
		||||
node_modules
 | 
			
		||||
dist
 | 
			
		||||
dist-ssr
 | 
			
		||||
*.local
 | 
			
		||||
 | 
			
		||||
# Editor directories and files
 | 
			
		||||
.vscode/*
 | 
			
		||||
!.vscode/extensions.json
 | 
			
		||||
.idea
 | 
			
		||||
.DS_Store
 | 
			
		||||
*.suo
 | 
			
		||||
*.ntvs*
 | 
			
		||||
*.njsproj
 | 
			
		||||
*.sln
 | 
			
		||||
*.sw?
 | 
			
		||||
							
								
								
									
										38
									
								
								src/Guestbooky-admin/eslint.config.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/Guestbooky-admin/eslint.config.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,38 @@
 | 
			
		|||
import js from '@eslint/js'
 | 
			
		||||
import globals from 'globals'
 | 
			
		||||
import react from 'eslint-plugin-react'
 | 
			
		||||
import reactHooks from 'eslint-plugin-react-hooks'
 | 
			
		||||
import reactRefresh from 'eslint-plugin-react-refresh'
 | 
			
		||||
 | 
			
		||||
export default [
 | 
			
		||||
  { ignores: ['dist'] },
 | 
			
		||||
  {
 | 
			
		||||
    files: ['**/*.{js,jsx}'],
 | 
			
		||||
    languageOptions: {
 | 
			
		||||
      ecmaVersion: 2020,
 | 
			
		||||
      globals: globals.browser,
 | 
			
		||||
      parserOptions: {
 | 
			
		||||
        ecmaVersion: 'latest',
 | 
			
		||||
        ecmaFeatures: { jsx: true },
 | 
			
		||||
        sourceType: 'module',
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    settings: { react: { version: '18.3' } },
 | 
			
		||||
    plugins: {
 | 
			
		||||
      react,
 | 
			
		||||
      'react-hooks': reactHooks,
 | 
			
		||||
      'react-refresh': reactRefresh,
 | 
			
		||||
    },
 | 
			
		||||
    rules: {
 | 
			
		||||
      ...js.configs.recommended.rules,
 | 
			
		||||
      ...react.configs.recommended.rules,
 | 
			
		||||
      ...react.configs['jsx-runtime'].rules,
 | 
			
		||||
      ...reactHooks.configs.recommended.rules,
 | 
			
		||||
      'react/jsx-no-target-blank': 'off',
 | 
			
		||||
      'react-refresh/only-export-components': [
 | 
			
		||||
        'warn',
 | 
			
		||||
        { allowConstantExport: true },
 | 
			
		||||
      ],
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
]
 | 
			
		||||
							
								
								
									
										13
									
								
								src/Guestbooky-admin/index.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/Guestbooky-admin/index.html
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,13 @@
 | 
			
		|||
<!doctype html>
 | 
			
		||||
<html lang="en">
 | 
			
		||||
  <head>
 | 
			
		||||
    <meta charset="UTF-8" />
 | 
			
		||||
    <link rel="icon" type="image/png" href="/src/assets/guestbooky.png" />
 | 
			
		||||
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 | 
			
		||||
    <title>Guestbooky Admin Panel</title>
 | 
			
		||||
  </head>
 | 
			
		||||
  <body>
 | 
			
		||||
    <div id="root"></div>
 | 
			
		||||
    <script type="module" src="/src/main.jsx"></script>
 | 
			
		||||
  </body>
 | 
			
		||||
</html>
 | 
			
		||||
							
								
								
									
										4391
									
								
								src/Guestbooky-admin/package-lock.json
									
										
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										4391
									
								
								src/Guestbooky-admin/package-lock.json
									
										
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
							
								
								
									
										30
									
								
								src/Guestbooky-admin/package.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/Guestbooky-admin/package.json
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,30 @@
 | 
			
		|||
{
 | 
			
		||||
  "name": "guestbooky-admin",
 | 
			
		||||
  "private": true,
 | 
			
		||||
  "version": "0.0.0",
 | 
			
		||||
  "type": "module",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "dev": "vite",
 | 
			
		||||
    "build": "vite build",
 | 
			
		||||
    "lint": "eslint .",
 | 
			
		||||
    "preview": "vite preview"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "axios": "^1.7.7",
 | 
			
		||||
    "guestbooky-admin": "file:",
 | 
			
		||||
    "react": "^18.3.1",
 | 
			
		||||
    "react-dom": "^18.3.1"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@eslint/js": "^9.13.0",
 | 
			
		||||
    "@types/react": "^18.3.11",
 | 
			
		||||
    "@types/react-dom": "^18.3.1",
 | 
			
		||||
    "@vitejs/plugin-react": "^4.3.3",
 | 
			
		||||
    "eslint": "^9.13.0",
 | 
			
		||||
    "eslint-plugin-react": "^7.37.1",
 | 
			
		||||
    "eslint-plugin-react-hooks": "^5.0.0",
 | 
			
		||||
    "eslint-plugin-react-refresh": "^0.4.13",
 | 
			
		||||
    "globals": "^15.11.0",
 | 
			
		||||
    "vite": "^5.4.9"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										24
									
								
								src/Guestbooky-admin/src/App.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/Guestbooky-admin/src/App.css
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,24 @@
 | 
			
		|||
.header{
 | 
			
		||||
    font-size: 3em;
 | 
			
		||||
    word-spacing: 0.3em;
 | 
			
		||||
    letter-spacing: -0.05em;
 | 
			
		||||
    background: linear-gradient(180deg, hsl(15, 80%, 10%) 0%, hsl(15, 50%, 60%) 100%);
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    padding: 2ch;
 | 
			
		||||
    border-bottom: 0.1em solid hsl(15, 80%, 20%);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.header h1 {
 | 
			
		||||
    margin-inline: auto;
 | 
			
		||||
    width: 1200px;
 | 
			
		||||
    color: hsl(15, 100%, 90%);
 | 
			
		||||
    text-shadow: 0.01em 0.01em 0.01em hsl(15, 0%, 20%);
 | 
			
		||||
    font-family: "Carattere", cursive;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.content {
 | 
			
		||||
    width: 1200px;
 | 
			
		||||
    max-width: 1200px;
 | 
			
		||||
    margin-inline: auto;
 | 
			
		||||
    padding: 2ch;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										24
									
								
								src/Guestbooky-admin/src/App.jsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/Guestbooky-admin/src/App.jsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,24 @@
 | 
			
		|||
import {useState} from 'react'
 | 
			
		||||
import AdminPage from './components/AdminPage'
 | 
			
		||||
import LoginPage from './components/LoginPage'
 | 
			
		||||
 | 
			
		||||
import './App.css'
 | 
			
		||||
 | 
			
		||||
function App() {
 | 
			
		||||
    const [isLoggedIn, setIsLoggedIn] = useState(false)
 | 
			
		||||
 | 
			
		||||
    const handleLoggedIn = (newValue) => {
 | 
			
		||||
        setIsLoggedIn(newValue)
 | 
			
		||||
    }
 | 
			
		||||
    return (
 | 
			
		||||
        <>
 | 
			
		||||
          <div className="header"><h1>Guestbooky Admin</h1></div>
 | 
			
		||||
            <div className="content">
 | 
			
		||||
                {isLoggedIn ? <AdminPage className="content"/> :
 | 
			
		||||
                    <LoginPage onLoggedIn={handleLoggedIn} className="content"/>}
 | 
			
		||||
            </div>
 | 
			
		||||
        </>
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default App
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								src/Guestbooky-admin/src/assets/guestbooky.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/Guestbooky-admin/src/assets/guestbooky.png
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 11 KiB  | 
							
								
								
									
										111
									
								
								src/Guestbooky-admin/src/components/AdminPage.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								src/Guestbooky-admin/src/components/AdminPage.css
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,111 @@
 | 
			
		|||
@keyframes smallFadeIn {
 | 
			
		||||
0% {
 | 
			
		||||
    opacity: 0;
 | 
			
		||||
    transform: translateY(20px);
 | 
			
		||||
}
 | 
			
		||||
    100% {
 | 
			
		||||
        opacity: 1;
 | 
			
		||||
        transform: translateY(0);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.list-item {
 | 
			
		||||
    opacity: 0;
 | 
			
		||||
    transform: translateY(20px);
 | 
			
		||||
    animation: smallFadeIn 0.5s ease-out forwards;
 | 
			
		||||
    animation-delay: calc(0.2s * var(--child-index));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.warning{
 | 
			
		||||
    background-color: hsla(15, 100%, 90%, 0.75);
 | 
			
		||||
    border: 0.2em solid hsla(15, 80%, 70%, 1);
 | 
			
		||||
    border-radius: 0.3em;
 | 
			
		||||
    padding: 2ch 1ch;
 | 
			
		||||
    width: fit-content;
 | 
			
		||||
    font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.navigation{
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-direction: row;
 | 
			
		||||
    justify-content: center;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.navigation button {
 | 
			
		||||
    font-size: 1em;
 | 
			
		||||
    margin: 2em;
 | 
			
		||||
    background: hsla(15, 60%, 80%, 1);
 | 
			
		||||
    border-radius: 0.2em;
 | 
			
		||||
    width: fit-content;
 | 
			
		||||
    padding: 0.8em 0.5em;
 | 
			
		||||
    font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.navigation button:hover {
 | 
			
		||||
    background: hsla(15, 80%, 90%, 0.75);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
ul {
 | 
			
		||||
    list-style-type: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
ul li{
 | 
			
		||||
    line-height: 1.6em;
 | 
			
		||||
    display: grid;
 | 
			
		||||
    grid-row-gap: 2em;
 | 
			
		||||
    padding: 1em;
 | 
			
		||||
    grid-template-rows: auto 1fr;
 | 
			
		||||
    grid-template-columns: 1fr auto auto;
 | 
			
		||||
    font-family: 'Solway', serif;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
ul li h2{
 | 
			
		||||
    align-self: center;
 | 
			
		||||
    grid-row: 1;
 | 
			
		||||
    background: linear-gradient(to right, hsl(15, 40%, 85%), hsla(15, 40%, 85%, 0));
 | 
			
		||||
    border-radius: 0.2em;
 | 
			
		||||
    padding: 0.5em 0 0.5em 0.5em;
 | 
			
		||||
    font-weight: 500;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
ul li h3{
 | 
			
		||||
    align-self: center;
 | 
			
		||||
    grid-row: 1;
 | 
			
		||||
    justify-self: right;
 | 
			
		||||
    padding-right: 0.5em;
 | 
			
		||||
    vertical-align: bottom;
 | 
			
		||||
    font-weight: 500;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
ul li button{
 | 
			
		||||
    grid-row: 1;
 | 
			
		||||
    justify-self: right;
 | 
			
		||||
    width: fit-content;
 | 
			
		||||
    border-radius: 0.5em;
 | 
			
		||||
    padding: 0.5em;
 | 
			
		||||
    font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
 | 
			
		||||
    background: hsla(15, 60%, 80%, 0.75);
 | 
			
		||||
}
 | 
			
		||||
ul li button:hover {
 | 
			
		||||
    background: hsla(15, 80%, 90%, 0.75);
 | 
			
		||||
}
 | 
			
		||||
.message-text {
 | 
			
		||||
    grid-row: 2;
 | 
			
		||||
    grid-column: 1 / 4;
 | 
			
		||||
    background-color: hsl(15, 50%, 90%);
 | 
			
		||||
    margin: 1em 0.5em;
 | 
			
		||||
    padding: 1em 0.5em;
 | 
			
		||||
    font-weight: 300;
 | 
			
		||||
    border-radius: 0.5em;
 | 
			
		||||
    box-shadow: 0.05em 0.05em 0.05em hsla(15, 20%, 20%, 0.5);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.message-text p{
 | 
			
		||||
    line-height: 1.5em;
 | 
			
		||||
    text-align: justify;
 | 
			
		||||
    text-justify: inter-word;
 | 
			
		||||
    text-align-last: left;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										51
									
								
								src/Guestbooky-admin/src/components/AdminPage.jsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								src/Guestbooky-admin/src/components/AdminPage.jsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,51 @@
 | 
			
		|||
import useMessages from "../hooks/useMessages.js";
 | 
			
		||||
 | 
			
		||||
import './AdminPage.css'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
const AdminPage = () => {
 | 
			
		||||
    const {messages, totalMessages, loading, error, removeMessage, page, nextPage, previousPage} = useMessages();
 | 
			
		||||
    const localeOptions = {
 | 
			
		||||
        weekday: 'long',
 | 
			
		||||
        year: 'numeric',
 | 
			
		||||
        month: 'long',
 | 
			
		||||
        day: 'numeric',
 | 
			
		||||
        hour: 'numeric',
 | 
			
		||||
        minute: 'numeric',
 | 
			
		||||
        second: 'numeric',
 | 
			
		||||
        timeZoneName: 'short'
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (loading) {
 | 
			
		||||
        return (<div className='warning'><p>Loading...</p></div>);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (messages.length === 0 && page > 1) {
 | 
			
		||||
        previousPage();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (messages.length === 0) {
 | 
			
		||||
        return (<div className='warning'><p>No messages.</p></div>);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <>
 | 
			
		||||
            <div className='warning'><p>{error ? 'Error : ' + error : 'Total messages: ' + totalMessages }</p></div>
 | 
			
		||||
            <ul>
 | 
			
		||||
            {messages.length > 0 ? messages.map((message, index) => (
 | 
			
		||||
                    <li key={message.id} className='list-item' style={{ '--child-index': index}} >
 | 
			
		||||
                        <h2>{message.author}</h2>
 | 
			
		||||
                        <h3>{new Date(message.timestamp).toLocaleString(navigator.language, localeOptions)}</h3>
 | 
			
		||||
                        <button onClick={() => removeMessage(message.id)}>Delete</button>
 | 
			
		||||
                        <div className='message-text'><p>{message.message}</p></div>
 | 
			
		||||
                    </li>
 | 
			
		||||
                )) : ''}
 | 
			
		||||
            </ul>
 | 
			
		||||
            <div className='navigation'>
 | 
			
		||||
                <button onClick={() => previousPage()}>Previous Page</button>
 | 
			
		||||
                <button onClick={() => nextPage()}>Next Page</button>
 | 
			
		||||
            </div>
 | 
			
		||||
        </>
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
export default AdminPage;
 | 
			
		||||
							
								
								
									
										64
									
								
								src/Guestbooky-admin/src/components/LoginPage.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								src/Guestbooky-admin/src/components/LoginPage.css
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,64 @@
 | 
			
		|||
.login-form{
 | 
			
		||||
    display: flex;
 | 
			
		||||
    margin-inline: auto;
 | 
			
		||||
    justify-content: center;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    vertical-align: center;
 | 
			
		||||
    margin-top: 20vh;
 | 
			
		||||
    background-color: hsla(15, 100%, 93%);
 | 
			
		||||
    width: fit-content;
 | 
			
		||||
    padding: 2em;
 | 
			
		||||
    border-radius: 1em;
 | 
			
		||||
    box-shadow: 0.05em 0.05em 0.05em hsla(15, 20%, 20%, 0.5);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.fade-in{
 | 
			
		||||
    opacity: 0;
 | 
			
		||||
    transform: translateY(10vh);
 | 
			
		||||
    animation: fadeIn 0.5s ease-out forwards;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes fadeIn {
 | 
			
		||||
    0% {
 | 
			
		||||
        opacity: 0;
 | 
			
		||||
        transform: translateY(20vh);
 | 
			
		||||
    }
 | 
			
		||||
    100% {
 | 
			
		||||
        opacity: 1;
 | 
			
		||||
        transform: translateY(0);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
form {
 | 
			
		||||
    display: grid;
 | 
			
		||||
    grid-template-columns: auto 20ch;
 | 
			
		||||
    grid-column-gap: 2ch;
 | 
			
		||||
    grid-row-gap: 2ch;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    font-family: 'Solway', serif;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
form label {
 | 
			
		||||
    font-size: 1.4em;
 | 
			
		||||
    text-align: right;
 | 
			
		||||
    font-weight: 500;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
form input[type=text], form input[type=password] {
 | 
			
		||||
    font-size: 1.4em;
 | 
			
		||||
    border: 0.2em hsl(15, 100%, 50%) outset;
 | 
			
		||||
    border-radius: 0.2em;
 | 
			
		||||
    padding: 0.2em 0.2em;
 | 
			
		||||
    font-weight: 300;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
form button {
 | 
			
		||||
    margin-top: 1em;
 | 
			
		||||
    grid-column: 2 / 3;
 | 
			
		||||
    padding: 1em 2em;
 | 
			
		||||
    font-size: 1.4em;
 | 
			
		||||
    border-radius: 0.2em;
 | 
			
		||||
    background-color: hsl(15, 50%, 85%);
 | 
			
		||||
    font-family: 'Solway', serif;
 | 
			
		||||
    font-weight: 300;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										40
									
								
								src/Guestbooky-admin/src/components/LoginPage.jsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/Guestbooky-admin/src/components/LoginPage.jsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,40 @@
 | 
			
		|||
import React, { useRef } from 'react';
 | 
			
		||||
import useAuth from '../hooks/useAuth.js';
 | 
			
		||||
 | 
			
		||||
import './LoginPage.css'
 | 
			
		||||
 | 
			
		||||
const LoginPage = ({onLoggedIn}) =>
 | 
			
		||||
{
 | 
			
		||||
    const userInputRef = useRef(null);
 | 
			
		||||
    const passInputRef = useRef(null);
 | 
			
		||||
    const { authenticate, error } = useAuth();
 | 
			
		||||
 | 
			
		||||
    const handleSubmit = (e) => {
 | 
			
		||||
        e.preventDefault()
 | 
			
		||||
        const user = userInputRef.current.value;
 | 
			
		||||
        const pass = passInputRef.current.value;
 | 
			
		||||
 | 
			
		||||
        authenticate({user, pass})
 | 
			
		||||
            .then(() => {
 | 
			
		||||
                if (error === null) {
 | 
			
		||||
                    onLoggedIn(true)
 | 
			
		||||
                }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return(
 | 
			
		||||
        <>
 | 
			
		||||
        <div className="login-form fade-in">
 | 
			
		||||
            <form action="post" onSubmit={handleSubmit}>
 | 
			
		||||
                <label>User: </label><input type='text' name='username' placeholder=''
 | 
			
		||||
                                                 ref={userInputRef}></input>
 | 
			
		||||
                <label>Password: </label><input type='password' name='password' placeholder=''
 | 
			
		||||
                                                ref={passInputRef}></input>
 | 
			
		||||
                <button type='submit' className='login-button'>Login</button>
 | 
			
		||||
            </form>
 | 
			
		||||
        </div>
 | 
			
		||||
        </>
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default LoginPage;
 | 
			
		||||
							
								
								
									
										1
									
								
								src/Guestbooky-admin/src/environment/constants.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/Guestbooky-admin/src/environment/constants.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export const API_URL = 'http://localhost:8080';
 | 
			
		||||
							
								
								
									
										19
									
								
								src/Guestbooky-admin/src/hooks/useAuth.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/Guestbooky-admin/src/hooks/useAuth.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,19 @@
 | 
			
		|||
import { useState } from "react";
 | 
			
		||||
import { post } from '../services/httpService.js'
 | 
			
		||||
 | 
			
		||||
const useAuth = () => {
 | 
			
		||||
    const [error, setError] = useState(null);
 | 
			
		||||
 | 
			
		||||
    const authenticate = async ({user, pass}) => {
 | 
			
		||||
        try {
 | 
			
		||||
            await post('/auth/login', {username: user, password: pass});
 | 
			
		||||
        }
 | 
			
		||||
        catch(err){
 | 
			
		||||
            setError(err);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return { authenticate, error };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default useAuth;
 | 
			
		||||
							
								
								
									
										67
									
								
								src/Guestbooky-admin/src/hooks/useMessages.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								src/Guestbooky-admin/src/hooks/useMessages.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,67 @@
 | 
			
		|||
import { useState, useEffect } from "react";
 | 
			
		||||
import { get, del } from '../services/httpService.js'
 | 
			
		||||
 | 
			
		||||
const useMessages = () => {
 | 
			
		||||
    const [messages, setMessages] = useState([]);
 | 
			
		||||
    const [loading, setLoading] = useState(true);
 | 
			
		||||
    const [error, setError] = useState(null);
 | 
			
		||||
    const [totalMessages, setTotalMessages] = useState(0);
 | 
			
		||||
    const [page, setPage] = useState(1);
 | 
			
		||||
    const amountPerPage = 10;
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        const fetchMessages = async () => {
 | 
			
		||||
            try{
 | 
			
		||||
                setLoading(true);
 | 
			
		||||
                const data = await get('/message', {
 | 
			
		||||
                    headers: {
 | 
			
		||||
                        'Range': 'messages=' + ((page - 1) * amountPerPage) + '-' + (((page - 1) * amountPerPage) + amountPerPage),
 | 
			
		||||
                    },
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
                const [, rangeInfo] = data.headers['content-range'].split(' ');
 | 
			
		||||
                const [, total] = rangeInfo.split('/');
 | 
			
		||||
 | 
			
		||||
                setTotalMessages(parseInt(total));
 | 
			
		||||
                setMessages(data.data);
 | 
			
		||||
            } catch(err) {
 | 
			
		||||
                setError(err);
 | 
			
		||||
            } finally {
 | 
			
		||||
                setLoading(false);
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
        fetchMessages();
 | 
			
		||||
    }, [page]);
 | 
			
		||||
 | 
			
		||||
    const removeMessage = async (id) => {
 | 
			
		||||
        try{
 | 
			
		||||
            await del('/message', {id: id});
 | 
			
		||||
            setMessages((prevlist) => prevlist.filter(message => message.id !== id));
 | 
			
		||||
            setTotalMessages(totalMessages - 1)
 | 
			
		||||
        }catch(err){
 | 
			
		||||
            setError(err);
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const nextPage = () => {
 | 
			
		||||
        if(page * amountPerPage < totalMessages)
 | 
			
		||||
            setPage((page + 1));
 | 
			
		||||
    }
 | 
			
		||||
    const previousPage = () => {
 | 
			
		||||
        if (page > 1)
 | 
			
		||||
            setPage((page - 1));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        messages,
 | 
			
		||||
        totalMessages,
 | 
			
		||||
        loading,
 | 
			
		||||
        error,
 | 
			
		||||
        removeMessage,
 | 
			
		||||
        page,
 | 
			
		||||
        nextPage,
 | 
			
		||||
        previousPage
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default useMessages;
 | 
			
		||||
							
								
								
									
										40
									
								
								src/Guestbooky-admin/src/index.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/Guestbooky-admin/src/index.css
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,40 @@
 | 
			
		|||
@import url(https://fonts.bunny.net/css?family=carattere:400|solway:300,500);
 | 
			
		||||
 | 
			
		||||
@media (prefers-reduced-motion: reduce){
 | 
			
		||||
    html:focus-within {
 | 
			
		||||
        scroll-behavior: auto;
 | 
			
		||||
    }
 | 
			
		||||
    *, *::before, *::after {
 | 
			
		||||
        animation-duration: 0.01ms !important;
 | 
			
		||||
        animation-iteration-count: 1 !important;
 | 
			
		||||
        transition: none;
 | 
			
		||||
        transition-duration: 0.01ms !important;
 | 
			
		||||
        scroll-behavior: auto !important;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
*, *::before, *::after{
 | 
			
		||||
    box-sizing: border-box;
 | 
			
		||||
    transition: all 0.05s ease;
 | 
			
		||||
    animation-duration: initial;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
* {
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    padding: 0;
 | 
			
		||||
 }
 | 
			
		||||
 | 
			
		||||
html:focus-within{
 | 
			
		||||
    scroll-behavior: smooth;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
img, picture, svg, video, canvas{
 | 
			
		||||
    max-width: 100%;
 | 
			
		||||
    height: auto;
 | 
			
		||||
    vertical-align: middle;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body {
 | 
			
		||||
    background: linear-gradient(90deg, hsl(15, 80%, 90%) 0%, hsl(15, 80%, 98%) 5%, hsl(15, 80%, 98%) 95%,  hsl(15, 80%, 90%) 100%);
 | 
			
		||||
    color: hsl(15, 70%, 5%);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										7
									
								
								src/Guestbooky-admin/src/main.jsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/Guestbooky-admin/src/main.jsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,7 @@
 | 
			
		|||
import { createRoot } from 'react-dom/client'
 | 
			
		||||
import './index.css'
 | 
			
		||||
import App from './App.jsx'
 | 
			
		||||
 | 
			
		||||
createRoot(document.getElementById('root')).render(
 | 
			
		||||
    <App />
 | 
			
		||||
)
 | 
			
		||||
							
								
								
									
										21
									
								
								src/Guestbooky-admin/src/services/httpService.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/Guestbooky-admin/src/services/httpService.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,21 @@
 | 
			
		|||
import axios from 'axios';
 | 
			
		||||
import { API_URL } from '../environment/constants.js'
 | 
			
		||||
 | 
			
		||||
const httpClient = axios.create({
 | 
			
		||||
    baseURL: API_URL,
 | 
			
		||||
    withCredentials: true,
 | 
			
		||||
    timeout: 5000,
 | 
			
		||||
    headers: {'Content-Type': 'application/json',}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const get = (endpoint, params) => {
 | 
			
		||||
    return httpClient.get(endpoint, { headers: params.headers, data: params.data});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const post = (endpoint, data) => {
 | 
			
		||||
    return httpClient.post(endpoint, data);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const del = (endpoint, data) => {
 | 
			
		||||
    return httpClient.delete(endpoint, {data: data });
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										7
									
								
								src/Guestbooky-admin/vite.config.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/Guestbooky-admin/vite.config.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,7 @@
 | 
			
		|||
import { defineConfig } from 'vite'
 | 
			
		||||
import react from '@vitejs/plugin-react'
 | 
			
		||||
 | 
			
		||||
// https://vite.dev/config/
 | 
			
		||||
export default defineConfig({
 | 
			
		||||
  plugins: [react()],
 | 
			
		||||
})
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,8 @@
 | 
			
		|||
using Guestbooky.API.Enums;
 | 
			
		||||
 | 
			
		||||
namespace Guestbooky.API.Configurations;
 | 
			
		||||
 | 
			
		||||
public class APISettings
 | 
			
		||||
{
 | 
			
		||||
    public required RunningEnvironment RunningEnvironment { get; set; }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,4 +1,5 @@
 | 
			
		|||
using Guestbooky.API.DTOs.Auth;
 | 
			
		||||
using Guestbooky.API.Configurations;
 | 
			
		||||
using Guestbooky.API.DTOs.Auth;
 | 
			
		||||
using Guestbooky.Application.UseCases.AuthenticateUser;
 | 
			
		||||
using Guestbooky.Application.UseCases.RefreshToken;
 | 
			
		||||
using MediatR;
 | 
			
		||||
| 
						 | 
				
			
			@ -12,11 +13,13 @@ public class AuthController : ControllerBase
 | 
			
		|||
{
 | 
			
		||||
    private readonly IMediator _mediator;
 | 
			
		||||
    private readonly ILogger<AuthController> _logger;
 | 
			
		||||
    private readonly APISettings _apiSettings;
 | 
			
		||||
 | 
			
		||||
    public AuthController(IMediator mediator, ILogger<AuthController> logger)
 | 
			
		||||
    public AuthController(IMediator mediator, ILogger<AuthController> logger, APISettings apiSettings)
 | 
			
		||||
    {
 | 
			
		||||
        _mediator = mediator;
 | 
			
		||||
        _logger = logger;
 | 
			
		||||
        _apiSettings = apiSettings;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [ProducesResponseType(typeof(LoginResponseDto), 200)]
 | 
			
		||||
| 
						 | 
				
			
			@ -35,6 +38,7 @@ public class AuthController : ControllerBase
 | 
			
		|||
            if(result.IsAuthenticated)
 | 
			
		||||
            {
 | 
			
		||||
                _logger.LogInformation("Authentication successful. Returning LoginResponse.");
 | 
			
		||||
                SetJwtCookie(result.Token);
 | 
			
		||||
                return Ok(new LoginResponseDto(result.Token, result.RefreshToken));
 | 
			
		||||
            }
 | 
			
		||||
            else
 | 
			
		||||
| 
						 | 
				
			
			@ -80,4 +84,16 @@ public class AuthController : ControllerBase
 | 
			
		|||
            return Problem($"An error occurred on the server: {e.Message}", statusCode: StatusCodes.Status500InternalServerError);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void SetJwtCookie(string token)
 | 
			
		||||
    {
 | 
			
		||||
        var cookieOptions = new CookieOptions
 | 
			
		||||
        {
 | 
			
		||||
            HttpOnly = true,
 | 
			
		||||
            Secure = _apiSettings.RunningEnvironment == Enums.RunningEnvironment.Production,
 | 
			
		||||
            SameSite = SameSiteMode.Strict,
 | 
			
		||||
            Expires = DateTimeOffset.UtcNow.AddHours(2)
 | 
			
		||||
        };
 | 
			
		||||
        Response.Cookies.Append("token", token, cookieOptions);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,8 @@
 | 
			
		|||
namespace Guestbooky.API.Enums;
 | 
			
		||||
 | 
			
		||||
public enum RunningEnvironment
 | 
			
		||||
{
 | 
			
		||||
    Unknown = 0,
 | 
			
		||||
    Development,
 | 
			
		||||
    Production
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,11 +1,13 @@
 | 
			
		|||
using Guestbooky.Application.DependencyInjection;
 | 
			
		||||
using Guestbooky.Infrastructure.DependencyInjection;
 | 
			
		||||
using Guestbooky.Infrastructure.Environment;
 | 
			
		||||
using Guestbooky.API.Configurations;
 | 
			
		||||
using Guestbooky.API.Enums;
 | 
			
		||||
using Guestbooky.API.Validations;
 | 
			
		||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
 | 
			
		||||
using Microsoft.IdentityModel.Tokens;
 | 
			
		||||
using System.Text;
 | 
			
		||||
using Serilog;
 | 
			
		||||
using Guestbooky.API.Validations;
 | 
			
		||||
 | 
			
		||||
namespace Guestbooky.API
 | 
			
		||||
{
 | 
			
		||||
| 
						 | 
				
			
			@ -74,9 +76,10 @@ namespace Guestbooky.API
 | 
			
		|||
                var corsOrigins = builder.Configuration[Constants.CORS_ORIGINS]?.Split(',') ?? Array.Empty<string>();
 | 
			
		||||
                cfg.AddPolicy(name: "local", policy =>
 | 
			
		||||
                {
 | 
			
		||||
                    policy.WithExposedHeaders("Content-Range", "Accept-Ranges")
 | 
			
		||||
                    policy.WithExposedHeaders("Content-Range", "Accept-Ranges", "Set-Cookie")
 | 
			
		||||
                    .WithOrigins(corsOrigins)
 | 
			
		||||
                    .AllowAnyHeader()
 | 
			
		||||
                    .AllowCredentials()
 | 
			
		||||
                    .WithMethods("GET", "POST", "DELETE", "OPTIONS");
 | 
			
		||||
                });
 | 
			
		||||
            });
 | 
			
		||||
| 
						 | 
				
			
			@ -86,7 +89,11 @@ namespace Guestbooky.API
 | 
			
		|||
                options.InvalidModelStateResponseFactory = InvalidModelStateResponseFactory.DefaultInvalidModelStateResponse;
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
 | 
			
		||||
            builder.Services.AddAuthentication(o => 
 | 
			
		||||
            {
 | 
			
		||||
                o.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
 | 
			
		||||
                o.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
 | 
			
		||||
            })
 | 
			
		||||
                .AddJwtBearer(o =>
 | 
			
		||||
                {
 | 
			
		||||
                    o.RequireHttpsMetadata = false;
 | 
			
		||||
| 
						 | 
				
			
			@ -102,12 +109,22 @@ namespace Guestbooky.API
 | 
			
		|||
                        ValidateLifetime = true,
 | 
			
		||||
                        ClockSkew = TimeSpan.Zero
 | 
			
		||||
                    };
 | 
			
		||||
                    o.Events = new JwtBearerEvents
 | 
			
		||||
                    {
 | 
			
		||||
                        OnMessageReceived = context =>
 | 
			
		||||
                        {
 | 
			
		||||
                            context.Token = context.Request.Cookies["token"];
 | 
			
		||||
                            return Task.CompletedTask;
 | 
			
		||||
                        }
 | 
			
		||||
                    };
 | 
			
		||||
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
            builder.Services.AddInfrastructure(builder.Configuration);
 | 
			
		||||
            builder.Services.AddApplication();
 | 
			
		||||
 | 
			
		||||
            builder.Services.AddSingleton(new APISettings(){ RunningEnvironment = GetRunningEnvironment(builder.Configuration["ASPNETCORE_ENVIRONMENT"]!) });
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
            if (builder.Environment.IsDevelopment())
 | 
			
		||||
            {
 | 
			
		||||
| 
						 | 
				
			
			@ -170,5 +187,12 @@ namespace Guestbooky.API
 | 
			
		|||
            _ => conf.MinimumLevel.Information()
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        public static RunningEnvironment GetRunningEnvironment(string env) => env.ToUpper() switch 
 | 
			
		||||
        {
 | 
			
		||||
            "DEVELOPMENT" => RunningEnvironment.Development,
 | 
			
		||||
            "PRODUCTION" => RunningEnvironment.Production,
 | 
			
		||||
            _ => RunningEnvironment.Unknown
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -53,7 +53,7 @@ namespace Guestbooky.Infrastructure.Persistence.Repositories
 | 
			
		|||
            var messageDtos = await _messages.Find(_ => true)
 | 
			
		||||
                .SortBy(x => x.Timestamp)
 | 
			
		||||
                .Skip((int?)offset)
 | 
			
		||||
                .Limit(50)
 | 
			
		||||
                .Limit(10)
 | 
			
		||||
                .ToCursorAsync(cancellationToken);
 | 
			
		||||
            return messageDtos.ToEnumerable().Select(MapToDomainModel).ToArray();
 | 
			
		||||
        }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		
		Reference in a new issue