esoe 2 weeks ago
parent
commit
3b7db03aea
  1. 30
      docs/states.puml
  2. 58
      main/src/main/java/gsp/technologies/main/access/login/AuthController.java
  3. 25
      main/src/main/java/gsp/technologies/main/access/login/AuthDTO.java
  4. 33
      main/src/main/java/gsp/technologies/main/access/logout/LogoutController.java
  5. 73
      main/src/main/java/gsp/technologies/main/account/AccountController.java
  6. 21
      main/src/main/java/gsp/technologies/main/account/AccountDTO.java
  7. 19
      main/src/main/java/gsp/technologies/main/account/CourseDTO.java
  8. 15
      main/src/main/java/gsp/technologies/main/account/OrganizationDTO.java
  9. 18
      main/src/main/java/gsp/technologies/main/account/PositionDTO.java
  10. 17
      main/src/main/java/gsp/technologies/main/code/Code.java
  11. 53
      main/src/main/java/gsp/technologies/main/controllers/MainframeController.java
  12. 22
      main/src/main/java/gsp/technologies/main/course/CourseController.java
  13. 24
      main/src/main/java/gsp/technologies/main/mainframe/MainframeController.java
  14. 2
      main/src/main/resources/static/content/courses/1/main.md
  15. BIN
      main/src/main/resources/static/content/courses/1/pictures/states.png
  16. 31
      main/src/main/resources/templates/account.html
  17. 31
      main/src/main/resources/templates/course.html
  18. 46
      main/src/main/resources/templates/fragments/account.html
  19. 2
      main/src/main/resources/templates/fragments/common/footer.html
  20. 13
      main/src/main/resources/templates/fragments/controls.html
  21. 10
      main/src/main/resources/templates/fragments/course.html
  22. 2
      main/src/main/resources/templates/mainframe.html
  23. BIN
      out/docs/states/states.png

30
docs/states.puml

@ -5,31 +5,33 @@ left to right direction
[*] --> mainframe : exam-main (localhost:100) [*] --> mainframe : exam-main (localhost:100)
' mainframe ' mainframe
state "mainframe" as mainframe { state "Страница входа (mainframe)" as mainframe {
state "fragment: authorization" as authorization{ state "fragment: login" as login{
authorization: - поле для внесения кода попытки login: - поле для внесения кода попытки
authorization: - кнопка перехода к продолжению существующей попытки (курсу) login: - кнопка перехода к продолжению существующей попытки (курсу)
authorization: фрагмент может быть скрыт от пользователя, если хотим исключить \nвозможность входа по коду попытки login: фрагмент может быть скрыт от пользователя, если хотим исключить \nвозможность входа по коду попытки
state "hidden: Аутентификация" as authentification
state "hidden: Авторизация" as authorization
state "hidden: session active" as active
} }
state "fragment: registration" as registration { state "fragment: registration" as registration {
registration: - селект наименования организации registration: - селект наименования организации
registration: - селект наименования должности registration: - селект наименования должности
registration: - кнопка перехода к новой попытке (курсу) registration: - кнопка перехода к новой попытке (курсу)
} }
state "fragment: hello" as hello { state "static: hello" as hello {
hello : - Приветствие (*.md статический контент) hello : - Приветствие (*.md статический контент)
hello : - Коментарии для новых пользователей (*.md статический контент) hello : - Коментарии для новых пользователей (*.md статический контент)
hello : - Пояснения по обработке персональных данных (*.md статический контент) hello : - Пояснения по обработке персональных данных (*.md статический контент)
} }
state "hidden: session active" as active
} }
authorization --> access : id аккаунта
registration --> access : наименование организации и должность registration --> access : наименование организации и должность
authorization --> access : код попытки
active --> access : id текущей сессии active --> access : id текущей сессии
state "Проверка доступа" as access { state "Проверка доступа (access)" as access {
access: проверка наличия кода попытки в базе access: проверка наличия кода попытки в базе
access: проверка наличия текущей сессии в базе access: проверка наличия текущей сессии в базе
access: создание нового аккаунта access: создание нового аккаунта
@ -39,7 +41,7 @@ state "Проверка доступа" as access {
access --> mainframe : запрошенный аккаунт отсутствует access --> mainframe : запрошенный аккаунт отсутствует
access --> account : направление пользователя к запрошенному аккаунту access --> account : направление пользователя к запрошенному аккаунту
state "Назначенные курсы" as account { state "Аккаунт (account)" as account {
account: - ID попытки account: - ID попытки
account: - перечень доступных курсов account: - перечень доступных курсов
account: - выход из id (сброс сессии) account: - выход из id (сброс сессии)
@ -60,9 +62,9 @@ state "Назначенные курсы" as account {
exit --> mainframe : сброс сессии и возврат на главную страницу exit --> mainframe : сброс сессии и возврат на главную страницу
state "Курс" as course { state "Курс (course)" as course {
state "fragment: content" as courseContent{ state "static: content" as courseContent {
courseContent: статический контент *.md courseContent: статический контент *.md
courseContent: - img courseContent: - img
courseContent: - video courseContent: - video
@ -89,7 +91,7 @@ courseControls --> account : переход к списку доступных
courseControls --> question : запрос первого вопроса courseControls --> question : запрос первого вопроса
quiz --> question : следющий вопрос quiz --> question : следющий вопрос
state "Вопос" as question { state "Вопос (question)" as question {
question: тело вопроса question: тело вопроса
question: варианты ответа question: варианты ответа
question: переход к следующему вопросу question: переход к следующему вопросу

58
main/src/main/java/gsp/technologies/main/access/login/AuthController.java

@ -0,0 +1,58 @@
package gsp.technologies.main.access.login;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.context.request.RequestContextHolder;
import gsp.technologies.main.mainframe.MainframeController;
/**
* Контроллер авторизации пользователя
* - обработка запросов с учетными данными пользователя (id аккаунта)
* - обработка запросов с данным сессии (id сессии)
*
* сюда обращаются сервисы, которые хотят получить доступ к аккаунту
* - mainframe (содержит поля для перехода к аккаунту)
* - login (разработка формы пока не предусмотрена)
*/
@Controller
@RequestMapping(path = "/auth")
public class AuthController {
private static final Logger log = LoggerFactory.getLogger(MainframeController.class);
/**
* Проверка наличия аккаунта в базе.
* В метод передается номер аккаунта в 35-ричной системе счисления
* номер аккаунта декодируется и направляется запрос на проверку в базу
* при отсутствии аккаунта в базе возвращается соответствующее сообщение
* при наличии аккаунта в базе возвращаются данные для перехода к запрошенному аккаунту
*
* @return
*
*/
@GetMapping("/check")
public AuthDTO checkAccount(@RequestParam("account") String account) {
log.info("GET /auth/check");
log.info("Запрошен номер аккаунта: {}", account);
//сравнить номер сессии с имеющимися в базе
//при наличии вернуть порядковый номер существующей сессии (в 35-ричной системе счисления)
//при отсутствии внести новую запись в базу и вернуть ее порядковый номер
return new AuthDTO();
}
@GetMapping("/current")
public AuthDTO checkSession(@RequestParam("session") String session) {
log.info("GET /auth/current");
log.info("Запрошен номер сессии: {}", session);
return new AuthDTO();
}
}

25
main/src/main/java/gsp/technologies/main/access/login/AuthDTO.java

@ -0,0 +1,25 @@
package gsp.technologies.main.access.login;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO для обмена данными с сервисом авторизации
* хранит:
* - номер сессии пользователя
* - номер запрошенного аккаунта
*/
@AllArgsConstructor
@NoArgsConstructor
@Data
public class AuthDTO implements Serializable {
private String sessionId;
private String accountId;
private String code35;
}

33
main/src/main/java/gsp/technologies/main/access/logout/LogoutController.java

@ -0,0 +1,33 @@
package gsp.technologies.main.access.logout;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
/**
* Контроллер для выхода из системы
*/
@Controller
@RequestMapping(path = "/logout")
public class LogoutController {
private static final Logger log = LoggerFactory.getLogger(LogoutController.class);
@GetMapping("")
public String mainframe() {
log.info("GET /logout");
log.info("текущая сессия: {}", RequestContextHolder.currentRequestAttributes().getSessionId());
//logout
ServletRequestAttributes attr = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
attr.getRequest().getSession().invalidate();
log.info("новая сессия: {}", RequestContextHolder.currentRequestAttributes().getSessionId());
//возвращаем пользователя на исходную страницу, с новой сссией
String referer = attr.getRequest().getHeader("Referer");
return "redirect:"+ referer;
}
}

73
main/src/main/java/gsp/technologies/main/account/AccountController.java

@ -0,0 +1,73 @@
package gsp.technologies.main.account;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.context.request.RequestContextHolder;
import gsp.technologies.main.code.Code;
/**
* Контроллер формы аккаунта
* возвращает thymeleaf шаблон
*/
@Controller
@RequestMapping(path = "/account")
public class AccountController {
private static final Logger log = LoggerFactory.getLogger(AccountController.class);
@GetMapping("")
public String account(Model model) {
log.info("GET /account");
//данные аккаунта (id, organization, position)
Long id = 1L;
String code = Code.encode(id);
OrganizationDTO organization = OrganizationDTO.builder()
.id(1L)
.name("ГСП-Т")
.build();
PositionDTO position = PositionDTO.builder()
.id(1L)
.name("Сварщик")
.organization(organization)
.build();
String sessionid = RequestContextHolder.currentRequestAttributes().getSessionId();
List<CourseDTO> courses = List.of(
CourseDTO.builder()
.id(1L)
.name("Охрана труда")
.passed(true)
.build(),
CourseDTO.builder()
.id(2L)
.name("Работы на высоте")
.passed(false)
.build(),
CourseDTO.builder()
.id(3L)
.name("Первая помощь")
.passed(true)
.build()
);
AccountDTO account = AccountDTO.builder()
.id(id)
.code(code)
.position(position)
.sessionid(sessionid)
.courses(courses)
.build();
model.addAttribute("account", account);
log.info("account: " + account);
//перечень назначенных курсов
// ...
return "account";
}
}

21
main/src/main/java/gsp/technologies/main/account/AccountDTO.java

@ -0,0 +1,21 @@
package gsp.technologies.main.account;
import java.io.Serializable;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
public class AccountDTO implements Serializable {
private Long id;
private String code;
private PositionDTO position;
private String sessionid;
private List<CourseDTO> courses;
}

19
main/src/main/java/gsp/technologies/main/account/CourseDTO.java

@ -0,0 +1,19 @@
package gsp.technologies.main.account;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Data
public class CourseDTO implements Serializable {
private Long id;
private String name;
private Boolean passed;
}

15
main/src/main/java/gsp/technologies/main/account/OrganizationDTO.java

@ -0,0 +1,15 @@
package gsp.technologies.main.account;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Data
public class OrganizationDTO {
private Long id;
private String name; //наименование организации
}

18
main/src/main/java/gsp/technologies/main/account/PositionDTO.java

@ -0,0 +1,18 @@
package gsp.technologies.main.account;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Data
public class PositionDTO implements Serializable {
private Long id;
private String name;
private OrganizationDTO organization;
}

17
main/src/main/java/gsp/technologies/main/code/Code.java

@ -0,0 +1,17 @@
package gsp.technologies.main.code;
/**
* Класс, содержащий методы преобразования id в 35-ричное представление
*/
public abstract class Code {
public static String encode(Long value) {
String code = value.toString();
return code;
}
public static Long decode(String code) {
Long value = Long.valueOf(code);
return value;
}
}

53
main/src/main/java/gsp/technologies/main/controllers/MainframeController.java

@ -1,53 +0,0 @@
package gsp.technologies.main.controllers;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
/**
* Контроллер для главной страницы:
* - приветствие
* - пояснения
* - переход к учебному курсу moodle
* - переход к учебному курсу custom
*/
@Controller
@RequestMapping(path = "/mainframe")
public class MainframeController {
private static final Logger log = LoggerFactory.getLogger(MainframeController.class);
@GetMapping("")
public String mainframe() {
log.info("GET /mainframe");
log.info("текущая сессия: {}", RequestContextHolder.currentRequestAttributes().getSessionId());
//сравнить номер сессии с имеющимися в базе
//при наличии вернуть порядковый номер существующей сессии (в 35-ричной системе счисления)
//при отсутствии внести новую запись в базу и вернуть ее порядковый номер
return "mainframe";
}
@PostMapping("/moodle")
public String moodle(@RequestBody String entity) {
//TODO: process POST request
return entity;
}
@PostMapping("/exam")
public String exam(@RequestBody String entity) {
//TODO: process POST request
return entity;
}
}

22
main/src/main/java/gsp/technologies/main/course/CourseController.java

@ -0,0 +1,22 @@
package gsp.technologies.main.course;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping(path = "/courses")
public class CourseController {
private static final Logger log = LoggerFactory.getLogger(CourseController.class);
@GetMapping("/view/{id}")
public String course(@PathVariable Long id) {
log.info("GET /courses");
return "course";
}
}

24
main/src/main/java/gsp/technologies/main/controllers/AuthController.java → main/src/main/java/gsp/technologies/main/mainframe/MainframeController.java

@ -1,29 +1,37 @@
package gsp.technologies.main.controllers; package gsp.technologies.main.mainframe;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import jakarta.servlet.ServletException;
import org.springframework.web.bind.annotation.GetMapping;
/** /**
* Контроллер авторизации пользователя * Контроллер для главной страницы:
* - приветствие / пояснения
* - форма регистрации
* - форма авторизации/login
*
*/ */
@Controller @Controller
@RequestMapping(path = "/auth") @RequestMapping(path = "/mainframe")
public class AuthController { public class MainframeController {
private static final Logger log = LoggerFactory.getLogger(MainframeController.class); private static final Logger log = LoggerFactory.getLogger(MainframeController.class);
@GetMapping("") @GetMapping("")
public String mainframe() { public String mainframe() {
log.info("GET /auth"); log.info("GET /mainframe");
log.info("текущая сессия: {}", RequestContextHolder.currentRequestAttributes().getSessionId()); log.info("текущая сессия: {}", RequestContextHolder.currentRequestAttributes().getSessionId());
//сравнить номер сессии с имеющимися в базе //сравнить номер сессии с имеющимися в базе
//при наличии вернуть порядковый номер существующей сессии (в 35-ричной системе счисления) //при наличии вернуть порядковый номер существующей сессии (в 35-ричной системе счисления)
//при отсутствии внести новую запись в базу и вернуть ее порядковый номер //при отсутствии внести новую запись в базу и вернуть ее порядковый номер
return "auth"; return "mainframe";
} }
} }

2
main/src/main/resources/static/content/courses/1/main.md

@ -0,0 +1,2 @@
# Первая помощь
![pic](/content/courses/1/pictures/states.png)

BIN
main/src/main/resources/static/content/courses/1/pictures/states.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

31
main/src/main/resources/templates/account.html

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>exam-account</title>
<script src="https://cdn.jsdelivr.net/npm/@webcomponents/webcomponentsjs@2/webcomponents-loader.min.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/gh/zerodevx/zero-md@1/src/zero-md.min.js"></script>
<style>
.code {
color: red;
font-weight: bold;
font-size: 300%;
}
</style>
</head>
<header>
<!-- Информация об аккаунте: id, organization, position -->
<div th:insert="~{fragments/account :: info(account=${account})}"></div>
</header>
<body>
<!-- Перечень доступных курсов -->
<div th:insert="~{fragments/account :: courses(account=${account})}"></div>
</body>
<footer>
<!-- Выход из аккаунта, обновление сессии -->
<div th:insert="~{fragments/controls :: logout}"></div>
<div th:insert="~{fragments/common/footer :: copy}"></div>
</footer>
</html>

31
main/src/main/resources/templates/course.html

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>exam-account</title>
<script src="https://cdn.jsdelivr.net/npm/@webcomponents/webcomponentsjs@2/webcomponents-loader.min.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/gh/zerodevx/zero-md@1/src/zero-md.min.js"></script>
<style>
.code {
color: red;
font-weight: bold;
font-size: 300%;
}
</style>
</head>
<header>
<!-- Информация об аккаунте: id, organization, position -->
<!-- <div th:insert="~{fragments/account :: info(account=${account})}"></div> -->
</header>
<body>
<!-- первый курс -->
<div th:insert="~{fragments/course :: main}"></div>
</body>
<footer>
<!-- Выход из аккаунта, обновление сессии -->
<!-- <div th:insert="~{fragments/controls :: logout}"></div>
<div th:insert="~{fragments/common/footer :: copy}"></div> -->
</footer>
</html>

46
main/src/main/resources/templates/fragments/account.html

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<!-- Данные об аккаунте: id, organization, position -->
<div th:fragment="info(account)">
<hr>
<!-- <span>id: <span th:text="${account.id}"></span></span>
<br> -->
<span class="code">ID: <span th:text="${account.code}"></span></span>
<!-- <br>
<span>session: <span th:text="${account.sessionid}"></span></span>
<br>
<span>organization: <span th:text="${account.position.organization.name}"></span></span>
<br>
<span>position: <span th:text="${account.position.name}"></span></span> -->
</div>
<!-- Доступные на аккаунте курсы -->
<div th:fragment="courses(account)">
<hr>
<table>
<thead>
<th>номер</th>
<th>наименование</th>
<th>статус</th>
</thead>
<tbody>
<tr th:each="course : ${account.courses}">
<td th:text="${course.id}"></td>
<td>
<a th:href="@{/courses/view/{id}(id=${course.id})}" th:text="${course.name}"></a>
</td>
<td>
<div th:switch="${course.passed}">
<span th:case="true">пройден</span>
<span th:case="false">не пройден</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

2
main/src/main/resources/templates/fragments/common/footer.html

@ -3,7 +3,7 @@
<body> <body>
<div th:fragment="copy"> <div th:fragment="copy">
<hr> <hr>
<b>© 2024 ООО "ГСП-Технологии" : gsp.technologies.exam.main</b> <b>© 2024 ООО "ГСП-Технологии" : gsp.technologies.exam</b>
</div> </div>
<div th:fragment="info"> <div th:fragment="info">
<hr> <hr>

13
main/src/main/resources/templates/fragments/controls.html

@ -34,7 +34,7 @@
<p>Укажите данные для создания нового задания</p> <p>Укажите данные для создания нового задания</p>
<!-- отправляем наименование компании и наименование должности <!-- отправляем наименование компании и наименование должности
для перехода на целевой курс --> для перехода на целевой курс -->
<form th:action="@{/}" th:method="post"> <form th:action="@{/account}" th:method="get">
<label for="company">компания: </label> <label for="company">компания: </label>
<select> <select>
<option value="1">ГСП-1</option> <option value="1">ГСП-1</option>
@ -53,7 +53,7 @@
</select> </select>
<br> <br>
<input type="submit" value="Перейти к exam"> <input type="submit" value="Создать аккаунт">
</form> </form>
</div> </div>
@ -61,10 +61,17 @@
<!-- Отправляем запрос с параметром code <!-- Отправляем запрос с параметром code
открываем существующую попытку --> открываем существующую попытку -->
<form th:action="@{/auth}" th:method="post"> <form th:action="@{/auth}" th:method="post">
<label for="code">Если Вам нужно продолжить прохождение уже начатого курса - введите код попытки: </label> <label for="code">Если Вам нужно продолжить прохождение уже начатого курса - введите ID своего аккаунта: </label>
<input type="text" id="code" name="code"> <input type="text" id="code" name="code">
<input type="submit" value="Проверить код"> <input type="submit" value="Проверить код">
</form> </form>
</div> </div>
<div th:fragment="logout">
<hr>
<form th:action="@{/logout}" th:method="get">
<input type="submit" value="выйти (сбросить сессию)">
</form>
</div>
</body> </body>
</html> </html>

10
main/src/main/resources/templates/fragments/course.html

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div th:fragment="main">
<hr>
<zero-md th:src="@{/content/courses/1/main.md}"></zero-md>
</div>
</body>
</html>

2
main/src/main/resources/templates/mainframe.html

@ -19,7 +19,7 @@ xmlns:th="http://www.thymeleaf.org">
<div th:insert="~{fragments/controls :: exam}"></div> <div th:insert="~{fragments/controls :: exam}"></div>
</body> </body>
<footer> <footer>
<!-- <div th:insert="~{fragments/common/footer :: info}"></div> --> <div th:insert="~{fragments/controls :: logout}"></div>
<div th:insert="~{fragments/common/footer :: copy}"></div> <div th:insert="~{fragments/common/footer :: copy}"></div>
</footer> </footer>
</html> </html>

BIN
out/docs/states/states.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Loading…
Cancel
Save