diff --git a/.PVS-Studio/State/WindowState.json b/.PVS-Studio/State/WindowState.json new file mode 100644 index 0000000000000000000000000000000000000000..9b06f6a21f746ac813f2d58650b5f32cd8240262 --- /dev/null +++ b/.PVS-Studio/State/WindowState.json @@ -0,0 +1,23 @@ +{ + "isDataChanged": false, + "filters": { + "code": "", + "cwe": "", + "sastId": "", + "message": "", + "files": "", + "level": [ + 0, + 1, + 2 + ], + "isColumnFiltersVisible": false, + "isLevelFiltersVisible": true, + "isGroupFiltersVisible": true, + "type": [ + "General", + "Optimization" + ] + }, + "loadableProcessIds": [] +} \ No newline at end of file diff --git a/.PVS-Studio/Temp/.PVS-Studio/.gitignore b/.PVS-Studio/Temp/.PVS-Studio/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..6b1f31f1da3c679fbfa6cdf3ba0e88fcefc943fe --- /dev/null +++ b/.PVS-Studio/Temp/.PVS-Studio/.gitignore @@ -0,0 +1,2 @@ +logs/ +models/ \ No newline at end of file diff --git a/.PVS-Studio/nonAnalyzedProjects.log b/.PVS-Studio/nonAnalyzedProjects.log new file mode 100644 index 0000000000000000000000000000000000000000..0637a088a01e8ddab3bf3fa98dbe804cbde1a0dc --- /dev/null +++ b/.PVS-Studio/nonAnalyzedProjects.log @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/.PVS-Studio/project.json b/.PVS-Studio/project.json new file mode 100644 index 0000000000000000000000000000000000000000..31656ce98cb72e30413d1e0be0e0542cd8fe9235 --- /dev/null +++ b/.PVS-Studio/project.json @@ -0,0 +1,329 @@ +{ + "modules": [ + { + "name": "DarkMoon1", + "path": "f:\\DarkMoon1\\", + "sources": [ + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\DarkFantasyApplication.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\annotation\\ChangePasswordMatch.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\annotation\\RegisterPasswordMatch.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\annotation\\ResetPasswordMatch.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\config\\SchedulingConfig.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\config\\WebConfig.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\constant\\CmsMenu.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\constant\\Routes.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\controller\\AdminController.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\controller\\ArticleController.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\controller\\ContactMessageController.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\controller\\DashboardController.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\controller\\FaqController.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\controller\\GameCharacterController.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\controller\\ImageController.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\controller\\InternalController.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\controller\\PageController.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\controller\\StoryController.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\controller\\UserController.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\controller\\WorldController.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\article\\ArticleResponse.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\article\\CreateArticleRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\article\\UpdateArticleRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\contact\\ContactMessageResponse.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\contact\\CreateContactMessageRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\faq\\CreateFaqRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\faq\\FaqResponse.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\faq\\UpdateFaqRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\gamecharacter\\CreateGameCharacterRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\gamecharacter\\GameCharacterResponse.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\gamecharacter\\UpdateGameCharacterRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\log\\AuditLogResponse.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\story\\CreateStoryRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\story\\StoryResponse.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\story\\UpdateStoryRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\user\\ChangePasswordRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\user\\ForgotPasswordRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\user\\LoginRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\user\\RegisterRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\user\\ResetPasswordRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\user\\UserResponse.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\user\\VerifyOtpRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\world\\CreateWorldRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\world\\UpdateWorldRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\world\\WorldResponse.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\Article.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\AuditLog.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\ContactMessage.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\Faq.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\GameCharacter.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\PasswordResetToken.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\Story.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\User.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\World.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\converter\\ArticleTypeConverter.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\converter\\ContactCategoryConverter.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\converter\\ContactPlatformConverter.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\converter\\LogActionConverter.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\converter\\LogEntityTypeConverter.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\converter\\RoleConverter.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\enums\\ArticleType.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\enums\\ContactCategory.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\enums\\ContactPlatform.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\enums\\LogAction.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\enums\\LogEntityType.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\enums\\Role.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\exception\\GlobalExceptionHandler.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\repository\\ArticleRepository.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\repository\\AuditLogRepository.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\repository\\ContactMessageRepository.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\repository\\FaqRepository.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\repository\\GameCharacterRepository.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\repository\\PasswordResetTokenRepository.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\repository\\StoryRepository.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\repository\\UserRepository.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\repository\\WorldRepository.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\security\\ForceChangePasswordInterceptor.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\security\\SecurityConfig.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\ArticleService.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\AuditLogService.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\ContactMessageService.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\CustomUserDetailsService.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\FaqService.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\FileStorageService.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\GameCharacterService.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\StoryService.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\UserService.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\WorldService.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\impl\\ArticleServiceImpl.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\impl\\AuditLogServiceImpl.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\impl\\ContactMessageServiceImpl.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\impl\\FaqServiceImpl.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\impl\\FileStorageServiceImpl.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\impl\\GameCharacterServiceImpl.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\impl\\StoryServiceImpl.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\impl\\UserServiceImpl.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\impl\\WorldServiceImpl.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\util\\SecurityUtil.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\util\\TimeUtil.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\validator\\PasswordChangeMatchValidator.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\validator\\PasswordRegisterMatchValidator.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\validator\\ResetPasswordMatchValidator.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\DarkFantasyApplication.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\annotation\\ChangePasswordMatch.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\annotation\\RegisterPasswordMatch.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\annotation\\ResetPasswordMatch.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\config\\SchedulingConfig.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\config\\WebConfig.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\constant\\CmsMenu.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\constant\\Routes.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\controller\\AdminController.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\controller\\ArticleController.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\controller\\ContactMessageController.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\controller\\DashboardController.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\controller\\FaqController.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\controller\\GameCharacterController.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\controller\\ImageController.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\controller\\InternalController.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\controller\\PageController.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\controller\\StoryController.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\controller\\UserController.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\controller\\WorldController.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\article\\ArticleResponse.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\article\\CreateArticleRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\article\\UpdateArticleRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\contact\\ContactMessageResponse.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\contact\\CreateContactMessageRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\faq\\CreateFaqRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\faq\\FaqResponse.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\faq\\UpdateFaqRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\gamecharacter\\CreateGameCharacterRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\gamecharacter\\GameCharacterResponse.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\gamecharacter\\UpdateGameCharacterRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\log\\AuditLogResponse.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\story\\CreateStoryRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\story\\StoryResponse.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\story\\UpdateStoryRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\user\\ChangePasswordRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\user\\ForgotPasswordRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\user\\LoginRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\user\\RegisterRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\user\\ResetPasswordRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\user\\UserResponse.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\user\\VerifyOtpRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\world\\CreateWorldRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\world\\UpdateWorldRequest.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\dto\\world\\WorldResponse.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\Article.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\AuditLog.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\ContactMessage.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\Faq.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\GameCharacter.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\PasswordResetToken.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\Story.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\User.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\World.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\converter\\ArticleTypeConverter.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\converter\\ContactCategoryConverter.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\converter\\ContactPlatformConverter.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\converter\\LogActionConverter.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\converter\\LogEntityTypeConverter.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\converter\\RoleConverter.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\enums\\ArticleType.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\enums\\ContactCategory.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\enums\\ContactPlatform.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\enums\\LogAction.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\enums\\LogEntityType.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\entity\\enums\\Role.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\exception\\GlobalExceptionHandler.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\repository\\ArticleRepository.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\repository\\AuditLogRepository.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\repository\\ContactMessageRepository.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\repository\\FaqRepository.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\repository\\GameCharacterRepository.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\repository\\PasswordResetTokenRepository.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\repository\\StoryRepository.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\repository\\UserRepository.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\repository\\WorldRepository.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\security\\ForceChangePasswordInterceptor.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\security\\SecurityConfig.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\ArticleService.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\AuditLogService.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\ContactMessageService.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\CustomUserDetailsService.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\FaqService.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\FileStorageService.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\GameCharacterService.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\StoryService.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\UserService.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\WorldService.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\impl\\ArticleServiceImpl.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\impl\\AuditLogServiceImpl.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\impl\\ContactMessageServiceImpl.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\impl\\FaqServiceImpl.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\impl\\FileStorageServiceImpl.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\impl\\GameCharacterServiceImpl.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\impl\\StoryServiceImpl.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\impl\\UserServiceImpl.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\service\\impl\\WorldServiceImpl.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\util\\SecurityUtil.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\util\\TimeUtil.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\validator\\PasswordChangeMatchValidator.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\validator\\PasswordRegisterMatchValidator.java", + "f:\\DarkMoon1\\src\\main\\java\\com\\darkfantasy\\validator\\ResetPasswordMatchValidator.java", + "f:\\DarkMoon1\\target\\" + ], + "testSources": [], + "generatedSources": [ + "f:\\DarkMoon1\\target\\" + ], + "classpath": [ + "F:\\DarkMoon1\\target\\classes", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\boot\\spring-boot-starter-mail\\3.2.5\\spring-boot-starter-mail-3.2.5.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\boot\\spring-boot-starter\\3.2.5\\spring-boot-starter-3.2.5.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\boot\\spring-boot-starter-logging\\3.2.5\\spring-boot-starter-logging-3.2.5.jar", + "C:\\Users\\Admin\\.m2\\repository\\ch\\qos\\logback\\logback-classic\\1.4.14\\logback-classic-1.4.14.jar", + "C:\\Users\\Admin\\.m2\\repository\\ch\\qos\\logback\\logback-core\\1.4.14\\logback-core-1.4.14.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\apache\\logging\\log4j\\log4j-to-slf4j\\2.21.1\\log4j-to-slf4j-2.21.1.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\apache\\logging\\log4j\\log4j-api\\2.21.1\\log4j-api-2.21.1.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\slf4j\\jul-to-slf4j\\2.0.13\\jul-to-slf4j-2.0.13.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\spring-core\\6.1.6\\spring-core-6.1.6.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\spring-jcl\\6.1.6\\spring-jcl-6.1.6.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\yaml\\snakeyaml\\2.2\\snakeyaml-2.2.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\spring-context-support\\6.1.6\\spring-context-support-6.1.6.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\spring-beans\\6.1.6\\spring-beans-6.1.6.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\spring-context\\6.1.6\\spring-context-6.1.6.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\eclipse\\angus\\jakarta.mail\\2.0.3\\jakarta.mail-2.0.3.jar", + "C:\\Users\\Admin\\.m2\\repository\\jakarta\\activation\\jakarta.activation-api\\2.1.3\\jakarta.activation-api-2.1.3.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\eclipse\\angus\\angus-activation\\2.0.2\\angus-activation-2.0.2.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\thymeleaf\\extras\\thymeleaf-extras-springsecurity6\\3.1.2.RELEASE\\thymeleaf-extras-springsecurity6-3.1.2.RELEASE.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\thymeleaf\\thymeleaf-spring6\\3.1.2.RELEASE\\thymeleaf-spring6-3.1.2.RELEASE.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\thymeleaf\\thymeleaf\\3.1.2.RELEASE\\thymeleaf-3.1.2.RELEASE.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\attoparser\\attoparser\\2.0.7.RELEASE\\attoparser-2.0.7.RELEASE.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\unbescape\\unbescape\\1.1.6.RELEASE\\unbescape-1.1.6.RELEASE.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\slf4j\\slf4j-api\\2.0.13\\slf4j-api-2.0.13.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\boot\\spring-boot-starter-thymeleaf\\3.2.5\\spring-boot-starter-thymeleaf-3.2.5.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\mariadb\\jdbc\\mariadb-java-client\\3.3.3\\mariadb-java-client-3.3.3.jar", + "C:\\Users\\Admin\\.m2\\repository\\com\\github\\waffle\\waffle-jna\\3.3.0\\waffle-jna-3.3.0.jar", + "C:\\Users\\Admin\\.m2\\repository\\net\\java\\dev\\jna\\jna\\5.13.0\\jna-5.13.0.jar", + "C:\\Users\\Admin\\.m2\\repository\\net\\java\\dev\\jna\\jna-platform\\5.13.0\\jna-platform-5.13.0.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\slf4j\\jcl-over-slf4j\\2.0.13\\jcl-over-slf4j-2.0.13.jar", + "C:\\Users\\Admin\\.m2\\repository\\com\\github\\ben-manes\\caffeine\\caffeine\\3.1.8\\caffeine-3.1.8.jar", + "C:\\Users\\Admin\\.m2\\repository\\com\\google\\errorprone\\error_prone_annotations\\2.21.1\\error_prone_annotations-2.21.1.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\checkerframework\\checker-qual\\3.32.0\\checker-qual-3.32.0.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\boot\\spring-boot-starter-validation\\3.2.5\\spring-boot-starter-validation-3.2.5.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\apache\\tomcat\\embed\\tomcat-embed-el\\10.1.20\\tomcat-embed-el-10.1.20.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\hibernate\\validator\\hibernate-validator\\8.0.1.Final\\hibernate-validator-8.0.1.Final.jar", + "C:\\Users\\Admin\\.m2\\repository\\jakarta\\validation\\jakarta.validation-api\\3.0.2\\jakarta.validation-api-3.0.2.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\jboss\\logging\\jboss-logging\\3.5.3.Final\\jboss-logging-3.5.3.Final.jar", + "C:\\Users\\Admin\\.m2\\repository\\com\\fasterxml\\classmate\\1.6.0\\classmate-1.6.0.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\boot\\spring-boot-starter-data-jpa\\3.2.5\\spring-boot-starter-data-jpa-3.2.5.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\boot\\spring-boot-starter-aop\\3.2.5\\spring-boot-starter-aop-3.2.5.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\spring-aop\\6.1.6\\spring-aop-6.1.6.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\aspectj\\aspectjweaver\\1.9.22\\aspectjweaver-1.9.22.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\boot\\spring-boot-starter-jdbc\\3.2.5\\spring-boot-starter-jdbc-3.2.5.jar", + "C:\\Users\\Admin\\.m2\\repository\\com\\zaxxer\\HikariCP\\5.0.1\\HikariCP-5.0.1.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\spring-jdbc\\6.1.6\\spring-jdbc-6.1.6.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\hibernate\\orm\\hibernate-core\\6.4.4.Final\\hibernate-core-6.4.4.Final.jar", + "C:\\Users\\Admin\\.m2\\repository\\jakarta\\persistence\\jakarta.persistence-api\\3.1.0\\jakarta.persistence-api-3.1.0.jar", + "C:\\Users\\Admin\\.m2\\repository\\jakarta\\transaction\\jakarta.transaction-api\\2.0.1\\jakarta.transaction-api-2.0.1.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\hibernate\\common\\hibernate-commons-annotations\\6.0.6.Final\\hibernate-commons-annotations-6.0.6.Final.jar", + "C:\\Users\\Admin\\.m2\\repository\\io\\smallrye\\jandex\\3.1.2\\jandex-3.1.2.jar", + "C:\\Users\\Admin\\.m2\\repository\\net\\bytebuddy\\byte-buddy\\1.14.13\\byte-buddy-1.14.13.jar", + "C:\\Users\\Admin\\.m2\\repository\\jakarta\\xml\\bind\\jakarta.xml.bind-api\\4.0.2\\jakarta.xml.bind-api-4.0.2.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\glassfish\\jaxb\\jaxb-runtime\\4.0.5\\jaxb-runtime-4.0.5.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\glassfish\\jaxb\\jaxb-core\\4.0.5\\jaxb-core-4.0.5.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\glassfish\\jaxb\\txw2\\4.0.5\\txw2-4.0.5.jar", + "C:\\Users\\Admin\\.m2\\repository\\com\\sun\\istack\\istack-commons-runtime\\4.1.2\\istack-commons-runtime-4.1.2.jar", + "C:\\Users\\Admin\\.m2\\repository\\jakarta\\inject\\jakarta.inject-api\\2.0.1\\jakarta.inject-api-2.0.1.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\antlr\\antlr4-runtime\\4.13.0\\antlr4-runtime-4.13.0.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\data\\spring-data-jpa\\3.2.5\\spring-data-jpa-3.2.5.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\data\\spring-data-commons\\3.2.5\\spring-data-commons-3.2.5.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\spring-orm\\6.1.6\\spring-orm-6.1.6.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\spring-tx\\6.1.6\\spring-tx-6.1.6.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\spring-aspects\\6.1.6\\spring-aspects-6.1.6.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\projectlombok\\lombok\\1.18.32\\lombok-1.18.32.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\boot\\spring-boot-starter-oauth2-client\\3.2.5\\spring-boot-starter-oauth2-client-3.2.5.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\security\\spring-security-config\\6.2.4\\spring-security-config-6.2.4.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\security\\spring-security-core\\6.2.4\\spring-security-core-6.2.4.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\security\\spring-security-crypto\\6.2.4\\spring-security-crypto-6.2.4.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\spring-expression\\6.1.6\\spring-expression-6.1.6.jar", + "C:\\Users\\Admin\\.m2\\repository\\io\\micrometer\\micrometer-observation\\1.12.5\\micrometer-observation-1.12.5.jar", + "C:\\Users\\Admin\\.m2\\repository\\io\\micrometer\\micrometer-commons\\1.12.5\\micrometer-commons-1.12.5.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\security\\spring-security-oauth2-client\\6.2.4\\spring-security-oauth2-client-6.2.4.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\security\\spring-security-oauth2-core\\6.2.4\\spring-security-oauth2-core-6.2.4.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\security\\spring-security-web\\6.2.4\\spring-security-web-6.2.4.jar", + "C:\\Users\\Admin\\.m2\\repository\\com\\nimbusds\\oauth2-oidc-sdk\\9.43.3\\oauth2-oidc-sdk-9.43.3.jar", + "C:\\Users\\Admin\\.m2\\repository\\com\\github\\stephenc\\jcip\\jcip-annotations\\1.0-1\\jcip-annotations-1.0-1.jar", + "C:\\Users\\Admin\\.m2\\repository\\com\\nimbusds\\content-type\\2.2\\content-type-2.2.jar", + "C:\\Users\\Admin\\.m2\\repository\\net\\minidev\\json-smart\\2.5.1\\json-smart-2.5.1.jar", + "C:\\Users\\Admin\\.m2\\repository\\net\\minidev\\accessors-smart\\2.5.1\\accessors-smart-2.5.1.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\ow2\\asm\\asm\\9.6\\asm-9.6.jar", + "C:\\Users\\Admin\\.m2\\repository\\com\\nimbusds\\lang-tag\\1.7\\lang-tag-1.7.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\security\\spring-security-oauth2-jose\\6.2.4\\spring-security-oauth2-jose-6.2.4.jar", + "C:\\Users\\Admin\\.m2\\repository\\com\\nimbusds\\nimbus-jose-jwt\\9.24.4\\nimbus-jose-jwt-9.24.4.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\boot\\spring-boot-starter-web\\3.2.5\\spring-boot-starter-web-3.2.5.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\boot\\spring-boot-starter-json\\3.2.5\\spring-boot-starter-json-3.2.5.jar", + "C:\\Users\\Admin\\.m2\\repository\\com\\fasterxml\\jackson\\core\\jackson-databind\\2.15.4\\jackson-databind-2.15.4.jar", + "C:\\Users\\Admin\\.m2\\repository\\com\\fasterxml\\jackson\\core\\jackson-annotations\\2.15.4\\jackson-annotations-2.15.4.jar", + "C:\\Users\\Admin\\.m2\\repository\\com\\fasterxml\\jackson\\core\\jackson-core\\2.15.4\\jackson-core-2.15.4.jar", + "C:\\Users\\Admin\\.m2\\repository\\com\\fasterxml\\jackson\\datatype\\jackson-datatype-jdk8\\2.15.4\\jackson-datatype-jdk8-2.15.4.jar", + "C:\\Users\\Admin\\.m2\\repository\\com\\fasterxml\\jackson\\datatype\\jackson-datatype-jsr310\\2.15.4\\jackson-datatype-jsr310-2.15.4.jar", + "C:\\Users\\Admin\\.m2\\repository\\com\\fasterxml\\jackson\\module\\jackson-module-parameter-names\\2.15.4\\jackson-module-parameter-names-2.15.4.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\spring-web\\6.1.6\\spring-web-6.1.6.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\spring-webmvc\\6.1.6\\spring-webmvc-6.1.6.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\boot\\spring-boot-starter-tomcat\\3.2.5\\spring-boot-starter-tomcat-3.2.5.jar", + "C:\\Users\\Admin\\.m2\\repository\\jakarta\\annotation\\jakarta.annotation-api\\2.1.1\\jakarta.annotation-api-2.1.1.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\apache\\tomcat\\embed\\tomcat-embed-core\\10.1.20\\tomcat-embed-core-10.1.20.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\apache\\tomcat\\embed\\tomcat-embed-websocket\\10.1.20\\tomcat-embed-websocket-10.1.20.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\boot\\spring-boot-devtools\\3.2.5\\spring-boot-devtools-3.2.5.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\boot\\spring-boot\\3.2.5\\spring-boot-3.2.5.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\springframework\\boot\\spring-boot-autoconfigure\\3.2.5\\spring-boot-autoconfigure-3.2.5.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\apache\\tomcat\\embed\\tomcat-embed-jasper\\10.1.20\\tomcat-embed-jasper-10.1.20.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\eclipse\\jdt\\ecj\\3.33.0\\ecj-3.33.0.jar", + "C:\\Users\\Admin\\.m2\\repository\\jakarta\\servlet\\jsp\\jstl\\jakarta.servlet.jsp.jstl-api\\3.0.0\\jakarta.servlet.jsp.jstl-api-3.0.0.jar", + "C:\\Users\\Admin\\.m2\\repository\\jakarta\\servlet\\jakarta.servlet-api\\6.0.0\\jakarta.servlet-api-6.0.0.jar", + "C:\\Users\\Admin\\.m2\\repository\\jakarta\\el\\jakarta.el-api\\5.0.0\\jakarta.el-api-5.0.0.jar", + "C:\\Users\\Admin\\.m2\\repository\\org\\glassfish\\web\\jakarta.servlet.jsp.jstl\\3.0.1\\jakarta.servlet.jsp.jstl-3.0.1.jar", + "C:\\Users\\Admin\\.m2\\repository\\com\\mysql\\mysql-connector-j\\8.3.0\\mysql-connector-j-8.3.0.jar" + ] + } + ] +} \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..df9be75206e198947c7a9ced31a6403540d2568c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +/mvnw text eol=lf +*.cmd text eol=crlf +*.zip filter=lfs diff=lfs merge=lfs -text +*.png filter=lfs diff=lfs merge=lfs -text +*.jpg filter=lfs diff=lfs merge=lfs -text +*.webp filter=lfs diff=lfs merge=lfs -text +*.mp4 filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..667aaef0c891a18c6177b09b53418bf59c6ab91f --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000000000000000000000000000000000000..52913720fda62b57fd584c1bae1415049b51e9fa --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.15/apache-maven-3.9.15-bin.zip diff --git a/DockerFile b/DockerFile new file mode 100644 index 0000000000000000000000000000000000000000..4d847cbe3addfa6e658620ca29af1642b0a827d2 --- /dev/null +++ b/DockerFile @@ -0,0 +1,13 @@ +# ---Build code --- +FROM maven:3.9.6-eclipse-temurin-21-alpine AS build +WORKDIR /app +COPY . . +# Build run +RUN mvn clean package -DskipTests + +FROM eclipse-temurin:21-jdk-alpine +WORKDIR /app +COPY --from=build /app/target/*.war app.war + +EXPOSE 8080 +ENTRYPOINT ["java","-jar","app.war"] diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..7530e1cfb7759380859c3889bb4c4956dd09983b --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# DarkMoon \ No newline at end of file diff --git a/TEST_CASES.md b/TEST_CASES.md new file mode 100644 index 0000000000000000000000000000000000000000..3bb45c58919d59debcf1ad5e4cab3c7bec62edb4 --- /dev/null +++ b/TEST_CASES.md @@ -0,0 +1,104 @@ +# Test Cases - DarkMoon / Moon Blight + +| Test Case ID | Mô tả | Pre-condition | Steps | Expected Result | Actual Result | Status | +|---|---|---|---|---|---|---| +| TC-PUB-001 | Kiểm tra trang chủ hiển thị đúng dữ liệu nổi bật | Ứng dụng chạy, có dữ liệu article/character/story/world chưa xóa | 1. Mở `/` 2. Quan sát banner, latest articles, characters, top story, top world | Trang chủ load thành công, hiển thị tối đa 3 bài viết mới, 4 nhân vật, story/world ưu tiên cao nhất | | Chưa chạy | +| TC-PUB-002 | Kiểm tra trang chủ khi chưa có dữ liệu nổi bật | Database không có dữ liệu public hoặc toàn bộ dữ liệu đã soft-delete | 1. Mở `/` 2. Quan sát các khu vực dữ liệu động | Trang không lỗi 500, các khu vực trống/ẩn hợp lý | | Chưa chạy | +| TC-PUB-003 | Kiểm tra trang Story public | Có ít nhất 2 story chưa xóa với priority khác nhau | 1. Mở `/story` 2. Quan sát danh sách story | Chỉ hiển thị story chưa xóa, sắp xếp priority giảm dần | | Chưa chạy | +| TC-PUB-004 | Kiểm tra trang Characters public | Có nhân vật chưa xóa và nhân vật đã xóa | 1. Mở `/characters` 2. Quan sát danh sách | Chỉ hiển thị nhân vật chưa xóa, sắp xếp priority giảm dần | | Chưa chạy | +| TC-PUB-005 | Kiểm tra trang World public | Có world chưa xóa và world đã xóa | 1. Mở `/world` 2. Quan sát danh sách | Chỉ hiển thị world chưa xóa, sắp xếp priority giảm dần | | Chưa chạy | +| TC-PUB-006 | Kiểm tra trang News public mặc định | Có ít nhất 7 bài viết chưa xóa | 1. Mở `/news` 2. Quan sát số bài viết và nút load thêm | Hiển thị 6 bài viết, `hasMore` đúng, có nextLimit = 12 | | Chưa chạy | +| TC-PUB-007 | Kiểm tra trang News với tham số limit | Có ít nhất 12 bài viết chưa xóa | 1. Mở `/news?limit=12` 2. Đếm số bài viết | Hiển thị tối đa 12 bài viết, nextLimit = 18 nếu còn dữ liệu | | Chưa chạy | +| TC-PUB-008 | Kiểm tra bài viết quan trọng nổi bật trong News | Có bài viết type IMPORTANT chưa xóa mới nhất | 1. Mở `/news` 2. Kiểm tra khu vực featured article | Featured article là bài IMPORTANT mới nhất chưa xóa | | Chưa chạy | +| TC-PUB-009 | Kiểm tra chi tiết tin tức hợp lệ | Có bài viết chưa xóa với ID hợp lệ | 1. Mở `/news/{id}` 2. Quan sát nội dung và bài liên quan | Hiển thị đúng bài viết, danh sách liên quan không chứa chính bài đang xem | | Chưa chạy | +| TC-PUB-010 | Kiểm tra chi tiết tin tức ID không tồn tại | Không có article với ID test | 1. Mở `/news/999999` | Hệ thống xử lý lỗi hợp lý, không lộ stack trace cho người dùng | | Chưa chạy | +| TC-CON-001 | Kiểm tra trang Support hiển thị form và FAQ | Có FAQ chưa xóa | 1. Mở `/support` 2. Quan sát form liên hệ và FAQ | Trang hiển thị categories, platforms và FAQ chưa xóa | | Chưa chạy | +| TC-CON-002 | Gửi contact message hợp lệ | Người dùng ở trang `/support` | 1. Nhập tên, email hợp lệ, category, platform, message 2. Submit | Lưu contact message với processed = false, redirect `/support`, hiển thị thông báo thành công | | Chưa chạy | +| TC-CON-003 | Gửi contact thiếu tên | Người dùng ở trang `/support` | 1. Để trống tên 2. Nhập các trường còn lại hợp lệ 3. Submit | Không lưu dữ liệu, ở lại trang support, hiển thị lỗi kiểm tra thông tin | | Chưa chạy | +| TC-CON-004 | Gửi contact email sai định dạng | Người dùng ở trang `/support` | 1. Nhập email `abc` 2. Nhập các trường khác hợp lệ 3. Submit | Không lưu dữ liệu, hiển thị lỗi email không hợp lệ | | Chưa chạy | +| TC-CON-005 | Gửi contact thiếu category | Người dùng ở trang `/support` | 1. Không chọn category 2. Nhập các trường khác hợp lệ 3. Submit | Không lưu dữ liệu, hiển thị lỗi chọn danh mục | | Chưa chạy | +| TC-CON-007 | Gửi contact thiếu nội dung | Người dùng ở trang `/support` | 1. Để trống message 2. Nhập các trường khác hợp lệ 3. Submit | Không lưu dữ liệu, hiển thị lỗi nhập nội dung | | Chưa chạy | +| TC-CON-008 | Gửi contact message vượt quá 5000 ký tự | Người dùng ở trang `/support` | 1. Nhập message 5001 ký tự 2. Submit | Không lưu dữ liệu, hiển thị lỗi độ dài nội dung | | Chưa chạy | +| TC-CON-009 | Xem danh sách contact trong CMS | Có tài khoản STAFF đã đăng nhập, có contact messages | 1. Mở `/contact/blightmoon/list` | Hiển thị danh sách contact phân trang size 20 | | Chưa chạy | +| TC-CON-010 | Kiểm tra phân quyền contact CMS khi chưa đăng nhập | Chưa đăng nhập | 1. Mở `/contact/blightmoon/list` | Kỳ vọng bảo mật: bị chặn hoặc chuyển login. Nếu truy cập được là lỗi cấu hình route | | Chưa chạy | +| TC-AUTH-002 | Đăng nhập STAFF hợp lệ | Có tài khoản STAFF active, mật khẩu đúng | 1. Nhập username/email và password 2. Submit login | Đăng nhập thành công, tạo session, redirect `/dashboard/moonblight`, ghi audit LOGIN | | Chưa chạy | +| TC-AUTH-003 | Đăng nhập ADMIN hợp lệ | Có tài khoản ADMIN active, mật khẩu đúng | 1. Nhập credentials admin 2. Submit login | Đăng nhập thành công, redirect `/admin/moonblight/dashboard`, ghi audit LOGIN | | Chưa chạy | +| TC-AUTH-004 | Đăng nhập sai mật khẩu | Có tài khoản hợp lệ | 1. Nhập username/email đúng 2. Nhập password sai 3. Submit | Ở lại login, hiển thị `Tên đăng nhập hoặc mật khẩu không đúng.` | | Chưa chạy | +| TC-AUTH-005 | Đăng nhập thiếu username/email | Chưa đăng nhập | 1. Để trống login 2. Nhập password hợp lệ 3. Submit | Validation lỗi, không gọi authenticate, ở lại login | | Chưa chạy | +| TC-AUTH-007 | Đăng nhập mật khẩu dưới 8 ký tự | Chưa đăng nhập | 1. Nhập login 2. Nhập password 7 ký tự 3. Submit | Validation lỗi mật khẩu tối thiểu 8 ký tự | | Chưa chạy | +| TC-AUTH-008 | Đăng nhập tài khoản bị khóa | Có STAFF isActive = false | 1. Nhập credentials tài khoản bị khóa 2. Submit | Đăng nhập thất bại hoặc bị Spring Security từ chối, không vào dashboard | | Chưa chạy | +| TC-AUTH-010 | Đăng ký tài khoản STAFF hợp lệ | Username/email chưa tồn tại | 1. Nhập username 3-30 ký tự chữ/số, email hợp lệ, password >= 8, rePassword khớp, fullName 2. Submit | Tạo user role STAFF, isActive = false, password BCrypt, redirect login, thông báo thành công | | Chưa chạy | +| TC-AUTH-011 | Đăng ký username đã tồn tại | Database có username test | 1. Nhập username đã tồn tại 2. Nhập thông tin khác hợp lệ 3. Submit | Không tạo user, hiển thị `Tên đăng nhập đã tồn tại!`, xóa password trên form | | Chưa chạy | +| TC-AUTH-012 | Đăng ký email đã tồn tại | Database có email test | 1. Nhập email đã tồn tại 2. Nhập thông tin khác hợp lệ 3. Submit | Không tạo user, hiển thị `Email này đã được đăng ký!`, xóa password trên form | | Chưa chạy | +| TC-AUTH-017 | Đăng ký mật khẩu và xác nhận không khớp | Chưa đăng nhập | 1. Nhập password và rePassword khác nhau 2. Submit | Validation lỗi password confirmation, không tạo user | | Chưa chạy | +| TC-AUTH-018 | Đăng xuất khi đã đăng nhập | User đã đăng nhập | 1. POST `/user/moonblight/logout` | Session bị clear, redirect login, ghi audit LOGOUT | | Chưa chạy | +| TC-AUTH-020 | Gửi OTP cho email tồn tại | Có user với email hợp lệ | 1. Nhập email tồn tại 2. Submit forgot-password | Tạo OTP 6 chữ số, hết hạn sau 5 phút, gửi email, session `OTP_EMAIL`, redirect verify-otp | | Chưa chạy | +| TC-AUTH-021 | Gửi OTP cho email không tồn tại | Email chưa có trong DB | 1. Nhập email chưa đăng ký 2. Submit | Ở lại forgot-password, hiển thị `Email không tồn tại` | | Chưa chạy | +| TC-AUTH-022 | Verify OTP hợp lệ | Đã gửi OTP, OTP chưa hết hạn | 1. Mở verify-otp 2. Nhập OTP đúng 3. Submit | Session lưu RESET_EMAIL và RESET_OTP, redirect reset-password | | Chưa chạy | +| TC-AUTH-023 | Verify OTP sai | Đã gửi OTP | 1. Nhập OTP sai 2. Submit | Ở lại verify-otp, hiển thị `OTP không hợp lệ` | | Chưa chạy | +| TC-AUTH-024 | Verify OTP hết hạn | OTP đã quá 5 phút | 1. Nhập OTP hết hạn 2. Submit | Ở lại verify-otp, hiển thị `OTP đã hết hạn` | | Chưa chạy | +| TC-AUTH-025 | Reset password hợp lệ sau OTP | Session có RESET_EMAIL và RESET_OTP hợp lệ | 1. Mở reset-password 2. Nhập newPassword >= 8 và confirm khớp 3. Submit | Password được mã hóa mới, OTP marked used, session reset bị xóa, redirect login | | Chưa chạy | +| TC-AUTH-027 | Reset password xác nhận không khớp | Session OTP hợp lệ | 1. Nhập newPassword và confirmPassword khác nhau 2. Submit | Validation lỗi, ở lại reset-password | | Chưa chạy | +| TC-AUTH-028 | Reset password dưới 8 ký tự | Session OTP hợp lệ | 1. Nhập newPassword 7 ký tự 2. Submit | Validation lỗi min 8, không đổi mật khẩu | | Chưa chạy | +| TC-AUTH-029 | Đổi mật khẩu current user hợp lệ | User đã đăng nhập | 1. Mở `/user/moonblight/change-password` 2. Nhập oldPassword đúng, newPassword >= 8, confirm khớp 3. Submit | Password đổi thành công, mustChangePassword = false, redirect theo role, ghi audit UPDATE | | Chưa chạy | +| TC-AUTH-031 | Đổi mật khẩu xác nhận không khớp | User đã đăng nhập | 1. Nhập oldPassword đúng 2. Nhập newPassword và confirm khác nhau 3. Submit | Validation/lỗi xác nhận không khớp, không đổi mật khẩu | | Chưa chạy | +| TC-SEC-001 | Chặn truy cập dashboard STAFF khi chưa đăng nhập | Chưa đăng nhập | 1. Mở `/dashboard/moonblight` | Không cho xem dashboard, chuyển tới trang login hoặc trả 403 theo cấu hình Spring Security | | Chưa chạy | +| TC-SEC-002 | Chặn truy cập CMS Article khi chưa đăng nhập | Chưa đăng nhập | 1. Mở `/article/moonblight/list` | Không cho xem danh sách article CMS | | Chưa chạy | +| TC-SEC-003 | Chặn STAFF truy cập admin dashboard | Đăng nhập STAFF active | 1. Mở `/admin/moonblight/dashboard` | Bị từ chối 403 hoặc chuyển trang lỗi, không thấy dữ liệu admin | | Chưa chạy | +| TC-SEC-004 | Cho ADMIN truy cập admin dashboard | Đăng nhập ADMIN active | 1. Mở `/admin/moonblight/dashboard` | Hiển thị dashboard admin với totalUsers, activeUsers, inActiveUsers | | Chưa chạy | +| TC-SEC-005 | Cho STAFF truy cập CMS content | Đăng nhập STAFF active | 1. Mở `/article/moonblight/list` 2. Mở `/story/moonblight/list` | STAFF truy cập được các trang CMS content | | Chưa chạy | +| TC-SEC-007 | Force change password redirect | User đăng nhập có mustChangePassword = true | 1. Mở `/dashboard/moonblight` | Bị redirect tới `/user/moonblight/change-password` | | Chưa chạy | +| TC-SEC-008 | Force change password cho phép mở trang change-password | User đăng nhập có mustChangePassword = true | 1. Mở `/user/moonblight/change-password` | Trang đổi mật khẩu hiển thị, không bị redirect vòng lặp | | Chưa chạy | +| TC-SEC-010 | Kiểm tra API hash password internal public | Chưa đăng nhập | 1. Gọi `/api/moonblight/internal/hash?password=12345678` | Kỳ vọng bảo mật: API nội bộ không nên public. Nếu trả BCrypt khi chưa đăng nhập là rủi ro cần ghi nhận | | Chưa chạy | +| TC-ADM-001 | Xem danh sách tài khoản | Đăng nhập ADMIN | 1. Mở `/admin/moonblight/list` | Hiển thị accounts phân trang size 10, sắp xếp id tăng dần | | Chưa chạy | +| TC-ADM-002 | Xem chi tiết tài khoản hợp lệ | Đăng nhập ADMIN, có user ID hợp lệ | 1. Mở `/admin/moonblight/account/{id}` | Hiển thị đúng thông tin account | | Chưa chạy | +| TC-ADM-004 | Approve tài khoản STAFF chờ duyệt | Đăng nhập ADMIN, STAFF isActive = false | 1. POST `/admin/moonblight/account/{id}/approve` | User isActive = true, redirect detail, thông báo duyệt thành công, ghi audit UNLOCK | | Chưa chạy | +| TC-ADM-006 | Khóa tài khoản STAFF | Đăng nhập ADMIN, STAFF active | 1. POST `/admin/moonblight/account/{id}/lock` | User isActive = false, thông báo khóa thành công, ghi audit LOCK | | Chưa chạy | +| TC-ADM-007 | Mở khóa tài khoản STAFF | Đăng nhập ADMIN, STAFF inactive | 1. POST `/admin/moonblight/account/{id}/unlock` | User isActive = true, thông báo mở khóa thành công, ghi audit UNLOCK | | Chưa chạy | +| TC-ADM-008 | Không cho khóa tài khoản ADMIN | Đăng nhập ADMIN, target user role ADMIN | 1. POST `/admin/moonblight/account/{adminId}/lock` | Không đổi trạng thái admin, hiển thị lỗi `Không có thẩm quyền` hoặc xử lý lỗi phù hợp | | Chưa chạy | +| TC-ADM-009 | Xem audit log | Đăng nhập ADMIN, có audit logs | 1. Mở `/admin/moonblight/log` | Hiển thị logs size 20, sort createdAt giảm dần, activeMenu audit-log | | Chưa chạy | +| TC-ADM-010 | Dashboard admin thống kê user | Đăng nhập ADMIN, DB có active/inactive users | 1. Mở `/admin/moonblight/dashboard` | totalUsers = activeUsers + inActiveUsers, số liệu đúng DB | | Chưa chạy | +| TC-DASH-001 | Dashboard STAFF hiển thị thống kê content | Đăng nhập STAFF active | 1. Mở `/dashboard/moonblight` | Hiển thị count article/story/world/character/faq và contact chưa xử lý | | Chưa chạy | +| TC-ART-001 | Xem danh sách bài viết CMS | Đăng nhập STAFF, có articles | 1. Mở `/article/moonblight/list` | Hiển thị tất cả article kể cả đã xóa, phân trang size 10 | | Chưa chạy | +| TC-ART-003 | Tạo bài viết hợp lệ không thumbnail | Đăng nhập STAFF | 1. Nhập title, content, type 2. Không chọn thumbnail 3. Submit | Tạo article, createdBy là current user, redirect list, flash success, ghi audit CREATE | | Chưa chạy | +| TC-ART-004 | Tạo bài viết hợp lệ có thumbnail | Đăng nhập STAFF, có file ảnh hợp lệ | 1. Chọn thumbnail 2. Nhập thông tin hợp lệ 3. Submit | File lưu vào `uploads/articles`, thumbnailUrl được set, tạo article thành công | | Chưa chạy | +| TC-ART-005 | Tạo bài viết thiếu title | Đăng nhập STAFF | 1. Để trống title 2. Nhập content/type 3. Submit | Không tạo article, ở lại form, hiển thị lỗi title không được trống | | Chưa chạy | +| TC-ART-006 | Tạo bài viết title trên 255 ký tự | Đăng nhập STAFF | 1. Nhập title 256 ký tự 2. Nhập content/type 3. Submit | Không tạo article, hiển thị lỗi tiêu đề quá dài | | Chưa chạy | +| TC-ART-007 | Tạo bài viết thiếu content | Đăng nhập STAFF | 1. Nhập title/type 2. Để trống content 3. Submit | Không tạo article, hiển thị lỗi nội dung không được trống | | Chưa chạy | +| TC-ART-008 | Tạo bài viết thiếu type | Đăng nhập STAFF | 1. Nhập title/content 2. Không chọn type 3. Submit | Không tạo article, hiển thị lỗi loại bài viết không được trống | | Chưa chạy | +| TC-ART-009 | Sửa bài viết hợp lệ không đổi thumbnail | Đăng nhập STAFF, có article | 1. Mở `/article/moonblight/{id}/edit` 2. Sửa title/content/type 3. Không chọn thumbnail 4. Submit | Article cập nhật, thumbnailUrl cũ được giữ, updatedBy set, audit UPDATE | | Chưa chạy | +| TC-ART-011 | Soft-delete bài viết | Đăng nhập STAFF, có article chưa xóa | 1. POST `/article/moonblight/{id}/delete` | Article deleted = true, public news không còn hiển thị, CMS vẫn thấy, audit DELETE | | Chưa chạy | +| TC-ART-012 | Restore bài viết | Đăng nhập STAFF, có article deleted = true | 1. POST `/article/moonblight/{id}/restore` | Article deleted = false, public news hiển thị lại, audit RESTORE | | Chưa chạy | +| TC-STO-001 | Xem danh sách story CMS | Đăng nhập STAFF | 1. Mở `/story/moonblight/list` | Hiển thị stories phân trang size 10, activeMenu STORY | | Chưa chạy | +| TC-STO-002 | Tạo story hợp lệ có ảnh | Đăng nhập STAFF, có file ảnh | 1. Mở create 2. Nhập tag, title, content, quoteContent, quoteAuthor, priority 3. Chọn image 4. Submit | Tạo story thành công, lưu ảnh `uploads/stories`, audit CREATE | | Chưa chạy | +| TC-STO-005 | Tạo story priority nhỏ hơn 0 | Đăng nhập STAFF | 1. Nhập priority = -1 2. Submit | Không tạo story, hiển thị lỗi priority 0-100 | | Chưa chạy | +| TC-STO-006 | Tạo story priority lớn hơn 100 | Đăng nhập STAFF | 1. Nhập priority = 101 2. Submit | Không tạo story, hiển thị lỗi priority 0-100 | | Chưa chạy | +| TC-STO-007 | Sửa story không đổi ảnh | Đăng nhập STAFF, có story | 1. Mở edit 2. Sửa title/content/priority 3. Không chọn image 4. Submit | Story cập nhật, giữ image cũ, audit UPDATE | | Chưa chạy | +| TC-STO-008 | Soft-delete story | Đăng nhập STAFF, có story chưa xóa | 1. POST `/story/moonblight/{id}/delete` | Story deleted = true, public `/story` không hiển thị, audit DELETE | | Chưa chạy | +| TC-STO-009 | Restore story | Đăng nhập STAFF, có story deleted = true | 1. POST `/story/moonblight/{id}/restore` | Story deleted = false, public `/story` hiển thị lại, audit RESTORE | | Chưa chạy | +| TC-WLD-001 | Xem danh sách world CMS | Đăng nhập STAFF | 1. Mở `/world/moonblight/list` | Hiển thị worlds phân trang size 10, activeMenu WORLD | | Chưa chạy | +| TC-WLD-002 | Tạo world hợp lệ có ảnh | Đăng nhập STAFF, có file ảnh | 1. Mở create 2. Nhập tag, title, content, priority 3. Chọn image 4. Submit | Tạo world thành công, lưu ảnh `uploads/worlds`, audit CREATE | | Chưa chạy | +| TC-WLD-003 | Tạo world thiếu title | Đăng nhập STAFF | 1. Để trống title 2. Nhập các trường khác hợp lệ 3. Submit | Không tạo world, hiển thị lỗi tiêu đề không được trống | | Chưa chạy | +| TC-WLD-005 | Tạo world thiếu content | Đăng nhập STAFF | 1. Để trống content 2. Submit form | Không tạo world, hiển thị lỗi nội dung không được trống | | Chưa chạy | +| TC-WLD-007 | Sửa world hợp lệ không đổi ảnh | Đăng nhập STAFF, có world | 1. Mở edit 2. Sửa tag/title/content/priority 3. Không chọn image 4. Submit | World cập nhật, giữ image cũ, audit UPDATE | | Chưa chạy | +| TC-WLD-008 | Soft-delete world | Đăng nhập STAFF, có world chưa xóa | 1. POST `/world/moonblight/{id}/delete` | World deleted = true, public `/world` không hiển thị, audit DELETE | | Chưa chạy | +| TC-WLD-009 | Restore world | Đăng nhập STAFF, có world deleted = true | 1. POST `/world/moonblight/{id}/restore` | World deleted = false, public `/world` hiển thị lại, audit RESTORE | | Chưa chạy | +| TC-CHR-001 | Xem danh sách character CMS | Đăng nhập STAFF | 1. Mở `/character/moonblight/list` | Hiển thị characters phân trang size 10, activeMenu CHARACTER | | Chưa chạy | +| TC-CHR-002 | Tạo character hợp lệ có ảnh | Đăng nhập STAFF, có file ảnh | 1. Mở create 2. Nhập name, race, description, quote, priority 3. Chọn image 4. Submit | Tạo character thành công, lưu ảnh `uploads/characters`, audit CREATE | | Chưa chạy | +| TC-CHR-003 | Tạo character thiếu name | Đăng nhập STAFF | 1. Để trống name 2. Submit form hợp lệ còn lại | Không tạo character, hiển thị lỗi tên nhân vật không được trống | | Chưa chạy | +| TC-CHR-005 | Tạo character thiếu race | Đăng nhập STAFF | 1. Để trống race 2. Submit | Không tạo character, hiển thị lỗi chủng tộc không được trống | | Chưa chạy | +| TC-CHR-008 | Tạo character priority ngoài biên | Đăng nhập STAFF | 1. Nhập priority = 101 hoặc -1 2. Submit | Không tạo character, hiển thị lỗi priority 0-100 | | Chưa chạy | +| TC-CHR-009 | Sửa character không đổi ảnh | Đăng nhập STAFF, có character | 1. Mở edit 2. Sửa name/race/description/priority 3. Không chọn image 4. Submit | Character cập nhật, giữ image cũ, audit UPDATE | | Chưa chạy | +| TC-CHR-010 | Soft-delete character | Đăng nhập STAFF, có character chưa xóa | 1. POST `/character/moonblight/{id}/delete` | Character deleted = true, public `/characters` không hiển thị, audit DELETE | | Chưa chạy | +| TC-CHR-011 | Restore character | Đăng nhập STAFF, có character deleted = true | 1. POST `/character/moonblight/{id}/restore` | Character deleted = false, public `/characters` hiển thị lại, audit RESTORE | | Chưa chạy | +| TC-FAQ-001 | Xem danh sách FAQ CMS | Đăng nhập STAFF | 1. Mở `/faq/moonblight/list` | Hiển thị FAQs phân trang size 10, activeMenu FAQ | | Chưa chạy | +| TC-FAQ-002 | Tạo FAQ hợp lệ | Đăng nhập STAFF | 1. Mở create 2. Nhập title, content, priority 3. Submit | Tạo FAQ thành công, redirect list, audit CREATE | | Chưa chạy | +| TC-FAQ-003 | Tạo FAQ thiếu title | Đăng nhập STAFF | 1. Để trống title 2. Nhập content/priority 3. Submit | Không tạo FAQ, hiển thị lỗi tiêu đề không được trống | | Chưa chạy | +| TC-FAQ-006 | Sửa FAQ hợp lệ | Đăng nhập STAFF, có FAQ | 1. Mở `/faq/moonblight/{id}/edit` 2. Sửa title/content/priority 3. Submit | FAQ cập nhật, redirect list, audit UPDATE | | Chưa chạy | +| TC-FAQ-007 | Soft-delete FAQ | Đăng nhập STAFF, có FAQ chưa xóa | 1. POST `/faq/moonblight/{id}/delete` | FAQ deleted = true, public support không hiển thị FAQ đó, audit DELETE | | Chưa chạy | +| TC-FAQ-008 | Restore FAQ | Đăng nhập STAFF, có FAQ deleted = true | 1. POST `/faq/moonblight/{id}/restore` | FAQ deleted = false, public support hiển thị lại, audit RESTORE | | Chưa chạy | +| TC-UPL-001 | Upload ảnh editor hợp lệ | Đăng nhập STAFF hoặc user được phép truy cập editor, có file ảnh | 1. POST `/api/upload` với multipart field `image` | Trả JSON `{url: ...}`, file lưu trong `uploads/editor` | | Chưa chạy | +| TC-UPL-002 | Upload ảnh editor file rỗng | Có quyền gọi API upload | 1. POST `/api/upload` với file rỗng | Trả HTTP 400 và JSON error `File upload không hợp lệ` | | Chưa chạy | +| TC-AUD-003 | Đánh dấu contact đã xử lý | Đăng nhập STAFF, có contact chưa xử lý | 1. POST `/contact/blightmoon/{id}/process` | Contact processed = true, redirect list, flash success, audit PROCESS | | Chưa chạy | +| TC-AUD-004 | Đánh dấu contact chưa xử lý | Đăng nhập STAFF, có contact đã xử lý | 1. POST `/contact/blightmoon/{id}/unprocess` | Contact processed = false, redirect list, flash success | | Chưa chạy | +| TC-AUD-005 | Xem chi tiết contact | Đăng nhập STAFF, có contact ID hợp lệ | 1. Mở `/contact/blightmoon/{id}` | Hiển thị đúng visitorName, visitorEmail, category, platform, message, processed | | Chưa chạy | diff --git a/mvnw b/mvnw new file mode 100644 index 0000000000000000000000000000000000000000..bd8896bf2217b46faa0291585e01ac1a3441a958 --- /dev/null +++ b/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000000000000000000000000000000000000..92450f93273470af42eeee491874afb2039b700a --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000000000000000000000000000000000000..2288be4bbf05b52180d9972deb262b01a9851d14 --- /dev/null +++ b/pom.xml @@ -0,0 +1,130 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + com.darkfantasy + darkfantasy-landing + 1.0.0 + war + Dark Fantasy Landing Page + Gothic Horror Dark Fantasy Landing Page - Spring Boot + JSP + + + 21 + UTF-8 + + + + + org.springframework.boot + spring-boot-starter-mail + + + org.thymeleaf.extras + thymeleaf-extras-springsecurity6 + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.mariadb.jdbc + mariadb-java-client + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.projectlombok + lombok + + true + + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-tomcat + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + org.apache.tomcat.embed + tomcat-embed-jasper + + + + + jakarta.servlet.jsp.jstl + jakarta.servlet.jsp.jstl-api + + + org.glassfish.web + jakarta.servlet.jsp.jstl + + + com.mysql + mysql-connector-j + runtime + + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + + 21 + + + + org.projectlombok + lombok + 1.18.32 + + + + + + + + \ No newline at end of file diff --git a/src/main/java/com/darkfantasy/DarkFantasyApplication.java b/src/main/java/com/darkfantasy/DarkFantasyApplication.java new file mode 100644 index 0000000000000000000000000000000000000000..0c9e72d0dff43742c1a05e77cead295d260a9a2a --- /dev/null +++ b/src/main/java/com/darkfantasy/DarkFantasyApplication.java @@ -0,0 +1,19 @@ +package com.darkfantasy; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.web.servlet.support.SpringBootServletInitializer; +import org.springframework.boot.builder.SpringApplicationBuilder; + +@SpringBootApplication +public class DarkFantasyApplication extends SpringBootServletInitializer { + + @Override + protected SpringApplicationBuilder configure(SpringApplicationBuilder application) { + return application.sources(DarkFantasyApplication.class); + } + + public static void main(String[] args) { + SpringApplication.run(DarkFantasyApplication.class, args); + } +} diff --git a/src/main/java/com/darkfantasy/annotation/ChangePasswordMatch.java b/src/main/java/com/darkfantasy/annotation/ChangePasswordMatch.java new file mode 100644 index 0000000000000000000000000000000000000000..e361fd04934c8300827b00ba6ebbf7de6df75b8d --- /dev/null +++ b/src/main/java/com/darkfantasy/annotation/ChangePasswordMatch.java @@ -0,0 +1,23 @@ +package com.darkfantasy.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import com.darkfantasy.validator.PasswordChangeMatchValidator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = PasswordChangeMatchValidator.class) +public @interface ChangePasswordMatch { + + String message() default "Mật khẩu xác nhận không khớp"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} \ No newline at end of file diff --git a/src/main/java/com/darkfantasy/annotation/RegisterPasswordMatch.java b/src/main/java/com/darkfantasy/annotation/RegisterPasswordMatch.java new file mode 100644 index 0000000000000000000000000000000000000000..86186e0b93242718a8a41f1b68b7d95c48623dd2 --- /dev/null +++ b/src/main/java/com/darkfantasy/annotation/RegisterPasswordMatch.java @@ -0,0 +1,23 @@ +package com.darkfantasy.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import com.darkfantasy.validator.PasswordRegisterMatchValidator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = PasswordRegisterMatchValidator.class) +public @interface RegisterPasswordMatch { + + String message() default "Mật khẩu xác nhận không khớp"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} \ No newline at end of file diff --git a/src/main/java/com/darkfantasy/annotation/ResetPasswordMatch.java b/src/main/java/com/darkfantasy/annotation/ResetPasswordMatch.java new file mode 100644 index 0000000000000000000000000000000000000000..b3d24a1107b778773745533b04eae886fc092667 --- /dev/null +++ b/src/main/java/com/darkfantasy/annotation/ResetPasswordMatch.java @@ -0,0 +1,23 @@ +package com.darkfantasy.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import com.darkfantasy.validator.ResetPasswordMatchValidator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = ResetPasswordMatchValidator.class) +public @interface ResetPasswordMatch { + + String message() default "Mật khẩu xác nhận không khớp"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} \ No newline at end of file diff --git a/src/main/java/com/darkfantasy/config/SchedulingConfig.java b/src/main/java/com/darkfantasy/config/SchedulingConfig.java new file mode 100644 index 0000000000000000000000000000000000000000..cb39ebcf089593de3632ef1ccb33cc4b9e4b1ab3 --- /dev/null +++ b/src/main/java/com/darkfantasy/config/SchedulingConfig.java @@ -0,0 +1,5 @@ +// package com.darkfantasy.config; + +// public class SchedulingConfig { + +// } diff --git a/src/main/java/com/darkfantasy/config/WebConfig.java b/src/main/java/com/darkfantasy/config/WebConfig.java new file mode 100644 index 0000000000000000000000000000000000000000..de495f4fd0eaf1bae987b81b6b108b985eb7ec81 --- /dev/null +++ b/src/main/java/com/darkfantasy/config/WebConfig.java @@ -0,0 +1,27 @@ +package com.darkfantasy.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import com.darkfantasy.security.ForceChangePasswordInterceptor; + +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + private final ForceChangePasswordInterceptor interceptor; + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler("/uploads/**") + .addResourceLocations("file:uploads/"); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(interceptor); + } +} \ No newline at end of file diff --git a/src/main/java/com/darkfantasy/constant/CmsMenu.java b/src/main/java/com/darkfantasy/constant/CmsMenu.java new file mode 100644 index 0000000000000000000000000000000000000000..e6f33b36e8f041f33ea68a23b25f655e7265b4d3 --- /dev/null +++ b/src/main/java/com/darkfantasy/constant/CmsMenu.java @@ -0,0 +1,16 @@ +package com.darkfantasy.constant; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class CmsMenu { + public static final String DASHBOARD = "dashboard"; + public static final String ARTICLE = "article"; + public static final String STORY = "story"; + public static final String WORLD = "world"; + public static final String CHARACTER = "character"; + public static final String FAQ = "faq"; + public static final String CONTRIBUTOR = "contributor"; + public static final String CONTACT = "contact"; +} diff --git a/src/main/java/com/darkfantasy/constant/Routes.java b/src/main/java/com/darkfantasy/constant/Routes.java new file mode 100644 index 0000000000000000000000000000000000000000..06e70e27f71b4d0dd4eedd0847d8908233115036 --- /dev/null +++ b/src/main/java/com/darkfantasy/constant/Routes.java @@ -0,0 +1,18 @@ +package com.darkfantasy.constant; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class Routes { + public static final String USER = "/user/moonblight"; + public static final String ADMIN = "/admin/moonblight"; + public static final String DASHBOARD = "/dashboard/moonblight"; + public static final String ARTICLE = "/article/moonblight"; + public static final String CHARACTER = "/character/moonblight"; + public static final String WORLD = "/world/moonblight"; + public static final String STORY = "/story/moonblight"; + public static final String FAQ = "/faq/moonblight"; + public static final String CONTRIBUTOR = "/contributor/moonblight"; + public static final String CONTACT = "/contact/moonblight"; +} diff --git a/src/main/java/com/darkfantasy/controller/AdminController.java b/src/main/java/com/darkfantasy/controller/AdminController.java new file mode 100644 index 0000000000000000000000000000000000000000..b08ef9b88834a078fdd76a6733d2c57865c3bea1 --- /dev/null +++ b/src/main/java/com/darkfantasy/controller/AdminController.java @@ -0,0 +1,109 @@ +package com.darkfantasy.controller; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.data.web.PageableDefault; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import lombok.RequiredArgsConstructor; + +import com.darkfantasy.constant.Routes; +import com.darkfantasy.dto.user.UserResponse; +import com.darkfantasy.service.AuditLogService; +import com.darkfantasy.service.UserService; +import org.springframework.web.bind.annotation.PostMapping; + +@Controller +@RequiredArgsConstructor +@RequestMapping(Routes.ADMIN + "/") +public class AdminController { + private final AuditLogService auditLogService; + private final UserService userService; + + @GetMapping("dashboard") + public String toDashboard(Model model) { + model.addAttribute("totalUsers", userService.count()); + model.addAttribute("activeUsers", userService.countActiveUsers()); + model.addAttribute("inActiveUsers", userService.countInactiveUsers()); + return "cms/admin/admin-dashboard"; + } + + @GetMapping("list") + public String toAccountList( + @PageableDefault(size = 10) Pageable pageable, + Model model) { + model.addAttribute("accounts", userService.getAccounts(pageable)); + return "cms/admin/account-list"; + } + + @GetMapping("account/{id}") + public String toDetailAccount( + @PathVariable("id") Long id, + Model model) { + + UserResponse response = userService.findAccountById(id); + + model.addAttribute("account", response); + return "cms/admin/account-detail"; + } + + @GetMapping("log") + public String toLogList( + @PageableDefault(size = 20, sort = "createdAt", direction = Direction.DESC) Pageable pageable, + Model model) { + + model.addAttribute("logs", auditLogService.getLogs(pageable)); + model.addAttribute("activeMenu", "audit-log"); + return "cms/admin/audit-log"; + } + + @PostMapping("account/{id}/approve") + public String approveAccount( + @PathVariable("id") Long id, + RedirectAttributes redirectAttributes) { + + userService.unlockUser(id); + + redirectAttributes.addFlashAttribute("successMessage", "Duyệt tài khoản thành công"); + return "redirect:" + Routes.ADMIN + "/account/" + id; + } + + @PostMapping("account/{id}/reject") + public String rejectAccount( + @PathVariable("id") Long id, + RedirectAttributes redirectAttributes) { + + userService.lockUser(id); + + redirectAttributes.addFlashAttribute("successMessage", "Từ chối tài khoản thành công"); + return "redirect:" + Routes.ADMIN + "/account/" + id; + } + + @PostMapping("account/{id}/lock") + public String lockAccount( + @PathVariable("id") Long id, + RedirectAttributes redirectAttributes) { + + userService.lockUser(id); + + redirectAttributes.addFlashAttribute("successMessage", "Khóa tài khoản thành công"); + return "redirect:" + Routes.ADMIN + "/account/" + id; + } + + @PostMapping("account/{id}/unlock") + public String unlockAccount( + @PathVariable("id") Long id, + RedirectAttributes redirectAttributes) { + + userService.unlockUser(id); + + redirectAttributes.addFlashAttribute("successMessage", "Mở khóa tài khoản thành công"); + return "redirect:" + Routes.ADMIN + "/account/" + id; + } + +} diff --git a/src/main/java/com/darkfantasy/controller/ArticleController.java b/src/main/java/com/darkfantasy/controller/ArticleController.java new file mode 100644 index 0000000000000000000000000000000000000000..25de5072f3019adba168ab49b7fafc34bc0131dd --- /dev/null +++ b/src/main/java/com/darkfantasy/controller/ArticleController.java @@ -0,0 +1,195 @@ +package com.darkfantasy.controller; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import com.darkfantasy.constant.CmsMenu; +import com.darkfantasy.constant.Routes; +import com.darkfantasy.dto.article.ArticleResponse; +import com.darkfantasy.dto.article.CreateArticleRequest; +import com.darkfantasy.dto.article.UpdateArticleRequest; +import com.darkfantasy.entity.enums.ArticleType; +import com.darkfantasy.service.ArticleService; +import com.darkfantasy.service.FileStorageService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Controller +@RequestMapping(Routes.ARTICLE + "/") +@RequiredArgsConstructor +public class ArticleController { + + private final ArticleService articleService; + private final FileStorageService fileStorageService; + + @ModelAttribute("articleTypes") + public ArticleType[] articleTypes() { + return ArticleType.values(); + } + + @GetMapping({ "/list", "/list/" }) + public String articleList( + @PageableDefault(size = 10) Pageable pageable, + Model model) { + model.addAttribute( + "articles", + articleService.getArticles(pageable)); + model.addAttribute("activeMenu", CmsMenu.ARTICLE); + return "cms/article/article-list"; + } + + @GetMapping({ "/create", "/create/" }) + public String toCreatePage(Model model) { + model.addAttribute("createArticleRequest", new CreateArticleRequest()); + return "cms/article/article-create"; + } + + @PostMapping("/create") + public String createArticle( + @Valid @ModelAttribute("createArticleRequest") CreateArticleRequest request, + BindingResult result, + @RequestParam("thumbnail") MultipartFile thumbnailFile, + Model model, + RedirectAttributes redirectAttributes) { + + if (result.hasErrors()) { + return "cms/article/article-create"; + } + + try { + + if (!thumbnailFile.isEmpty()) { + request.setThumbnailUrl(fileStorageService.saveFile(thumbnailFile, "articles")); + } + + ArticleResponse response = articleService.createArticle(request); + + redirectAttributes.addFlashAttribute( + "successMessage", + "Tạo bài viết thành công: " + + response.getTitle()); + + return "redirect:"+ Routes.ARTICLE +"/list"; + + } catch (Exception e) { + + model.addAttribute( + "errorMessage", + e.getMessage()); + + return "cms/article/article-create"; + } + } + + @PostMapping("{id}/delete") + public String deleteArticle( + @PathVariable("id") Long id, + RedirectAttributes redirectAttributes) { + + try { + articleService.deleteArticle(id); + + redirectAttributes.addFlashAttribute( + "successMessage", + "Xóa bài viết thành công"); + + } catch (Exception e) { + redirectAttributes.addFlashAttribute( + "errorMessage", + e.getMessage()); + } + + return "redirect:"+ Routes.ARTICLE +"/list"; + } + + @PostMapping("{id}/restore") + public String restoreArticle( + @PathVariable("id") Long id, + RedirectAttributes redirectAttributes) { + try { + articleService.restoreArticle(id); + redirectAttributes.addFlashAttribute( + "successMessage", + "Khôi phục bài viết thành công"); + } catch (Exception e) { + redirectAttributes.addFlashAttribute( + "errorMessage", + e.getMessage()); + } + return "redirect:"+ Routes.ARTICLE +"/list"; + } + + @GetMapping({ "/{id}/edit", "/{id}/edit/" }) + public String editArticlePage( + @PathVariable Long id, + Model model) { + + ArticleResponse article = articleService.getArticleById(id); + + model.addAttribute("article", article); + + return "cms/article/article-edit"; + } + + @PostMapping("/{id}/edit") + public String editArticle( + @PathVariable Long id, + @Valid @ModelAttribute("article") UpdateArticleRequest request, + BindingResult result, + @RequestParam("thumbnail") MultipartFile thumbnailFile, + Model model) { + + if (result.hasErrors()) { + ArticleResponse oldArticle = articleService.getArticleById(id); + + request.setThumbnailUrl( + oldArticle.getThumbnailUrl()); + + model.addAttribute( + "articleTypes", + ArticleType.values()); + + return "cms/article/article-edit"; + } + + request.setId(id); + + try { + + if (!thumbnailFile.isEmpty()) { + + request.setThumbnailUrl( + fileStorageService.saveFile(thumbnailFile, "articles")); + } + + articleService.updateArticle(request); + + return "redirect:"+ Routes.ARTICLE +"/list"; + + } catch (Exception e) { + + model.addAttribute( + "articleTypes", + ArticleType.values()); + + model.addAttribute( + "errorMessage", + e.getMessage()); + + return "cms/article/article-edit"; + } + } + +} diff --git a/src/main/java/com/darkfantasy/controller/ContactMessageController.java b/src/main/java/com/darkfantasy/controller/ContactMessageController.java new file mode 100644 index 0000000000000000000000000000000000000000..c4d6a8b0301afe206832ca3d131f2d56b7db2341 --- /dev/null +++ b/src/main/java/com/darkfantasy/controller/ContactMessageController.java @@ -0,0 +1,62 @@ +package com.darkfantasy.controller; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import com.darkfantasy.constant.CmsMenu; +import com.darkfantasy.constant.Routes; +import com.darkfantasy.service.ContactMessageService; + +import lombok.RequiredArgsConstructor; + +@Controller +@RequiredArgsConstructor +@RequestMapping(Routes.CONTACT + "/") +public class ContactMessageController { + private final ContactMessageService contactMessageService; + @GetMapping("list") + public String toList( + @PageableDefault(size = 20) Pageable pageable, + Model model) { + model.addAttribute("messages", contactMessageService.getMessages(pageable)); + model.addAttribute("activeMenu", CmsMenu.CONTACT); + + return "cms/contact/contact-list"; + } + + @GetMapping("{id}") + public String detail( + @PathVariable Long id, + Model model) { + + model.addAttribute("message", contactMessageService.getMessageById(id)); + model.addAttribute("activeMenu", CmsMenu.CONTACT); + return "cms/contact/contact-detail"; + } + + @PostMapping("{id}/process") + public String process(@PathVariable Long id, RedirectAttributes redirectAttributes) { + contactMessageService.markAsProcessed(id); + redirectAttributes.addFlashAttribute("successMessage", "Đã đánh dấu xử lý."); + return "redirect:" + Routes.CONTACT + "/list"; + } + + @PostMapping("{id}/unprocess") + public String unprocess( + @PathVariable Long id, + RedirectAttributes redirectAttributes) { + + contactMessageService.markAsUnprocessed(id); + + redirectAttributes.addFlashAttribute("successMessage", "Đã đánh dấu chưa xử lý."); + + return "redirect:" + Routes.CONTACT + "/list"; + } +} diff --git a/src/main/java/com/darkfantasy/controller/ContributorController.java b/src/main/java/com/darkfantasy/controller/ContributorController.java new file mode 100644 index 0000000000000000000000000000000000000000..62a5aeabb663b421146a5281ba781372a5d11c80 --- /dev/null +++ b/src/main/java/com/darkfantasy/controller/ContributorController.java @@ -0,0 +1,181 @@ +package com.darkfantasy.controller; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import com.darkfantasy.constant.CmsMenu; +import com.darkfantasy.constant.Routes; +import com.darkfantasy.dto.contributor.ContributorResponse; +import com.darkfantasy.dto.contributor.CreateContributorRequest; +import com.darkfantasy.dto.contributor.UpdateContributorRequest; +import com.darkfantasy.service.ContributorService; +import com.darkfantasy.service.FileStorageService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Controller +@RequestMapping(Routes.CONTRIBUTOR + "/") +@RequiredArgsConstructor +public class ContributorController { + + private final ContributorService contributorService; + private final FileStorageService fileStorageService; + + @GetMapping({ "/list", "/list/" }) + public String contributorList( + @PageableDefault(size = 10) Pageable pageable, + Model model) { + model.addAttribute( + "contributors", + contributorService.getContributors(pageable)); + model.addAttribute("activeMenu", CmsMenu.CONTRIBUTOR); + return "cms/contributor/contributor-list"; + } + + @GetMapping({ "/create", "/create/" }) + public String toCreatePage(Model model) { + model.addAttribute("createContributorRequest", new CreateContributorRequest()); + return "cms/contributor/contributor-create"; + } + + @PostMapping("/create") + public String createContributor( + @Valid @ModelAttribute("createContributorRequest") CreateContributorRequest request, + BindingResult result, + @RequestParam("thumbnail") MultipartFile thumbnailFile, + Model model, + RedirectAttributes redirectAttributes) { + + if (result.hasErrors()) { + return "cms/contributor/contributor-create"; + } + + try { + + if (!thumbnailFile.isEmpty()) { + request.setAvatar(fileStorageService.saveFile(thumbnailFile, "contributors")); + } + + ContributorResponse response = contributorService.createContributor(request); + + redirectAttributes.addFlashAttribute( + "successMessage", + "Tạo người đóng góp thành công: " + + response.getName()); + + return "redirect:" + Routes.CONTRIBUTOR + "/list"; + + } catch (Exception e) { + + model.addAttribute( + "errorMessage", + e.getMessage()); + + return "cms/contributor/contributor-create"; + } + } + + @PostMapping("{id}/delete") + public String deleteContributor( + @PathVariable("id") Long id, + RedirectAttributes redirectAttributes) { + + try { + contributorService.deleteContributor(id); + + redirectAttributes.addFlashAttribute( + "successMessage", + "Xóa người đóng góp thành công"); + + } catch (Exception e) { + redirectAttributes.addFlashAttribute( + "errorMessage", + e.getMessage()); + } + + return "redirect:" + Routes.CONTRIBUTOR + "/list"; + } + + @PostMapping("{id}/restore") + public String restoreContributor( + @PathVariable("id") Long id, + RedirectAttributes redirectAttributes) { + try { + contributorService.restoreContributor(id); + redirectAttributes.addFlashAttribute( + "successMessage", + "Khôi phục người đóng góp thành công"); + } catch (Exception e) { + redirectAttributes.addFlashAttribute( + "errorMessage", + e.getMessage()); + } + return "redirect:" + Routes.CONTRIBUTOR + "/list"; + } + + @GetMapping({ "/{id}/edit", "/{id}/edit/" }) + public String editContributorPage( + @PathVariable Long id, + Model model) { + + ContributorResponse contributor = contributorService.getContributorById(id); + + model.addAttribute("contributor", contributor); + + return "cms/contributor/contributor-edit"; + } + + @PostMapping("/{id}/edit") + public String editContributor( + @PathVariable Long id, + @Valid @ModelAttribute("contributor") UpdateContributorRequest request, + BindingResult result, + @RequestParam("thumbnail") MultipartFile thumbnailFile, + Model model) { + + if (result.hasErrors()) { + ContributorResponse oldContributor = contributorService.getContributorById(id); + + request.setAvatar( + oldContributor.getAvatar()); + + return "cms/contributor/contributor-edit"; + } + + request.setId(id); + + try { + + if (!thumbnailFile.isEmpty()) { + + request.setAvatar( + fileStorageService.saveFile(thumbnailFile, "contributors")); + } + + contributorService.updateContributor(request); + + return "redirect:" + Routes.CONTRIBUTOR + "/list"; + + } catch (Exception e) { + + model.addAttribute( + "errorMessage", + e.getMessage()); + + return "cms/contributor/contributor-edit"; + } + } + +} diff --git a/src/main/java/com/darkfantasy/controller/DashboardController.java b/src/main/java/com/darkfantasy/controller/DashboardController.java new file mode 100644 index 0000000000000000000000000000000000000000..e27f5cb95fc380757af3ee704b4cc88ac23fde73 --- /dev/null +++ b/src/main/java/com/darkfantasy/controller/DashboardController.java @@ -0,0 +1,46 @@ +package com.darkfantasy.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.util.RouteMatcher.Route; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +import com.darkfantasy.constant.CmsMenu; +import com.darkfantasy.constant.Routes; +import com.darkfantasy.service.ArticleService; +import com.darkfantasy.service.ContactMessageService; +import com.darkfantasy.service.ContributorService; +import com.darkfantasy.service.FaqService; +import com.darkfantasy.service.GameCharacterService; +import com.darkfantasy.service.StoryService; +import com.darkfantasy.service.WorldService; + +import lombok.RequiredArgsConstructor; + +@Controller +@RequiredArgsConstructor +@RequestMapping(Routes.DASHBOARD) + +public class DashboardController { + private final ArticleService articleService; + private final GameCharacterService gameCharacterService; + private final WorldService worldService; + private final StoryService storyService; + private final FaqService faqService; + private final ContributorService contributorService; + private final ContactMessageService contactMessageService; + + @GetMapping({ "", "/" }) + public String toDashboard(Model model) { + model.addAttribute("activeMenu", CmsMenu.DASHBOARD); + model.addAttribute("articleCount", articleService.count()); + model.addAttribute("storyCount", storyService.count()); + model.addAttribute("worldCount", worldService.count()); + model.addAttribute("characterCount", gameCharacterService.count()); + model.addAttribute("faqCount", faqService.count()); + model.addAttribute("contributorCount", contributorService.count()); + model.addAttribute("unprocessedContactCount", contactMessageService.countByProcessedFalse()); + return "cms/dashboard"; + } +} diff --git a/src/main/java/com/darkfantasy/controller/FaqController.java b/src/main/java/com/darkfantasy/controller/FaqController.java new file mode 100644 index 0000000000000000000000000000000000000000..aadc1996a8cb96a53e2743f645c79cc40ace4cc0 --- /dev/null +++ b/src/main/java/com/darkfantasy/controller/FaqController.java @@ -0,0 +1,161 @@ +package com.darkfantasy.controller; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.util.RouteMatcher.Route; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import com.darkfantasy.constant.CmsMenu; +import com.darkfantasy.constant.Routes; +import com.darkfantasy.dto.faq.CreateFaqRequest; +import com.darkfantasy.dto.faq.FaqResponse; +import com.darkfantasy.dto.faq.UpdateFaqRequest; +import com.darkfantasy.service.FaqService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Controller +@RequiredArgsConstructor +@RequestMapping(Routes.FAQ + "/") +public class FaqController { + private final FaqService faqService; + + @GetMapping({ "list", "list/" }) + public String toList( + @PageableDefault(size = 10) Pageable pageable, + Model model) { + + model.addAttribute( + "faqs", + faqService.getFaqs(pageable)); + model.addAttribute("activeMenu", CmsMenu.FAQ); + return "cms/faq/faq-list"; + } + + @GetMapping({ "create", "create/" }) + public String toCreatePage(Model model) { + model.addAttribute("createFaqRequest", new CreateFaqRequest()); + return "cms/faq/faq-create"; + } + + @PostMapping({ "create", "create/" }) + public String createFaq( + @Valid @ModelAttribute("createFaqRequest") CreateFaqRequest request, + BindingResult result, + Model model, + RedirectAttributes redirectAttributes) { + + if (result.hasErrors()) { + model.addAttribute( + "errorMessage", + "Vui lòng kiểm tra lại thông tin FAQ."); + return "cms/faq/faq-create"; + } + + try { + + FaqResponse response = faqService.createFaq(request); + + redirectAttributes.addFlashAttribute( + "successMessage", + "Tạo FAQ thành công: " + + response.getTitle()); + + return "redirect:" + Routes.FAQ + "/list"; + + } catch (Exception e) { + + model.addAttribute( + "errorMessage", + e.getMessage()); + + return "cms/faq/faq-create"; + } + } + + @GetMapping({ "{id}/edit", "{id}/edit/" }) + public String toEditPage(@PathVariable("id") Long id, Model model) { + FaqResponse faq = faqService.getFaqById(id); + UpdateFaqRequest request = UpdateFaqRequest.builder() + .id(faq.getId()) + .title(faq.getTitle()) + .content(faq.getContent()) + .priority(faq.getPriority()) + .build(); + + model.addAttribute( + "updateFaqRequest", + request); + model.addAttribute("faq", faq); + return "cms/faq/faq-edit"; + } + + @PostMapping("{id}/edit") + public String editFaq(@PathVariable Long id, + @Valid @ModelAttribute("faq") UpdateFaqRequest request, + BindingResult result, + Model model) { + if (result.hasErrors()) { + return "cms/faq/faq-edit"; + } + + request.setId(id); + + try { + faqService.updateFaq(request); + + return "redirect:" + Routes.FAQ + "/list"; + } catch (Exception e) { + model.addAttribute( + "errorMessage", + e.getMessage()); + + return "cms/faq/faq-edit"; + } + } + + @PostMapping("{id}/delete") + public String deleteFaq(@PathVariable("id") Long id, RedirectAttributes redirectAttributes) { + try { + faqService.deleteFaq(id); + + redirectAttributes.addFlashAttribute( + "successMessage", + "Xóa faq thành công"); + + } catch (Exception e) { + redirectAttributes.addFlashAttribute( + "errorMessage", + e.getMessage()); + } + + return "redirect:" + Routes.FAQ + "/list"; + } + + @PostMapping("{id}/restore") + public String restoreFaq(@PathVariable("id") Long id, RedirectAttributes redirectAttributes) { + try { + faqService.restoreFaq(id); + + redirectAttributes.addFlashAttribute( + "successMessage", + "Khôi phục faq thành công"); + + } catch (Exception e) { + redirectAttributes.addFlashAttribute( + "errorMessage", + e.getMessage()); + } + + return "redirect:" + Routes.FAQ + "/list"; + } +} diff --git a/src/main/java/com/darkfantasy/controller/GameCharacterController.java b/src/main/java/com/darkfantasy/controller/GameCharacterController.java new file mode 100644 index 0000000000000000000000000000000000000000..f9a3d03f1b0633ef407d54e1a7db2cb186662a9a --- /dev/null +++ b/src/main/java/com/darkfantasy/controller/GameCharacterController.java @@ -0,0 +1,163 @@ +package com.darkfantasy.controller; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import com.darkfantasy.constant.CmsMenu; +import com.darkfantasy.constant.Routes; +import com.darkfantasy.dto.gamecharacter.CreateGameCharacterRequest; +import com.darkfantasy.dto.gamecharacter.GameCharacterResponse; +import com.darkfantasy.dto.gamecharacter.UpdateGameCharacterRequest; +import com.darkfantasy.service.FileStorageService; +import com.darkfantasy.service.GameCharacterService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; + +@Controller +@RequestMapping(Routes.CHARACTER + "/") +@RequiredArgsConstructor +public class GameCharacterController { + private final GameCharacterService gameCharacterService; + private final FileStorageService fileStorageService; + + @GetMapping({ "list", "list/" }) + public String toList( + @PageableDefault(size = 10) Pageable pageable, + Model model) { + + model.addAttribute( + "characters", + gameCharacterService.getCharacters(pageable)); + model.addAttribute("activeMenu", CmsMenu.CHARACTER); + return "cms/character/character-list"; + } + + @GetMapping({ "create", "create/" }) + public String toCreatePage(Model model) { + model.addAttribute("createGameCharacterRequest", new CreateGameCharacterRequest()); + return "cms/character/character-create"; + } + + @PostMapping({ "create", "create/" }) + public String createCharacter( + @Valid @ModelAttribute("createGameCharacterRequest") CreateGameCharacterRequest request, + BindingResult result, + @RequestParam(value = "imageFile", required = false) MultipartFile imageFile, + Model model, + RedirectAttributes redirectAttributes) { + + if (result.hasErrors()) { + model.addAttribute("errorMessage", "Vui lòng kiểm tra lại thông tin nhân vật."); + return "cms/character/character-create"; + + } + try { + if (imageFile != null && !imageFile.isEmpty()) { + request.setImage(fileStorageService.saveFile(imageFile, "characters")); + } + GameCharacterResponse response = gameCharacterService.createGameCharacter(request); + + redirectAttributes.addFlashAttribute( + "successMessage", + "Tạo nhân vật thành công: " + + response.getName()); + return "redirect:" + Routes.CHARACTER + "/list"; + } catch (Exception e) { + model.addAttribute( + "errorMessage", + e.getMessage()); + + return "cms/character/character-create"; + } + } + + @GetMapping({ "{id}/edit", "{id}/edit/" }) + public String toEditPage(@PathVariable("id") Long id, Model model) { + GameCharacterResponse character = gameCharacterService.getGameCharacterById(id); + model.addAttribute("character", character); + return "cms/character/character-edit"; + } + + @PostMapping("{id}/edit") + public String editCharacter(@PathVariable Long id, + @Valid @ModelAttribute("character") UpdateGameCharacterRequest request, + BindingResult result, + @RequestParam(value = "imageFile", required = false) MultipartFile imageFile, + Model model) { + if (result.hasErrors()) { + GameCharacterResponse oldCharacter = gameCharacterService.getGameCharacterById(id); + + request.setImage( + oldCharacter.getImage()); + + return "cms/character/character-edit"; + } + + request.setId(id); + + try { + if (imageFile != null && !imageFile.isEmpty()) { + request.setImage( + fileStorageService.saveFile(imageFile, "characters")); + } + gameCharacterService.updateGameCharacter(request); + + return "redirect:" + Routes.CHARACTER + "/list"; + } catch (Exception e) { + model.addAttribute( + "errorMessage", + e.getMessage()); + + return "cms/character/character-edit"; + } + } + + @PostMapping("{id}/delete") + public String deleteCharacter(@PathVariable("id") Long id, RedirectAttributes redirectAttributes) { + try { + gameCharacterService.deleteCharacter(id); + + redirectAttributes.addFlashAttribute( + "successMessage", + "Xóa nhân vật thành công"); + + } catch (Exception e) { + redirectAttributes.addFlashAttribute( + "errorMessage", + e.getMessage()); + } + + return "redirect:" + Routes.CHARACTER + "/list"; + } + + @PostMapping("{id}/restore") + public String restoreCharacter(@PathVariable("id") Long id, RedirectAttributes redirectAttributes) { + try { + gameCharacterService.restoreCharacter(id); + + redirectAttributes.addFlashAttribute( + "successMessage", + "Khôi phục nhân vật thành công"); + + } catch (Exception e) { + redirectAttributes.addFlashAttribute( + "errorMessage", + e.getMessage()); + } + + return "redirect:" + Routes.CHARACTER + "/list"; + } + +} diff --git a/src/main/java/com/darkfantasy/controller/ImageController.java b/src/main/java/com/darkfantasy/controller/ImageController.java new file mode 100644 index 0000000000000000000000000000000000000000..44bf32f50e01ed22a849722f3a9a075a669417f4 --- /dev/null +++ b/src/main/java/com/darkfantasy/controller/ImageController.java @@ -0,0 +1,42 @@ +package com.darkfantasy.controller; + +import java.io.IOException; +import java.util.Map; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.multipart.MultipartFile; + +import com.darkfantasy.service.FileStorageService; + +import lombok.RequiredArgsConstructor; + +@Controller +@RequiredArgsConstructor +public class ImageController { + private final FileStorageService fileStorageService; + + @PostMapping("/api/upload") + @ResponseBody + public ResponseEntity uploadImage(@RequestParam("image") MultipartFile file) { + try { + // Gọi Service để lưu file vào thư mục "editor" (nó sẽ tạo ra uploads/editor/) + String fileUrl = fileStorageService.saveFile(file, "editors"); + + // Trả về JSON cho Quill Frontend: {"url": "/uploads/editor/abc123hash.png"} + return ResponseEntity.ok(Map.of("url", fileUrl)); + + } catch (IllegalArgumentException e) { + // Xử lý lỗi file rỗng theo Exception đã ném ở Service + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } catch (IOException e) { + // Xử lý lỗi đọc ghi file + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Lỗi xử lý file trên server")); + } + } +} diff --git a/src/main/java/com/darkfantasy/controller/InternalController.java b/src/main/java/com/darkfantasy/controller/InternalController.java new file mode 100644 index 0000000000000000000000000000000000000000..f413f9df8c235f67449d3a2adbe0b7fe97e4cc1e --- /dev/null +++ b/src/main/java/com/darkfantasy/controller/InternalController.java @@ -0,0 +1,26 @@ +package com.darkfantasy.controller; + +import org.springframework.security.crypto.password.PasswordEncoder; +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.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; + +import lombok.RequiredArgsConstructor; + +@Controller +@RequestMapping("/api/moonblight/internal") +@RequiredArgsConstructor +public class InternalController { + + private final PasswordEncoder passwordEncoder; + + @GetMapping("/hash") + @ResponseBody + public String hash( + @RequestParam String password) { + + return passwordEncoder.encode(password); + } +} \ No newline at end of file diff --git a/src/main/java/com/darkfantasy/controller/PageController.java b/src/main/java/com/darkfantasy/controller/PageController.java new file mode 100644 index 0000000000000000000000000000000000000000..0e0eb7c31bc8bed9e52d695e01cbec9c66ff4b2c --- /dev/null +++ b/src/main/java/com/darkfantasy/controller/PageController.java @@ -0,0 +1,206 @@ +package com.darkfantasy.controller; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import com.darkfantasy.dto.article.ArticleResponse; +import com.darkfantasy.dto.contact.CreateContactMessageRequest; +import com.darkfantasy.dto.contributor.ContributorResponse; +import com.darkfantasy.entity.enums.ContactCategory; +import com.darkfantasy.entity.enums.ContactPlatform; +import com.darkfantasy.service.ArticleService; +import com.darkfantasy.service.ContactMessageService; +import com.darkfantasy.service.ContributorService; +import com.darkfantasy.service.FaqService; +import com.darkfantasy.service.GameCharacterService; +import com.darkfantasy.service.StoryService; +import com.darkfantasy.service.WorldService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Controller +@RequiredArgsConstructor +@RequestMapping("/") +public class PageController { + private final ArticleService articleService; + private final GameCharacterService gameCharacterService; + private final WorldService worldService; + private final StoryService storyService; + private final FaqService faqService; + private final ContactMessageService contactMessageService; + private final ContributorService contributorService; + @GetMapping + public String index(Model model) { + + model.addAttribute("activePage", "home"); + model.addAttribute("pageTitle", "Moon Blight - Enter the Darkness"); + + model.addAttribute("articles", articleService.getLatestArticles(3)); + model.addAttribute("characters", gameCharacterService.getCharactersLimit(4)); + model.addAttribute("topWorld", worldService.getWorldDeletedFalseHighestPriority()); + model.addAttribute("topStory", storyService.getStoryDeletedFalseHighestPriority()); + + return "forward:/WEB-INF/views/index.jsp"; + // return "index"; + } + + @GetMapping("story") + public String story(Model model) { + model.addAttribute("activePage", "story"); + model.addAttribute("pageTitle", "The Story - Moon Blight"); + + model.addAttribute("stories", storyService.getStoriesDeletedFalse()); + return "forward:/WEB-INF/views/storys/story.jsp"; + // return "story/story"; + } + + @GetMapping("characters") + public String characters(Model model) { + model.addAttribute("activePage", "characters"); + model.addAttribute("pageTitle", "Characters - Moon Blight"); + + model.addAttribute("characters", gameCharacterService.getCharactersDeletedFalse()); + return "forward:/WEB-INF/views/characters/characters.jsp"; + } + + @GetMapping("enemies") + public String enemies(Model model) { + model.addAttribute("activePage", "enemies"); + model.addAttribute("pageTitle", "Enemies - Moon Blight"); + return "forward:/WEB-INF/views/enemies/enemies.jsp"; + } + + @GetMapping("weapons") + public String weapons(Model model) { + model.addAttribute("activePage", "weapons"); + model.addAttribute("pageTitle", "Weapons - Moon Blight"); + return "forward:/WEB-INF/views/weapons/weapons.jsp"; + } + + @GetMapping("world") + public String world(Model model) { + + model.addAttribute("activePage", "world"); + model.addAttribute("pageTitle", "The World - Moon Blight"); + + model.addAttribute("worlds", worldService.getWorldsDeletedFalse()); + + return "forward:/WEB-INF/views/worlds/world.jsp"; + } + + @GetMapping("news") + public String news( + @RequestParam(defaultValue = "6") int limit, + Model model) { + + Pageable pageable = PageRequest.of(0, limit); + + Page page = articleService.getArticlesDeletedFalse(pageable); + + model.addAttribute( + "articles", + page.getContent()); + + model.addAttribute( + "hasMore", + page.getTotalElements() > limit); + + model.addAttribute( + "nextLimit", + limit + 6); + model.addAttribute( + "featuredArticle", + articleService.getLatestImportantArticle()); + + return "forward:/WEB-INF/views/news/news.jsp"; + } + + @GetMapping("aboutUs") + public String community( + @RequestParam(defaultValue = "4") int limit, + Model model) { + + Pageable pageable = PageRequest.of(0, limit); + + Page page = contributorService.getContributorsDeletedFalse(pageable); + + model.addAttribute( + "contributors", + page.getContent()); + + model.addAttribute( + "hasMore", + page.getTotalElements() > limit); + + model.addAttribute( + "nextLimit", + limit + 4); + + model.addAttribute( + "activePage", + "community"); + + model.addAttribute( + "pageTitle", + "Community - Moon Blight"); + + return "forward:/WEB-INF/views/community/aboutUs.jsp"; + } + + @GetMapping("news/{id}") + public String newsDetail(@PathVariable("id") Long id, + Model model) { + model.addAttribute("article", articleService.getPublicArticleById(id)); + model.addAttribute("articles", articleService.getLatestArticlesExcept(id, 3)); + return "forward:/WEB-INF/views/news/newDetail.jsp"; + } + + @GetMapping("support") + public String support(Model model) { + populateSupportModel(model); + model.addAttribute("contactRequest", new CreateContactMessageRequest()); + return "forward:/WEB-INF/views/supports/support.jsp"; + } + + @PostMapping("support") + public String submitContactMessage( + @Valid @ModelAttribute("contactRequest") CreateContactMessageRequest request, + BindingResult result, + Model model, + RedirectAttributes redirectAttributes) { + + if (result.hasErrors()) { + populateSupportModel(model); + model.addAttribute("errorMessage", "Vui lòng kiểm tra lại thông tin liên hệ."); + return "forward:/WEB-INF/views/supports/support.jsp"; + } + + contactMessageService.createMessage(request); + + redirectAttributes.addFlashAttribute( + "successMessage", + "Tin nhắn đã được gửi thành công."); + + return "redirect:/support"; + } + + private void populateSupportModel(Model model) { + model.addAttribute("activePage", "support"); + model.addAttribute("pageTitle", "Support - Moon Blight"); + model.addAttribute("faqs", faqService.getFaqsDeletedFalse()); + model.addAttribute("categories", ContactCategory.values()); + model.addAttribute("platforms", ContactPlatform.values()); + } +} diff --git a/src/main/java/com/darkfantasy/controller/StoryController.java b/src/main/java/com/darkfantasy/controller/StoryController.java new file mode 100644 index 0000000000000000000000000000000000000000..fd2bfb2a8d6e625c07622cb5ec15e36762cbbcee --- /dev/null +++ b/src/main/java/com/darkfantasy/controller/StoryController.java @@ -0,0 +1,171 @@ +package com.darkfantasy.controller; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import com.darkfantasy.constant.CmsMenu; +import com.darkfantasy.constant.Routes; +import com.darkfantasy.dto.story.CreateStoryRequest; +import com.darkfantasy.dto.story.StoryResponse; +import com.darkfantasy.dto.story.UpdateStoryRequest; +import com.darkfantasy.service.FileStorageService; +import com.darkfantasy.service.StoryService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Controller +@RequiredArgsConstructor +@RequestMapping(Routes.STORY + "/") +public class StoryController { + private final StoryService storyService; + private final FileStorageService fileStorageService; + + @GetMapping({ "list", "list/" }) + public String toList( + @PageableDefault(size = 10) Pageable pageable, + Model model) { + + model.addAttribute( + "stories", + storyService.getStories(pageable)); + model.addAttribute("activeMenu", CmsMenu.STORY); + return "cms/story/story-list"; + } + + @GetMapping({ "create", "create/" }) + public String toCreatePage(Model model) { + model.addAttribute("createStoryRequest", new CreateStoryRequest()); + return "cms/story/story-create"; + } + + @PostMapping({ "create", "create/" }) + public String createStory( + @Valid @ModelAttribute("createStoryRequest") CreateStoryRequest request, + BindingResult result, + @RequestParam(value = "imageFile", required = false) MultipartFile imageFile, + Model model, + RedirectAttributes redirectAttributes) { + + if (result.hasErrors()) { + return "cms/story/story-create"; + + } + try { + if (imageFile != null && !imageFile.isEmpty()) { + request.setImage(fileStorageService.saveFile(imageFile, "stories")); + } + StoryResponse response = storyService.createStory(request); + + redirectAttributes.addFlashAttribute( + "successMessage", + "Tạo câu chuyện thành công: " + + response.getTitle()); + return "redirect:" + Routes.STORY + "/list"; + } catch (Exception e) { + model.addAttribute( + "errorMessage", + e.getMessage()); + + return "cms/story/story-create"; + } + } + + @GetMapping({ "{id}/edit", "{id}/edit/" }) + public String toEditPage(@PathVariable("id") Long id, Model model) { + StoryResponse story = storyService.getStoryById(id); + + model.addAttribute("story", story); + return "cms/story/story-edit"; + } + + @PostMapping("{id}/edit") + public String editStory(@PathVariable Long id, + @Valid @ModelAttribute("story") UpdateStoryRequest request, + BindingResult result, + @RequestParam(value = "imageFile", required = false) MultipartFile imageFile, + Model model) { + if (result.hasErrors()) { + StoryResponse oldStory = storyService.getStoryById(id); + + request.setImage( + oldStory.getImage()); + + return "cms/story/story-edit"; + } + + request.setId(id); + + try { + StoryResponse oldStory = storyService.getStoryById(id); + + if (imageFile != null && !imageFile.isEmpty()) { + + request.setImage( + fileStorageService.saveFile( + imageFile, + "stories")); + + } else { + request.setImage( + oldStory.getImage()); + } + storyService.updateStory(request); + + return "redirect:" + Routes.STORY + "/list"; + } catch (Exception e) { + model.addAttribute( + "errorMessage", + e.getMessage()); + + return "cms/story/story-edit"; + } + } + + @PostMapping("{id}/delete") + public String deleteStory(@PathVariable("id") Long id, RedirectAttributes redirectAttributes) { + try { + storyService.deleteStory(id); + + redirectAttributes.addFlashAttribute( + "successMessage", + "Xóa câu chuyện thành công"); + + } catch (Exception e) { + redirectAttributes.addFlashAttribute( + "errorMessage", + e.getMessage()); + } + + return "redirect:" + Routes.STORY + "/list"; + } + + @PostMapping("{id}/restore") + public String restoreStory(@PathVariable("id") Long id, RedirectAttributes redirectAttributes) { + try { + storyService.restoreStory(id); + + redirectAttributes.addFlashAttribute( + "successMessage", + "Khôi phục câu chuyện thành công"); + + } catch (Exception e) { + redirectAttributes.addFlashAttribute( + "errorMessage", + e.getMessage()); + } + + return "redirect:" + Routes.STORY + "/list"; + } +} diff --git a/src/main/java/com/darkfantasy/controller/UserController.java b/src/main/java/com/darkfantasy/controller/UserController.java new file mode 100644 index 0000000000000000000000000000000000000000..23f152f09b15bce18b8cd343ff4a1e15876188b4 --- /dev/null +++ b/src/main/java/com/darkfantasy/controller/UserController.java @@ -0,0 +1,321 @@ +package com.darkfantasy.controller; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import com.darkfantasy.constant.Routes; +import com.darkfantasy.dto.user.ChangePasswordRequest; +import com.darkfantasy.dto.user.ForgotPasswordRequest; +import com.darkfantasy.dto.user.LoginRequest; +import com.darkfantasy.dto.user.RegisterRequest; +import com.darkfantasy.dto.user.ResetPasswordRequest; +import com.darkfantasy.dto.user.UserResponse; +import com.darkfantasy.dto.user.VerifyOtpRequest; +import com.darkfantasy.entity.enums.LogAction; +import com.darkfantasy.entity.enums.LogEntityType; +import com.darkfantasy.service.AuditLogService; +import com.darkfantasy.service.UserService; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Controller +@RequiredArgsConstructor +@RequestMapping(Routes.USER + "/") +public class UserController { + private final UserService userService; + private final AuditLogService auditLogService; + + @GetMapping({ "login", "login/" }) + public String toLoginPage(Model model) { + model.addAttribute("loginRequest", new LoginRequest()); + return "cms/auth/login"; + } + + @GetMapping({ "register", "register/" }) + public String toRegisterPage(Model model) { + model.addAttribute("registerRequest", new RegisterRequest()); + return "cms/auth/register"; + } + + @GetMapping({ "reset", "reset/" }) + public String toResetPage() { + return "cms/auth/reset"; + } + + @GetMapping({ "change", "change/" }) + public String toChangePasswordPage() { + return "cms/auth/password-change"; + } + + @PostMapping("login") + public String login( + @Valid @ModelAttribute LoginRequest request, + BindingResult result, + Model model, + HttpServletRequest req) { + + if (result.hasErrors()) { + return "cms/auth/login"; + } + + try { + + UserResponse user = userService.login(request); + + req.getSession(true).setAttribute( + HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, + SecurityContextHolder.getContext()); + return switch (user.getRole()) { + case "ADMIN" -> "redirect:" + Routes.ADMIN + "/dashboard"; + case "STAFF" -> "redirect:" + Routes.DASHBOARD; + default -> "redirect:/"; + }; + + } catch (AuthenticationException e) { + + model.addAttribute( + "errorMessage", + "Tên đăng nhập hoặc mật khẩu không đúng."); + + return "cms/auth/login"; + } + } + + @PostMapping("logout") + public String logout( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) { + if (authentication != null) { + String username = authentication.getName(); + UserResponse user = userService.findByUsername(username); + auditLogService.log( + LogEntityType.USER, + user.getId(), + LogAction.LOGOUT, + "Đăng xuất"); + } + + new SecurityContextLogoutHandler() + .logout(request, response, authentication); + + return "redirect:" + Routes.USER + "/login"; + } + + @PostMapping("register") + public String registerAccount(@Valid @ModelAttribute RegisterRequest request, + BindingResult result, + Model model, + RedirectAttributes redirectAttributes) { + if (result.hasErrors()) { + return "cms/auth/register"; + } + try { + userService.register(request); + redirectAttributes.addFlashAttribute("successMessage", "Đăng ký thành công! Chờ admin xác nhận"); + return "redirect:" + Routes.USER + "/register"; + } catch (IllegalArgumentException e) { + request.setPassword(""); + request.setRePassword(""); + model.addAttribute("errorMessage", e.getMessage()); + return "cms/auth/register"; + } + } + + @GetMapping("change-password") + public String changePasswordPage( + Model model) { + + model.addAttribute( + "changePasswordRequest", + new ChangePasswordRequest()); + return "cms/auth/change-password"; + } + + @PostMapping("change-password") + public String changePassword( + @Valid @ModelAttribute("changePasswordRequest") ChangePasswordRequest request, + BindingResult result, + Model model, + RedirectAttributes redirectAttributes) { + + if (result.hasErrors()) { + return "cms/auth/change-password"; + } + + try { + + userService.changeCurrentUserPassword(request); + + redirectAttributes.addFlashAttribute( + "successMessage", + "Đổi mật khẩu thành công"); + + return "redirect:" + Routes.USER + "/change-password"; + + } catch (Exception e) { + + model.addAttribute( + "errorMessage", + e.getMessage()); + + return "cms/auth/change-password"; + } + } + // @PostMapping("reset/enter") + // public String resetPassword(@Valid @ModelAttribute ResetPasswordRequest + // request, BindingResult result, + // Model model, RedirectAttributes redirectAttributes) { + // if (result.hasErrors()) { + // return "reset"; + // } + // try { + // userService.changeForgottenPassword(request); + // redirectAttributes.addAttribute("successMessage", "Thay đổi mật khẩu thành + // công!"); + // return "redirect:/user/login"; + // } catch (IllegalArgumentException e) { + // model.addAttribute("errorMessage", e.getMessage()); + // return "reset"; + // } + + // } + @GetMapping("forgot-password") + public String forgotPasswordPage(Model model) { + model.addAttribute("forgotPasswordRequest", new ForgotPasswordRequest()); + return "cms/auth/forgot-password"; + } + + @PostMapping("forgot-password") + public String forgotPassword( + @Valid @ModelAttribute ForgotPasswordRequest request, + BindingResult result, HttpSession session, + Model model) { + if (result.hasErrors()) { + return "cms/auth/forgot-password"; + } + try { + userService.sendOtp(request); + session.setAttribute("OTP_EMAIL", request.getEmail()); + return "redirect:" + Routes.USER + "/verify-otp"; + } catch (Exception e) { + model.addAttribute("errorMessage", e.getMessage()); + return "cms/auth/forgot-password"; + } + } + + @GetMapping("verify-otp") + public String verifyOtpPage(HttpSession session, Model model) { + VerifyOtpRequest request = new VerifyOtpRequest(); + request.setEmail((String) session.getAttribute("OTP_EMAIL")); + + model.addAttribute("verifyOtpRequest", request); + return "cms/auth/verify-otp"; + } + + @PostMapping("verify-otp") + public String verifyOtp( + @Valid @ModelAttribute VerifyOtpRequest request, + BindingResult result, + HttpSession session, + Model model) { + + if (result.hasErrors()) { + return "cms/auth/verify-otp"; + } + + try { + + userService.verifyOtp(request); + session.setAttribute( + "RESET_OTP", + request.getOtp()); + session.setAttribute( + "RESET_EMAIL", + request.getEmail()); + return "redirect:" + Routes.USER + "/reset-password"; + + } catch (Exception e) { + model.addAttribute( + "errorMessage", e.getMessage()); + + return "cms/auth/verify-otp"; + } + } + + @GetMapping("/reset-password") + public String resetPasswordPage( + HttpSession session, + Model model) { + ResetPasswordRequest request = new ResetPasswordRequest(); + request.setEmail( + (String) session.getAttribute("RESET_EMAIL")); + + request.setOtp( + (String) session.getAttribute("RESET_OTP")); + + model.addAttribute("resetPasswordRequest", request); + if (request.getEmail() == null || request.getOtp() == null) { + return "redirect:" + Routes.USER + "/forgot-password"; + } + return "cms/auth/reset-password"; + } + + @PostMapping("/reset-password") + public String resetPassword( + @Valid @ModelAttribute ResetPasswordRequest request, + BindingResult result, + HttpSession session, + Model model, + RedirectAttributes redirectAttributes) { + if (result.hasErrors()) { + return "cms/auth/reset-password"; + } + + try { + String email = (String) session.getAttribute("RESET_EMAIL"); + String otp = (String) session.getAttribute("RESET_OTP"); + if (email == null || otp == null) { + return "redirect:" + Routes.USER + "/forgot-password"; + } + request.setEmail(email); + request.setOtp(otp); + userService.resetPassword(request); + + session.removeAttribute( + "RESET_EMAIL"); + + session.removeAttribute( + "RESET_OTP"); + + redirectAttributes.addFlashAttribute( + "successMessage", + "Đổi mật khẩu thành công"); + + return "redirect:" + Routes.USER + "/login"; + + } catch (Exception e) { + e.printStackTrace(); + model.addAttribute( + "errorMessage", + e.getMessage()); + + return "cms/auth/reset-password"; + } + } +} diff --git a/src/main/java/com/darkfantasy/controller/WorldController.java b/src/main/java/com/darkfantasy/controller/WorldController.java new file mode 100644 index 0000000000000000000000000000000000000000..72679f2b906d5d0b536d6eb6b8a52f086db4ddce --- /dev/null +++ b/src/main/java/com/darkfantasy/controller/WorldController.java @@ -0,0 +1,162 @@ +package com.darkfantasy.controller; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import com.darkfantasy.constant.CmsMenu; +import com.darkfantasy.constant.Routes; +import com.darkfantasy.dto.world.CreateWorldRequest; +import com.darkfantasy.dto.world.UpdateWorldRequest; +import com.darkfantasy.dto.world.WorldResponse; +import com.darkfantasy.service.FileStorageService; +import com.darkfantasy.service.WorldService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; + +@Controller +@RequiredArgsConstructor +@RequestMapping(Routes.WORLD + "/") +public class WorldController { + private final WorldService worldService; + private final FileStorageService fileStorageService; + + @GetMapping({ "list", "list/" }) + public String toList( + @PageableDefault(size = 10) Pageable pageable, + Model model) { + + model.addAttribute( + "worlds", + worldService.getWorlds(pageable)); + model.addAttribute("activeMenu", CmsMenu.WORLD); + return "cms/world/world-list"; + } + + @GetMapping({ "create", "create/" }) + public String toCreatePage(Model model) { + model.addAttribute("createWorldRequest", new CreateWorldRequest()); + return "cms/world/world-create"; + } + + @PostMapping({ "create", "create/" }) + public String createWorld( + @Valid @ModelAttribute("createWorldRequest") CreateWorldRequest request, + BindingResult result, + @RequestParam(value = "imageFile", required = false) MultipartFile imageFile, + Model model, + RedirectAttributes redirectAttributes) { + + if (result.hasErrors()) { + model.addAttribute("errorMessage", "Vui lòng kiểm tra lại thông tin thế giới."); + return "cms/world/world-create"; + + } + try { + if (imageFile != null && !imageFile.isEmpty()) { + request.setImage(fileStorageService.saveFile(imageFile, "worlds")); + } + WorldResponse response = worldService.createWorld(request); + + redirectAttributes.addFlashAttribute( + "successMessage", + "Tạo thế giới thành công: " + + response.getTitle()); + return "redirect:" + Routes.WORLD + "/list"; + } catch (Exception e) { + model.addAttribute( + "errorMessage", + e.getMessage()); + + return "cms/world/world-create"; + } + } + + @GetMapping({ "{id}/edit", "{id}/edit/" }) + public String toEditPage(@PathVariable("id") Long id, Model model) { + WorldResponse world = worldService.getWorldById(id); + model.addAttribute("world", world); + return "cms/world/world-edit"; + } + + @PostMapping("{id}/edit") + public String editWorld(@PathVariable Long id, + @Valid @ModelAttribute("world") UpdateWorldRequest request, + BindingResult result, + @RequestParam(value = "imageFile", required = false) MultipartFile imageFile, + Model model) { + if (result.hasErrors()) { + WorldResponse oldWorld = worldService.getWorldById(id); + + request.setImage( + oldWorld.getImage()); + + return "cms/world/world-edit"; + } + + request.setId(id); + + try { + if (imageFile != null && !imageFile.isEmpty()) { + request.setImage( + fileStorageService.saveFile(imageFile, "worlds")); + } + worldService.updateWorld(request); + + return "redirect:" + Routes.WORLD + "/list"; + } catch (Exception e) { + model.addAttribute( + "errorMessage", + e.getMessage()); + + return "cms/world/world-edit"; + } + } + + @PostMapping("{id}/delete") + public String deleteWorld(@PathVariable("id") Long id, RedirectAttributes redirectAttributes) { + try { + worldService.deleteWorld(id); + + redirectAttributes.addFlashAttribute( + "successMessage", + "Xóa thế giới thành công"); + + } catch (Exception e) { + redirectAttributes.addFlashAttribute( + "errorMessage", + e.getMessage()); + } + + return "redirect:" + Routes.WORLD + "/list"; + } + + @PostMapping("{id}/restore") + public String restoreWorld(@PathVariable("id") Long id, RedirectAttributes redirectAttributes) { + try { + worldService.restoreWorld(id); + + redirectAttributes.addFlashAttribute( + "successMessage", + "Khôi phục thế giới thành công"); + + } catch (Exception e) { + redirectAttributes.addFlashAttribute( + "errorMessage", + e.getMessage()); + } + + return "redirect:" + Routes.WORLD + "/list"; + } +} diff --git a/src/main/java/com/darkfantasy/dto/article/ArticleResponse.java b/src/main/java/com/darkfantasy/dto/article/ArticleResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..1a303f4f04576cdb3b2ed1469ee24d7471dddabc --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/article/ArticleResponse.java @@ -0,0 +1,58 @@ +package com.darkfantasy.dto.article; + +import com.darkfantasy.entity.Article; +import com.darkfantasy.entity.User; +import com.darkfantasy.entity.enums.ArticleType; +import com.darkfantasy.util.TimeUtil; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder + +public class ArticleResponse { + private Long id; + private String title; + private String content; + private String authorUsername; + private String thumbnailUrl; + private String type; + private String createdByUserName; + private String createdAt; + private String updatedByUserName; + private String updatedAt; + private boolean deleted; + + public static ArticleResponse fromEntity(Article article) { + return ArticleResponse.builder() + .id(article.getId()) + .title(article.getTitle()) + .content(article.getContent()) + .authorUsername(article.getCreatedBy().getUsername()) + .thumbnailUrl(article.getThumbnailUrl()) + .type(article.getType().getDisplayName()) + .createdByUserName(article.getCreatedBy()!= null? article.getCreatedBy().getUsername() : null) + .createdAt(TimeUtil.formatInstant(article.getCreatedAt())) + .updatedByUserName(article.getUpdatedBy()!= null? article.getUpdatedBy().getUsername() : null) + .updatedAt(TimeUtil.formatInstant(article.getUpdatedAt())) + .deleted(article.isDeleted()) + .build(); + } + + public String getSummary() { + if (content == null) { + return ""; + } + + if (content.length() <= 150) { + return content; + } + + return content.substring(0, 150) + "..."; + } +} diff --git a/src/main/java/com/darkfantasy/dto/article/CreateArticleRequest.java b/src/main/java/com/darkfantasy/dto/article/CreateArticleRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..b96c05bf6f5e397e4b572cfc2e3a97c157138ec8 --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/article/CreateArticleRequest.java @@ -0,0 +1,40 @@ +package com.darkfantasy.dto.article; + +import com.darkfantasy.entity.Article; +import com.darkfantasy.entity.enums.ArticleType; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CreateArticleRequest { + + @NotBlank(message = "Tiêu đề không được để trống") + @Size(max = 255, message = "Tiêu đề quá dài") + private String title; + + @NotBlank(message = "Nội dung không được để trống") + private String content; + + private String thumbnailUrl; + + @NotNull(message = "Loại bài viết không được để trống") + private ArticleType type; + + public Article toEntity() { + return Article.builder() + .title(this.title) + .content(this.content) + .thumbnailUrl(this.thumbnailUrl) + .type(this.type) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/darkfantasy/dto/article/UpdateArticleRequest.java b/src/main/java/com/darkfantasy/dto/article/UpdateArticleRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..22f9c8cb6c9ec9bca590ee60831b4e668dde3cf5 --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/article/UpdateArticleRequest.java @@ -0,0 +1,32 @@ +package com.darkfantasy.dto.article; + +import com.darkfantasy.entity.enums.ArticleType; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UpdateArticleRequest { + @NotNull + private Long id; + @NotBlank(message = "Tiêu đề không được để trống") + @Size(max = 255) + private String title; + + @NotBlank(message = "Nội dung không được để trống") + private String content; + + private String thumbnailUrl; + + @NotNull(message = "Loại bài viết không được để trống") + private ArticleType type; + +} diff --git a/src/main/java/com/darkfantasy/dto/contact/ContactMessageResponse.java b/src/main/java/com/darkfantasy/dto/contact/ContactMessageResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..5b49b810eb039cbc816439c58f00e00219b4f7d4 --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/contact/ContactMessageResponse.java @@ -0,0 +1,42 @@ +package com.darkfantasy.dto.contact; + + +import com.darkfantasy.entity.ContactMessage; +import com.darkfantasy.entity.enums.ContactCategory; +import com.darkfantasy.entity.enums.ContactPlatform; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ContactMessageResponse { + private Long id; + private String visitorName; + + private String visitorEmail; + + private ContactCategory category; + + private ContactPlatform platform; + + private String message; + + private boolean processed; + + public static ContactMessageResponse fromEntity(ContactMessage message) { + return ContactMessageResponse.builder() + .id(message.getId()) + .visitorName(message.getVisitorName()) + .visitorEmail(message.getVisitorEmail()) + .category(message.getCategory()) + .platform(message.getPlatform()) + .message(message.getMessage()) + .processed(message.isProcessed()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/darkfantasy/dto/contact/CreateContactMessageRequest.java b/src/main/java/com/darkfantasy/dto/contact/CreateContactMessageRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..a882838b305f0f4c376215fbe5431122840e4864 --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/contact/CreateContactMessageRequest.java @@ -0,0 +1,49 @@ +package com.darkfantasy.dto.contact; + +import com.darkfantasy.entity.ContactMessage; +import com.darkfantasy.entity.enums.ContactCategory; +import com.darkfantasy.entity.enums.ContactPlatform; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CreateContactMessageRequest { + + @NotBlank(message = "Vui lòng nhập tên") + private String visitorName; + + @NotBlank(message = "Vui lòng nhập email") + @Email(message = "Email không hợp lệ") + private String visitorEmail; + + @NotNull(message = "Vui lòng chọn danh mục") + private ContactCategory category; + + @NotNull(message = "Vui lòng chọn nền tảng") + private ContactPlatform platform; + + @NotBlank(message = "Vui lòng nhập nội dung") + @Size(max = 5000) + private String message; + + public ContactMessage toEntity() { + return ContactMessage.builder() + .visitorName(visitorName) + .visitorEmail(visitorEmail) + .category(category) + .platform(platform) + .message(message) + .processed(false) + .build(); + } +} diff --git a/src/main/java/com/darkfantasy/dto/contributor/ContributorResponse.java b/src/main/java/com/darkfantasy/dto/contributor/ContributorResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..522f14e4aa156d19f33f79706f0d9f6aa7fe384a --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/contributor/ContributorResponse.java @@ -0,0 +1,41 @@ +package com.darkfantasy.dto.contributor; + +import com.darkfantasy.entity.Contributor; +import com.darkfantasy.util.TimeUtil; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ContributorResponse { + private Long id; + private String name; + private String position; + private String avatar; + private Integer priority; + private boolean deleted; + private String createdByUserName; + private String createdAt; + private String updatedByUserName; + private String updatedAt; + + public static ContributorResponse fromEntity(Contributor contributor) { + return ContributorResponse.builder() + .id(contributor.getId()) + .name(contributor.getName()) + .position(contributor.getPosition()) + .avatar(contributor.getAvatar()) + .priority(contributor.getPriority()) + .deleted(contributor.isDeleted()) + .createdByUserName(contributor.getCreatedBy() != null ? contributor.getCreatedBy().getUsername() : null) + .createdAt(TimeUtil.formatInstant(contributor.getCreatedAt())) + .updatedByUserName(contributor.getUpdatedBy() != null ? contributor.getUpdatedBy().getUsername() : null) + .updatedAt(TimeUtil.formatInstant(contributor.getUpdatedAt())) + .build(); + } +} diff --git a/src/main/java/com/darkfantasy/dto/contributor/CreateContributorRequest.java b/src/main/java/com/darkfantasy/dto/contributor/CreateContributorRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..e496fff361c27be5117ced018c94bed29ce3d44c --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/contributor/CreateContributorRequest.java @@ -0,0 +1,44 @@ +package com.darkfantasy.dto.contributor; + +import com.darkfantasy.entity.Article; +import com.darkfantasy.entity.Contributor; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CreateContributorRequest { + @NotBlank(message = "Tên người đóng góp không được để trống") + @Size(max = 255, message = "Tên người đóng góp không được vượt quá 255 ký tự") + private String name; + + @NotBlank(message = "Chức danh không được để trống") + @Size(max = 255, message = "Chức danh không được vượt quá 255 ký tự") + private String position; + + private String avatar; + + @NotNull(message = "Độ ưu tiên không được để trống") + @Min(value = 0, message = "Độ ưu tiên phải từ 0 đến 100") + @Max(value = 100, message = "Độ ưu tiên phải từ 0 đến 100") + private Integer priority; + + public Contributor toEntity() { + return Contributor.builder() + .name(this.name) + .position(this.position) + .avatar(this.avatar) + .priority(this.priority) + .build(); + } +} diff --git a/src/main/java/com/darkfantasy/dto/contributor/UpdateContributorRequest.java b/src/main/java/com/darkfantasy/dto/contributor/UpdateContributorRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..7a7d5690cc98d0b48b1d660f33cce586108fa74b --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/contributor/UpdateContributorRequest.java @@ -0,0 +1,34 @@ +package com.darkfantasy.dto.contributor; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UpdateContributorRequest { + private Long id; + @NotBlank(message = "Tên người đóng góp không được để trống") + @Size(max = 255, message = "Tên người đóng góp không được vượt quá 255 ký tự") + private String name; + + @NotBlank(message = "Chức danh không được để trống") + @Size(max = 255, message = "Chức danh không được vượt quá 255 ký tự") + private String position; + + private String avatar; + + @NotNull(message = "Độ ưu tiên không được để trống") + @Min(value = 0, message = "Độ ưu tiên phải từ 0 đến 100") + @Max(value = 100, message = "Độ ưu tiên phải từ 0 đến 100") + private Integer priority; + +} diff --git a/src/main/java/com/darkfantasy/dto/faq/CreateFaqRequest.java b/src/main/java/com/darkfantasy/dto/faq/CreateFaqRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..b95d8e8e90c0e63a80b58a3672df2f7b61fafa1e --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/faq/CreateFaqRequest.java @@ -0,0 +1,41 @@ +package com.darkfantasy.dto.faq; + +import com.darkfantasy.entity.Faq; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CreateFaqRequest { + + @NotBlank(message = "Tiêu đề không được để trống!") + @Size(max = 255, message = "Tiêu đề không được vượt quá 255 ký tự!") + private String title; + @NotBlank(message = "Nội dung không được để trống!") + private String content; + + @NotNull(message = "Độ ưu tiên không được để trống!") + @Min(value = 0, message = "Độ ưu tiên phải từ 0 đến 100!") + @Max(value = 100, message = "Độ ưu tiên phải từ 0 đến 100!") + private Integer priority; + private boolean deleted; + + public Faq toEntity() { + return Faq.builder() + .title(this.title) + .content(this.content) + .priority(this.priority) + .deleted(this.deleted) + .build(); + } +} diff --git a/src/main/java/com/darkfantasy/dto/faq/FaqResponse.java b/src/main/java/com/darkfantasy/dto/faq/FaqResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..a33d7f8eb02649c8f70ad0bce8d9878222f8974f --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/faq/FaqResponse.java @@ -0,0 +1,30 @@ +package com.darkfantasy.dto.faq; + +import com.darkfantasy.entity.Faq; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class FaqResponse { + private Long id; + private String title; + private String content; + private Integer priority; + private boolean deleted; + + public static FaqResponse fromEntity(Faq faq) { + return FaqResponse.builder() + .id(faq.getId()) + .title(faq.getTitle()) + .content(faq.getContent()) + .priority(faq.getPriority()) + .deleted(faq.isDeleted()) + .build(); + } +} diff --git a/src/main/java/com/darkfantasy/dto/faq/UpdateFaqRequest.java b/src/main/java/com/darkfantasy/dto/faq/UpdateFaqRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..5f2f8ddcd56cae5f60eb3396a4a1bce362962072 --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/faq/UpdateFaqRequest.java @@ -0,0 +1,29 @@ +package com.darkfantasy.dto.faq; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UpdateFaqRequest { + private Long id; + + @NotBlank(message = "Tiêu đề không được để trống!") + @Size(max = 255, message = "Tiêu đề không được vượt quá 255 ký tự!") + private String title; + @NotBlank(message = "Nội dung không được để trống!") + private String content; + @NotNull(message = "Độ ưu tiên không được để trống!") + @Min(value = 0, message = "Độ ưu tiên phải từ 0 đến 100!") + @Max(value = 100, message = "Độ ưu tiên phải từ 0 đến 100!") + private Integer priority; +} diff --git a/src/main/java/com/darkfantasy/dto/gamecharacter/CreateGameCharacterRequest.java b/src/main/java/com/darkfantasy/dto/gamecharacter/CreateGameCharacterRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..fbaeff7ba4e11a1d158558c391750beb9a148ea7 --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/gamecharacter/CreateGameCharacterRequest.java @@ -0,0 +1,52 @@ +package com.darkfantasy.dto.gamecharacter; + +import com.darkfantasy.entity.GameCharacter; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CreateGameCharacterRequest { + + @NotBlank(message = "Tên nhân vật không được để trống") + @Size(max = 255, message = "Tên nhân vật không được vượt quá 255 ký tự") + private String name; + + @NotBlank(message = "Chủng tộc không được để trống") + @Size(max = 100, message = "Chủng tộc không được vượt quá 100 ký tự") + private String race; + + @NotBlank(message = "Mô tả không được để trống") + private String description; + + @Size(max = 255, message = "Quote không được vượt quá 255 ký tự") + private String quote; + + private String image; + + @NotNull(message = "Độ ưu tiên không được để trống") + @Min(value = 0, message = "Độ ưu tiên phải từ 0 đến 100") + @Max(value = 100, message = "Độ ưu tiên phải từ 0 đến 100") + private Integer priority; + + public GameCharacter toEntity() { + return GameCharacter.builder() + .name(this.name) + .race(this.race) + .description(this.description) + .quote(this.quote) + .image(this.image) + .priority(this.priority) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/darkfantasy/dto/gamecharacter/GameCharacterResponse.java b/src/main/java/com/darkfantasy/dto/gamecharacter/GameCharacterResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..46ed3c0034c26a0b7d9533252fed03915dd5aeb0 --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/gamecharacter/GameCharacterResponse.java @@ -0,0 +1,36 @@ +package com.darkfantasy.dto.gamecharacter; + +import com.darkfantasy.entity.GameCharacter; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class GameCharacterResponse { + private Long id; + private String name; + private String race; + private String description; + private String quote; + private String image; + private Integer priority; + private boolean deleted; + + public static GameCharacterResponse fromEntity(GameCharacter character) { + return GameCharacterResponse.builder() + .id(character.getId()) + .name(character.getName()) + .race(character.getRace()) + .description(character.getDescription()) + .quote(character.getQuote()) + .image(character.getImage()) + .priority(character.getPriority()) + .deleted(character.isDeleted()) + .build(); + } +} diff --git a/src/main/java/com/darkfantasy/dto/gamecharacter/UpdateGameCharacterRequest.java b/src/main/java/com/darkfantasy/dto/gamecharacter/UpdateGameCharacterRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..6c1c8c8d57c2f50295cfc6690b024961721301f4 --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/gamecharacter/UpdateGameCharacterRequest.java @@ -0,0 +1,48 @@ +package com.darkfantasy.dto.gamecharacter; + +import com.darkfantasy.entity.GameCharacter; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UpdateGameCharacterRequest { + private Long id; + @NotBlank(message = "Tên nhân vật không được để trống") + @Size(max = 255, message = "Tên nhân vật không được vượt quá 255 ký tự") + private String name; + @NotBlank(message = "Chủng tộc không được để trống") + @Size(max = 100, message = "Chủng tộc không được vượt quá 100 ký tự") + private String race; + @NotBlank(message = "Mô tả không được để trống") + private String description; + @Size(max = 255, message = "Quote không được vượt quá 255 ký tự") + private String quote; + private String image; + @NotNull(message = "Độ ưu tiên không được để trống") + @Min(value = 0, message = "Độ ưu tiên phải từ 0 đến 100") + @Max(value = 100, message = "Độ ưu tiên phải từ 0 đến 100") + private Integer priority; + + public GameCharacter toEntity() { + return GameCharacter.builder() + .id(this.id) + .name(this.name) + .race(this.race) + .description(this.description) + .quote(this.quote) + .image(this.image) + .priority(this.priority) + .build(); + } +} diff --git a/src/main/java/com/darkfantasy/dto/log/AuditLogResponse.java b/src/main/java/com/darkfantasy/dto/log/AuditLogResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..09e80f28296a4e8e0c69edf391aa341f2cedac68 --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/log/AuditLogResponse.java @@ -0,0 +1,37 @@ +package com.darkfantasy.dto.log; + +import java.time.Instant; + +import com.darkfantasy.entity.AuditLog; +import com.darkfantasy.entity.enums.LogAction; +import com.darkfantasy.entity.enums.LogEntityType; +import com.darkfantasy.util.TimeUtil; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class AuditLogResponse { + private Long id; + private Long userId; + private String username; + private LogEntityType entityType; + private Long entityId; + private LogAction action; + private String description; + private String createdAt; + + public static AuditLogResponse fromEntity(AuditLog auditLog) { + return AuditLogResponse.builder() + .id(auditLog.getId()) + .userId(auditLog.getUser().getId()) + .username(auditLog.getUser().getUsername()) + .entityType(auditLog.getEntityType()) + .entityId(auditLog.getEntityId()) + .action(auditLog.getAction()) + .description(auditLog.getDescription()) + .createdAt(TimeUtil.formatInstant(auditLog.getCreatedAt())) + .build(); + } +} diff --git a/src/main/java/com/darkfantasy/dto/story/CreateStoryRequest.java b/src/main/java/com/darkfantasy/dto/story/CreateStoryRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..03b80f3c4ed0f81d334b3a6cb91bcb2d62fd6d02 --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/story/CreateStoryRequest.java @@ -0,0 +1,50 @@ +package com.darkfantasy.dto.story; + +import com.darkfantasy.entity.Story; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CreateStoryRequest { + @NotBlank(message = "Tag không được để trống!") + @Size(max = 255, message = "Tag không được vượt quá 255 ký tự!") + private String tag; + + @NotBlank(message = "Tiêu đề không được để trống!") + @Size(max = 255, message = "Tiêu đề không được vượt quá 255 ký tự!") + private String title; + @NotBlank(message = "Nội dung không được để trống!") + private String content; + private String image; + @NotBlank(message = "Nội dung quote không được để trống!") + private String quoteContent; + @NotBlank(message = "Tác giả trích dẫn không được để trống!") + private String quoteAuthor; + @NotNull(message = "Độ ưu tiên không được để trống!") + @Min(value = 0, message = "Độ ưu tiên phải từ 0 đến 100!") + @Max(value = 100, message = "Độ ưu tiên phải từ 0 đến 100!") + private Integer priority; + + public Story toEntity() { + return Story.builder() + .tag(this.tag) + .title(this.title) + .content(this.content) + .image(this.image) + .quoteContent(this.quoteContent) + .quoteAuthor(this.quoteAuthor) + .priority(this.priority) + .build(); + } +} diff --git a/src/main/java/com/darkfantasy/dto/story/StoryResponse.java b/src/main/java/com/darkfantasy/dto/story/StoryResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..e0fd19b152cf50d3e1872640bc5c707ee76b4e12 --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/story/StoryResponse.java @@ -0,0 +1,59 @@ +package com.darkfantasy.dto.story; + +import com.darkfantasy.entity.Story; +import com.darkfantasy.util.TimeUtil; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class StoryResponse { + private Long id; + private String tag; + private String title; + private String content; + private String image; + private String quoteContent; + private String quoteAuthor; + private Integer priority; + private String createdByUserName; + private String createdAt; + private String updatedByUserName; + private String updatedAt; + private boolean deleted; + + public static StoryResponse fromEntity(Story story) { + return StoryResponse.builder() + .id(story.getId()) + .tag(story.getTag()) + .title(story.getTitle()) + .content(story.getContent()) + .image(story.getImage()) + .quoteContent(story.getQuoteContent()) + .quoteAuthor(story.getQuoteAuthor()) + .priority(story.getPriority()) + .createdByUserName(story.getCreatedBy()!=null ? story.getCreatedBy().getUsername(): null) + .createdAt(TimeUtil.formatInstant(story.getCreatedAt())) + .updatedByUserName(story.getUpdatedBy()!=null ? story.getUpdatedBy().getUsername(): null) + .updatedAt(TimeUtil.formatInstant(story.getUpdatedAt())) + .deleted(story.isDeleted()) + .build(); + } + + public String getSummary() { + if (content == null) { + return ""; + } + + if (content.length() <= 350) { + return content; + } + + return content.substring(0, 350) + "..."; + } +} diff --git a/src/main/java/com/darkfantasy/dto/story/UpdateStoryRequest.java b/src/main/java/com/darkfantasy/dto/story/UpdateStoryRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..5a1e66bfaf613e1d9aeee3ad939477703c7c24fb --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/story/UpdateStoryRequest.java @@ -0,0 +1,50 @@ +package com.darkfantasy.dto.story; + +import com.darkfantasy.entity.Story; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UpdateStoryRequest { + private Long id; + @NotBlank(message = "Tag không được để trống!") + @Size(max = 255, message = "Tag không được vượt quá 255 ký tự!") + private String tag; + @NotBlank(message = "Tiêu đề không được để trống!") + @Size(max = 255, message = "Tiêu đề không được vượt quá 255 ký tự!") + private String title; + @NotBlank(message = "Nội dung không được để trống!") + private String content; + private String image; + @NotBlank(message = "Nội dung trích dẫn không được để trống!") + private String quoteContent; + @NotBlank(message = "Tác giả trích dẫn không được để trống!") + private String quoteAuthor; + @NotNull(message = "Độ ưu tiên không được để trống!") + @Min(value = 0, message = "Độ ưu tiên phải từ 0 đến 100!") + @Max(value = 100, message = "Độ ưu tiên phải từ 0 đến 100!") + private Integer priority; + + public Story toEntity() { + return Story.builder() + .id(this.id) + .tag(this.tag) + .title(this.title) + .content(this.content) + .image(this.image) + .quoteContent(this.quoteContent) + .quoteAuthor(this.quoteAuthor) + .build(); + } +} diff --git a/src/main/java/com/darkfantasy/dto/user/ChangePasswordRequest.java b/src/main/java/com/darkfantasy/dto/user/ChangePasswordRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..648ebfc9451ed29c96cc114f4bbf328fe54faf0b --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/user/ChangePasswordRequest.java @@ -0,0 +1,24 @@ +package com.darkfantasy.dto.user; + +import com.darkfantasy.annotation.ChangePasswordMatch; +import com.fasterxml.jackson.annotation.JsonProperty; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +@ChangePasswordMatch +public class ChangePasswordRequest { + + @NotBlank(message = "Mật khẩu cũ không được để trống") + private String oldPassword; + + @NotBlank(message = "Mật khẩu không được để trống") + @Size(min = 8, message = "Mật khẩu phải chứa ít nhất 8 kí tự") + private String newPassword; + + @NotBlank(message = "Mật khẩu xác nhận không được để trống") + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + private String confirmPassword; +} diff --git a/src/main/java/com/darkfantasy/dto/user/ForgotPasswordRequest.java b/src/main/java/com/darkfantasy/dto/user/ForgotPasswordRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..95b05ed3c7d06de500523ddea7ced1a91916feb7 --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/user/ForgotPasswordRequest.java @@ -0,0 +1,13 @@ +package com.darkfantasy.dto.user; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class ForgotPasswordRequest { + + @NotBlank + @Email + private String email; +} \ No newline at end of file diff --git a/src/main/java/com/darkfantasy/dto/user/LoginRequest.java b/src/main/java/com/darkfantasy/dto/user/LoginRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..60b1b54935b13adc181e1a01efcfee6573429239 --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/user/LoginRequest.java @@ -0,0 +1,24 @@ +package com.darkfantasy.dto.user; + + + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class LoginRequest { + @NotBlank(message = "Tên đăng nhập hoặc email không được để trống") + private String login; + @NotBlank(message = "Mật khẩu không được để trống") + @Size(min = 8, message = "Mật khẩu phải chứa ít nhất 8 kí tự") + private String password; + + +} diff --git a/src/main/java/com/darkfantasy/dto/user/RegisterRequest.java b/src/main/java/com/darkfantasy/dto/user/RegisterRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..1080842055c4d6578c0b059538d6cb09113e158e --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/user/RegisterRequest.java @@ -0,0 +1,55 @@ +package com.darkfantasy.dto.user; + +import org.springframework.lang.NonNull; + +import com.darkfantasy.annotation.RegisterPasswordMatch; +import com.darkfantasy.entity.User; +import com.darkfantasy.entity.enums.Role; +import com.fasterxml.jackson.annotation.JsonProperty; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + + +@Data +@NoArgsConstructor +@AllArgsConstructor +@RegisterPasswordMatch(message = "Mật khẩu xác nhận không đúng") +public class RegisterRequest { + @NotBlank(message = "Tên đăng nhập không được để trống") + @Size(min = 3, max = 30, message = "Tên đăng nhập phải có độ dài từ 3 đến 30 ký tự") + @Pattern(regexp = "^[a-zA-Z0-9]+$", message = "Tên đăng nhập không được chứa ký tự đặc biệt") + private String username; + @NotBlank(message = "Email không được để trống") + @Size(max = 255, message = "Email không được vượt quá 255 ký tự") + @Email(message = "Email không đúng định dạng") + private String email; + @NotBlank(message = "Mật khẩu không được để trống") + @Size(min = 8, message = "Mật khẩu phải chứa ít nhất 8 kí tự") + private String password; + @NotBlank(message = "Mật khẩu xác nhận không được để trống") + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + private String rePassword; + + @NotBlank(message = "Họ tên không được để trống") + @Size(max = 255, message = "Họ tên không được vượt quá 255 ký tự") + private String fullName; + + + @NonNull + public User toEntity(String hashPassword){ + return User.builder() + .username(this.username) + .email(this.email) + .password(hashPassword) + .fullName(this.fullName) + .role(Role.STAFF) + .isActive(false) + .build(); + } +} diff --git a/src/main/java/com/darkfantasy/dto/user/ResetPasswordRequest.java b/src/main/java/com/darkfantasy/dto/user/ResetPasswordRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..75b91988176115fc62c4af8dc67664f5b496cb8a --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/user/ResetPasswordRequest.java @@ -0,0 +1,25 @@ +package com.darkfantasy.dto.user; + +import com.darkfantasy.annotation.ResetPasswordMatch; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +@ResetPasswordMatch +public class ResetPasswordRequest { + + @NotBlank + private String email; + + @NotBlank + private String otp; + + @NotBlank + @Size(min = 8) + private String newPassword; + + @NotBlank + private String confirmPassword; +} \ No newline at end of file diff --git a/src/main/java/com/darkfantasy/dto/user/UserResponse.java b/src/main/java/com/darkfantasy/dto/user/UserResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..3bfdc61921eca5bc9dbd5b808e142792d7595b56 --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/user/UserResponse.java @@ -0,0 +1,43 @@ +package com.darkfantasy.dto.user; + +import com.darkfantasy.entity.User; +import com.darkfantasy.entity.enums.Role; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserResponse { + private Long id; + private String username; + private String email; + private String fullName; + private boolean isActive; + private String role; + + public User toEntity() { + return User.builder() + .id(this.id) + .username(this.username) + .email(this.email) + .fullName(this.fullName) + .isActive(this.isActive) + .role(Role.valueOf(this.role)) + .build(); + } + public static UserResponse fromEntity(User user) { + return UserResponse.builder() + .id(user.getId()) + .username(user.getUsername()) + .email(user.getEmail()) + .fullName(user.getFullName()) + .isActive(user.isActive()) + .role(user.getRole().name()) + .build(); + } +} diff --git a/src/main/java/com/darkfantasy/dto/user/VerifyOtpRequest.java b/src/main/java/com/darkfantasy/dto/user/VerifyOtpRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..03030172bc14e36f0e17385bf9aeeb1089c2596a --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/user/VerifyOtpRequest.java @@ -0,0 +1,14 @@ +package com.darkfantasy.dto.user; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class VerifyOtpRequest { + + @NotBlank + private String email; + + @NotBlank(message = "OTP không được trống") + private String otp; +} \ No newline at end of file diff --git a/src/main/java/com/darkfantasy/dto/world/CreateWorldRequest.java b/src/main/java/com/darkfantasy/dto/world/CreateWorldRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..3d3dde4242e2efe26e77bbd49a6e4d7b0efefdb6 --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/world/CreateWorldRequest.java @@ -0,0 +1,43 @@ +package com.darkfantasy.dto.world; + +import com.darkfantasy.entity.World; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CreateWorldRequest { + @NotBlank(message = "Tag không được để trống!") + @Size(max = 255, message = "Tag không được vượt quá 255 ký tự!") + private String tag; + @NotBlank(message = "Tiêu đề không được để trống!") + @Size(max = 255, message = "Tiêu đề không được vượt quá 255 ký tự!") + private String title; + @NotBlank(message = "Nội dung không được để trống!") + private String content; + @NotNull(message = "Độ ưu tiên không được để trống!") + @Min(value = 0, message = "Độ ưu tiên phải từ 0 đến 100!") + @Max(value = 100, message = "Độ ưu tiên phải từ 0 đến 100!") + private Integer priority; + private String image; + + public World toEntity() { + return World.builder() + .tag(this.tag) + .title(this.title) + .content(this.content) + .priority(this.priority) + .image(this.image) + .build(); + } +} diff --git a/src/main/java/com/darkfantasy/dto/world/UpdateWorldRequest.java b/src/main/java/com/darkfantasy/dto/world/UpdateWorldRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..9bed0a9a6f790e02a1c34d42e873ee09e75dcecb --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/world/UpdateWorldRequest.java @@ -0,0 +1,45 @@ +package com.darkfantasy.dto.world; + +import com.darkfantasy.entity.World; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UpdateWorldRequest { + private Long id; + + @NotBlank(message = "Tag không được để trống!") + private String tag; + @NotBlank(message = "Tiêu đề không được để trống!") + @Size(max = 255, message = "Tiêu đề không được vượt quá 255 ký tự!") + private String title; + @NotBlank(message = "Nội dung không được để trống!") + private String content; + @NotNull(message = "Độ ưu tiên không được để trống!") + @Min(value = 0, message = "Độ ưu tiên phải từ 0 đến 100!") + @Max(value = 100, message = "Độ ưu tiên phải từ 0 đến 100!") + private Integer priority; + private String image; + + public World toEntity() { + return World.builder() + .id(this.id) + .tag(this.tag) + .title(this.title) + .content(this.content) + .priority(this.priority) + .image(this.image) + .build(); + } +} diff --git a/src/main/java/com/darkfantasy/dto/world/WorldResponse.java b/src/main/java/com/darkfantasy/dto/world/WorldResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..f6af6479307e140bd0dc2581bab83908fbd8724b --- /dev/null +++ b/src/main/java/com/darkfantasy/dto/world/WorldResponse.java @@ -0,0 +1,52 @@ +package com.darkfantasy.dto.world; + +import com.darkfantasy.entity.World; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class WorldResponse { + private Long id; + private String tag; + private String title; + private String content; + private String image; + @NotNull + @Min(0) + @Max(100) + private Integer priority; + private boolean deleted; + + public static WorldResponse fromEntity(World world) { + return WorldResponse.builder() + .id(world.getId()) + .tag(world.getTag()) + .title(world.getTitle()) + .content(world.getContent()) + .image(world.getImage()) + .priority(world.getPriority()) + .deleted(world.isDeleted()) + .build(); + } + + public String getSummary() { + if (content == null) { + return ""; + } + + if (content.length() <= 350) { + return content; + } + + return content.substring(0, 350) + "..."; + } +} diff --git a/src/main/java/com/darkfantasy/entity/Article.java b/src/main/java/com/darkfantasy/entity/Article.java new file mode 100644 index 0000000000000000000000000000000000000000..4aa8a04218699aa0ab58e0f904b4b966d72ed347 --- /dev/null +++ b/src/main/java/com/darkfantasy/entity/Article.java @@ -0,0 +1,69 @@ +package com.darkfantasy.entity; + +import java.time.Instant; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import com.darkfantasy.entity.enums.ArticleType; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "articles") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Article { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 255) + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(nullable = false) + private ArticleType type; + + @Column(name = "thumbnail_url", length = 500) + private String thumbnailUrl; + + @Builder.Default + @Column(name = "is_deleted") + private boolean deleted = false; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by", nullable = false, updatable = false) + private User createdBy; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "updated_by", nullable = true) + private User updatedBy; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = true) + private Instant updatedAt; + +} diff --git a/src/main/java/com/darkfantasy/entity/AuditLog.java b/src/main/java/com/darkfantasy/entity/AuditLog.java new file mode 100644 index 0000000000000000000000000000000000000000..8b27c82092d43a23bf98f80015caf3eaca50c2c0 --- /dev/null +++ b/src/main/java/com/darkfantasy/entity/AuditLog.java @@ -0,0 +1,57 @@ +package com.darkfantasy.entity; + +import java.time.Instant; + +import org.hibernate.annotations.CreationTimestamp; + +import com.darkfantasy.entity.enums.LogAction; +import com.darkfantasy.entity.enums.LogEntityType; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "audit_logs") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class AuditLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "entity_id") + private Long entityId; + + @Column(name = "entity_type", nullable = false) + private LogEntityType entityType; + + @Column(name = "action", nullable = false) + private LogAction action; + + @Column(name = "description", columnDefinition = "LONGTEXT") + private String description; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private Instant createdAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false, updatable = false) + private User user; +} diff --git a/src/main/java/com/darkfantasy/entity/ContactMessage.java b/src/main/java/com/darkfantasy/entity/ContactMessage.java new file mode 100644 index 0000000000000000000000000000000000000000..b4600f614d2ff1db49eac4279f98f34170fdc652 --- /dev/null +++ b/src/main/java/com/darkfantasy/entity/ContactMessage.java @@ -0,0 +1,58 @@ +package com.darkfantasy.entity; + +import java.time.Instant; + +import org.hibernate.annotations.UpdateTimestamp; + +import com.darkfantasy.entity.enums.ContactCategory; +import com.darkfantasy.entity.enums.ContactPlatform; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "contact_messages") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ContactMessage { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + @Column(name = "visitor_name", nullable = false) + private String visitorName; + @Column(name = "visitor_email", nullable = false) + private String visitorEmail; + @Column(name = "category", nullable = false) + private ContactCategory category; + @Column(name = "platform", nullable = true) + private ContactPlatform platform; + @Column(columnDefinition = "LONGTEXT", nullable = false) + private String message; + @Builder.Default + @Column(name = "processed") + private boolean processed = false; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "processed_by", nullable = true) + private User processedBy; + + @UpdateTimestamp + @Column(name = "processed_at", nullable = true) + private Instant processedAt; +} diff --git a/src/main/java/com/darkfantasy/entity/Contributor.java b/src/main/java/com/darkfantasy/entity/Contributor.java new file mode 100644 index 0000000000000000000000000000000000000000..609ccbf7c023f38c21f70485d3202b26f3528540 --- /dev/null +++ b/src/main/java/com/darkfantasy/entity/Contributor.java @@ -0,0 +1,66 @@ +package com.darkfantasy.entity; + +import java.time.Instant; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "contributors") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Contributor { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "name", nullable = false, length = 255) + private String name; + + @Column(name = "position", nullable = false, length = 255) + private String position; + + @Column(name = "avatar", length = 500) + private String avatar; + + @Column(name = "priority" , nullable = false) + private Integer priority; + + @Builder.Default + @Column(name = "deleted", nullable = false) + private boolean deleted = false; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by", nullable = false, updatable = false) + private User createdBy; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "updated_by") + private User updatedBy; + + @UpdateTimestamp + @Column(name = "updated_at") + private Instant updatedAt; +} diff --git a/src/main/java/com/darkfantasy/entity/Faq.java b/src/main/java/com/darkfantasy/entity/Faq.java new file mode 100644 index 0000000000000000000000000000000000000000..cb31cd1349f9c1f9efc182282820cb670e1503d7 --- /dev/null +++ b/src/main/java/com/darkfantasy/entity/Faq.java @@ -0,0 +1,61 @@ +package com.darkfantasy.entity; + +import java.time.Instant; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "faqs") +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +@Builder +public class Faq { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + @Column(name = "title", length = 500, nullable = false) + private String title; + @Column(name = "content", columnDefinition = "LONGTEXT", nullable = false) + private String content; + @Column(name = "priority", nullable = false) + private Integer priority; + @Builder.Default + @Column(name = "deleted") + private boolean deleted = false; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by", nullable = false, updatable = false) + private User createdBy; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "updated_by", nullable = true) + private User updatedBy; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = true) + private Instant updatedAt; + +} diff --git a/src/main/java/com/darkfantasy/entity/GameCharacter.java b/src/main/java/com/darkfantasy/entity/GameCharacter.java new file mode 100644 index 0000000000000000000000000000000000000000..c3ea6acd0dd35cfda731bd6b275ca98afd73344d --- /dev/null +++ b/src/main/java/com/darkfantasy/entity/GameCharacter.java @@ -0,0 +1,72 @@ +/* +Priority đại diện cho mức độ ưu tiên của nhân vật đối với cốt truyện. +Càng lên cao thì mức độ quan trọng của nhân vật càng lớn. +Tính tại thang điểm 100(quan trọng nhất) xuống 0 (Không quan trọng một chút nào) +*/ + +package com.darkfantasy.entity; + +import java.time.Instant; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "characters") +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +@Builder +public class GameCharacter { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + @Column(name = "name", nullable = false) + private String name; + @Column(name = "race", nullable = true) + private String race; + @Column(name = "description", columnDefinition = "LONGTEXT", nullable = false) + private String description; + @Column(name = "quote", nullable = true) + private String quote; + @Column(name = "image", nullable = true) + private String image; + @Column(name = "priority", nullable = false) + private Integer priority; + @Builder.Default + @Column(name = "deleted") + private boolean deleted = false; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by", nullable = false, updatable = false) + private User createdBy; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "updated_by", nullable = true) + private User updatedBy; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = true) + private Instant updatedAt; +} diff --git a/src/main/java/com/darkfantasy/entity/PasswordResetToken.java b/src/main/java/com/darkfantasy/entity/PasswordResetToken.java new file mode 100644 index 0000000000000000000000000000000000000000..6509832d60fa31daf5e16f4cbe16a06532280235 --- /dev/null +++ b/src/main/java/com/darkfantasy/entity/PasswordResetToken.java @@ -0,0 +1,41 @@ +package com.darkfantasy.entity; + +import java.time.Instant; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "password_reset_tokens") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PasswordResetToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String email; + + @Column(nullable = false, length = 6) + private String otp; + + @Column(nullable = false) + private Instant expiredAt; + + @Column(nullable = false) + private boolean used; +} diff --git a/src/main/java/com/darkfantasy/entity/Story.java b/src/main/java/com/darkfantasy/entity/Story.java new file mode 100644 index 0000000000000000000000000000000000000000..ded61af984c09547f78881d1e2295858516914ec --- /dev/null +++ b/src/main/java/com/darkfantasy/entity/Story.java @@ -0,0 +1,69 @@ +package com.darkfantasy.entity; + +import java.time.Instant; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "stories") +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +@Builder +public class Story { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + @Column(name = "tag", nullable = true) + private String tag; + @Column(name = "title", nullable = false) + private String title; + @Column(name = "content", columnDefinition = "LONGTEXT", nullable = false) + private String content; + @Column(name = "image", nullable = true) + private String image; + @Column(name = "quote_content", columnDefinition = "LONGTEXT", nullable = true) + private String quoteContent; + @Column(name = "quote_author", nullable = false) + private String quoteAuthor; + @Column(name = "priority", nullable = false) + private Integer priority; + @Builder.Default + @Column(name = "deleted") + private boolean deleted = false; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by", nullable = false, updatable = false) + private User createdBy; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "updated_by", nullable = true) + private User updatedBy; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = true) + private Instant updatedAt; + +} diff --git a/src/main/java/com/darkfantasy/entity/User.java b/src/main/java/com/darkfantasy/entity/User.java new file mode 100644 index 0000000000000000000000000000000000000000..2386bedd076a3045fc14944ee3010cda5dc604d1 --- /dev/null +++ b/src/main/java/com/darkfantasy/entity/User.java @@ -0,0 +1,82 @@ +/* +Thông tin định danh (Identity): + + id: Khóa chính (Primary Key). + +Thông tin đăng nhập (Authentication): + + username (hoặc email): Tên đăng nhập, yêu cầu duy nhất (UNIQUE) và không được để trống. + + password: Mật khẩu. Sẽ lưu chuỗi đã được mã hóa (ví dụ: BCrypt qua Spring Security), do đó độ dài trường này nên để khá rộng (thường là VARCHAR(255)). + +Thông tin phân quyền (Authorization): + + role: Lưu vai trò của user. Vì bạn đang dùng số nguyên (1 cho ADMIN, 2 cho STAFF), trường này sẽ là kiểu INT. + +Thông tin quản trị trạng thái (Status - Bắt buộc cho CMS): + + isActive (hoặc isEnabled): Kiểu BOOLEAN. Trong hệ thống nội bộ, khi một Staff nghỉ việc, bạn tuyệt đối không được xóa (DELETE) record của họ trong database (để giữ nguyên vẹn lịch sử log hoặc dữ liệu cũ nếu sau này có nối bảng). Bạn chỉ chuyển isActive = false để chặn họ đăng nhập. + +Thông tin hiển thị (Display - Tùy chọn): + + fullName: Tên hiển thị trên góc phải màn hình CMS (vd: "Xin chào, Nguyễn Văn A"). +*/ + +package com.darkfantasy.entity; + +import java.time.Instant; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import com.darkfantasy.entity.enums.Role; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "users") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class User { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + @Column(name = "username", unique = true, nullable = false, length = 30) + private String username; + @Column(name = "email", unique = true, nullable = false, length = 255) + private String email; + @Column(name = "password", nullable = false, length = 255) + private String password; + @Column(name = "role", nullable = false) + private Role role; + @Column(name = "is_active", nullable = false) + @Builder.Default + private boolean isActive = true; + @Column(name = "full_name", nullable = false, length = 255) + private String fullName; + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Column(name = "must_change_password", nullable = false) + @Builder.Default + private boolean mustChangePassword = false; + +} diff --git a/src/main/java/com/darkfantasy/entity/World.java b/src/main/java/com/darkfantasy/entity/World.java new file mode 100644 index 0000000000000000000000000000000000000000..3a075bc57212d7c01ff1a2f24e77cdef6bc97283 --- /dev/null +++ b/src/main/java/com/darkfantasy/entity/World.java @@ -0,0 +1,64 @@ +package com.darkfantasy.entity; + +import java.time.Instant; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "worlds") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class World { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + @Column(name = "tag", nullable = true) + private String tag; + @Column(name = "title", nullable = false) + private String title; + @Column(name = "content", columnDefinition = "LONGTEXT", nullable = false) + private String content; + @Column(name = "image", nullable = true) + private String image; + @Column(name = "priority", nullable = false) + private Integer priority; + @Builder.Default + @Column(name = "deleted") + private boolean deleted = false; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by", nullable = false, updatable = false) + private User createdBy; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "updated_by", nullable = true) + private User updatedBy; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = true) + private Instant updatedAt; +} diff --git a/src/main/java/com/darkfantasy/entity/converter/ArticleTypeConverter.java b/src/main/java/com/darkfantasy/entity/converter/ArticleTypeConverter.java new file mode 100644 index 0000000000000000000000000000000000000000..258ce7a714240823a6b7976e7892b0bbb55b3816 --- /dev/null +++ b/src/main/java/com/darkfantasy/entity/converter/ArticleTypeConverter.java @@ -0,0 +1,21 @@ +package com.darkfantasy.entity.converter; + +import com.darkfantasy.entity.enums.ArticleType; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = true) +public class ArticleTypeConverter implements AttributeConverter { + + @Override + public Integer convertToDatabaseColumn(ArticleType type) { + return type == null ? null : type.getValue(); + } + + @Override + public ArticleType convertToEntityAttribute(Integer dbData) { + return dbData == null ? null : ArticleType.fromValue(dbData); + } + +} diff --git a/src/main/java/com/darkfantasy/entity/converter/ContactCategoryConverter.java b/src/main/java/com/darkfantasy/entity/converter/ContactCategoryConverter.java new file mode 100644 index 0000000000000000000000000000000000000000..fc65affa5d0a4e56a80f973715fc583aaebcb2cc --- /dev/null +++ b/src/main/java/com/darkfantasy/entity/converter/ContactCategoryConverter.java @@ -0,0 +1,29 @@ +package com.darkfantasy.entity.converter; + +import com.darkfantasy.entity.enums.ContactCategory; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = true) +public class ContactCategoryConverter implements AttributeConverter{ + + @Override + public Integer convertToDatabaseColumn(ContactCategory attribute) { + return attribute == null? null:attribute.getValue(); + } + + @Override + public ContactCategory convertToEntityAttribute(Integer dbData) { + if (dbData == null) { + return null; + } + + try { + return ContactCategory.fromValue(dbData); + } catch (IllegalArgumentException e) { + return ContactCategory.OTHER; + } + } + +} diff --git a/src/main/java/com/darkfantasy/entity/converter/ContactPlatformConverter.java b/src/main/java/com/darkfantasy/entity/converter/ContactPlatformConverter.java new file mode 100644 index 0000000000000000000000000000000000000000..5d395460d9dfc5c2ca848b1e3624ac40c8d4f95f --- /dev/null +++ b/src/main/java/com/darkfantasy/entity/converter/ContactPlatformConverter.java @@ -0,0 +1,27 @@ +package com.darkfantasy.entity.converter; + +import com.darkfantasy.entity.enums.ContactPlatform; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = true) +public class ContactPlatformConverter implements AttributeConverter { + @Override + public Integer convertToDatabaseColumn(ContactPlatform attribute) { + return attribute == null ? null : attribute.getValue(); + } + + @Override + public ContactPlatform convertToEntityAttribute(Integer dbData) { + if (dbData == null) { + return null; + } + + try { + return ContactPlatform.fromValue(dbData); + } catch (IllegalArgumentException e) { + return ContactPlatform.OTHER; + } + } +} diff --git a/src/main/java/com/darkfantasy/entity/converter/LogActionConverter.java b/src/main/java/com/darkfantasy/entity/converter/LogActionConverter.java new file mode 100644 index 0000000000000000000000000000000000000000..cd1156ace17c56b1408017b1ea2aed6e98ba478a --- /dev/null +++ b/src/main/java/com/darkfantasy/entity/converter/LogActionConverter.java @@ -0,0 +1,21 @@ +package com.darkfantasy.entity.converter; + +import com.darkfantasy.entity.enums.LogAction; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = true) +public class LogActionConverter implements AttributeConverter { + + @Override + public Long convertToDatabaseColumn(LogAction attribute) { + return attribute == null ? null : attribute.getCode(); + } + + @Override + public LogAction convertToEntityAttribute(Long dbData) { + return dbData == null ? null : LogAction.fromValue(dbData); + } + +} diff --git a/src/main/java/com/darkfantasy/entity/converter/LogEntityTypeConverter.java b/src/main/java/com/darkfantasy/entity/converter/LogEntityTypeConverter.java new file mode 100644 index 0000000000000000000000000000000000000000..ec5398315bd7700117011ba85c6a81cd3e3d1d3e --- /dev/null +++ b/src/main/java/com/darkfantasy/entity/converter/LogEntityTypeConverter.java @@ -0,0 +1,21 @@ +package com.darkfantasy.entity.converter; + +import com.darkfantasy.entity.enums.LogEntityType; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = true) +public class LogEntityTypeConverter implements AttributeConverter{ + + @Override + public Long convertToDatabaseColumn(LogEntityType attribute) { + return attribute == null ? null : attribute.getCode(); + } + + @Override + public LogEntityType convertToEntityAttribute(Long dbData) { + return dbData == null ? null : LogEntityType.fromValue(dbData); + } + +} diff --git a/src/main/java/com/darkfantasy/entity/converter/RoleConverter.java b/src/main/java/com/darkfantasy/entity/converter/RoleConverter.java new file mode 100644 index 0000000000000000000000000000000000000000..93f5d39ad9127c08d8f93afd86fd53c84a91a9f9 --- /dev/null +++ b/src/main/java/com/darkfantasy/entity/converter/RoleConverter.java @@ -0,0 +1,20 @@ +package com.darkfantasy.entity.converter; + +import com.darkfantasy.entity.enums.Role; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = true) +public class RoleConverter implements AttributeConverter { + + @Override + public Integer convertToDatabaseColumn(Role role) { + return role == null ? null : role.getValue(); + } + + @Override + public Role convertToEntityAttribute(Integer dbData) { + return dbData == null ? null :Role.fromValue(dbData); + } +} diff --git a/src/main/java/com/darkfantasy/entity/enums/ArticleType.java b/src/main/java/com/darkfantasy/entity/enums/ArticleType.java new file mode 100644 index 0000000000000000000000000000000000000000..e81b7ba13b82c57c80c564dc77515798ae9e62ff --- /dev/null +++ b/src/main/java/com/darkfantasy/entity/enums/ArticleType.java @@ -0,0 +1,25 @@ +package com.darkfantasy.entity.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ArticleType { + IMPORTANT(1, "Quan trọng"), + UPDATE(2, "Cập nhật"), + NEWS(3, "Tin tức"), + EVENT(4, "Sự kiện"); + + public final int value; + private final String displayName; + + public static ArticleType fromValue(int value) { + for (ArticleType type : ArticleType.values()) { + if (type.value == value) { + return type; + } + } + throw new IllegalArgumentException("Không tìm thấy loại bài viết: " + value); + } +} diff --git a/src/main/java/com/darkfantasy/entity/enums/ContactCategory.java b/src/main/java/com/darkfantasy/entity/enums/ContactCategory.java new file mode 100644 index 0000000000000000000000000000000000000000..8b8318e4f46d90566307db514c05751094936529 --- /dev/null +++ b/src/main/java/com/darkfantasy/entity/enums/ContactCategory.java @@ -0,0 +1,28 @@ +package com.darkfantasy.entity.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ContactCategory { + TECHNICAL_SUPPORT(1, "Sự cố kĩ thuật"), + BUG_REPORT(2, "Báo cáo lỗi"), + ACCOUNT_BILLING(3, "Tài khoản / Mua hàng"), + ACCESSIBILITY(4, "Khả năng tiếp cận"), + CREATOR_PROGRAM(5, "Chương trình Creator"), + PRESS_CONTACT(6, "Liên hệ báo chí"), + OTHER(7, "Khác"); + + private final int value; + private final String displayName; + + public static ContactCategory fromValue(int value) { + for (ContactCategory category : ContactCategory.values()) { + if (category.value == value) { + return category; + } + } + throw new IllegalArgumentException("Không tìm thấy loại nhãn: " + value); + } +} diff --git a/src/main/java/com/darkfantasy/entity/enums/ContactPlatform.java b/src/main/java/com/darkfantasy/entity/enums/ContactPlatform.java new file mode 100644 index 0000000000000000000000000000000000000000..34e86a0b521b5d8239f25dff0a622d06e431910d --- /dev/null +++ b/src/main/java/com/darkfantasy/entity/enums/ContactPlatform.java @@ -0,0 +1,26 @@ +package com.darkfantasy.entity.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ContactPlatform { + WINDOW(1, "Window"), + LINUX(2, "Linux"), + ANDROID(3, "Android"), + IOS(4, "Ios"), + OTHER(5, "Khác"); + + private final int value; + private final String displayName; + + public static ContactPlatform fromValue(int value) { + for (ContactPlatform platform : ContactPlatform.values()) { + if (platform.value == value) { + return platform; + } + } + throw new IllegalArgumentException("Không tìm thấy loại nền tảng: " + value); + } +} diff --git a/src/main/java/com/darkfantasy/entity/enums/LogAction.java b/src/main/java/com/darkfantasy/entity/enums/LogAction.java new file mode 100644 index 0000000000000000000000000000000000000000..c5bb39724940046d5786fb32a424239ce043fca8 --- /dev/null +++ b/src/main/java/com/darkfantasy/entity/enums/LogAction.java @@ -0,0 +1,31 @@ +package com.darkfantasy.entity.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum LogAction { + CREATE(1), + UPDATE(2), + DELETE(3), + RESTORE(4), + APPROVE(5), + LOCK(6), + UNLOCK(7), + CHANGE_PASSWORD(8), + LOGIN(9), + LOGOUT(10), + PROCESS(11); + + private final long code; + + public static LogAction fromValue(long code) { + for (LogAction action : LogAction.values()) { + if (action.code == code) { + return action; + } + } + throw new IllegalArgumentException("Không tìm thấy loại hành động: " + code); + } +} diff --git a/src/main/java/com/darkfantasy/entity/enums/LogEntityType.java b/src/main/java/com/darkfantasy/entity/enums/LogEntityType.java new file mode 100644 index 0000000000000000000000000000000000000000..00ada3f4d3e9b5f3efcd0457968070da6bd65be8 --- /dev/null +++ b/src/main/java/com/darkfantasy/entity/enums/LogEntityType.java @@ -0,0 +1,27 @@ +package com.darkfantasy.entity.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum LogEntityType { + ARTICLE(1), + STORY(2), + WORLD(3), + CHARACTER(4), + FAQ(5), + USER(6), + CONTACT(7), + CONTRIBUTOR(8); + + private final long code; + public static LogEntityType fromValue(long code) { + for (LogEntityType action : LogEntityType.values()) { + if (action.code == code) { + return action; + } + } + throw new IllegalArgumentException("Không tìm thấy loại Entity: " + code); + } +} diff --git a/src/main/java/com/darkfantasy/entity/enums/Role.java b/src/main/java/com/darkfantasy/entity/enums/Role.java new file mode 100644 index 0000000000000000000000000000000000000000..86626f22a7da881dfdd348a1594a112d867ef35e --- /dev/null +++ b/src/main/java/com/darkfantasy/entity/enums/Role.java @@ -0,0 +1,20 @@ +package com.darkfantasy.entity.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Role { + ADMIN(1), STAFF(2); + + public final int value; + public static Role fromValue(int value){ + for(Role role : Role.values()){ + if(role.value == value){ + return role; + } + } + throw new IllegalArgumentException("Không tìm thấy vai trò: "+ value); + } +} diff --git a/src/main/java/com/darkfantasy/exception/GlobalExceptionHandler.java b/src/main/java/com/darkfantasy/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000000000000000000000000000000000000..8a42493475569eadf7b8e31ea5af4df7c8ecf7a7 --- /dev/null +++ b/src/main/java/com/darkfantasy/exception/GlobalExceptionHandler.java @@ -0,0 +1,41 @@ +package com.darkfantasy.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; + +import com.darkfantasy.exception.custom.BadRequestException; +import com.darkfantasy.exception.custom.ResourceNotFoundException; + +@ControllerAdvice +public class GlobalExceptionHandler { + @ExceptionHandler(ResourceNotFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public String notFound() { + return "error/404"; + } + + @ExceptionHandler(AccessDeniedException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public String forbidden() { + return "error/403"; + } + + @ExceptionHandler(BadRequestException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public String badRequest() { + return "error/400"; + } + + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public String internalServerError(Exception ex) { + + ex.printStackTrace(); + + return "error/500"; + } + +} \ No newline at end of file diff --git a/src/main/java/com/darkfantasy/exception/custom/BadRequestException.java b/src/main/java/com/darkfantasy/exception/custom/BadRequestException.java new file mode 100644 index 0000000000000000000000000000000000000000..fddae4456f4422d4e6e6866efb8c05b8ce173e7f --- /dev/null +++ b/src/main/java/com/darkfantasy/exception/custom/BadRequestException.java @@ -0,0 +1,7 @@ +package com.darkfantasy.exception.custom; + +public class BadRequestException extends RuntimeException{ + public BadRequestException(String message){ + super(message); + } +} diff --git a/src/main/java/com/darkfantasy/exception/custom/ResourceNotFoundException.java b/src/main/java/com/darkfantasy/exception/custom/ResourceNotFoundException.java new file mode 100644 index 0000000000000000000000000000000000000000..a0db6900749c50483e24c645f54be20fbe218f66 --- /dev/null +++ b/src/main/java/com/darkfantasy/exception/custom/ResourceNotFoundException.java @@ -0,0 +1,7 @@ +package com.darkfantasy.exception.custom; + +public class ResourceNotFoundException extends RuntimeException{ + public ResourceNotFoundException(String message){ + super(message); + } +} diff --git a/src/main/java/com/darkfantasy/repository/ArticleRepository.java b/src/main/java/com/darkfantasy/repository/ArticleRepository.java new file mode 100644 index 0000000000000000000000000000000000000000..4bb0a3b5ec55eeb73e2db774b998629c53ab8462 --- /dev/null +++ b/src/main/java/com/darkfantasy/repository/ArticleRepository.java @@ -0,0 +1,18 @@ +package com.darkfantasy.repository; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.darkfantasy.entity.Article; +import com.darkfantasy.entity.enums.ArticleType; + +@Repository +public interface ArticleRepository extends JpaRepository { + Page
findByDeletedFalseOrderByCreatedAtDesc(Pageable pageable); + Optional
findFirstByTypeAndDeletedFalseOrderByCreatedAtDesc(ArticleType type); + Optional
findByIdAndDeletedFalse(Long id); +} diff --git a/src/main/java/com/darkfantasy/repository/AuditLogRepository.java b/src/main/java/com/darkfantasy/repository/AuditLogRepository.java new file mode 100644 index 0000000000000000000000000000000000000000..6ed433a9490eed4c67098519d0dd1f62d6c88968 --- /dev/null +++ b/src/main/java/com/darkfantasy/repository/AuditLogRepository.java @@ -0,0 +1,13 @@ +package com.darkfantasy.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.darkfantasy.entity.AuditLog; + +@Repository +public interface AuditLogRepository extends JpaRepository { + Page findAllByOrderByCreatedAtDesc(Pageable pageable); +} diff --git a/src/main/java/com/darkfantasy/repository/ContactMessageRepository.java b/src/main/java/com/darkfantasy/repository/ContactMessageRepository.java new file mode 100644 index 0000000000000000000000000000000000000000..0ebc7302c99984ad634a0375efd437122932474d --- /dev/null +++ b/src/main/java/com/darkfantasy/repository/ContactMessageRepository.java @@ -0,0 +1,15 @@ +package com.darkfantasy.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.darkfantasy.entity.ContactMessage; + +@Repository +public interface ContactMessageRepository extends JpaRepository { + Page findByProcessedFalse(Pageable pageable); + Page findByProcessedTrue(Pageable pageable); + Long countByProcessedFalse(); +} diff --git a/src/main/java/com/darkfantasy/repository/ContributorRepository.java b/src/main/java/com/darkfantasy/repository/ContributorRepository.java new file mode 100644 index 0000000000000000000000000000000000000000..981158e77f2f520dca2fcaa2de67a8866d458757 --- /dev/null +++ b/src/main/java/com/darkfantasy/repository/ContributorRepository.java @@ -0,0 +1,17 @@ +package com.darkfantasy.repository; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.darkfantasy.entity.Contributor; + + +@Repository +public interface ContributorRepository extends JpaRepository{ + Page findByDeletedFalseOrderByPriorityDesc(Pageable pageable); + Optional findByIdAndDeletedFalse(Long id); +} diff --git a/src/main/java/com/darkfantasy/repository/FaqRepository.java b/src/main/java/com/darkfantasy/repository/FaqRepository.java new file mode 100644 index 0000000000000000000000000000000000000000..c2cfe5b045f1959f021e08f898047d91b2710c48 --- /dev/null +++ b/src/main/java/com/darkfantasy/repository/FaqRepository.java @@ -0,0 +1,18 @@ +package com.darkfantasy.repository; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.darkfantasy.entity.Faq; + +@Repository +public interface FaqRepository extends JpaRepository{ + Page findAllByOrderByPriorityDesc(Pageable pageable); + Page findByDeletedFalseOrderByPriorityDesc(Pageable pageable); + Page findAllByOrderByIdAsc(Pageable pageable); + List findByDeletedFalseOrderByPriorityDesc(); +} \ No newline at end of file diff --git a/src/main/java/com/darkfantasy/repository/GameCharacterRepository.java b/src/main/java/com/darkfantasy/repository/GameCharacterRepository.java new file mode 100644 index 0000000000000000000000000000000000000000..62f55b1505a1f7b1d8a7799d0326fd3e7222c17c --- /dev/null +++ b/src/main/java/com/darkfantasy/repository/GameCharacterRepository.java @@ -0,0 +1,18 @@ +package com.darkfantasy.repository; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.darkfantasy.entity.GameCharacter; + +@Repository +public interface GameCharacterRepository extends JpaRepository{ + Page findAllByOrderByPriorityDesc(Pageable pageable); + Page findByDeletedFalseOrderByPriorityDesc(Pageable pageable); + Page findAllByOrderByIdAsc(Pageable pageable); + List findByDeletedFalseOrderByPriorityDesc(); +} diff --git a/src/main/java/com/darkfantasy/repository/PasswordResetTokenRepository.java b/src/main/java/com/darkfantasy/repository/PasswordResetTokenRepository.java new file mode 100644 index 0000000000000000000000000000000000000000..4a68d095683e50f784d1cc1de311252bc38f2bc2 --- /dev/null +++ b/src/main/java/com/darkfantasy/repository/PasswordResetTokenRepository.java @@ -0,0 +1,26 @@ +package com.darkfantasy.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.darkfantasy.entity.PasswordResetToken; + +@Repository +public interface PasswordResetTokenRepository extends JpaRepository { + Optional findByEmailAndOtpAndUsedFalse(String email,String otp); + + Optional findTopByEmailAndOtpAndUsedFalseOrderByIdDesc(String email, String otp); + + @Modifying + @Query(""" + update PasswordResetToken t + set t.used = true + where t.email = :email and t.used = false + """) + void invalidateAllUnusedByEmail(@Param("email") String email); +} diff --git a/src/main/java/com/darkfantasy/repository/StoryRepository.java b/src/main/java/com/darkfantasy/repository/StoryRepository.java new file mode 100644 index 0000000000000000000000000000000000000000..b16c9df9d7e5b4d2207289276d587ff08d5fc1f4 --- /dev/null +++ b/src/main/java/com/darkfantasy/repository/StoryRepository.java @@ -0,0 +1,17 @@ +package com.darkfantasy.repository; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.darkfantasy.entity.Story; + +@Repository +public interface StoryRepository extends JpaRepository { + Page findAllByOrderByIdAsc(Pageable pageable); + List findByDeletedFalseOrderByPriorityDesc(); + Story findTopByDeletedFalseOrderByPriorityDesc(); +} diff --git a/src/main/java/com/darkfantasy/repository/UserRepository.java b/src/main/java/com/darkfantasy/repository/UserRepository.java new file mode 100644 index 0000000000000000000000000000000000000000..3de9fa99ece623b3349c7bd985d65b6a040db04d --- /dev/null +++ b/src/main/java/com/darkfantasy/repository/UserRepository.java @@ -0,0 +1,24 @@ +package com.darkfantasy.repository; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.darkfantasy.entity.User; + +@Repository +public interface UserRepository extends JpaRepository { + Optional findUserByUsername(String username); + Optional findUserByEmail(String email); + boolean existsByUsername(String username); + boolean existsByEmail(String email); + Optional findUserByUsernameOrEmail(String username, String email); + boolean existsByUsernameOrEmail(String username, String email); + Page findAllByOrderByIdAsc(Pageable pageable); + long countByIsActiveTrue(); + long countByIsActiveFalse(); + Optional findByUsername(String username); +} diff --git a/src/main/java/com/darkfantasy/repository/WorldRepository.java b/src/main/java/com/darkfantasy/repository/WorldRepository.java new file mode 100644 index 0000000000000000000000000000000000000000..e269f005bcce6acd5548b829cf67ca238e96ba64 --- /dev/null +++ b/src/main/java/com/darkfantasy/repository/WorldRepository.java @@ -0,0 +1,17 @@ +package com.darkfantasy.repository; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.darkfantasy.entity.World; + +@Repository +public interface WorldRepository extends JpaRepository { + Page findAllByOrderByIdAsc(Pageable pageable); + List findByDeletedFalseOrderByPriorityDesc(); + World findTopByDeletedFalseOrderByPriorityDesc(); +} diff --git a/src/main/java/com/darkfantasy/security/ActiveUserFilter.java b/src/main/java/com/darkfantasy/security/ActiveUserFilter.java new file mode 100644 index 0000000000000000000000000000000000000000..737d9b259135c9728a4bce71e157a477e3b1d64c --- /dev/null +++ b/src/main/java/com/darkfantasy/security/ActiveUserFilter.java @@ -0,0 +1,67 @@ +package com.darkfantasy.security; + +import java.io.IOException; + +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.darkfantasy.constant.Routes; +import com.darkfantasy.entity.User; +import com.darkfantasy.repository.UserRepository; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ActiveUserFilter extends OncePerRequestFilter { + + private final UserRepository userRepository; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication != null + && authentication.isAuthenticated() + && !(authentication instanceof AnonymousAuthenticationToken)) { + + String username = authentication.getName(); + + User user = userRepository.findByUsername(username).orElse(null); + + if (user == null || !user.isActive()) { + SecurityContextHolder.clearContext(); + HttpSession session = request.getSession(false); + if (session != null) { + session.invalidate(); + } + response.sendRedirect(Routes.USER + "/login"); + return; + } + } + + filterChain.doFilter(request, response); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String uri = request.getRequestURI(); + + return uri.startsWith("/css/") + || uri.startsWith("/js/") + || uri.startsWith("/images/"); + } +} \ No newline at end of file diff --git a/src/main/java/com/darkfantasy/security/ForceChangePasswordInterceptor.java b/src/main/java/com/darkfantasy/security/ForceChangePasswordInterceptor.java new file mode 100644 index 0000000000000000000000000000000000000000..4cf7bb1e6b16f76f7d4648ef94696a3e9028104e --- /dev/null +++ b/src/main/java/com/darkfantasy/security/ForceChangePasswordInterceptor.java @@ -0,0 +1,41 @@ +package com.darkfantasy.security; + +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import com.darkfantasy.constant.Routes; +import com.darkfantasy.entity.User; +import com.darkfantasy.repository.UserRepository; +import com.darkfantasy.util.SecurityUtil; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ForceChangePasswordInterceptor implements HandlerInterceptor { + private final UserRepository userRepository; + + @Override + public boolean preHandle( + HttpServletRequest request, + HttpServletResponse response, + Object handler) + throws Exception { + String username = SecurityUtil.getCurrentUserName(); + if (username == null) { + return true; + } + User user = userRepository.findUserByUsername(username).orElse(null); + if (user == null) { + return true; + } + String uri = request.getRequestURI(); + if (user.isMustChangePassword() && !uri.startsWith(Routes.USER + "/change-password")) { + response.sendRedirect(Routes.USER + "/change-password"); + return false; + } + return true; + } +} \ No newline at end of file diff --git a/src/main/java/com/darkfantasy/security/SecurityConfig.java b/src/main/java/com/darkfantasy/security/SecurityConfig.java new file mode 100644 index 0000000000000000000000000000000000000000..30cd158e18d3d11397cd4759a15a55ed43c85e63 --- /dev/null +++ b/src/main/java/com/darkfantasy/security/SecurityConfig.java @@ -0,0 +1,79 @@ +package com.darkfantasy.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import com.darkfantasy.constant.Routes; +import com.darkfantasy.entity.enums.Role; + +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + private final ActiveUserFilter activeUserFilter; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(auth -> auth + .requestMatchers(Routes.ADMIN + "/**").hasRole(Role.ADMIN.name()) + .requestMatchers( + Routes.DASHBOARD + "/**", + Routes.ARTICLE + "/**", + Routes.CHARACTER + "/**", + Routes.WORLD + "/**", + Routes.STORY + "/**", + Routes.FAQ + "/**", + Routes.CONTRIBUTOR + "/**", + Routes.CONTACT + "/**") + .hasRole(Role.STAFF.name()) + .requestMatchers(Routes.USER + "/change-password") + .hasAnyRole(Role.ADMIN.name(), Role.STAFF.name()) + .anyRequest().permitAll()) + .addFilterBefore(activeUserFilter, UsernamePasswordAuthenticationFilter.class); + // .csrf(csrf -> csrf.disable()); + return http.build(); + } + + @Bean + public AuthenticationManager authenticationManager( + UserDetailsService userDetailsService, + PasswordEncoder passwordEncoder) { + + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); + provider.setUserDetailsService(userDetailsService); + provider.setPasswordEncoder(passwordEncoder); + + return new ProviderManager(provider); + } + // Chỉ dùng để test admin + // @Bean + // public UserDetailsService userDetailsService( + // PasswordEncoder passwordEncoder) { + + // UserDetails admin = User.builder() + // .username("admin") + // .password(passwordEncoder.encode("123456")) + // .roles(Role.ADMIN.name()) + // .build(); + + // return new InMemoryUserDetailsManager(admin); + // } +} diff --git a/src/main/java/com/darkfantasy/service/ArticleService.java b/src/main/java/com/darkfantasy/service/ArticleService.java new file mode 100644 index 0000000000000000000000000000000000000000..117495fdc578d554bc7bd829a3d9b6247e6da762 --- /dev/null +++ b/src/main/java/com/darkfantasy/service/ArticleService.java @@ -0,0 +1,37 @@ +package com.darkfantasy.service; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.darkfantasy.dto.article.ArticleResponse; +import com.darkfantasy.dto.article.CreateArticleRequest; +import com.darkfantasy.dto.article.UpdateArticleRequest; + +public interface ArticleService { + ArticleResponse getArticleById(Long id); + + ArticleResponse getPublicArticleById(Long id); // Website + + ArticleResponse createArticle(CreateArticleRequest request); + + ArticleResponse updateArticle(UpdateArticleRequest request); + + void deleteArticle(Long articleId); + + void restoreArticle(Long articleId); + + Page getArticles(Pageable pageable); + + Page getArticlesDeletedFalse(Pageable pageable); + + List getLatestArticles(int limit); + + List getLatestArticlesExcept(Long id, int limit); + + ArticleResponse getLatestImportantArticle(); + + long count(); + +} diff --git a/src/main/java/com/darkfantasy/service/AuditLogService.java b/src/main/java/com/darkfantasy/service/AuditLogService.java new file mode 100644 index 0000000000000000000000000000000000000000..9e901103a493beb3aa75ea324a93a36fc6d70a17 --- /dev/null +++ b/src/main/java/com/darkfantasy/service/AuditLogService.java @@ -0,0 +1,26 @@ +package com.darkfantasy.service; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.darkfantasy.dto.log.AuditLogResponse; +import com.darkfantasy.entity.User; +import com.darkfantasy.entity.enums.LogAction; +import com.darkfantasy.entity.enums.LogEntityType; + +public interface AuditLogService { + void log( + LogEntityType entityType, + Long entityId, + LogAction action, + String description); + + void log( + User user, + LogEntityType entityType, + Long entityId, + LogAction action, + String description); + + Page getLogs(Pageable pageable); +} diff --git a/src/main/java/com/darkfantasy/service/ContactMessageService.java b/src/main/java/com/darkfantasy/service/ContactMessageService.java new file mode 100644 index 0000000000000000000000000000000000000000..ad6637739c67d401874e7432b0937af10582a377 --- /dev/null +++ b/src/main/java/com/darkfantasy/service/ContactMessageService.java @@ -0,0 +1,21 @@ +package com.darkfantasy.service; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.darkfantasy.dto.contact.ContactMessageResponse; +import com.darkfantasy.dto.contact.CreateContactMessageRequest; + +public interface ContactMessageService { + ContactMessageResponse createMessage(CreateContactMessageRequest request); + + Page getMessages(Pageable pageable); + + ContactMessageResponse getMessageById(Long id); + + void markAsProcessed(Long id); + + void markAsUnprocessed(Long id); + + long countByProcessedFalse(); +} diff --git a/src/main/java/com/darkfantasy/service/ContributorService.java b/src/main/java/com/darkfantasy/service/ContributorService.java new file mode 100644 index 0000000000000000000000000000000000000000..9baa2594d95a67e38a2b81d1294cb9a458b2c998 --- /dev/null +++ b/src/main/java/com/darkfantasy/service/ContributorService.java @@ -0,0 +1,26 @@ +package com.darkfantasy.service; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.darkfantasy.dto.contributor.ContributorResponse; +import com.darkfantasy.dto.contributor.CreateContributorRequest; +import com.darkfantasy.dto.contributor.UpdateContributorRequest; + +public interface ContributorService { + ContributorResponse getContributorById(Long id); + + ContributorResponse createContributor(CreateContributorRequest request); + + ContributorResponse updateContributor(UpdateContributorRequest request); + + void deleteContributor(Long contributorId); + + void restoreContributor(Long contributorId); + + Page getContributors(Pageable pageable); + + Page getContributorsDeletedFalse(Pageable pageable); + + long count(); +} diff --git a/src/main/java/com/darkfantasy/service/CustomUserDetailsService.java b/src/main/java/com/darkfantasy/service/CustomUserDetailsService.java new file mode 100644 index 0000000000000000000000000000000000000000..72f3257eecc791f77b1f7fe3b33669e369b71206 --- /dev/null +++ b/src/main/java/com/darkfantasy/service/CustomUserDetailsService.java @@ -0,0 +1,33 @@ +package com.darkfantasy.service; + +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import com.darkfantasy.entity.User; +import com.darkfantasy.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String login) throws UsernameNotFoundException { + User user = userRepository + .findUserByUsernameOrEmail(login, login) + .orElseThrow(() -> new UsernameNotFoundException( + "Không tìm thấy tài khoản")); + + return org.springframework.security.core.userdetails.User + .withUsername(user.getUsername()) + .password(user.getPassword()) + .disabled(!user.isActive()) + .authorities("ROLE_" + user.getRole().name()) + .build(); + } + +} diff --git a/src/main/java/com/darkfantasy/service/FaqService.java b/src/main/java/com/darkfantasy/service/FaqService.java new file mode 100644 index 0000000000000000000000000000000000000000..e50415c2623ab4c2d0758898c5a49a836cee9840 --- /dev/null +++ b/src/main/java/com/darkfantasy/service/FaqService.java @@ -0,0 +1,29 @@ +package com.darkfantasy.service; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.darkfantasy.dto.faq.CreateFaqRequest; +import com.darkfantasy.dto.faq.FaqResponse; +import com.darkfantasy.dto.faq.UpdateFaqRequest; + +public interface FaqService { + FaqResponse getFaqById(Long id); + + FaqResponse createFaq(CreateFaqRequest request); + + FaqResponse updateFaq(UpdateFaqRequest request); + + void deleteFaq(Long id); + + void restoreFaq(Long id); + + Page getFaqs(Pageable pageable); + + List getFaqsDeletedFalse(); + + long count(); + +} diff --git a/src/main/java/com/darkfantasy/service/FileStorageService.java b/src/main/java/com/darkfantasy/service/FileStorageService.java new file mode 100644 index 0000000000000000000000000000000000000000..932d739e489cd930ca1b48ba2b2a9883c2113b4f --- /dev/null +++ b/src/main/java/com/darkfantasy/service/FileStorageService.java @@ -0,0 +1,9 @@ +package com.darkfantasy.service; + +import java.io.IOException; + +import org.springframework.web.multipart.MultipartFile; + +public interface FileStorageService { + String saveFile(MultipartFile file, String directory) throws IOException; +} diff --git a/src/main/java/com/darkfantasy/service/GameCharacterService.java b/src/main/java/com/darkfantasy/service/GameCharacterService.java new file mode 100644 index 0000000000000000000000000000000000000000..0a92e87994a0953d6c3439d3e47a0b5b8800f05e --- /dev/null +++ b/src/main/java/com/darkfantasy/service/GameCharacterService.java @@ -0,0 +1,24 @@ +package com.darkfantasy.service; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.darkfantasy.dto.gamecharacter.CreateGameCharacterRequest; +import com.darkfantasy.dto.gamecharacter.GameCharacterResponse; +import com.darkfantasy.dto.gamecharacter.UpdateGameCharacterRequest; + +public interface GameCharacterService { + GameCharacterResponse getGameCharacterById(Long id); + GameCharacterResponse createGameCharacter(CreateGameCharacterRequest request); + GameCharacterResponse updateGameCharacter(UpdateGameCharacterRequest request); + void deleteCharacter(Long id); + void restoreCharacter(Long id); + + Page getCharacters(Pageable pageable); + List getCharactersDeletedFalse(); + List getCharactersLimit(int limit); + + long count(); +} diff --git a/src/main/java/com/darkfantasy/service/StoryService.java b/src/main/java/com/darkfantasy/service/StoryService.java new file mode 100644 index 0000000000000000000000000000000000000000..9d027b13651683cd19192540215342230fd5fb80 --- /dev/null +++ b/src/main/java/com/darkfantasy/service/StoryService.java @@ -0,0 +1,25 @@ +package com.darkfantasy.service; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.darkfantasy.dto.story.CreateStoryRequest; +import com.darkfantasy.dto.story.UpdateStoryRequest; +import com.darkfantasy.dto.story.StoryResponse; + +public interface StoryService { + StoryResponse getStoryById(Long id); + StoryResponse createStory(CreateStoryRequest request); + StoryResponse updateStory(UpdateStoryRequest request); + void deleteStory(Long id); + void restoreStory(Long id); + + Page getStories(Pageable pageable); + List getStoriesDeletedFalse(); + StoryResponse getStoryDeletedFalseHighestPriority(); + + long count(); + +} diff --git a/src/main/java/com/darkfantasy/service/UserService.java b/src/main/java/com/darkfantasy/service/UserService.java new file mode 100644 index 0000000000000000000000000000000000000000..70feb00c77412fd93a24998258cf44b2bc2202d8 --- /dev/null +++ b/src/main/java/com/darkfantasy/service/UserService.java @@ -0,0 +1,44 @@ +package com.darkfantasy.service; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.darkfantasy.dto.user.ChangePasswordRequest; +import com.darkfantasy.dto.user.ForgotPasswordRequest; +import com.darkfantasy.dto.user.LoginRequest; +import com.darkfantasy.dto.user.RegisterRequest; +import com.darkfantasy.dto.user.ResetPasswordRequest; +import com.darkfantasy.dto.user.UserResponse; +import com.darkfantasy.dto.user.VerifyOtpRequest; + +public interface UserService { + UserResponse findByUsername(String user); + + UserResponse login(LoginRequest request); + + void register(RegisterRequest request); + + // void changeForgottenPassword(ResetPasswordRequest request); + void changeCurrentUserPassword(ChangePasswordRequest request); + + // void changeEmail(ChangeEmailRequest request); + UserResponse findAccountById(Long id); + + Page getAccounts(Pageable pageable); + + void lockUser(Long id); + + void unlockUser(Long id); + + long count(); + + long countActiveUsers(); + + long countInactiveUsers(); + + void sendOtp(ForgotPasswordRequest request); + + void verifyOtp(VerifyOtpRequest request); + + void resetPassword(ResetPasswordRequest request); +} diff --git a/src/main/java/com/darkfantasy/service/WorldService.java b/src/main/java/com/darkfantasy/service/WorldService.java new file mode 100644 index 0000000000000000000000000000000000000000..1196722ff93b780edbfb8edbd8aaa22db0b8872f --- /dev/null +++ b/src/main/java/com/darkfantasy/service/WorldService.java @@ -0,0 +1,26 @@ +package com.darkfantasy.service; + + + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.darkfantasy.dto.world.CreateWorldRequest; +import com.darkfantasy.dto.world.UpdateWorldRequest; +import com.darkfantasy.dto.world.WorldResponse; + +public interface WorldService { + WorldResponse getWorldById(Long id); + WorldResponse createWorld(CreateWorldRequest request); + WorldResponse updateWorld(UpdateWorldRequest request); + void deleteWorld(Long id); + void restoreWorld(Long id); + + Page getWorlds(Pageable pageable); + List getWorldsDeletedFalse(); + WorldResponse getWorldDeletedFalseHighestPriority(); + + long count(); +} diff --git a/src/main/java/com/darkfantasy/service/impl/ArticleServiceImpl.java b/src/main/java/com/darkfantasy/service/impl/ArticleServiceImpl.java new file mode 100644 index 0000000000000000000000000000000000000000..a88d3e12286877244c46b18e79048ae965ae7a77 --- /dev/null +++ b/src/main/java/com/darkfantasy/service/impl/ArticleServiceImpl.java @@ -0,0 +1,197 @@ +package com.darkfantasy.service.impl; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.darkfantasy.dto.article.ArticleResponse; +import com.darkfantasy.dto.article.CreateArticleRequest; +import com.darkfantasy.dto.article.UpdateArticleRequest; +import com.darkfantasy.entity.Article; +import com.darkfantasy.entity.User; +import com.darkfantasy.entity.enums.ArticleType; +import com.darkfantasy.entity.enums.LogAction; +import com.darkfantasy.entity.enums.LogEntityType; +import com.darkfantasy.exception.custom.ResourceNotFoundException; +import com.darkfantasy.repository.ArticleRepository; +import com.darkfantasy.repository.UserRepository; +import com.darkfantasy.service.ArticleService; +import com.darkfantasy.service.AuditLogService; +import com.darkfantasy.util.SecurityUtil; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ArticleServiceImpl implements ArticleService { + private final UserRepository userRepository; + private final ArticleRepository articleRepository; + private final AuditLogService auditLogService; + + @Override + public ArticleResponse getArticleById(Long id) { + return ArticleResponse.fromEntity(findArticle(id)); + } + + @Transactional + @Override + public ArticleResponse createArticle(CreateArticleRequest request) { + if (request == null) { + throw new IllegalArgumentException("Không thể tạo bài viết với dữ liệu null"); + } + User currentUser = getCurrentUser(); + Article article = request.toEntity(); + article.setCreatedBy(currentUser); + Article savedArticle = articleRepository.save(article); + auditLogService.log( + LogEntityType.ARTICLE, + savedArticle.getId(), + LogAction.CREATE, + "Tạo bài viết: " + savedArticle.getTitle()); + return ArticleResponse.fromEntity(savedArticle); + } + + @Transactional + @Override + public ArticleResponse updateArticle(UpdateArticleRequest request) { + + Article article = findArticle(request.getId()); + + String currentUsername = SecurityUtil.getCurrentUserName(); + + if (currentUsername == null) { + throw new IllegalStateException("Không tìm thấy người dùng hiện tại"); + } + + User currentUser = getCurrentUser(); + + article.setTitle(request.getTitle()); + article.setContent(request.getContent()); + article.setType(request.getType()); + + if (request.getThumbnailUrl() != null) { + article.setThumbnailUrl(request.getThumbnailUrl()); + } + + article.setUpdatedBy(currentUser); + auditLogService.log( + LogEntityType.ARTICLE, + article.getId(), + LogAction.UPDATE, + "Sửa bài viết: " + article.getTitle()); + return ArticleResponse.fromEntity(article); + } + + @Transactional + @Override + public void deleteArticle(Long articleId) { + User currentUser = getCurrentUser(); + if (articleId == null) { + throw new IllegalArgumentException("Không thể xóa bài viết với ID null"); + } + Article article = findArticle(articleId); + article.setDeleted(true); + article.setUpdatedBy(currentUser); + auditLogService.log( + LogEntityType.ARTICLE, + article.getId(), + LogAction.DELETE, + "Xóa bài viết: " + article.getTitle()); + } + + @Override + public Page getArticlesDeletedFalse(Pageable pageable) { + return articleRepository.findByDeletedFalseOrderByCreatedAtDesc(pageable) + .map(ArticleResponse::fromEntity); + } + + @Override + public List getLatestArticles(int limit) { + + Pageable pageable = PageRequest.of(0, limit); + + return articleRepository.findByDeletedFalseOrderByCreatedAtDesc(pageable) + .stream() + .map(ArticleResponse::fromEntity) + .toList(); + } + + @Override + public ArticleResponse getLatestImportantArticle() { + + return articleRepository.findFirstByTypeAndDeletedFalseOrderByCreatedAtDesc( + ArticleType.IMPORTANT) + .map(ArticleResponse::fromEntity) + .orElse(null); + } + + @Override + public Page getArticles(Pageable pageable) { + if (pageable == null) { + throw new IllegalArgumentException("Pageable không được null"); + } + return articleRepository.findAll(pageable).map(ArticleResponse::fromEntity); + } + + @Transactional + @Override + public void restoreArticle(Long articleId) { + User currentUser = getCurrentUser(); + if (articleId == null) { + throw new IllegalArgumentException("Không thể khôi phục bài viết với ID null"); + } + Article article = findArticle(articleId); + article.setDeleted(false); + article.setUpdatedBy(currentUser); + auditLogService.log( + LogEntityType.ARTICLE, + article.getId(), + LogAction.RESTORE, + "Khôi phục bài viết: " + article.getTitle()); + } + + @Override + public List getLatestArticlesExcept(Long id, int limit) { + Pageable pageable = PageRequest.of(0, limit + 1); + + return articleRepository.findByDeletedFalseOrderByCreatedAtDesc(pageable) + .stream() + .filter(article -> !article.getId().equals(id)) + .limit(limit) + .map(ArticleResponse::fromEntity) + .toList(); + } + + @Override + public long count() { + return articleRepository.count(); + } + + private Article findArticle(Long id) { + return articleRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Không tìm thấy bài viết với ID: " + id)); + } + + private User getCurrentUser() { + + String currentUsername = SecurityUtil.getCurrentUserName(); + + if (currentUsername == null) { + throw new IllegalStateException("Không tìm thấy người dùng hiện tại"); + } + + return userRepository.findUserByUsername(currentUsername) + .orElseThrow(() -> new ResourceNotFoundException("Không tìm thấy người dùng")); + } + + @Override + public ArticleResponse getPublicArticleById(Long id) { + Article article = articleRepository.findByIdAndDeletedFalse(id) + .orElseThrow(() -> new ResourceNotFoundException("Không tìm thấy bài viết")); + + return ArticleResponse.fromEntity(article); + } +} diff --git a/src/main/java/com/darkfantasy/service/impl/AuditLogServiceImpl.java b/src/main/java/com/darkfantasy/service/impl/AuditLogServiceImpl.java new file mode 100644 index 0000000000000000000000000000000000000000..213f2c673cf9c955ed1d45d3b376da2bb631fd71 --- /dev/null +++ b/src/main/java/com/darkfantasy/service/impl/AuditLogServiceImpl.java @@ -0,0 +1,83 @@ +package com.darkfantasy.service.impl; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.darkfantasy.dto.log.AuditLogResponse; +import com.darkfantasy.entity.AuditLog; +import com.darkfantasy.entity.User; +import com.darkfantasy.entity.enums.LogAction; +import com.darkfantasy.entity.enums.LogEntityType; +import com.darkfantasy.repository.AuditLogRepository; +import com.darkfantasy.repository.UserRepository; +import com.darkfantasy.service.AuditLogService; +import com.darkfantasy.util.SecurityUtil; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AuditLogServiceImpl implements AuditLogService { + + private final AuditLogRepository auditLogRepository; + private final UserRepository userRepository; + + @Transactional + @Override + public void log( + LogEntityType entityType, + Long entityId, + LogAction action, + String description) { + + String username = SecurityUtil.getCurrentUserName(); + + if (username == null || "anonymousUser".equals(username)) { + return; + } + + User currentUser = userRepository + .findUserByUsername(username) + .orElseThrow(() -> new IllegalArgumentException( + "Không tìm thấy người dùng")); + + AuditLog log = AuditLog.builder() + .user(currentUser) + .entityType(entityType) + .entityId(entityId) + .action(action) + .description(description) + .build(); + + auditLogRepository.save(log); + } + + @Override + public Page getLogs(Pageable pageable) { + return auditLogRepository + .findAll(pageable) + .map(AuditLogResponse::fromEntity); + } + + @Transactional + @Override + public void log( + User user, + LogEntityType entityType, + Long entityId, + LogAction action, + String description) { + + AuditLog log = AuditLog.builder() + .user(user) + .entityType(entityType) + .entityId(entityId) + .action(action) + .description(description) + .build(); + + auditLogRepository.save(log); + } +} diff --git a/src/main/java/com/darkfantasy/service/impl/ContactMessageServiceImpl.java b/src/main/java/com/darkfantasy/service/impl/ContactMessageServiceImpl.java new file mode 100644 index 0000000000000000000000000000000000000000..5a31f9ece393929608c4872ce26ba74adb6f026b --- /dev/null +++ b/src/main/java/com/darkfantasy/service/impl/ContactMessageServiceImpl.java @@ -0,0 +1,95 @@ +package com.darkfantasy.service.impl; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.darkfantasy.dto.contact.ContactMessageResponse; +import com.darkfantasy.dto.contact.CreateContactMessageRequest; +import com.darkfantasy.entity.ContactMessage; +import com.darkfantasy.entity.enums.LogAction; +import com.darkfantasy.entity.enums.LogEntityType; +import com.darkfantasy.repository.ContactMessageRepository; +import com.darkfantasy.service.AuditLogService; +import com.darkfantasy.service.ContactMessageService; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ContactMessageServiceImpl implements ContactMessageService { + private final ContactMessageRepository contactMessageRepository; + private final AuditLogService auditLogService; + + @Transactional + @Override + public ContactMessageResponse createMessage(CreateContactMessageRequest request) { + if (request == null) { + throw new IllegalArgumentException("Không thể tạo tin nhắn liên hệ với dữ liệu null"); + } + + ContactMessage message = request.toEntity(); + ContactMessage savedMessage = contactMessageRepository.save(message); + + // auditLogService.log( + // LogEntityType.CONTACT, + // savedMessage.getId(), + // LogAction.CREATE, + // "Tạo liên hệ: " + savedMessage.getMessage()); + return ContactMessageResponse.fromEntity(savedMessage); + } + + @Override + public Page getMessages(Pageable pageable) { + if (pageable == null) { + throw new IllegalArgumentException("Pageable không được null"); + } + + return contactMessageRepository + .findAll(pageable) + .map(ContactMessageResponse::fromEntity); + } + + @Override + public ContactMessageResponse getMessageById(Long id) { + return ContactMessageResponse.fromEntity(findContactMessage(id)); + } + + @Transactional + @Override + public void markAsProcessed(Long id) { + if (id == null) { + throw new IllegalArgumentException("Không thể đánh dấu đã xử lý với ID null"); + } + + ContactMessage message = findContactMessage(id); + message.setProcessed(true); + auditLogService.log( + LogEntityType.CONTACT, + message.getId(), + LogAction.PROCESS, + "Đánh dấu đã xử lý: " + message.getMessage()); + } + + @Transactional + @Override + public void markAsUnprocessed(Long id) { + if (id == null) { + throw new IllegalArgumentException("Không thể đánh dấu chưa xử lý với ID null"); + } + + ContactMessage message = findContactMessage(id); + message.setProcessed(false); + } + + private ContactMessage findContactMessage(Long id) { + return contactMessageRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Không tìm thấy tin nhắn liên hệ với ID: " + id)); + } + + @Override + public long countByProcessedFalse() { + return contactMessageRepository.countByProcessedFalse(); + } +} diff --git a/src/main/java/com/darkfantasy/service/impl/ContributorServiceImpl.java b/src/main/java/com/darkfantasy/service/impl/ContributorServiceImpl.java new file mode 100644 index 0000000000000000000000000000000000000000..78ce74ade6fa21be66f3a55b5fc4dab05c1f303a --- /dev/null +++ b/src/main/java/com/darkfantasy/service/impl/ContributorServiceImpl.java @@ -0,0 +1,156 @@ +package com.darkfantasy.service.impl; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.darkfantasy.dto.contributor.ContributorResponse; +import com.darkfantasy.dto.contributor.CreateContributorRequest; +import com.darkfantasy.dto.contributor.UpdateContributorRequest; +import com.darkfantasy.entity.Contributor; +import com.darkfantasy.entity.User; +import com.darkfantasy.entity.enums.LogAction; +import com.darkfantasy.entity.enums.LogEntityType; +import com.darkfantasy.exception.custom.ResourceNotFoundException; +import com.darkfantasy.repository.ContributorRepository; +import com.darkfantasy.repository.UserRepository; +import com.darkfantasy.service.ContributorService; +import com.darkfantasy.service.AuditLogService; +import com.darkfantasy.util.SecurityUtil; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ContributorServiceImpl implements ContributorService { + private final UserRepository userRepository; + private final ContributorRepository contributorRepository; + private final AuditLogService auditLogService; + + @Override + public ContributorResponse getContributorById(Long id) { + return ContributorResponse.fromEntity(findContributor(id)); + } + + @Transactional + @Override + public ContributorResponse createContributor(CreateContributorRequest request) { + if (request == null) { + throw new IllegalArgumentException("Không thể tạo người đóng góp với dữ liệu null"); + } + User currentUser = getCurrentUser(); + Contributor contributor = request.toEntity(); + contributor.setCreatedBy(currentUser); + Contributor savedContributor = contributorRepository.save(contributor); + auditLogService.log( + LogEntityType.CONTRIBUTOR, + savedContributor.getId(), + LogAction.CREATE, + "Tạo người đóng góp: " + savedContributor.getName()); + return ContributorResponse.fromEntity(savedContributor); + } + + @Transactional + @Override + public ContributorResponse updateContributor(UpdateContributorRequest request) { + + Contributor contributor = findContributor(request.getId()); + + String currentUsername = SecurityUtil.getCurrentUserName(); + + if (currentUsername == null) { + throw new IllegalStateException("Không tìm thấy người dùng hiện tại"); + } + + User currentUser = getCurrentUser(); + + contributor.setName(request.getName()); + contributor.setPosition(request.getPosition()); + contributor.setPriority(request.getPriority()); + + if (request.getAvatar() != null) { + contributor.setAvatar(request.getAvatar()); + } + + contributor.setUpdatedBy(currentUser); + auditLogService.log( + LogEntityType.CONTRIBUTOR, + contributor.getId(), + LogAction.UPDATE, + "Sửa người đóng góp: " + contributor.getName()); + return ContributorResponse.fromEntity(contributor); + } + + @Transactional + @Override + public void deleteContributor(Long contributorId) { + User currentUser = getCurrentUser(); + if (contributorId == null) { + throw new IllegalArgumentException("Không thể xóa người đóng góp với ID null"); + } + Contributor contributor = findContributor(contributorId); + contributor.setDeleted(true); + contributor.setUpdatedBy(currentUser); + auditLogService.log( + LogEntityType.CONTRIBUTOR, + contributor.getId(), + LogAction.DELETE, + "Xóa người đóng góp: " + contributor.getName()); + } + + @Override + public Page getContributorsDeletedFalse(Pageable pageable) { + return contributorRepository.findByDeletedFalseOrderByPriorityDesc(pageable) + .map(ContributorResponse::fromEntity); + } + + @Override + public Page getContributors(Pageable pageable) { + if (pageable == null) { + throw new IllegalArgumentException("Pageable không được null"); + } + return contributorRepository.findAll(pageable).map(ContributorResponse::fromEntity); + } + + @Transactional + @Override + public void restoreContributor(Long contributorId) { + User currentUser = getCurrentUser(); + if (contributorId == null) { + throw new IllegalArgumentException("Không thể khôi phục người đóng góp với ID null"); + } + Contributor contributor = findContributor(contributorId); + contributor.setDeleted(false); + contributor.setUpdatedBy(currentUser); + auditLogService.log( + LogEntityType.CONTRIBUTOR, + contributor.getId(), + LogAction.RESTORE, + "Khôi phục người đóng góp: " + contributor.getName()); + } + + @Override + public long count() { + return contributorRepository.count(); + } + + private Contributor findContributor(Long id) { + return contributorRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Không tìm thấy người đóng góp với ID: " + id)); + } + + private User getCurrentUser() { + + String currentUsername = SecurityUtil.getCurrentUserName(); + + if (currentUsername == null) { + throw new IllegalStateException("Không tìm thấy người dùng hiện tại"); + } + + return userRepository.findUserByUsername(currentUsername) + .orElseThrow(() -> new ResourceNotFoundException("Không tìm thấy người dùng")); + } +} diff --git a/src/main/java/com/darkfantasy/service/impl/FaqServiceImpl.java b/src/main/java/com/darkfantasy/service/impl/FaqServiceImpl.java new file mode 100644 index 0000000000000000000000000000000000000000..f7214116e1b88c0d8052dfb0b76046496889691f --- /dev/null +++ b/src/main/java/com/darkfantasy/service/impl/FaqServiceImpl.java @@ -0,0 +1,147 @@ +package com.darkfantasy.service.impl; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.darkfantasy.dto.faq.CreateFaqRequest; +import com.darkfantasy.dto.faq.UpdateFaqRequest; +import com.darkfantasy.entity.Faq; +import com.darkfantasy.entity.User; +import com.darkfantasy.entity.enums.LogAction; +import com.darkfantasy.entity.enums.LogEntityType; +import com.darkfantasy.dto.faq.FaqResponse; +import com.darkfantasy.repository.FaqRepository; +import com.darkfantasy.repository.UserRepository; +import com.darkfantasy.service.AuditLogService; +import com.darkfantasy.service.FaqService; +import com.darkfantasy.util.SecurityUtil; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class FaqServiceImpl implements FaqService { + private final FaqRepository faqRepository; + private final UserRepository userRepository; + private final AuditLogService auditLogService; + + @Override + public Page getFaqs(Pageable pageable) { + return faqRepository + .findAllByOrderByIdAsc(pageable) + .map(FaqResponse::fromEntity); + } + + @Override + public FaqResponse getFaqById(Long id) { + return FaqResponse.fromEntity(findFaq(id)); + } + + @Transactional + @Override + public FaqResponse createFaq(CreateFaqRequest request) { + if (request == null) { + throw new IllegalArgumentException( + "Không thể tạo faq với dữ liệu null"); + } + + Faq faq = request.toEntity(); + faq.setCreatedBy(getCurrentUser()); + Faq savedFaq = faqRepository.save(faq); + auditLogService.log( + LogEntityType.FAQ, + savedFaq.getId(), + LogAction.CREATE, + "Tạo FAQ: " + savedFaq.getTitle()); + return FaqResponse.fromEntity(faq); + } + + @Transactional + @Override + public FaqResponse updateFaq(UpdateFaqRequest request) { + if (request == null) { + throw new IllegalArgumentException( + "Không thể thay đổi faq với dữ liệu null"); + } + Faq faq = findFaq(request.getId()); + faq.setTitle(request.getTitle()); + faq.setContent(request.getContent()); + faq.setPriority(request.getPriority()); + faq.setUpdatedBy(getCurrentUser()); + auditLogService.log( + LogEntityType.FAQ, + faq.getId(), + LogAction.UPDATE, + "Sửa FAQ: " + faq.getTitle()); + return FaqResponse.fromEntity(faq); + } + + @Transactional + @Override + public void deleteFaq(Long id) { + if (id == null) { + throw new IllegalArgumentException("Không thể xóa faq với ID null"); + } + Faq found = findFaq(id); + found.setDeleted(true); + found.setUpdatedBy(getCurrentUser()); + auditLogService.log( + LogEntityType.FAQ, + found.getId(), + LogAction.DELETE, + "Xóa FAQ: " + found.getTitle()); + } + + @Transactional + @Override + public void restoreFaq(Long id) { + if (id == null) { + throw new IllegalArgumentException("Không thể khôi phục faq với ID null"); + } + Faq found = findFaq(id); + found.setDeleted(false); + found.setUpdatedBy(getCurrentUser()); + auditLogService.log( + LogEntityType.FAQ, + found.getId(), + LogAction.RESTORE, + "Khôi phục FAQ: " + found.getTitle()); + } + + @Override + public List getFaqsDeletedFalse() { + return faqRepository + .findByDeletedFalseOrderByPriorityDesc() + .stream() + .map(FaqResponse::fromEntity) + .toList(); + } + + private Faq findFaq(Long id) { + return faqRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Không tìm thấy faq với ID: " + id)); + } + + @Override + public long count() { + return faqRepository.count(); + } + + private User getCurrentUser() { + + String currentUsername = SecurityUtil.getCurrentUserName(); + + if (currentUsername == null) { + throw new IllegalStateException("Không tìm thấy người dùng hiện tại"); + } + + return userRepository + .findUserByUsername(currentUsername) + .orElseThrow(() -> new IllegalArgumentException("Không tìm thấy người dùng")); + } + +} diff --git a/src/main/java/com/darkfantasy/service/impl/FileStorageServiceImpl.java b/src/main/java/com/darkfantasy/service/impl/FileStorageServiceImpl.java new file mode 100644 index 0000000000000000000000000000000000000000..7c324d5a60ffcfe7d8a6f679f11a30b106afd92d --- /dev/null +++ b/src/main/java/com/darkfantasy/service/impl/FileStorageServiceImpl.java @@ -0,0 +1,60 @@ +package com.darkfantasy.service.impl; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.springframework.util.DigestUtils; +import org.springframework.util.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import com.darkfantasy.service.FileStorageService; + +@Service +public class FileStorageServiceImpl implements FileStorageService { + + @Override + public String saveFile( + MultipartFile file, + String directory) throws IOException { + + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException( + "File upload không hợp lệ"); + } + + String originalFilename = file.getOriginalFilename(); + + String extension = StringUtils.getFilenameExtension( + originalFilename); + + extension = extension == null + ? "" + : "." + extension.toLowerCase(); + + String fileHash; + + try (InputStream is = file.getInputStream()) { + fileHash = DigestUtils.md5DigestAsHex(is); + } + + String fileName = fileHash + extension; + + Path uploadDir = Paths.get("uploads", directory); + + Files.createDirectories(uploadDir); + + Path targetPath = uploadDir.resolve(fileName); + + if (!Files.exists(targetPath)) { + file.transferTo(targetPath); + } + + return "/uploads/" + + directory + + "/" + + fileName; + } +} \ No newline at end of file diff --git a/src/main/java/com/darkfantasy/service/impl/GameCharacterServiceImpl.java b/src/main/java/com/darkfantasy/service/impl/GameCharacterServiceImpl.java new file mode 100644 index 0000000000000000000000000000000000000000..6b700da45f1815c6f479a03c22d4486c82e2f2d1 --- /dev/null +++ b/src/main/java/com/darkfantasy/service/impl/GameCharacterServiceImpl.java @@ -0,0 +1,168 @@ +package com.darkfantasy.service.impl; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.darkfantasy.dto.gamecharacter.CreateGameCharacterRequest; +import com.darkfantasy.dto.gamecharacter.GameCharacterResponse; +import com.darkfantasy.dto.gamecharacter.UpdateGameCharacterRequest; +import com.darkfantasy.entity.GameCharacter; +import com.darkfantasy.entity.User; +import com.darkfantasy.entity.enums.LogAction; +import com.darkfantasy.entity.enums.LogEntityType; +import com.darkfantasy.repository.GameCharacterRepository; +import com.darkfantasy.repository.UserRepository; +import com.darkfantasy.service.AuditLogService; +import com.darkfantasy.service.GameCharacterService; +import com.darkfantasy.util.SecurityUtil; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class GameCharacterServiceImpl implements GameCharacterService { + + private final GameCharacterRepository gameCharacterRepository; + private final UserRepository userRepository; + private final AuditLogService auditLogService; + + @Override + public GameCharacterResponse getGameCharacterById(Long id) { + return GameCharacterResponse.fromEntity(findGameCharacter(id)); + } + + @Transactional + @Override + public GameCharacterResponse createGameCharacter( + CreateGameCharacterRequest request) { + + if (request == null) { + throw new IllegalArgumentException( + "Không thể tạo nhân vật với dữ liệu null"); + } + + GameCharacter character = request.toEntity(); + character.setCreatedBy(getCurrentUser()); + + GameCharacter savedCharacter = gameCharacterRepository.save(character); + auditLogService.log( + LogEntityType.CHARACTER, + savedCharacter.getId(), + LogAction.CREATE, + "Thêm nhân vật: " + savedCharacter.getName()); + return GameCharacterResponse.fromEntity( + savedCharacter); + } + + @Transactional + @Override + public GameCharacterResponse updateGameCharacter(UpdateGameCharacterRequest request) { + if (request == null) { + throw new IllegalArgumentException( + "Không thể thay đổi nhân vật với dữ liệu null"); + } + GameCharacter character = findGameCharacter(request.getId()); + character.setName(request.getName()); + character.setRace(request.getRace()); + character.setDescription(request.getDescription()); + character.setQuote(request.getQuote()); + character.setPriority(request.getPriority()); + character.setUpdatedBy(getCurrentUser()); + if (request.getImage() != null) { + character.setImage(request.getImage()); + } + auditLogService.log( + LogEntityType.CHARACTER, + character.getId(), + LogAction.UPDATE, + "Sửa nhân vật: " + character.getName()); + return GameCharacterResponse.fromEntity(character); + + } + + @Transactional + @Override + public void deleteCharacter(Long id) { + if (id == null) { + throw new IllegalArgumentException("Không thể xóa nhân vật với ID null"); + } + GameCharacter found = findGameCharacter(id); + found.setDeleted(true); + found.setUpdatedBy(getCurrentUser()); + auditLogService.log( + LogEntityType.CHARACTER, + found.getId(), + LogAction.DELETE, + "Xóa nhân vật: " + found.getName()); + } + + @Transactional + @Override + public void restoreCharacter(Long id) { + if (id == null) { + throw new IllegalArgumentException("Không thể khôi phục nhân vật với ID null"); + } + GameCharacter found = findGameCharacter(id); + found.setDeleted(false); + found.setUpdatedBy(getCurrentUser()); + auditLogService.log( + LogEntityType.CHARACTER, + found.getId(), + LogAction.RESTORE, + "Khôi phục nhân vật: " + found.getName()); + } + + @Override + public Page getCharacters(Pageable pageable) { + return gameCharacterRepository + .findAllByOrderByIdAsc(pageable) + .map(GameCharacterResponse::fromEntity); + } + + @Override + public List getCharactersLimit(int limit) { + if (limit <= 0) + throw new IllegalArgumentException("Limit phải lớn hơn 0"); + return gameCharacterRepository + .findByDeletedFalseOrderByPriorityDesc(PageRequest.of(0, limit)) + .stream() + .map(GameCharacterResponse::fromEntity).toList(); + } + + @Override + public List getCharactersDeletedFalse() { + return gameCharacterRepository + .findByDeletedFalseOrderByPriorityDesc() + .stream() + .map(GameCharacterResponse::fromEntity) + .toList(); + } + + @Override + public long count() { + return gameCharacterRepository.count(); + } + + private GameCharacter findGameCharacter(Long id) { + return gameCharacterRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Không tìm thấy nhân vật với ID: " + id)); + } + + private User getCurrentUser() { + + String currentUsername = SecurityUtil.getCurrentUserName(); + + if (currentUsername == null) { + throw new IllegalStateException("Không tìm thấy người dùng hiện tại"); + } + + return userRepository + .findUserByUsername(currentUsername) + .orElseThrow(() -> new IllegalArgumentException("Không tìm thấy người dùng")); + } +} diff --git a/src/main/java/com/darkfantasy/service/impl/StoryServiceImpl.java b/src/main/java/com/darkfantasy/service/impl/StoryServiceImpl.java new file mode 100644 index 0000000000000000000000000000000000000000..4afa75a757ec4405d9abd84e9fc8ec838701becc --- /dev/null +++ b/src/main/java/com/darkfantasy/service/impl/StoryServiceImpl.java @@ -0,0 +1,163 @@ +package com.darkfantasy.service.impl; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.darkfantasy.dto.story.CreateStoryRequest; +import com.darkfantasy.dto.story.UpdateStoryRequest; +import com.darkfantasy.entity.Story; +import com.darkfantasy.entity.User; +import com.darkfantasy.entity.enums.LogAction; +import com.darkfantasy.entity.enums.LogEntityType; +import com.darkfantasy.dto.story.StoryResponse; +import com.darkfantasy.repository.StoryRepository; +import com.darkfantasy.repository.UserRepository; +import com.darkfantasy.service.AuditLogService; +import com.darkfantasy.service.StoryService; +import com.darkfantasy.util.SecurityUtil; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class StoryServiceImpl implements StoryService { + private final StoryRepository storyRepository; + private final UserRepository userRepository; + private final AuditLogService auditLogService; + + @Override + public Page getStories(Pageable pageable) { + return storyRepository + .findAllByOrderByIdAsc(pageable) + .map(StoryResponse::fromEntity); + } + + @Override + public StoryResponse getStoryById(Long id) { + return StoryResponse.fromEntity(findStory(id)); + } + + @Transactional + @Override + public StoryResponse createStory(CreateStoryRequest request) { + if (request == null) { + throw new IllegalArgumentException( + "Không thể tạo câu chuyện với dữ liệu null"); + } + + Story story = request.toEntity(); + + story.setCreatedBy(getCurrentUser()); + + Story savedStory = storyRepository.save(story); + auditLogService.log( + LogEntityType.STORY, + savedStory.getId(), + LogAction.CREATE, + "Thêm câu chuyện: " + savedStory.getTitle()); + return StoryResponse.fromEntity(savedStory); + } + + @Transactional + @Override + public StoryResponse updateStory(UpdateStoryRequest request) { + if (request == null) { + throw new IllegalArgumentException( + "Không thể thay đổi câu chuyện với dữ liệu null"); + } + Story story = findStory(request.getId()); + story.setTag(request.getTag()); + story.setTitle(request.getTitle()); + story.setContent(request.getContent()); + if (request.getImage() != null) { + story.setImage(request.getImage()); + } + story.setQuoteContent(request.getQuoteContent()); + story.setQuoteAuthor(request.getQuoteAuthor()); + story.setPriority(request.getPriority()); + story.setUpdatedBy(getCurrentUser()); + auditLogService.log( + LogEntityType.STORY, + story.getId(), + LogAction.UPDATE, + "Sửa câu chuyện: " + story.getTitle()); + return StoryResponse.fromEntity(story); + } + + @Transactional + @Override + public void deleteStory(Long id) { + if (id == null) { + throw new IllegalArgumentException("Không thể xóa câu chuyện với ID null"); + } + Story found = findStory(id); + found.setDeleted(true); + found.setUpdatedBy(getCurrentUser()); + auditLogService.log( + LogEntityType.STORY, + found.getId(), + LogAction.DELETE, + "Xóa câu chuyện: " + found.getTitle()); + } + + @Transactional + @Override + public void restoreStory(Long id) { + if (id == null) { + throw new IllegalArgumentException("Không thể khôi phục câu chuyện với ID null"); + } + Story found = findStory(id); + found.setDeleted(false); + found.setUpdatedBy(getCurrentUser()); + auditLogService.log( + LogEntityType.STORY, + found.getId(), + LogAction.RESTORE, + "Khôi phục câu chuyện: " + found.getTitle()); + } + + @Override + public StoryResponse getStoryDeletedFalseHighestPriority() { + Story story = storyRepository.findTopByDeletedFalseOrderByPriorityDesc(); + if(story == null){ + return null; + } + return StoryResponse.fromEntity(story); + } + + @Override + public List getStoriesDeletedFalse() { + return storyRepository + .findByDeletedFalseOrderByPriorityDesc() + .stream() + .map(StoryResponse::fromEntity) + .toList(); + } + + @Override + public long count() { + return storyRepository.count(); + } + + private Story findStory(Long id) { + return storyRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Không tìm thấy câu chuyện với ID: " + id)); + } + + private User getCurrentUser() { + + String currentUsername = SecurityUtil.getCurrentUserName(); + + if (currentUsername == null) { + throw new IllegalStateException("Không tìm thấy người dùng hiện tại"); + } + + return userRepository + .findUserByUsername(currentUsername) + .orElseThrow(() -> new IllegalArgumentException("Không tìm thấy người dùng")); + } +} diff --git a/src/main/java/com/darkfantasy/service/impl/UserServiceImpl.java b/src/main/java/com/darkfantasy/service/impl/UserServiceImpl.java new file mode 100644 index 0000000000000000000000000000000000000000..6620c6c35f820439694474d57f6eae5a1b3f905b --- /dev/null +++ b/src/main/java/com/darkfantasy/service/impl/UserServiceImpl.java @@ -0,0 +1,264 @@ +package com.darkfantasy.service.impl; + +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Optional; +import java.util.Random; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.darkfantasy.dto.user.ChangePasswordRequest; +import com.darkfantasy.dto.user.ForgotPasswordRequest; +import com.darkfantasy.dto.user.LoginRequest; +import com.darkfantasy.dto.user.RegisterRequest; +import com.darkfantasy.dto.user.ResetPasswordRequest; +import com.darkfantasy.dto.user.UserResponse; +import com.darkfantasy.dto.user.VerifyOtpRequest; +import com.darkfantasy.entity.PasswordResetToken; +import com.darkfantasy.entity.User; +import com.darkfantasy.entity.enums.LogAction; +import com.darkfantasy.entity.enums.LogEntityType; +import com.darkfantasy.entity.enums.Role; +import com.darkfantasy.exception.custom.ResourceNotFoundException; +import com.darkfantasy.repository.PasswordResetTokenRepository; +import com.darkfantasy.repository.UserRepository; +import com.darkfantasy.service.AuditLogService; +import com.darkfantasy.service.UserService; +import com.darkfantasy.util.SecurityUtil; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class UserServiceImpl implements UserService { + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final AuditLogService auditLogService; + private final PasswordResetTokenRepository tokenRepository; + private final JavaMailSender mailSender; + private final AuthenticationManager authenticationManager; + + @Transactional + @Override + public void register(RegisterRequest request) { + if (existsByKeyword(request.getUsername())) { + throw new IllegalArgumentException("Tên đăng nhập đã tồn tại!"); + } + if (existsByKeyword(request.getEmail())) { + throw new IllegalArgumentException("Email này đã được đăng ký!"); + } + String hashPassword = passwordEncoder.encode(request.getPassword()); + User savedUser = userRepository.save( + request.toEntity(hashPassword)); + auditLogService.log(savedUser, + LogEntityType.USER, + savedUser.getId(), + LogAction.CREATE, + "Đăng kí: " + savedUser.getUsername()); + } + + @Transactional + @Override + public UserResponse login(LoginRequest request) { + + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken( + request.getLogin(), + request.getPassword())); + + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + SecurityContextHolder.setContext(context); + + User user = findUser(authentication.getName()) + .orElseThrow(() -> new IllegalArgumentException( + "Tên đăng nhập hoặc mật khẩu không đúng.")); + + auditLogService.log( + user, + LogEntityType.USER, + user.getId(), + LogAction.LOGIN, + "Đăng nhập: " + user.getUsername()); + + return UserResponse.fromEntity(user); + } + + @Transactional + @Override + public void changeCurrentUserPassword(ChangePasswordRequest request) { + + User currentUser = getCurrentUser(); + + if (!passwordEncoder.matches( + request.getOldPassword(), + currentUser.getPassword())) { + throw new IllegalArgumentException("Mật khẩu hiện tại không đúng"); + } + + if (!request.getNewPassword() + .equals(request.getConfirmPassword())) { + throw new IllegalArgumentException("Mật khẩu xác nhận không khớp"); + } + + currentUser.setPassword(passwordEncoder.encode(request.getNewPassword())); + currentUser.setMustChangePassword(false); + + auditLogService.log( + LogEntityType.USER, + currentUser.getId(), + LogAction.UPDATE, + "Đổi mật khẩu: " + currentUser.getUsername()); + } + + private Optional findUser(String keyword) { + return userRepository.findUserByUsernameOrEmail(keyword, keyword); + } + + private boolean existsByKeyword(String keyword) { + return userRepository.existsByUsernameOrEmail(keyword, keyword); + } + + private User findUser(Long id) { + return id == null ? null : userRepository.getReferenceById(id); + } + + @Override + public UserResponse findAccountById(Long id) { + if (id == null) + throw new IllegalArgumentException("Id không thể là null"); + User user = userRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Không tìm thấy người dùng")); + return UserResponse.fromEntity(user); + } + + private User getCurrentUser() { + String currentUsername = SecurityUtil.getCurrentUserName(); + + if (currentUsername == null) { + throw new IllegalStateException("Không tìm thấy người dùng hiện tại"); + } + + return userRepository.findUserByUsername(currentUsername) + .orElseThrow(() -> new IllegalArgumentException("Không tìm thấy người dùng")); + } + + @Override + public Page getAccounts(Pageable pageable) { + return userRepository.findAllByOrderByIdAsc(pageable) + .map(UserResponse::fromEntity); + } + + @Transactional + @Override + public void lockUser(Long id) { + User user = findUser(id); + if (user.getRole() == Role.ADMIN) + throw new IllegalArgumentException("Không có thẩm quyền"); + user.setActive(false); + auditLogService.log( + LogEntityType.USER, + user.getId(), + LogAction.LOCK, + "Khóa tài khoản: " + user.getUsername()); + } + + @Transactional + @Override + public void unlockUser(Long id) { + User user = findUser(id); + if (user.getRole() == Role.ADMIN) + throw new IllegalArgumentException("Không có thẩm quyền"); + user.setActive(true); + auditLogService.log( + LogEntityType.USER, + user.getId(), + LogAction.UNLOCK, + "Mở khóa tài khoản: " + user.getUsername()); + } + + @Override + public long count() { + return userRepository.count(); + } + + @Override + public long countActiveUsers() { + return userRepository.countByIsActiveTrue(); + } + + @Override + public long countInactiveUsers() { + return userRepository.countByIsActiveFalse(); + } + + @Override + public UserResponse findByUsername(String username) { + User user = userRepository.findUserByUsername(username) + .orElseThrow(() -> new IllegalArgumentException("Không tìm thấy tài khoản")); + return UserResponse.fromEntity(user); + } + + @Transactional + @Override + public void sendOtp(ForgotPasswordRequest request) { + User user = userRepository.findUserByEmail(request.getEmail()) + .orElseThrow(() -> new IllegalArgumentException("Email không tồn tại")); + tokenRepository.invalidateAllUnusedByEmail(user.getEmail()); + String otp = String.format("%06d", new SecureRandom().nextInt(1000000)); + PasswordResetToken token = PasswordResetToken.builder() + .email(user.getEmail()) + .otp(otp) + .expiredAt(Instant.now().plusSeconds(300)) + .used(false) + .build(); + tokenRepository.save(token); + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(user.getEmail()); + message.setSubject("Mã OTP đặt lại mật khẩu"); + message.setText("Mã OTP của bạn là: " + otp + "\n\nMã có hiệu lực trong 5 phút."); + mailSender.send(message); + } + + @Transactional(readOnly = true) + @Override + public void verifyOtp(VerifyOtpRequest request) { + PasswordResetToken token = tokenRepository + .findTopByEmailAndOtpAndUsedFalseOrderByIdDesc( + request.getEmail(), + request.getOtp()) + .orElseThrow(() -> new IllegalArgumentException("OTP không hợp lệ")); + if (token.getExpiredAt().isBefore(Instant.now())) { + throw new IllegalArgumentException( + "OTP đã hết hạn"); + } + } + + @Transactional + @Override + public void resetPassword(ResetPasswordRequest request) { + PasswordResetToken token = tokenRepository + .findTopByEmailAndOtpAndUsedFalseOrderByIdDesc( + request.getEmail(), + request.getOtp()) + .orElseThrow(() -> new IllegalArgumentException("OTP không hợp lệ")); + if (token.getExpiredAt().isBefore(Instant.now())) { + throw new IllegalArgumentException("OTP đã hết hạn"); + } + User user = userRepository.findUserByEmail(request.getEmail()) + .orElseThrow(() -> new IllegalArgumentException("Không tìm thấy người dùng")); + user.setPassword(passwordEncoder.encode(request.getNewPassword())); + token.setUsed(true); + } +} diff --git a/src/main/java/com/darkfantasy/service/impl/WorldServiceImpl.java b/src/main/java/com/darkfantasy/service/impl/WorldServiceImpl.java new file mode 100644 index 0000000000000000000000000000000000000000..bff8348640f3d02150bfde28061e34b44f017ba7 --- /dev/null +++ b/src/main/java/com/darkfantasy/service/impl/WorldServiceImpl.java @@ -0,0 +1,164 @@ +package com.darkfantasy.service.impl; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.darkfantasy.dto.world.CreateWorldRequest; +import com.darkfantasy.dto.world.UpdateWorldRequest; +import com.darkfantasy.dto.world.WorldResponse; +import com.darkfantasy.entity.User; +import com.darkfantasy.entity.World; +import com.darkfantasy.entity.enums.LogAction; +import com.darkfantasy.entity.enums.LogEntityType; +import com.darkfantasy.repository.UserRepository; +import com.darkfantasy.repository.WorldRepository; +import com.darkfantasy.service.AuditLogService; +import com.darkfantasy.service.WorldService; +import com.darkfantasy.util.SecurityUtil; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class WorldServiceImpl implements WorldService { + + private final WorldRepository worldRepository; + private final UserRepository userRepository; + private final AuditLogService auditLogService; + + @Override + public Page getWorlds(Pageable pageable) { + return worldRepository + .findAllByOrderByIdAsc(pageable) + .map(WorldResponse::fromEntity); + } + + @Override + public WorldResponse getWorldById(Long id) { + return WorldResponse.fromEntity(findWorld(id)); + } + + @Transactional + @Override + public WorldResponse createWorld(CreateWorldRequest request) { + if (request == null) { + throw new IllegalArgumentException( + "Không thể tạo thế giới với dữ liệu null"); + } + + World world = request.toEntity(); + world.setCreatedBy(getCurrentUser()); + World savedWorld = worldRepository.save(world); + auditLogService.log( + LogEntityType.WORLD, + savedWorld.getId(), + LogAction.CREATE, + "Thêm thế giới: " + savedWorld.getTitle()); + return WorldResponse.fromEntity(world); + } + + @Transactional + @Override + public WorldResponse updateWorld(UpdateWorldRequest request) { + if (request == null) { + throw new IllegalArgumentException( + "Không thể thay đổi thế giới với dữ liệu null"); + } + World world = findWorld(request.getId()); + world.setTag(request.getTag()); + world.setTitle(request.getTitle()); + world.setContent(request.getContent()); + world.setPriority(request.getPriority()); + if (request.getImage() != null) { + world.setImage(request.getImage()); + } + world.setUpdatedBy(getCurrentUser()); + auditLogService.log( + LogEntityType.WORLD, + world.getId(), + LogAction.UPDATE, + "Sửa thế giới: " + world.getTitle()); + return WorldResponse.fromEntity(world); + } + + @Transactional + @Override + public void deleteWorld(Long id) { + if (id == null) { + throw new IllegalArgumentException("Không thể xóa thế giới với ID null"); + } + World found = findWorld(id); + found.setDeleted(true); + found.setCreatedBy(getCurrentUser()); + auditLogService.log( + LogEntityType.WORLD, + found.getId(), + LogAction.DELETE, + "Xóa thế giới: " + found.getTitle()); + } + + @Transactional + @Override + public void restoreWorld(Long id) { + if (id == null) { + throw new IllegalArgumentException("Không thể khôi phục thế giới với ID null"); + } + World found = findWorld(id); + found.setDeleted(false); + found.setUpdatedBy(getCurrentUser()); + auditLogService.log( + LogEntityType.WORLD, + found.getId(), + LogAction.RESTORE, + "Khôi phục thế giới: " + found.getTitle()); + } + + @Override + public WorldResponse getWorldDeletedFalseHighestPriority() { + World world = worldRepository + .findTopByDeletedFalseOrderByPriorityDesc(); + + if (world == null) { + return null; + } + + return WorldResponse.fromEntity(world); + } + + @Override + public List getWorldsDeletedFalse() { + return worldRepository + .findByDeletedFalseOrderByPriorityDesc() + .stream() + .map(WorldResponse::fromEntity) + .toList(); + } + + private World findWorld(Long id) { + return worldRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Không tìm thấy thế giới với ID: " + id)); + } + + @Override + public long count() { + return worldRepository.count(); + } + + private User getCurrentUser() { + + String currentUsername = SecurityUtil.getCurrentUserName(); + + if (currentUsername == null) { + throw new IllegalStateException("Không tìm thấy người dùng hiện tại"); + } + + return userRepository + .findUserByUsername(currentUsername) + .orElseThrow(() -> new IllegalArgumentException("Không tìm thấy người dùng")); + } + +} diff --git a/src/main/java/com/darkfantasy/util/SecurityUtil.java b/src/main/java/com/darkfantasy/util/SecurityUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..502934295c05caf92aefc8f57084aa408165dec4 --- /dev/null +++ b/src/main/java/com/darkfantasy/util/SecurityUtil.java @@ -0,0 +1,45 @@ +package com.darkfantasy.util; + +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +import com.darkfantasy.entity.enums.Role; + +import lombok.experimental.UtilityClass; + +@UtilityClass +public class SecurityUtil { + public String getCurrentUserName() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + return null; + } + return authentication.getName(); + } + + public Role getCurrentUserRole() { + + Authentication authentication = SecurityContextHolder + .getContext() + .getAuthentication(); + + if (authentication == null + || !authentication.isAuthenticated() + || authentication instanceof AnonymousAuthenticationToken) { + + return null; + } + + return authentication + .getAuthorities() + .stream() + .map(GrantedAuthority::getAuthority) + .filter(authority -> authority.startsWith("ROLE_")) + .findFirst() + .map(authority -> Role.valueOf( + authority.replace("ROLE_", ""))) + .orElse(null); + } +} diff --git a/src/main/java/com/darkfantasy/util/TimeUtil.java b/src/main/java/com/darkfantasy/util/TimeUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..d6e79eedf3d8cf3444bcc0d9c56f71d0fb64056b --- /dev/null +++ b/src/main/java/com/darkfantasy/util/TimeUtil.java @@ -0,0 +1,31 @@ +package com.darkfantasy.util; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +import lombok.experimental.UtilityClass; + +@UtilityClass +public class TimeUtil { + public static final ZoneId VN_ZONE = ZoneId.of("Asia/Ho_Chi_Minh"); + + public static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + public static String formatInstant(Instant instant) { + return instant.atZone(VN_ZONE) + .format(DATE_TIME_FORMATTER); + } + + public static LocalDateTime toLocalDateTime(Instant instant) { + return LocalDateTime.ofInstant( + instant, + VN_ZONE); + } + + public static Instant toInstant(LocalDateTime localDateTime) { + return localDateTime.atZone(VN_ZONE) + .toInstant(); + } +} diff --git a/src/main/java/com/darkfantasy/validator/PasswordChangeMatchValidator.java b/src/main/java/com/darkfantasy/validator/PasswordChangeMatchValidator.java new file mode 100644 index 0000000000000000000000000000000000000000..5c18eb6b578c7c12d23a0cb760bd258dbf699564 --- /dev/null +++ b/src/main/java/com/darkfantasy/validator/PasswordChangeMatchValidator.java @@ -0,0 +1,35 @@ +package com.darkfantasy.validator; + +import com.darkfantasy.annotation.ChangePasswordMatch; +import com.darkfantasy.dto.user.ChangePasswordRequest; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class PasswordChangeMatchValidator implements ConstraintValidator{ + + @Override + public boolean isValid(ChangePasswordRequest request, + ConstraintValidatorContext context) { + + if (request.getNewPassword() == null + || request.getConfirmPassword() == null) { + return true; + } + + boolean valid = request.getNewPassword() + .equals(request.getConfirmPassword()); + + if (!valid) { + context.disableDefaultConstraintViolation(); + + context.buildConstraintViolationWithTemplate( + "Mật khẩu xác nhận không khớp") + .addPropertyNode("confirmPassword") + .addConstraintViolation(); + } + + return valid; + } + +} diff --git a/src/main/java/com/darkfantasy/validator/PasswordRegisterMatchValidator.java b/src/main/java/com/darkfantasy/validator/PasswordRegisterMatchValidator.java new file mode 100644 index 0000000000000000000000000000000000000000..30b15196b393b147c7ec4d81eae81627b4201a45 --- /dev/null +++ b/src/main/java/com/darkfantasy/validator/PasswordRegisterMatchValidator.java @@ -0,0 +1,35 @@ +package com.darkfantasy.validator; + +import com.darkfantasy.annotation.RegisterPasswordMatch; +import com.darkfantasy.dto.user.RegisterRequest; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class PasswordRegisterMatchValidator + implements ConstraintValidator { + + @Override + public boolean isValid(RegisterRequest request, + ConstraintValidatorContext context) { + + if (request.getPassword() == null + || request.getRePassword() == null) { + return true; + } + + boolean valid = request.getPassword() + .equals(request.getRePassword()); + + if (!valid) { + context.disableDefaultConstraintViolation(); + + context.buildConstraintViolationWithTemplate( + "Mật khẩu xác nhận không khớp") + .addPropertyNode("rePassword") + .addConstraintViolation(); + } + + return valid; + } +} \ No newline at end of file diff --git a/src/main/java/com/darkfantasy/validator/ResetPasswordMatchValidator.java b/src/main/java/com/darkfantasy/validator/ResetPasswordMatchValidator.java new file mode 100644 index 0000000000000000000000000000000000000000..3c637bab20b834e446b0c4a096e83883cce609cc --- /dev/null +++ b/src/main/java/com/darkfantasy/validator/ResetPasswordMatchValidator.java @@ -0,0 +1,35 @@ +package com.darkfantasy.validator; + +import com.darkfantasy.annotation.ResetPasswordMatch; +import com.darkfantasy.dto.user.ResetPasswordRequest; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class ResetPasswordMatchValidator + implements ConstraintValidator { + + @Override + public boolean isValid(ResetPasswordRequest request, + ConstraintValidatorContext context) { + + if (request.getNewPassword() == null + || request.getConfirmPassword() == null) { + return true; + } + + boolean valid = request.getNewPassword() + .equals(request.getConfirmPassword()); + + if (!valid) { + context.disableDefaultConstraintViolation(); + + context.buildConstraintViolationWithTemplate( + "Mật khẩu xác nhận không khớp") + .addPropertyNode("confirmPassword") + .addConstraintViolation(); + } + + return valid; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000000000000000000000000000000000000..00231faa1ab6f0d50586dcd4fc6588e07d5f40e2 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,49 @@ +server.port=${PORT:8080} + +spring.servlet.multipart.max-file-size=10MB +spring.servlet.multipart.max-request-size=10MB +# JSP Configuration +spring.mvc.view.prefix=/WEB-INF/views/ +spring.mvc.view.suffix=.jsp +# Thymeleaf Configuration +spring.thymeleaf.prefix=classpath:/templates/ +spring.thymeleaf.suffix=.html +spring.thymeleaf.cache=false +spring.thymeleaf.encoding=UTF-8 +spring.thymeleaf.mode=HTML + +# Google OAuth2 +# spring.security.oauth2.client.registration.google.client-id= +# spring.security.oauth2.client.registration.google.client-secret= +# spring.security.oauth2.client.registration.google.scope=email,profile + +spring.devtools.restart.enabled=true +spring.devtools.livereload.enabled=true + +server.servlet.jsp.init-parameters.development=true + +# DataSource Configuration for SQL Server +spring.datasource.url=${SPRING_DATASOURCE_URL} +spring.datasource.username=${SPRING_DATASOURCE_USERNAME} +spring.datasource.password=${SPRING_DATASOURCE_PASSWORD} + +# SMPT EMAIL +spring.mail.host=smtp.gmail.com +spring.mail.port=587 +spring.mail.username=donhatquang401@gmail.com +spring.mail.password=lzzxdgzmirxdwqdv + +# JPA configuration +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=true + + + +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true + +# Catch exception +server.error.include-message=never +server.error.include-binding-errors=never +server.error.include-stacktrace=never +server.error.include-exception=false \ No newline at end of file diff --git a/src/main/resources/db/seed-data.sql b/src/main/resources/db/seed-data.sql new file mode 100644 index 0000000000000000000000000000000000000000..bb6a6c53aec391428bafe134e1fd74ffac02b958 --- /dev/null +++ b/src/main/resources/db/seed-data.sql @@ -0,0 +1,192 @@ +-- Seed data for DarkMoon / Moon Blight. +-- Target database: MariaDB/MySQL, schema generated from current JPA entities. +-- Safe to run multiple times because every INSERT uses stable IDs and ON DUPLICATE KEY UPDATE. +-- +-- Login seed accounts use password: 12345678 +-- +-- Enum values used by converters: +-- roles: ADMIN=1, STAFF=2 +-- article types: IMPORTANT=1, UPDATE=2, NEWS=3, EVENT=4 +-- contact categories: TECHNICAL_SUPPORT=1, BUG_REPORT=2, ACCOUNT_BILLING=3, +-- ACCESSIBILITY=4, CREATOR_PROGRAM=5, PRESS_CONTACT=6, OTHER=7 +-- contact platforms: WINDOW=1, LINUX=2, ANDROID=3, IOS=4, OTHER=5 +-- log actions: CREATE=1, UPDATE=2, DELETE=3, RESTORE=4, APPROVE=5, +-- LOCK=6, UNLOCK=7, CHANGE_PASSWORD=8, LOGIN=9, LOGOUT=10, PROCESS=11 +-- +-- log entity types: ARTICLE=1, STORY=2, WORLD=3, CHARACTER=4, FAQ=5, USER=6, CONTACT=7 + +SET NAMES utf8mb4; +START TRANSACTION; + +INSERT INTO `users` + (`id`, `username`, `email`, `password`, `role`, `is_active`, `full_name`, `created_at`, `updated_at`, `must_change_password`) +VALUES + (1001, 'seed_admin', 'seed.admin@moonblight.local', '$2a$10$BAa5Jhk8kWJZffXn5Ra1kepR73eo07kE3qXomqkT7I/byhOzCAmDm', 1, 1, 'Seed Admin', '2026-06-15 08:00:00', '2026-06-15 08:00:00', 0), + (1002, 'seed_staff', 'seed.staff@moonblight.local', '$2a$10$BAa5Jhk8kWJZffXn5Ra1kepR73eo07kE3qXomqkT7I/byhOzCAmDm', 2, 1, 'Seed Staff', '2026-06-15 08:05:00', '2026-06-15 08:05:00', 0), + (1003, 'seed_writer', 'seed.writer@moonblight.local', '$2a$10$BAa5Jhk8kWJZffXn5Ra1kepR73eo07kE3qXomqkT7I/byhOzCAmDm', 2, 1, 'Seed Writer', '2026-06-15 08:10:00', '2026-06-15 08:10:00', 1), + (1004, 'seed_locked', 'seed.locked@moonblight.local', '$2a$10$BAa5Jhk8kWJZffXn5Ra1kepR73eo07kE3qXomqkT7I/byhOzCAmDm', 2, 0, 'Seed Locked Staff', '2026-06-15 08:15:00', '2026-06-15 08:15:00', 0), + (1005, 'seed_reviewer', 'seed.reviewer@moonblight.local', '$2a$10$BAa5Jhk8kWJZffXn5Ra1kepR73eo07kE3qXomqkT7I/byhOzCAmDm', 2, 1, 'Seed Reviewer', '2026-06-15 08:20:00', '2026-06-15 08:20:00', 0) +ON DUPLICATE KEY UPDATE + `username` = VALUES(`username`), + `email` = VALUES(`email`), + `password` = VALUES(`password`), + `role` = VALUES(`role`), + `is_active` = VALUES(`is_active`), + `full_name` = VALUES(`full_name`), + `updated_at` = VALUES(`updated_at`), + `must_change_password` = VALUES(`must_change_password`); + +INSERT INTO `articles` + (`id`, `title`, `content`, `type`, `thumbnail_url`, `is_deleted`, `created_by`, `created_at`, `updated_by`, `updated_at`) +VALUES + (2001, 'Important Launch Notice', 'Moon Blight opens the first public test gate with core story and support content enabled.', 1, '/uploads/articles/16a9751eeb4bbd54ea9190bcf2aab9a6.webp', 0, 1002, '2026-06-15 09:00:00', NULL, '2026-06-15 09:00:00'), + (2002, 'Combat Update 1.1', 'Balance notes for stamina, dodge timing, shadow damage, and enemy patrol routes.', 2, '/uploads/articles/4cfd6bc19ee863a91b1beb0a5c6fdbe9.jpeg', 0, 1002, '2026-06-15 09:10:00', 1005, '2026-06-15 10:10:00'), + (2003, 'Developer Diary', 'A short news post about level design, atmosphere, and character writing direction.', 3, '/uploads/articles/51a99733bd15c9ed29fe179e90de71e4.jpeg', 0, 1003, '2026-06-15 09:20:00', NULL, '2026-06-15 09:20:00'), + (2004, 'Night Market Event', 'Limited event announcement with bonus rewards and new community challenges.', 4, '/uploads/articles/60d27877-07d6-4270-9801-1b0cc5e9e08c_okita-souji_FHD.jpg', 0, 1003, '2026-06-15 09:30:00', NULL, '2026-06-15 09:30:00'), + (2005, 'Archived Alpha Note', 'Old alpha announcement kept for CMS soft delete and restore testing.', 3, '/uploads/articles/9b5b143c373bbdd49fd391acdbfe81f4.jpeg', 1, 1002, '2026-06-15 09:40:00', 1002, '2026-06-15 11:00:00'), + (2006, 'Patch Preview', 'Preview of upcoming bug fixes, quality of life improvements, and UI polish.', 2, '/uploads/articles/a9cebea390a2239db48f94d7b33e80e0.webp', 0, 1005, '2026-06-15 09:50:00', NULL, '2026-06-15 09:50:00'), + (2007, 'Server Maintenance', 'Important maintenance notice for database backup and service restart.', 1, '/uploads/articles/f849fae7f2c60c8a7b156b6f618908e0.png', 0, 1001, '2026-06-15 10:00:00', NULL, '2026-06-15 10:00:00'), + (2008, 'Community Screenshot Event', 'Players can submit screenshots from the abandoned district for weekly highlights.', 4, '/uploads/articles/ec71368a-23bb-414e-8c72-d9f5e2dc80ed_cherry-blossoms_FHD.jpg', 0, 1005, '2026-06-15 10:05:00', NULL, '2026-06-15 10:05:00') +ON DUPLICATE KEY UPDATE + `title` = VALUES(`title`), + `content` = VALUES(`content`), + `type` = VALUES(`type`), + `thumbnail_url` = VALUES(`thumbnail_url`), + `is_deleted` = VALUES(`is_deleted`), + `updated_by` = VALUES(`updated_by`), + `updated_at` = VALUES(`updated_at`); + +INSERT INTO `stories` + (`id`, `tag`, `title`, `content`, `image`, `quote_content`, `quote_author`, `priority`, `deleted`, `created_by`, `created_at`, `updated_by`, `updated_at`) +VALUES + (3001, 'origin', 'The First Eclipse', 'The first eclipse split the old city from the living world and awakened the Moon Blight.', '/uploads/stories/16a9751eeb4bbd54ea9190bcf2aab9a6.webp', 'The moon did not rise. It opened.', 'Archivist Ren', 100, 0, 1002, '2026-06-15 10:20:00', NULL, '2026-06-15 10:20:00'), + (3002, 'chapter-one', 'Ash Road', 'Survivors cross the ash road to reach the signal tower before midnight.', '/uploads/stories/51a99733bd15c9ed29fe179e90de71e4.jpeg', 'Every footprint glowed behind us.', 'Mira Vale', 85, 0, 1002, '2026-06-15 10:30:00', 1005, '2026-06-15 11:15:00'), + (3003, 'faction', 'The Hollow Choir', 'A hidden faction turns memories into weapons and guards the inner district.', '/uploads/stories/f6a918a603258046f89efefe5a21c4ba.jpg', 'They sang names nobody should remember.', 'Old Hunter Kael', 70, 0, 1003, '2026-06-15 10:40:00', NULL, '2026-06-15 10:40:00'), + (3004, 'archive', 'Lost Prologue', 'A retired prologue kept for restore testing in the CMS.', '/uploads/stories/16a9751eeb4bbd54ea9190bcf2aab9a6.webp', 'This path was sealed for a reason.', 'Unknown', 20, 1, 1003, '2026-06-15 10:50:00', 1002, '2026-06-15 11:20:00'), + (3005, 'finale', 'Bell Under Black Water', 'The bell below the flooded chapel marks the border of the final act.', '/uploads/stories/51a99733bd15c9ed29fe179e90de71e4.jpeg', 'When it rings, the dead answer.', 'Sister Elia', 95, 0, 1005, '2026-06-15 11:00:00', NULL, '2026-06-15 11:00:00') +ON DUPLICATE KEY UPDATE + `tag` = VALUES(`tag`), + `title` = VALUES(`title`), + `content` = VALUES(`content`), + `image` = VALUES(`image`), + `quote_content` = VALUES(`quote_content`), + `quote_author` = VALUES(`quote_author`), + `priority` = VALUES(`priority`), + `deleted` = VALUES(`deleted`), + `updated_by` = VALUES(`updated_by`), + `updated_at` = VALUES(`updated_at`); + +INSERT INTO `worlds` + (`id`, `tag`, `title`, `content`, `image`, `priority`, `deleted`, `created_by`, `created_at`, `updated_by`, `updated_at`) +VALUES + (4001, 'capital', 'Nocturne Capital', 'A ruined capital where moonlight pools in streets and old machines still whisper.', '/uploads/worlds/4cfd6bc19ee863a91b1beb0a5c6fdbe9.jpeg', 100, 0, 1002, '2026-06-15 11:10:00', NULL, '2026-06-15 11:10:00'), + (4002, 'forest', 'Glassroot Forest', 'A forest of transparent roots that stores echoes from every traveler.', '/uploads/worlds/51a99733bd15c9ed29fe179e90de71e4.jpeg', 80, 0, 1003, '2026-06-15 11:20:00', NULL, '2026-06-15 11:20:00'), + (4003, 'chapel', 'Flooded Chapel', 'A drowned sanctuary connected to the hidden endgame route.', '/uploads/worlds/a9cebea390a2239db48f94d7b33e80e0.webp', 90, 0, 1005, '2026-06-15 11:30:00', 1005, '2026-06-15 11:55:00'), + (4004, 'old-map', 'Old District Draft', 'Removed draft area retained for deleted world testing.', '/uploads/worlds/f6a918a603258046f89efefe5a21c4ba.jpg', 10, 1, 1002, '2026-06-15 11:40:00', 1002, '2026-06-15 11:50:00'), + (4005, 'market', 'Night Market', 'A moving market where vendors trade in memories, charms, and broken compasses.', '/uploads/worlds/4cfd6bc19ee863a91b1beb0a5c6fdbe9.jpeg', 65, 0, 1003, '2026-06-15 11:45:00', NULL, '2026-06-15 11:45:00') +ON DUPLICATE KEY UPDATE + `tag` = VALUES(`tag`), + `title` = VALUES(`title`), + `content` = VALUES(`content`), + `image` = VALUES(`image`), + `priority` = VALUES(`priority`), + `deleted` = VALUES(`deleted`), + `updated_by` = VALUES(`updated_by`), + `updated_at` = VALUES(`updated_at`); + +INSERT INTO `characters` + (`id`, `name`, `race`, `description`, `quote`, `image`, `priority`, `deleted`, `created_by`, `created_at`, `updated_by`, `updated_at`) +VALUES + (5001, 'Mira Vale', 'Human', 'A field investigator who follows forbidden lunar signals through the capital.', 'I only need one true clue.', '/uploads/characters/51a99733bd15c9ed29fe179e90de71e4.jpeg', 100, 0, 1002, '2026-06-15 12:00:00', NULL, '2026-06-15 12:00:00'), + (5002, 'Kael Thorn', 'Human', 'An old hunter carrying a silver compass that points toward unfinished regrets.', 'Do not trust quiet streets.', '/uploads/characters/f6a918a603258046f89efefe5a21c4ba.jpg', 85, 0, 1003, '2026-06-15 12:10:00', 1005, '2026-06-15 12:40:00'), + (5003, 'Sister Elia', 'Vessel', 'A chapel keeper bound to the bell under black water.', 'Mercy can still be sharp.', '/uploads/characters/9b5b143c373bbdd49fd391acdbfe81f4.jpeg', 90, 0, 1002, '2026-06-15 12:20:00', NULL, '2026-06-15 12:20:00'), + (5004, 'Archivist Ren', 'Shade', 'A keeper of broken maps who remembers every version of the city.', 'History is a locked room.', '/uploads/characters/51a99733bd15c9ed29fe179e90de71e4.jpeg', 75, 0, 1005, '2026-06-15 12:30:00', NULL, '2026-06-15 12:30:00'), + (5005, 'Discarded Scout', 'Human', 'Deprecated test character kept to verify soft delete behavior.', 'This record should stay hidden.', '/uploads/characters/f6a918a603258046f89efefe5a21c4ba.jpg', 5, 1, 1003, '2026-06-15 12:35:00', 1002, '2026-06-15 12:45:00'), + (5006, 'The Hollow Conductor', 'Unknown', 'The leader of the Hollow Choir and a late-game narrative threat.', 'Sing, and the door will open.', '/uploads/characters/9b5b143c373bbdd49fd391acdbfe81f4.jpeg', 95, 0, 1005, '2026-06-15 12:50:00', NULL, '2026-06-15 12:50:00') +ON DUPLICATE KEY UPDATE + `name` = VALUES(`name`), + `race` = VALUES(`race`), + `description` = VALUES(`description`), + `quote` = VALUES(`quote`), + `image` = VALUES(`image`), + `priority` = VALUES(`priority`), + `deleted` = VALUES(`deleted`), + `updated_by` = VALUES(`updated_by`), + `updated_at` = VALUES(`updated_at`); + +INSERT INTO `faqs` + (`id`, `title`, `content`, `priority`, `deleted`, `created_by`, `created_at`, `updated_by`, `updated_at`) +VALUES + (6001, 'How do I report a bug?', 'Open the support page, choose Bug Report, select your platform, and describe the issue with clear steps.', 100, 0, 1002, '2026-06-15 13:00:00', NULL, '2026-06-15 13:00:00'), + (6002, 'Can I recover my account?', 'Use the forgot password flow and verify the OTP sent to your registered email address.', 90, 0, 1002, '2026-06-15 13:05:00', NULL, '2026-06-15 13:05:00'), + (6003, 'Which platforms are supported?', 'The current support form tracks Window, Linux, Android, iOS, and Other platform reports.', 80, 0, 1003, '2026-06-15 13:10:00', 1005, '2026-06-15 13:45:00'), + (6004, 'How are content priorities used?', 'Higher priority items are displayed earlier on public story, world, character, and FAQ pages.', 70, 0, 1005, '2026-06-15 13:15:00', NULL, '2026-06-15 13:15:00'), + (6005, 'Old beta FAQ', 'Deleted FAQ retained for restore testing.', 10, 1, 1003, '2026-06-15 13:20:00', 1002, '2026-06-15 13:50:00'), + (6006, 'How long does OTP last?', 'Password reset OTP records are expected to expire after five minutes.', 60, 0, 1005, '2026-06-15 13:25:00', NULL, '2026-06-15 13:25:00') +ON DUPLICATE KEY UPDATE + `title` = VALUES(`title`), + `content` = VALUES(`content`), + `priority` = VALUES(`priority`), + `deleted` = VALUES(`deleted`), + `updated_by` = VALUES(`updated_by`), + `updated_at` = VALUES(`updated_at`); + +INSERT INTO `contact_messages` + (`id`, `visitor_name`, `visitor_email`, `category`, `platform`, `message`, `processed`, `processed_by`, `processed_at`) +VALUES + (7001, 'Alice Test', 'alice.test@example.com', 1, 1, 'The game freezes after opening the settings screen on Window.', 0, NULL, NULL), + (7002, 'Binh Nguyen', 'binh.nguyen@example.com', 2, 3, 'The first boss can get stuck behind the broken gate.', 1, 1002, '2026-06-15 14:10:00'), + (7003, 'Cara Lee', 'cara.lee@example.com', 3, 4, 'I need help with account access after reinstalling on iOS.', 0, NULL, NULL), + (7004, 'Duc Tran', 'duc.tran@example.com', 4, 2, 'Please add a stronger subtitle contrast option on Linux.', 1, 1005, '2026-06-15 14:20:00'), + (7005, 'Evan Press', 'evan.press@example.com', 6, 5, 'Requesting press contact information for a preview article.', 0, NULL, NULL), + (7006, 'Faye Creator', 'faye.creator@example.com', 5, 1, 'I want to join the creator program for Moon Blight.', 1, 1002, '2026-06-15 14:30:00'), + (7007, 'Guest Other', 'guest.other@example.com', 7, NULL, 'General feedback message with no specific platform selected.', 0, NULL, NULL) +ON DUPLICATE KEY UPDATE + `visitor_name` = VALUES(`visitor_name`), + `visitor_email` = VALUES(`visitor_email`), + `category` = VALUES(`category`), + `platform` = VALUES(`platform`), + `message` = VALUES(`message`), + `processed` = VALUES(`processed`), + `processed_by` = VALUES(`processed_by`), + `processed_at` = VALUES(`processed_at`); + +INSERT INTO `password_reset_tokens` + (`id`, `email`, `otp`, `expired_at`, `used`) +VALUES + (8001, 'seed.staff@moonblight.local', '111111', '2099-01-01 00:00:00', 0), + (8002, 'seed.writer@moonblight.local', '222222', '2020-01-01 00:00:00', 0), + (8003, 'seed.locked@moonblight.local', '333333', '2099-01-01 00:00:00', 1), + (8004, 'seed.reviewer@moonblight.local', '444444', '2099-01-01 00:00:00', 0) +ON DUPLICATE KEY UPDATE + `email` = VALUES(`email`), + `otp` = VALUES(`otp`), + `expired_at` = VALUES(`expired_at`), + `used` = VALUES(`used`); + +INSERT INTO `audit_logs` + (`id`, `entity_id`, `entity_type`, `action`, `description`, `created_at`, `user_id`) +VALUES + (9001, 1002, 6, 9, 'Seed login: seed_staff', '2026-06-15 15:00:00', 1002), + (9002, 2001, 1, 1, 'Seed created article: Important Launch Notice', '2026-06-15 15:05:00', 1002), + (9003, 2002, 1, 2, 'Seed updated article: Combat Update 1.1', '2026-06-15 15:10:00', 1005), + (9004, 2005, 1, 3, 'Seed deleted article: Archived Alpha Note', '2026-06-15 15:15:00', 1002), + (9005, 3004, 2, 4, 'Seed restore-ready story: Lost Prologue', '2026-06-15 15:20:00', 1003), + (9006, 1004, 6, 6, 'Seed locked account: seed_locked', '2026-06-15 15:25:00', 1001), + (9007, 1004, 6, 7, 'Seed unlocked account test record', '2026-06-15 15:30:00', 1001), + (9008, 1003, 6, 8, 'Seed password change required for seed_writer', '2026-06-15 15:35:00', 1003), + (9009, 7002, 7, 11, 'Seed processed contact: boss stuck bug report', '2026-06-15 15:40:00', 1002), + (9010, 4003, 3, 2, 'Seed updated world: Flooded Chapel', '2026-06-15 15:45:00', 1005), + (9011, 5006, 4, 1, 'Seed created character: The Hollow Conductor', '2026-06-15 15:50:00', 1005), + (9012, 6005, 5, 3, 'Seed deleted FAQ: Old beta FAQ', '2026-06-15 15:55:00', 1002), + (9013, 1005, 6, 5, 'Seed approved staff reviewer', '2026-06-15 16:00:00', 1001), + (9014, 1002, 6, 10, 'Seed logout: seed_staff', '2026-06-15 16:05:00', 1002) +ON DUPLICATE KEY UPDATE + `entity_id` = VALUES(`entity_id`), + `entity_type` = VALUES(`entity_type`), + `action` = VALUES(`action`), + `description` = VALUES(`description`), + `created_at` = VALUES(`created_at`), + `user_id` = VALUES(`user_id`); + +COMMIT; diff --git a/src/main/resources/static/css/admin.css b/src/main/resources/static/css/admin.css new file mode 100644 index 0000000000000000000000000000000000000000..d8aab333e2ec0cacc158b2290d1e4ea8a444cf2a --- /dev/null +++ b/src/main/resources/static/css/admin.css @@ -0,0 +1,167 @@ +/* ===== Background ===== */ + +.bg-image { + position: fixed; + inset: 0; + background: url("/images/bg-dark-fantasy.jpg") center/cover no-repeat; + filter: brightness(.2); + z-index: -2; +} + +.bg-image::after { + content: ""; + position: absolute; + inset: 0; + background: rgba(0, 0, 0, .7); +} + +/* ===== Layout FIX ===== */ + +.layout { + display: flex; + min-height: 100vh; +} + +.content-wrapper { + flex: 1; + display: flex; + flex-direction: column; + margin-left: 260px; /* chỉnh theo sidebar */ + padding: 20px; +} + +/* ===== Container ===== */ + +.section-dark { + padding: 40px; +} + +.container-xl { + max-width: 1400px; + margin: auto; +} + +/* ===== Card ===== */ + +.story-card { + background: rgba(15, 15, 20, .92); + border-radius: 20px; + padding: 35px; + border: 1px solid rgba(255, 255, 255, .08); + backdrop-filter: blur(12px); +} + +/* ===== Header ===== */ + +.section-header { + text-align: center; + margin-bottom: 40px; +} + +.section-eyebrow { + color: #d4af37; + text-transform: uppercase; + letter-spacing: 2px; + font-size: .85rem; +} + +.section-title { + color: #fff; + margin: 10px 0; + font-size: 2rem; +} + +.section-divider { + width: 120px; + height: 3px; + margin: auto; + border-radius: 999px; + background: linear-gradient(to right, #d4af37, #f6d365); +} + +/* ===== Table ===== */ + +.table-title { + color: white; + margin-bottom: 20px; +} + +.table-wrapper { + overflow-x: auto; + border-radius: 12px; +} + +.dashboard-table { + width: 100%; + border-collapse: collapse; +} + +.dashboard-table th { + background: #1f2937; + color: white; + padding: 14px; +} + +.dashboard-table td { + padding: 14px; + color: #ddd; + border-bottom: 1px solid rgba(255, 255, 255, .08); +} + +.dashboard-table tbody tr { + transition: 0.2s ease; +} + +.dashboard-table tbody tr:hover { + background: rgba(212, 175, 55, 0.08); +} + +/* ===== Buttons ===== */ + +.btn { + text-decoration: none; + display: inline-block; + padding: 8px 14px; + border-radius: 8px; +} + +.btn-secondary { + background: #374151; + color: white; +} + +.btn-secondary:hover { + background: #4b5563; +} + +/* ===== Pagination ===== */ + +.pagination { + margin-top: 20px; + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +/* ===== Empty State ===== */ + +.empty-state { + text-align: center; + color: #888; + padding: 30px; +} + +/* ===== Responsive ===== */ + +@media (max-width: 900px) { + + .stats-grid { + grid-template-columns: 1fr; + } + + .content-wrapper { + margin-left: 0; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/article/article-create.css b/src/main/resources/static/css/article/article-create.css new file mode 100644 index 0000000000000000000000000000000000000000..6435c9f268b8e387305997a7f9aac05bc0bf6cac --- /dev/null +++ b/src/main/resources/static/css/article/article-create.css @@ -0,0 +1,220 @@ +/* ========================================================== + ARTICLE FORM + ========================================================== */ + +.article-form { + display: flex; + flex-direction: column; + gap: 1.75rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: .75rem; +} + +.form-label { + color: var(--parchment); + + font-size: .8rem; + font-weight: 600; + + letter-spacing: .12em; + text-transform: uppercase; +} + +/* ========================================================== + FORM CONTROL + ========================================================== */ + +.form-control { + width: 100%; + + padding: .9rem 1rem; + + color: var(--cream); + + background: + rgba(255, 255, 255, .03); + + border: + 1px solid var(--border-dark); + + outline: none; + + transition: .25s ease; +} + +.form-control:focus { + border-color: var(--crimson); + + box-shadow: + 0 0 0 3px rgba(196, 30, 58, .12); +} + +.form-control option { + background: var(--dark-card); + color: var(--cream); +} + +/* ========================================================== + FILE INPUT + ========================================================== */ + +.file-input { + width: 100%; + + padding: .8rem; + + color: var(--light-grey); + + background: + rgba(255,255,255,.03); + + border: + 1px solid var(--border-dark); + + cursor: pointer; +} + +.file-input::file-selector-button { + margin-right: 1rem; + + padding: .6rem 1rem; + + color: var(--cream); + + border: + 1px solid var(--crimson); + + background: + rgba(196, 30, 58, .08); + + cursor: pointer; + + transition: .3s ease; +} + +.file-input::file-selector-button:hover { + background: var(--crimson); +} + +/* ========================================================== + QUILL EDITOR + ========================================================== */ + +.article-editor { + min-height: 450px; + + background: + rgba(255,255,255,.02); + + border: + 1px solid var(--border-dark); +} + +/* Toolbar */ + +.ql-toolbar.ql-snow { + border: + 1px solid var(--border-dark); + + background: + rgba(12, 8, 6, .95); +} + +/* Editor */ + +.ql-container.ql-snow { + min-height: 400px; + + border: + 1px solid var(--border-dark); + + border-top: none; + + background: + rgba(255,255,255,.02); + + color: var(--cream); +} + +.ql-editor { + min-height: 400px; + + color: var(--cream); + + line-height: 1.8; +} + +.ql-editor.ql-blank::before { + color: rgba(255,255,255,.35); +} + +/* Quill icon */ + +.ql-snow .ql-stroke { + stroke: var(--cream); +} + +.ql-snow .ql-fill { + fill: var(--cream); +} + +.ql-snow .ql-picker { + color: var(--cream); +} + +.ql-snow .ql-picker-options { + background: var(--dark-card); + + border: + 1px solid var(--border-dark); +} + +/* ========================================================== + VALIDATION + ========================================================== */ + +.field-error { + color: #ff8a80; + + font-size: .85rem; +} + +/* ========================================================== + ACTIONS + ========================================================== */ + +.article-actions { + display: flex; + + gap: 1rem; + + margin-top: .5rem; +} + +/* ========================================================== + RESPONSIVE + ========================================================== */ + +@media (max-width: 768px) { + + .article-actions { + flex-direction: column; + } + + .article-actions .btn { + width: 100%; + } + + .article-editor { + min-height: 320px; + } + + .ql-container.ql-snow, + .ql-editor { + min-height: 280px; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/article/article-edit.css b/src/main/resources/static/css/article/article-edit.css new file mode 100644 index 0000000000000000000000000000000000000000..24439f5d18c4b506a54e210a254f2071164d2eb3 --- /dev/null +++ b/src/main/resources/static/css/article/article-edit.css @@ -0,0 +1,36 @@ +/* ========================================================== + ARTICLE IMAGE PREVIEW + ========================================================== */ + +.article-image { + display: block; + + max-width: 100%; + width: 420px; + + height: auto; + + border-radius: 8px; + + border: 1px solid var(--border-dark); + + background: rgba(255,255,255,.02); + + object-fit: cover; + + box-shadow: + 0 0 25px rgba(0,0,0,.45), + 0 0 15px rgba(196, 30, 58, .08); + + transition: .3s ease; +} + +.article-image:hover { + transform: scale(1.02); + + border-color: var(--crimson); + + box-shadow: + 0 0 30px rgba(196,30,58,.15), + 0 0 40px rgba(0,0,0,.6); +} \ No newline at end of file diff --git a/src/main/resources/static/css/article/article.css b/src/main/resources/static/css/article/article.css new file mode 100644 index 0000000000000000000000000000000000000000..aa1356c15ebb1d258758a123a29b5749956109b5 --- /dev/null +++ b/src/main/resources/static/css/article/article.css @@ -0,0 +1,440 @@ +@import "article-create.css"; +@import "../dashboard-user.css"; +@import "article-edit.css"; + + +/* ========================================================== + ARTICLE LIST + ========================================================== */ +body { + margin: 10px; +} +.article-card { + position: relative; + + padding: 2rem; + + background: + linear-gradient( + 180deg, + rgba(28, 20, 16, .96), + rgba(10, 8, 7, .98) + ); + + border: 1px solid var(--border-dark); + + overflow: hidden; + + box-shadow: + 0 0 40px rgba(0, 0, 0, .75), + 0 0 25px rgba(139, 0, 0, .08); +} + +.article-card::before { + content: ""; + + position: absolute; + + top: 0; + left: 0; + right: 0; + + height: 2px; + + background: + linear-gradient( + 90deg, + transparent, + var(--crimson), + transparent + ); + + box-shadow: + 0 0 12px var(--glow-red); +} + +/* ========================================================== + MESSAGE + ========================================================== */ + +.success-message { + margin-bottom: 1rem; + + padding: 1rem 1.25rem; + + color: #81c784; + + background: + rgba(129, 199, 132, .08); + + border: + 1px solid rgba(129, 199, 132, .2); +} + +.error-message { + margin-bottom: 1rem; + + padding: 1rem 1.25rem; + + color: #ff8a80; + + background: + rgba(255, 138, 128, .08); + + border: + 1px solid rgba(255, 138, 128, .2); +} + +/* ========================================================== + TABLE HEADER + ========================================================== */ + +.table-header { + display: flex; + + justify-content: flex-end; + + margin-bottom: 1.5rem; +} + +/* ========================================================== + TABLE + ========================================================== */ + +.table-wrapper { + overflow-x: auto; + + border: 1px solid var(--border-dark); + + background: + rgba(8, 6, 4, .65); +} + +.article-table { + width: 100%; + + border-collapse: collapse; +} + +.article-table thead { + background: + linear-gradient( + 180deg, + rgba(12, 8, 6, .95), + rgba(8, 6, 4, .98) + ); +} + +.article-table th { + padding: 1rem; + + text-align: left; + + font-size: .72rem; + + letter-spacing: .15em; + + text-transform: uppercase; + + color: var(--parchment); + + border-bottom: + 1px solid var(--border-dark); +} + +.article-table td { + padding: 1rem; + + vertical-align: middle; + + color: var(--light-grey); + + border-bottom: + 1px solid rgba(74, 48, 40, .35); +} + +.article-table tbody tr { + transition: .25s ease; +} + +.article-table tbody tr:hover { + background: + rgba(139, 0, 0, .08); +} + +/* ========================================================== + IMAGE + ========================================================== */ + +.table-image { + width: 90px; + height: 56px; + + object-fit: cover; + + border-radius: 4px; + + border: 1px solid var(--border-dark); +} + +/* ========================================================== + STATUS + ========================================================== */ + +.status-active { + display: inline-flex; + + align-items: center; + + padding: .35rem .8rem; + + border-radius: 999px; + + color: #81c784; + + background: + rgba(129, 199, 132, .08); + + border: + 1px solid rgba(129, 199, 132, .2); + + font-size: .85rem; + font-weight: 600; +} + +.status-deleted { + display: inline-flex; + + align-items: center; + + padding: .35rem .8rem; + + border-radius: 999px; + + color: #ff8a80; + + background: + rgba(255, 138, 128, .08); + + border: + 1px solid rgba(255, 138, 128, .2); + + font-size: .85rem; + font-weight: 600; +} + +/* ========================================================== + BUTTONS + ========================================================== */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + + padding: 0.75rem 1.25rem; + + text-decoration: none; + cursor: pointer; + + font-size: 0.75rem; + font-weight: 700; + + letter-spacing: 0.15em; + text-transform: uppercase; + + transition: all 0.25s ease; +} + +.btn-primary { + color: var(--cream); + + background: linear-gradient( + 180deg, + rgba(196, 30, 58, .25), + rgba(139, 0, 0, .2) + ); + + border: 1px solid var(--crimson); + + box-shadow: + 0 0 12px rgba(196, 30, 58, .15); +} + +.btn-primary:hover { + background: var(--crimson); + + color: #fff; + + box-shadow: + 0 0 20px rgba(196, 30, 58, .3); +} + +.btn-secondary { + color: var(--amber); + + border-color: var(--amber); + + background: + rgba(200, 114, 10, .05); +} + +.btn-secondary:hover { + background: var(--amber); + + color: var(--black-coal); +} + +.btn-success { + color: #81c784; + + border-color: #81c784; + + background: + rgba(129, 199, 132, .08); +} + +.btn-success:hover { + background: #81c784; + + color: #111; +} + +.btn-danger { + color: #ff8a80; + + border-color: #ff8a80; + + background: + rgba(255, 138, 128, .08); +} + +.btn-danger:hover { + background: #ff8a80; + + color: #111; +} + +.btn-sm { + min-width: 120px; + + padding: .65rem 1rem; + + font-size: .72rem; + + letter-spacing: .12em; + + text-transform: uppercase; +} + +/* ========================================================== + ACTION BUTTONS + ========================================================== */ + +.action-buttons { + display: flex; + + flex-wrap: wrap; + + gap: .5rem; +} + +.action-buttons form { + margin: 0; +} + +/* ========================================================== + PAGINATION + ========================================================== */ + +.pagination { + display: flex; + + justify-content: center; + align-items: center; + + gap: .5rem; + + margin-top: 2rem; + + flex-wrap: wrap; +} + +.page-btn, +.page-current { + width: 42px; + height: 42px; + + display: flex; + + align-items: center; + justify-content: center; + + text-decoration: none; + + border-radius: 8px; +} + +.page-btn { + color: var(--cream); + + border: + 1px solid var(--border-dark); + + background: + rgba(255, 255, 255, .02); +} + +.page-btn:hover { + border-color: var(--crimson); + + background: + rgba(196, 30, 58, .12); +} + +.page-current { + background: var(--crimson); + + border: + 1px solid var(--crimson); + + color: #fff; +} + +/* ========================================================== + PAGE CONTENT + ========================================================== */ + +.section-dark { + width: 100%; +} + +/* ========================================================== + RESPONSIVE ARTICLE + ========================================================== */ + +@media (max-width: 1200px) { + + .article-table { + min-width: 1200px; + } +} + +@media (max-width: 768px) { + + .article-card { + padding: 1.5rem; + } + + .table-header { + justify-content: center; + } + + .action-buttons { + flex-direction: column; + } + + .action-buttons .btn { + width: 100%; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/auth.css b/src/main/resources/static/css/auth.css new file mode 100644 index 0000000000000000000000000000000000000000..5d1e04aa532738c592a3fe91edfc7d35e124cf2e --- /dev/null +++ b/src/main/resources/static/css/auth.css @@ -0,0 +1,286 @@ +:root { + --blood-red: #8b0000; + --crimson: #c41e3a; + --dark-bg: #080604; + --dark-card: #1c1410; + --border-mid: #4a3028; + --cream: #ddd0b8; + --parchment: #c4a882; + --mid-grey: #8f7e76; + --amber: #c8720a; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +/* body{ + min-height:100vh; + + display:flex; + justify-content:center; + align-items:center; + + font-family:'Crimson Text', serif; + + background: transparent; +} */ +.layouts { + min-height: 100vh; + + display: flex; + justify-content: center; + align-items: center; + + font-family: "Crimson Text", serif; + + background: transparent; +} + +body { + min-height: 100vh; + font-family: "Crimson Text", serif; + /* background: var(--dark-bg); */ +} + +.content-wrapper { + display: flex; +} +/* CARD */ + +.error-message { + background: rgba(196, 30, 58, 0.15); + + color: #ff8a80; + + padding: 15px; + + border-left: 4px solid #c41e3a; + + margin-bottom: 20px; +} +.success-message { + background: rgba(34, 197, 94, 0.12); + color: #7cff9b; + padding: 12px 15px; + border-left: 4px solid #22c55e; + margin-bottom: 20px; + font-size: 0.9rem; +} +.register-card { + width: 400px; + max-width: 90%; + + padding: 2rem; + + background: linear-gradient( + 180deg, + rgba(28, 20, 16, 0.96), + rgba(8, 6, 4, 0.98) + ); + + border: 1px solid var(--border-mid); + + box-shadow: + 0 0 50px rgba(0, 0, 0, 0.8), + 0 0 25px rgba(139, 0, 0, 0.15); + + position: relative; +} + +.register-card::before { + content: ""; + + position: absolute; + top: 0; + left: 0; + right: 0; + + height: 2px; + + background: linear-gradient(90deg, transparent, var(--crimson), transparent); +} + +/* HEADER */ + +.register-symbol { + text-align: center; + + font-size: 1.8rem; + + color: var(--crimson); + + margin-bottom: 0.5rem; +} + +.register-eyebrow { + text-align: center; + + color: var(--amber); + + font-size: 0.75rem; + + letter-spacing: 0.35em; + + text-transform: uppercase; + + margin-bottom: 0.8rem; +} + +.register-title { + text-align: center; + + color: var(--cream); + + font-size: 2.4rem; + + font-family: "Cormorant Garamond", serif; + + margin-bottom: 0.5rem; +} + +.register-subtitle { + text-align: center; + + color: var(--mid-grey); + + font-style: italic; + + font-size: 0.95rem; + + margin-bottom: 1rem; +} + +.register-divider { + width: 80px; + height: 1px; + + margin: 0 auto 1.5rem; + + background: linear-gradient(90deg, transparent, var(--crimson), transparent); +} + +/* FORM */ + +.register-group { + margin-bottom: 0.9rem; +} + +.register-group label { + display: block; + + color: var(--parchment); + + font-size: 0.72rem; + + letter-spacing: 0.15em; + + text-transform: uppercase; + + margin-bottom: 0.35rem; +} + +.register-group input { + width: 100%; + + height: 46px; + + padding: 0 0.9rem; + + background: #0d0907; + + border: 1px solid var(--border-mid); + + color: var(--cream); + + outline: none; + + transition: 0.3s; +} + +.register-group input:focus { + border-color: var(--crimson); + + box-shadow: 0 0 12px rgba(196, 30, 58, 0.25); +} + +/* BUTTON */ + +.register-btn { + width: 100%; + + height: 48px; + + margin-top: 0.8rem; + + border: none; + + background: var(--blood-red); + + color: white; + + font-size: 0.9rem; + + letter-spacing: 0.18em; + + text-transform: uppercase; + + cursor: pointer; + + transition: 0.3s; +} + +.register-btn:hover { + background: var(--crimson); + + box-shadow: 0 0 20px rgba(196, 30, 58, 0.35); +} + +/* LINK */ + +.register-link { + display: block; + + text-align: center; + + margin-top: 1rem; + + color: var(--parchment); + + text-decoration: none; +} + +.register-link:hover { + color: var(--cream); +} + +.bg-image { + position: fixed; + inset: 0; + + background-image: + linear-gradient(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.25)), + url("../img/back.png"); + + background-size: cover; + background-position: center; + + z-index: -1; +} + +.bg-image::after { + content: ""; + + position: absolute; + inset: 0; + + background: radial-gradient( + circle at top, + rgba(196, 30, 58, 0.15), + transparent 35% + ); + + z-index: -1; +} diff --git a/src/main/resources/static/css/auth/change-pass.css b/src/main/resources/static/css/auth/change-pass.css new file mode 100644 index 0000000000000000000000000000000000000000..c9209ee7be819d08f10eeafca61980f0b91ab3ac --- /dev/null +++ b/src/main/resources/static/css/auth/change-pass.css @@ -0,0 +1,123 @@ +@import "../auth.css"; +@import "../layouts/header.css"; +@import "../layouts/sidebar.css"; + +/* ===== LAYOUT WRAPPER ===== */ +.layout { + display: flex; + min-height: 100vh; + background: var(--dark-bg); +} + +/* SIDEBAR SPACE HANDLING (nếu sidebar fixed) */ +.content-wrapper { + flex: 1; + margin-left: 260px; /* chỉnh theo width sidebar của bạn */ + display: flex; + flex-direction: column; +} +body { + margin: 10px; +} +/* MAIN AREA */ +main { + position: relative; + flex: 1; + display: flex; + justify-content: center; + align-items: center; + padding: 2rem; + overflow: hidden; +} + +/* ===== SUCCESS MESSAGE ===== */ +.success-message { + background: rgba(34, 197, 94, 0.12); + color: #7CFF9B; + padding: 12px 15px; + border-left: 4px solid #22c55e; + margin-bottom: 20px; + font-size: 0.9rem; +} + +/* ===== CARD ENHANCEMENT ===== */ +.register-card { + border-radius: 10px; + backdrop-filter: blur(6px); + animation: fadeInUp 0.4s ease; +} + +/* subtle glow */ +.register-card:hover { + box-shadow: + 0 0 60px rgba(0,0,0,.85), + 0 0 30px rgba(196,30,58,.2); +} + +/* ===== INPUT IMPROVEMENT ===== */ +.register-group input { + border-radius: 4px; + font-size: 0.95rem; +} + +.register-group input::placeholder { + color: rgba(221, 208, 184, 0.4); +} + +/* focus smoother */ +.register-group input:focus { + transform: translateY(-1px); +} + +/* ===== BUTTON IMPROVEMENT ===== */ +.register-btn { + border-radius: 4px; + font-weight: 600; +} + +.register-btn:active { + transform: scale(0.98); +} + +/* ===== BACKGROUND FIX ===== */ +.bg-image { + position: fixed; + inset: 0; + z-index: -2; +} + +/* dark overlay layer */ +.bg-image::before { + content: ""; + position: absolute; + inset: 0; + background: radial-gradient( + circle at center, + rgba(0,0,0,0.4), + rgba(0,0,0,0.85) + ); +} + +/* ===== ANIMATION ===== */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ===== RESPONSIVE ===== */ +@media (max-width: 768px) { + .content-wrapper { + margin-left: 0; + } + + .register-card { + width: 100%; + max-width: 92%; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/character/character-create.css b/src/main/resources/static/css/character/character-create.css new file mode 100644 index 0000000000000000000000000000000000000000..017efa932ab650737b9a04ac5d039a31182923f3 --- /dev/null +++ b/src/main/resources/static/css/character/character-create.css @@ -0,0 +1,112 @@ +/* ========================================================== +CREATE CHARACTER PAGE +========================================================== */ + +.character-form { + margin-top: 2rem; + display: flex; + flex-direction: column; + gap: 1.4rem; +} + +/* ========================================================== +VALIDATION ERRORS +========================================================== */ + +.field-error { + margin-top: .35rem; + padding-left: .25rem; + + color: #ff8a80; + font-size: .8rem; + + border-left: 2px solid #b71c1c; +} + +.form-control:invalid { + box-shadow: none; +} + +.form-control.error { + border-color: #b71c1c; +} + +/* ========================================================== +QUILL EDITOR +========================================================== */ + +.character-editor { + min-height: 320px; + background: rgba(10, 8, 7, .9); + border: 1px solid var(--border-mid); +} + +.ql-toolbar { + background: rgba(28, 20, 16, .95) !important; + border: 1px solid var(--border-mid) !important; +} + +.ql-container { + border: 1px solid var(--border-mid) !important; + background: rgba(10, 8, 7, .9); + color: var(--cream); +} + +.ql-editor { + min-height: 280px; + color: var(--cream); + line-height: 1.7; + font-size: .95rem; +} + +.ql-editor.ql-blank::before { + color: var(--mid-grey); + font-style: italic; +} + +/* ========================================================== +ACTIONS +========================================================== */ + +.character-actions { + display: flex; + justify-content: flex-end; + gap: 1rem; + margin-top: 1rem; + flex-wrap: wrap; +} + +/* ========================================================== +FILE INPUT +========================================================== */ + +.file-input { + padding: .75rem; + background: rgba(10, 8, 7, .75); + border: 1px solid var(--border-mid); + color: var(--mid-grey); + transition: .25s ease; +} + +.file-input:hover { + border-color: var(--amber); +} + +/* ========================================================== +RESPONSIVE +========================================================== */ + +@media (max-width: 768px) { + + .character-actions { + flex-direction: column; + } + + .character-actions .btn { + width: 100%; + } + + .character-editor { + min-height: 250px; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/character/character-edit.css b/src/main/resources/static/css/character/character-edit.css new file mode 100644 index 0000000000000000000000000000000000000000..9251165f4838f73d125509eeca4df84c0c971cb4 --- /dev/null +++ b/src/main/resources/static/css/character/character-edit.css @@ -0,0 +1,163 @@ +/* ========================================================== +CHARACTER EDIT PAGE +========================================================== */ + +.character-form { + margin-top: 2rem; + display: flex; + flex-direction: column; + gap: 1.4rem; +} + +/* ========================================================== +FORM +========================================================== */ + +.form-group { + display: flex; + flex-direction: column; + gap: .5rem; +} + +.form-label { + font-size: .75rem; + letter-spacing: .15em; + text-transform: uppercase; + color: var(--parchment); +} + +.form-control { + padding: .8rem 1rem; + background: rgba(10, 8, 7, .85); + border: 1px solid var(--border-mid); + color: var(--cream); + font-size: .95rem; + transition: .25s ease; + outline: none; +} + +.form-control:focus { + border-color: var(--crimson); + box-shadow: 0 0 15px rgba(196, 30, 58, .15); +} + +.form-control::placeholder { + color: var(--mid-grey); +} + +/* ========================================================== +FILE INPUT +========================================================== */ + +.file-input { + padding: .75rem; + background: rgba(10, 8, 7, .7); + border: 1px solid var(--border-mid); + color: var(--mid-grey); +} + +.file-input:hover { + border-color: var(--amber); +} + +/* ========================================================== +CURRENT IMAGE +========================================================== */ + +.current-image-wrapper { + display: flex; + align-items: center; + gap: 1rem; +} + +.current-image { + width: 180px; + height: 180px; + object-fit: cover; + + border: 1px solid var(--border-mid); + + box-shadow: + 0 0 18px rgba(0, 0, 0, .6); + + transition: .3s ease; +} + +.current-image:hover { + transform: scale(1.03); + box-shadow: + 0 0 25px rgba(196, 30, 58, .18); +} + +/* ========================================================== +QUILL EDITOR +========================================================== */ + +.character-editor { + min-height: 320px; + background: rgba(10, 8, 7, .9); + border: 1px solid var(--border-mid); +} + +.ql-toolbar { + background: rgba(28, 20, 16, .95); + border: 1px solid var(--border-mid) !important; +} + +.ql-container { + border: 1px solid var(--border-mid) !important; + background: rgba(10, 8, 7, .9); + color: var(--cream); +} + +.ql-editor { + min-height: 280px; + color: var(--cream); + font-size: .95rem; + line-height: 1.7; +} + +.ql-editor.ql-blank::before { + color: var(--mid-grey); + font-style: italic; +} + +.ql-toolbar button:hover { + color: var(--crimson); +} + +/* ========================================================== +ACTIONS +========================================================== */ + +.character-actions { + display: flex; + justify-content: flex-end; + gap: 1rem; + margin-top: 1rem; + flex-wrap: wrap; +} + +/* ========================================================== +RESPONSIVE +========================================================== */ + +@media (max-width: 768px) { + + .current-image { + width: 130px; + height: 130px; + } + + .character-actions { + flex-direction: column; + } + + .character-actions .btn { + width: 100%; + } + + .character-editor { + min-height: 250px; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/character/character.css b/src/main/resources/static/css/character/character.css new file mode 100644 index 0000000000000000000000000000000000000000..0930d0596abfefccafc2d0e70e60f2ec2e5fda05 --- /dev/null +++ b/src/main/resources/static/css/character/character.css @@ -0,0 +1,291 @@ +@import "../dashboard-user.css"; +@import "character-create.css"; +@import "character-edit.css"; + +/* ========================================================== +CHARACTER LIST +========================================================== */ + +.character-card { + position: relative; + padding: 2rem; + background: linear-gradient(180deg, + rgba(28, 20, 16, .96), + rgba(10, 8, 7, .98)); + + border: 1px solid var(--border-dark); + + overflow: hidden; + + box-shadow: + 0 0 40px rgba(0, 0, 0, .75), + 0 0 25px rgba(139, 0, 0, .08); +} + +.character-card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, + transparent, + var(--crimson), + transparent); + box-shadow: 0 0 12px var(--glow-red); +} + +.btn-secondary { + border-color: var(--amber); + color: var(--amber); + +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: .6rem 1rem; + border: 1px solid var(--border-mid); + font-size: .75rem; + font-weight: 900; + letter-spacing: .1em; + text-transform: uppercase; + color: var(--cream); + background: + linear-gradient(180deg, + rgba(28, 20, 16, .95), + rgba(8, 6, 4, .95)); + cursor: pointer; + transition: .3s ease; +} + + + +.btn-primary { + color: var(--cream); + background: linear-gradient(180deg, + rgba(196, 30, 58, .25), + + rgba(139, 0, 0, .2)); + border: 1px solid var(--crimson); + box-shadow: + 0 0 12px rgba(196, 30, 58, .15); + +} + + +/* ========================================================== +MESSAGE +========================================================== */ + +.success-message { + margin-bottom: 1rem; + padding: 1rem 1.25rem; + color: #81c784; + background: rgba(129, 199, 132, .08); + border: 1px solid rgba(129, 199, 132, .2); +} + +.error-message { + margin-bottom: 1rem; + padding: 1rem 1.25rem; + color: #ff8a80; + background: rgba(255, 138, 128, .08); + border: 1px solid rgba(255, 138, 128, .2); +} + +/* ========================================================== +ACTION HEADER +========================================================== */ + +.table-header { + display: flex; + justify-content: flex-end; + margin-bottom: 1.5rem; +} + +/* ========================================================== +TABLE +========================================================== */ + +.table-wrapper { + overflow-x: auto; + border: 1px solid var(--border-dark); + background: rgba(8, 6, 4, .65); +} + +.character-table { + width: 100%; + border-collapse: collapse; +} + +.character-table thead { + background: linear-gradient(180deg, + rgba(12, 8, 6, .95), + rgba(8, 6, 4, .98)); +} + +.character-table th { + padding: 1rem; + text-align: left; + font-size: .72rem; + letter-spacing: .15em; + text-transform: uppercase; + color: var(--parchment); + border-bottom: 1px solid var(--border-dark); +} + +.character-table td { + padding: 1rem; + vertical-align: middle; + color: var(--light-grey); + border-bottom: 1px solid rgba(74, 48, 40, .35); +} + +.character-table tbody tr { + transition: .25s ease; +} + +.character-table tbody tr:hover { + background: rgba(139, 0, 0, .08); +} + +/* ========================================================== +IMAGE +========================================================== */ + +.table-image { + width: 90px; + height: 56px; + object-fit: cover; + border-radius: 4px; + border: 1px solid var(--border-dark); +} + +/* ========================================================== +STATUS +========================================================== */ + +.status-active { + display: inline-flex; + align-items: center; + padding: .35rem .8rem; + border-radius: 999px; + color: #81c784; + background: rgba(129, 199, 132, .08); + border: 1px solid rgba(129, 199, 132, .2); + font-size: .85rem; + font-weight: 600; +} + +.status-deleted { + display: inline-flex; + align-items: center; + padding: .35rem .8rem; + border-radius: 999px; + color: #ff8a80; + background: rgba(255, 138, 128, .08); + border: 1px solid rgba(255, 138, 128, .2); + font-size: .85rem; + font-weight: 600; +} + +/* ========================================================== +ACTION BUTTONS +========================================================== */ + +.action-buttons { + display: flex; + flex-wrap: wrap; + gap: .5rem; +} + +.action-buttons form { + margin: 0; +} + +/* ========================================================== +PAGINATION +========================================================== */ + +.pagination { + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; + gap: .5rem; + margin-top: 2rem; +} + +.page-btn, +.page-current { + display: flex; + align-items: center; + justify-content: center; + min-width: 42px; + height: 42px; + padding: 0 .8rem; + border-radius: 8px; + text-decoration: none; +} + +.page-btn { + color: var(--cream); + border: 1px solid var(--border-dark); + background: rgba(255, 255, 255, .02); + transition: .25s ease; +} + +.page-btn:hover { + border-color: var(--crimson); + background: rgba(196, 30, 58, .12); +} + +.page-current { + color: #fff; + background: var(--crimson); + border: 1px solid var(--crimson); + font-weight: 600; +} + +/* ========================================================== +PAGE CONTENT +========================================================== */ + +.section-dark { + width: 100%; +} + +/* ========================================================== +RESPONSIVE +========================================================== */ + +@media (max-width: 1200px) { + + .character-table { + min-width: 1100px; + } + + +} + +@media (max-width: 768px) { + .character-card { + padding: 1.5rem; + } + + .table-header { + justify-content: center; + } + + .action-buttons { + flex-direction: column; + } + + .action-buttons .btn { + width: 100%; + } + +} \ No newline at end of file diff --git a/src/main/resources/static/css/contact/contact-detail.css b/src/main/resources/static/css/contact/contact-detail.css new file mode 100644 index 0000000000000000000000000000000000000000..54cb4ec271b18242b254623569486673bd4ed82f --- /dev/null +++ b/src/main/resources/static/css/contact/contact-detail.css @@ -0,0 +1,238 @@ +/* ========================================================== +CONTACT DETAIL - MOON BLIGHT CMS +========================================================== */ + +.contact-detail { + margin-top: 2rem; + + padding: 1.5rem; + + background: + rgba(15, 10, 7, .6); + + border: 1px solid var(--border-dark); + + box-shadow: + inset 0 0 30px rgba(0,0,0,.4); +} + +/* ========================================================== +DETAIL ROWS +========================================================== */ + +.detail-row { + display: flex; + justify-content: space-between; + gap: 1rem; + + padding: .9rem 0; + + border-bottom: 1px solid var(--border-dark); +} + +.detail-row:last-child { + border-bottom: none; +} + +.detail-label { + min-width: 140px; + + font-size: .75rem; + letter-spacing: .12em; + text-transform: uppercase; + + color: var(--parchment); +} + +/* VALUE TEXT */ +.detail-row span, +.detail-row a { + color: var(--cream); +} + +/* EMAIL LINK (enhanced hover glow) */ +.email-link { + color: var(--parchment); + text-decoration: none; + + transition: .25s ease; +} + +.email-link:hover { + color: var(--crimson); + text-shadow: 0 0 10px var(--glow-red); +} + +/* ========================================================== +STATUS BADGES +========================================================== */ + +.status-active { + color: #81c784; + font-weight: 600; +} + +.status-pending { + color: var(--amber); + font-weight: 600; +} + +/* ========================================================== +MESSAGE BOX (CONTENT AREA) +========================================================== */ + +.message-box { + margin-top: 1.5rem; + + padding: 1.2rem; + + background: + rgba(10, 8, 7, .8); + + border: 1px solid var(--border-mid); + + box-shadow: + inset 0 0 25px rgba(0,0,0,.5); +} + +.message-box .detail-label { + margin-bottom: .8rem; + + display: block; +} + +.message-box pre { + white-space: pre-wrap; + + word-break: break-word; + + color: var(--cream); + + font-family: "Crimson Text", serif; + + font-size: 1rem; + + line-height: 1.6; + + margin: 0; +} + +/* ========================================================== +ACTIONS +========================================================== */ + +.contact-actions { + display: flex; + justify-content: flex-end; + + gap: 1rem; + + margin-top: 2rem; +} + +/* BUTTONS (CMS consistent style) */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + + padding: .8rem 1.2rem; + + border: 1px solid var(--border-mid); + + font-size: .75rem; + font-weight: 700; + letter-spacing: .1em; + text-transform: uppercase; + + color: var(--cream); + + background: + linear-gradient( + 180deg, + rgba(28,20,16,.95), + rgba(8,6,4,.95) + ); + + cursor: pointer; + + transition: .3s ease; +} + +.btn:hover { + transform: translateY(-2px); +} + +/* SUCCESS BUTTON */ +.btn-success { + border-color: #2e7d32; + color: #a5d6a7; +} + +.btn-success:hover { + background: rgba(46,125,50,.15); + box-shadow: 0 0 15px rgba(46,125,50,.25); +} + +/* SECONDARY BUTTON */ +.btn-secondary:hover { + border-color: var(--amber); + box-shadow: 0 0 15px var(--glow-amber); +} + +/* ========================================================== +CARD ENHANCEMENT (DETAIL PAGE FEEL) +========================================================== */ + +.contact-card { + position: relative; + + overflow: hidden; +} + +/* subtle amber accent for detail page */ +.contact-card::after { + content: ""; + + position: absolute; + + bottom: 0; + left: 0; + right: 0; + + height: 2px; + + background: + linear-gradient( + 90deg, + transparent, + var(--amber), + transparent + ); + + opacity: .7; +} + +/* ========================================================== +RESPONSIVE +========================================================== */ + +@media (max-width: 768px) { + + .detail-row { + flex-direction: column; + align-items: flex-start; + } + + .detail-label { + margin-bottom: .3rem; + } + + .contact-actions { + flex-direction: column; + } + + .btn { + width: 100%; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/contact/contact.css b/src/main/resources/static/css/contact/contact.css new file mode 100644 index 0000000000000000000000000000000000000000..1deb78cb1060ef9544291b621db0d8ccf7f40482 --- /dev/null +++ b/src/main/resources/static/css/contact/contact.css @@ -0,0 +1,293 @@ +@import "../dashboard-user.css"; + +@import "contact-detail.css"; + +/* ========================================================== +CONTACT PAGE - MOON BLIGHT CMS +========================================================== */ +body { + margin: 10px; +} + +.contact-card { + position: relative; + + padding: 2rem; + + background: + linear-gradient(180deg, + rgba(28, 20, 16, .96), + rgba(10, 8, 7, .98)); + + border: 1px solid var(--border-dark); + + box-shadow: + 0 0 40px rgba(0, 0, 0, .75), + 0 0 25px rgba(139, 0, 0, .08); + + overflow: hidden; +} + +.contact-card::before { + content: ""; + position: absolute; + + top: 0; + left: 0; + right: 0; + + height: 2px; + + background: + linear-gradient(90deg, + transparent, + var(--crimson), + transparent); + + box-shadow: 0 0 12px var(--glow-red); +} + +/* ========================================================== +EMPTY STATE +========================================================== */ + +.empty-state { + text-align: center; + padding: 2rem; + + color: var(--mid-grey); + + border: 1px dashed var(--border-mid); + + background: rgba(10, 8, 7, .5); + + margin-top: 1.5rem; +} + +/* ========================================================== +EMAIL LINK +========================================================== */ + +.email-link { + color: var(--parchment); + text-decoration: none; + + transition: .2s ease; +} + +.email-link:hover { + color: var(--crimson); + text-shadow: 0 0 10px var(--glow-red); +} + +/* ========================================================== +STATUS BADGES +========================================================== */ + +.status-active { + color: #81c784; + font-weight: 600; +} + +.status-pending { + color: var(--amber); + font-weight: 600; +} + +/* ========================================================== +TABLE +========================================================== */ + +.table-wrapper { + overflow-x: auto; + margin-top: 1.5rem; +} + +.contact-table { + width: 100%; + border-collapse: collapse; + + background: rgba(15, 10, 7, .6); +} + +.contact-table th { + padding: 1rem; + + font-size: .75rem; + text-transform: uppercase; + letter-spacing: .12em; + + color: var(--parchment); + + background: rgba(196, 30, 58, .12); + + border-bottom: 1px solid var(--border-mid); +} + +.contact-table td { + padding: 1rem; + + color: var(--cream); + + border-bottom: 1px solid var(--border-dark); +} + +.contact-table tbody tr { + transition: .25s ease; +} + +.contact-table tbody tr:hover { + background: rgba(196, 30, 58, .05); +} + +/* ========================================================== +ACTIONS +========================================================== */ + +.action-buttons { + display: flex; + gap: .5rem; + flex-wrap: wrap; +} + +.action-buttons form { + margin: 0; +} + +/* ========================================================== +BUTTONS (reuse consistent CMS style) +========================================================== */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + + padding: .6rem 1rem; + + border: 1px solid var(--border-mid); + + font-size: .75rem; + font-weight: 700; + letter-spacing: .1em; + text-transform: uppercase; + + color: var(--cream); + + background: + linear-gradient(180deg, + rgba(28, 20, 16, .95), + rgba(8, 6, 4, .95)); + + cursor: pointer; + + transition: .3s ease; +} + +.btn-primary { + color: var(--cream); + background: linear-gradient(180deg, + + rgba(196, 30, 58, .25), + + rgba(139, 0, 0, .2)); + + border: 1px solid var(--crimson); + box-shadow: + 0 0 12px rgba(196, 30, 58, .15); + +} + +.btn-secondary { + + border-color: var(--amber); + + color: rgb(56, 245, 56); + +} + +.btn:hover { + transform: translateY(-2px); +} + +.btn-secondary:hover { + border-color: var(--amber); + box-shadow: 0 0 15px var(--glow-amber); +} + + + + + +/* ========================================================== +PAGINATION +========================================================== */ + +.pagination { + display: flex; + justify-content: center; + align-items: center; + + gap: 1rem; + + margin-top: 2rem; +} + +.page-btn { + display: inline-flex; + align-items: center; + justify-content: center; + + min-width: 36px; + height: 36px; + + padding: 0 .8rem; + + border: 1px solid var(--border-mid); + + color: var(--cream); + + text-decoration: none; + + background: + linear-gradient(180deg, + rgba(28, 20, 16, .95), + rgba(8, 6, 4, .95)); + + transition: .25s ease; +} + +.page-btn:hover { + border-color: var(--crimson); + box-shadow: 0 0 15px var(--glow-red); +} + +.page-current { + color: var(--parchment); + font-size: .9rem; +} + +/* ========================================================== +RESPONSIVE +========================================================== */ + +@media (max-width: 768px) { + + .contact-card { + padding: 1rem; + } + + .action-buttons { + flex-direction: column; + } + + .btn { + width: 100%; + } + + .contact-table th, + .contact-table td { + padding: .75rem; + font-size: .85rem; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/contributor/contributor-create.css b/src/main/resources/static/css/contributor/contributor-create.css new file mode 100644 index 0000000000000000000000000000000000000000..6918988495e67102183c40380885579628181b3a --- /dev/null +++ b/src/main/resources/static/css/contributor/contributor-create.css @@ -0,0 +1,112 @@ +/* ========================================================== +CREATE CONTRIBUTOR PAGE +========================================================== */ + +.contributor-form { + margin-top: 2rem; + display: flex; + flex-direction: column; + gap: 1.4rem; +} + +/* ========================================================== +VALIDATION ERRORS +========================================================== */ + +.field-error { + margin-top: .35rem; + padding-left: .25rem; + + color: #ff8a80; + font-size: .8rem; + + border-left: 2px solid #b71c1c; +} + +.form-control:invalid { + box-shadow: none; +} + +.form-control.error { + border-color: #b71c1c; +} + +/* ========================================================== +QUILL EDITOR +========================================================== */ + +.contributor-editor { + min-height: 320px; + background: rgba(10, 8, 7, .9); + border: 1px solid var(--border-mid); +} + +.ql-toolbar { + background: rgba(28, 20, 16, .95) !important; + border: 1px solid var(--border-mid) !important; +} + +.ql-container { + border: 1px solid var(--border-mid) !important; + background: rgba(10, 8, 7, .9); + color: var(--cream); +} + +.ql-editor { + min-height: 280px; + color: var(--cream); + line-height: 1.7; + font-size: .95rem; +} + +.ql-editor.ql-blank::before { + color: var(--mid-grey); + font-style: italic; +} + +/* ========================================================== +ACTIONS +========================================================== */ + +.contributor-actions { + display: flex; + justify-content: flex-end; + gap: 1rem; + margin-top: 1rem; + flex-wrap: wrap; +} + +/* ========================================================== +FILE INPUT +========================================================== */ + +.file-input { + padding: .75rem; + background: rgba(10, 8, 7, .75); + border: 1px solid var(--border-mid); + color: var(--mid-grey); + transition: .25s ease; +} + +.file-input:hover { + border-color: var(--amber); +} + +/* ========================================================== +RESPONSIVE +========================================================== */ + +@media (max-width: 768px) { + + .contributor-actions { + flex-direction: column; + } + + .contributor-actions .btn { + width: 100%; + } + + .contributor-editor { + min-height: 250px; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/contributor/contributor-edit.css b/src/main/resources/static/css/contributor/contributor-edit.css new file mode 100644 index 0000000000000000000000000000000000000000..c2934c691406b7053ee3ace4c42ce10cf95c9a14 --- /dev/null +++ b/src/main/resources/static/css/contributor/contributor-edit.css @@ -0,0 +1,163 @@ +/* ========================================================== +CONTRIBUTOR EDIT PAGE +========================================================== */ + +.contributor-form { + margin-top: 2rem; + display: flex; + flex-direction: column; + gap: 1.4rem; +} + +/* ========================================================== +FORM +========================================================== */ + +.form-group { + display: flex; + flex-direction: column; + gap: .5rem; +} + +.form-label { + font-size: .75rem; + letter-spacing: .15em; + text-transform: uppercase; + color: var(--parchment); +} + +.form-control { + padding: .8rem 1rem; + background: rgba(10, 8, 7, .85); + border: 1px solid var(--border-mid); + color: var(--cream); + font-size: .95rem; + transition: .25s ease; + outline: none; +} + +.form-control:focus { + border-color: var(--crimson); + box-shadow: 0 0 15px rgba(196, 30, 58, .15); +} + +.form-control::placeholder { + color: var(--mid-grey); +} + +/* ========================================================== +FILE INPUT +========================================================== */ + +.file-input { + padding: .75rem; + background: rgba(10, 8, 7, .7); + border: 1px solid var(--border-mid); + color: var(--mid-grey); +} + +.file-input:hover { + border-color: var(--amber); +} + +/* ========================================================== +CURRENT IMAGE +========================================================== */ + +.current-image-wrapper { + display: flex; + align-items: center; + gap: 1rem; +} + +.current-image { + width: 180px; + height: 180px; + object-fit: cover; + + border: 1px solid var(--border-mid); + + box-shadow: + 0 0 18px rgba(0, 0, 0, .6); + + transition: .3s ease; +} + +.current-image:hover { + transform: scale(1.03); + box-shadow: + 0 0 25px rgba(196, 30, 58, .18); +} + +/* ========================================================== +QUILL EDITOR +========================================================== */ + +.contributor-editor { + min-height: 320px; + background: rgba(10, 8, 7, .9); + border: 1px solid var(--border-mid); +} + +.ql-toolbar { + background: rgba(28, 20, 16, .95); + border: 1px solid var(--border-mid) !important; +} + +.ql-container { + border: 1px solid var(--border-mid) !important; + background: rgba(10, 8, 7, .9); + color: var(--cream); +} + +.ql-editor { + min-height: 280px; + color: var(--cream); + font-size: .95rem; + line-height: 1.7; +} + +.ql-editor.ql-blank::before { + color: var(--mid-grey); + font-style: italic; +} + +.ql-toolbar button:hover { + color: var(--crimson); +} + +/* ========================================================== +ACTIONS +========================================================== */ + +.contributor-actions { + display: flex; + justify-content: flex-end; + gap: 1rem; + margin-top: 1rem; + flex-wrap: wrap; +} + +/* ========================================================== +RESPONSIVE +========================================================== */ + +@media (max-width: 768px) { + + .current-image { + width: 130px; + height: 130px; + } + + .contributor-actions { + flex-direction: column; + } + + .contributor-actions .btn { + width: 100%; + } + + .contributor-editor { + min-height: 250px; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/contributor/contributor.css b/src/main/resources/static/css/contributor/contributor.css new file mode 100644 index 0000000000000000000000000000000000000000..8a25705ee969635656c8f7cac61798908c05b535 --- /dev/null +++ b/src/main/resources/static/css/contributor/contributor.css @@ -0,0 +1,291 @@ +@import "../dashboard-user.css"; +@import "contributor-create.css"; +@import "contributor-edit.css"; + +/* ========================================================== +CONTRIBUTOR LIST +========================================================== */ + +.contributor-card { + position: relative; + padding: 2rem; + background: linear-gradient(180deg, + rgba(28, 20, 16, .96), + rgba(10, 8, 7, .98)); + + border: 1px solid var(--border-dark); + + overflow: hidden; + + box-shadow: + 0 0 40px rgba(0, 0, 0, .75), + 0 0 25px rgba(139, 0, 0, .08); +} + +.contributor-card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, + transparent, + var(--crimson), + transparent); + box-shadow: 0 0 12px var(--glow-red); +} + +.btn-secondary { + border-color: var(--amber); + color: var(--amber); + +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: .6rem 1rem; + border: 1px solid var(--border-mid); + font-size: .75rem; + font-weight: 900; + letter-spacing: .1em; + text-transform: uppercase; + color: var(--cream); + background: + linear-gradient(180deg, + rgba(28, 20, 16, .95), + rgba(8, 6, 4, .95)); + cursor: pointer; + transition: .3s ease; +} + + + +.btn-primary { + color: var(--cream); + background: linear-gradient(180deg, + rgba(196, 30, 58, .25), + + rgba(139, 0, 0, .2)); + border: 1px solid var(--crimson); + box-shadow: + 0 0 12px rgba(196, 30, 58, .15); + +} + + +/* ========================================================== +MESSAGE +========================================================== */ + +.success-message { + margin-bottom: 1rem; + padding: 1rem 1.25rem; + color: #81c784; + background: rgba(129, 199, 132, .08); + border: 1px solid rgba(129, 199, 132, .2); +} + +.error-message { + margin-bottom: 1rem; + padding: 1rem 1.25rem; + color: #ff8a80; + background: rgba(255, 138, 128, .08); + border: 1px solid rgba(255, 138, 128, .2); +} + +/* ========================================================== +ACTION HEADER +========================================================== */ + +.table-header { + display: flex; + justify-content: flex-end; + margin-bottom: 1.5rem; +} + +/* ========================================================== +TABLE +========================================================== */ + +.table-wrapper { + overflow-x: auto; + border: 1px solid var(--border-dark); + background: rgba(8, 6, 4, .65); +} + +.contributor-table { + width: 100%; + border-collapse: collapse; +} + +.contributor-table thead { + background: linear-gradient(180deg, + rgba(12, 8, 6, .95), + rgba(8, 6, 4, .98)); +} + +.contributor-table th { + padding: 1rem; + text-align: left; + font-size: .72rem; + letter-spacing: .15em; + text-transform: uppercase; + color: var(--parchment); + border-bottom: 1px solid var(--border-dark); +} + +.contributor-table td { + padding: 1rem; + vertical-align: middle; + color: var(--light-grey); + border-bottom: 1px solid rgba(74, 48, 40, .35); +} + +.contributor-table tbody tr { + transition: .25s ease; +} + +.contributor-table tbody tr:hover { + background: rgba(139, 0, 0, .08); +} + +/* ========================================================== +IMAGE +========================================================== */ + +.table-image { + width: 90px; + height: 56px; + object-fit: cover; + border-radius: 4px; + border: 1px solid var(--border-dark); +} + +/* ========================================================== +STATUS +========================================================== */ + +.status-active { + display: inline-flex; + align-items: center; + padding: .35rem .8rem; + border-radius: 999px; + color: #81c784; + background: rgba(129, 199, 132, .08); + border: 1px solid rgba(129, 199, 132, .2); + font-size: .85rem; + font-weight: 600; +} + +.status-deleted { + display: inline-flex; + align-items: center; + padding: .35rem .8rem; + border-radius: 999px; + color: #ff8a80; + background: rgba(255, 138, 128, .08); + border: 1px solid rgba(255, 138, 128, .2); + font-size: .85rem; + font-weight: 600; +} + +/* ========================================================== +ACTION BUTTONS +========================================================== */ + +.action-buttons { + display: flex; + flex-wrap: wrap; + gap: .5rem; +} + +.action-buttons form { + margin: 0; +} + +/* ========================================================== +PAGINATION +========================================================== */ + +.pagination { + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; + gap: .5rem; + margin-top: 2rem; +} + +.page-btn, +.page-current { + display: flex; + align-items: center; + justify-content: center; + min-width: 42px; + height: 42px; + padding: 0 .8rem; + border-radius: 8px; + text-decoration: none; +} + +.page-btn { + color: var(--cream); + border: 1px solid var(--border-dark); + background: rgba(255, 255, 255, .02); + transition: .25s ease; +} + +.page-btn:hover { + border-color: var(--crimson); + background: rgba(196, 30, 58, .12); +} + +.page-current { + color: #fff; + background: var(--crimson); + border: 1px solid var(--crimson); + font-weight: 600; +} + +/* ========================================================== +PAGE CONTENT +========================================================== */ + +.section-dark { + width: 100%; +} + +/* ========================================================== +RESPONSIVE +========================================================== */ + +@media (max-width: 1200px) { + + .contributor-table { + min-width: 1100px; + } + + +} + +@media (max-width: 768px) { + .contributor-card { + padding: 1.5rem; + } + + .table-header { + justify-content: center; + } + + .action-buttons { + flex-direction: column; + } + + .action-buttons .btn { + width: 100%; + } + +} \ No newline at end of file diff --git a/src/main/resources/static/css/dashboard-user.css b/src/main/resources/static/css/dashboard-user.css new file mode 100644 index 0000000000000000000000000000000000000000..2fbb8ab161b4461eba25bb1a31695e3a56998f29 --- /dev/null +++ b/src/main/resources/static/css/dashboard-user.css @@ -0,0 +1,392 @@ +@import "layouts/sidebar.css"; +@import "layouts/header.css"; +/* ========================================================== +DASHBOARD - MOON BLIGHT CMS +========================================================== */ + +:root { + --blood-red: #8B0000; + --crimson: #C41E3A; + --deep-red: #4A0808; + + + --black-coal: #080604; + --dark-bg: #0D0A07; + --dark-surface: #160E0A; + --dark-card: #1C1410; + + --burnt-orange: #A84800; + --amber: #C8720A; + + --ash-grey: #4A4240; + --mid-grey: #8F7E76; + + --light-grey: #C8C1BC; + --parchment: #C4A882; + --cream: #DDD0B8; + + --gold: #C9A44C; + + --border-dark: #2E201A; + --border-mid: #4A3028; + + --shadow-red: rgba(139, 0, 0, .25); + --glow-red: rgba(196, 30, 58, .4); + --glow-amber: rgba(200, 114, 10, .35); + + +} + +/* ========================================================== +BASE +========================================================== */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background: var(--black-coal); + color: var(--cream); + + + font-family: "Crimson Text", serif; + + overflow-x: hidden; + margin: 10px; +} + +/* ========================================================== +BACKGROUND +========================================================== */ + +.bg-image { + position: fixed; + inset: 0; + + background: + linear-gradient(rgba(0, 0, 0, .45), + rgba(0, 0, 0, .65)), + url("/img/back.png") center/cover no-repeat; + + filter: + brightness(.65) saturate(.85) sepia(.2); + + z-index: -10; + +} + +.bg-image::after { + content: ""; + + + position: absolute; + inset: 0; + + background: + radial-gradient(circle at top, + rgba(139, 0, 0, .12), + transparent 45%); + +} + +/* ========================================================== +LAYOUT +========================================================== */ + +.layout { + display: flex; + min-height: calc(100vh - 70px); +} + +.content-wrapper { + flex: 1; + padding: 2.5rem; +} + +.container-xl { + width: 100%; +} + +/* ========================================================== +PANEL +========================================================== */ + +.dashboard-panel { + position: relative; + + + padding: 2rem; + + background: + linear-gradient(180deg, + rgba(28, 20, 16, .96), + rgba(10, 8, 7, .98)); + + border: 1px solid var(--border-dark); + + overflow: hidden; + + box-shadow: + 0 0 40px rgba(0, 0, 0, .75), + 0 0 25px rgba(139, 0, 0, .08); + +} + +.dashboard-panel::before { + content: ""; + position: absolute; + + top: 0; + left: 0; + right: 0; + height: 2px; + background: + linear-gradient(90deg, + transparent, + var(--crimson), + transparent); + box-shadow: + 0 0 12px var(--glow-red); +} + +/* ========================================================== +HEADER +========================================================== */ + +.section-header { + text-align: center; + margin-bottom: 3rem; +} + +.section-eyebrow { + font-size: .75rem; + letter-spacing: .35em; + text-transform: uppercase; + color: var(--crimson); + margin-bottom: .75rem; +} + +.section-title { + font-family: "Cormorant Garamond", serif; + font-size: clamp(2rem, 4vw, 3.5rem); + color: var(--cream); + text-shadow: + 0 0 25px rgba(196, 30, 58, .25), + 2px 3px 15px rgba(0, 0, 0, .8); + +} + +.section-divider { + width: 140px; + height: 1px; + margin: 1rem auto; + background: + linear-gradient(90deg, + transparent, + var(--crimson), + transparent); +} + +.subtitle { + color: var(--mid-grey); + font-size: 1rem; +} + +/* ========================================================== +MESSAGE +========================================================== */ + +.messages { + margin-bottom: 2rem; +} + +.msg-success, +.msg-error { + padding: 1rem 1.25rem; + margin-bottom: 1rem; +} + +.msg-success { + color: #81c784; + background: + rgba(129, 199, 132, .08); + border: + 1px solid rgba(129, 199, 132, .2); + +} + +.msg-error { + color: #ff8a80; + background: + rgba(255, 138, 128, .08); + + border: + 1px solid rgba(255, 138, 128, .2); +} + +/* ========================================================== +STATS +========================================================== */ + +.dashboard-grid { + display: grid; + grid-template-columns: + repeat(auto-fit, minmax(220px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.stat-card { + position: relative; + padding: 2rem; + background: + linear-gradient(180deg, + rgba(28, 20, 16, .95), + rgba(8, 6, 4, .95)); + border: 1px solid var(--border-dark); + transition: .3s ease; +} + +.stat-card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: + linear-gradient(90deg, + transparent, + var(--crimson), + transparent); + opacity: 0; + transition: .3s; +} + +.stat-card:hover { + transform: translateY(-5px); + border-color: var(--border-mid); + box-shadow: + 0 8px 30px rgba(0, 0, 0, .6), + 0 0 25px var(--shadow-red); +} + +.stat-card:hover::before { + opacity: 1; +} + +.stat-card h2 { + font-size: 2.5rem; + color: var(--crimson); + margin-bottom: .5rem; +} + +.stat-card p { + font-size: .75rem; + letter-spacing: .18em; + text-transform: uppercase; + color: var(--mid-grey); +} + +.stat-card.pending h2 { + color: var(--amber); +} + +/* ========================================================== +ALERT +========================================================== */ + +.alert-card { + margin-bottom: 2rem; + padding: 1.25rem 1.5rem; + border-left: 4px solid var(--amber); + background: + rgba(200, 114, 10, .08); + color: var(--cream); +} + +.alert-card strong { + color: var(--amber); +} + +/* ========================================================== +QUICK ACTIONS +========================================================== */ + +.quick-actions { + margin-top: 2rem; +} + +.table-title { + font-size: 1.6rem; + color: var(--parchment); + margin-bottom: 1rem; +} + +.action-grid { + display: grid; + grid-template-columns: + repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; +} + +.action-btn { + display: flex; + justify-content: center; + align-items: center; + min-height: 58px; + padding: 1rem; + text-decoration: none; + color: var(--cream); + font-size: .75rem; + font-weight: 700; + letter-spacing: .15em; + text-transform: uppercase; + border: 1px solid var(--border-mid); + background: + linear-gradient(180deg, + rgba(28, 20, 16, .9), + rgba(10, 8, 7, .95)); + transition: .3s ease; +} + +.action-btn:hover { + transform: translateY(-3px); + border-color: var(--crimson); + box-shadow: + 0 0 20px rgba(196, 30, 58, .25); + color: #fff; +} + +/* ========================================================== +RESPONSIVE +========================================================== */ + +@media (max-width: 900px) { + .content-wrapper { + padding: 1rem; + } + + .dashboard-grid { + grid-template-columns: 1fr; + } + + .action-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + + .dashboard-panel { + padding: 1.5rem; + } + + .section-title { + font-size: 2rem; + } + +} \ No newline at end of file diff --git a/src/main/resources/static/css/dashboard.css b/src/main/resources/static/css/dashboard.css new file mode 100644 index 0000000000000000000000000000000000000000..10c097da7692d33dcbb613cce7e01b130d3353b6 --- /dev/null +++ b/src/main/resources/static/css/dashboard.css @@ -0,0 +1,940 @@ +@import "layouts/sidebar.css"; +@import "layouts/header.css"; + + +/* ============================================================ + DASHBOARD — DARK FANTASY PREMIUM + ============================================================ */ +:root { + --blood-red: #8B0000; + --crimson: #C41E3A; + --deep-red: #4A0808; + --black-coal: #080604; + --dark-bg: #0D0A07; + --dark-surface: #160E0A; + --dark-card: #1C1410; + --burnt-orange: #A84800; + --amber: #C8720A; + --ash-grey: #4A4240; + --mid-grey: #8F7E76; + /* --mid-grey: #6B5E58; */ + + --light-grey: #C8C1BC; + --parchment: #C4A882; + --cream: #DDD0B8; + --gold: #C9A44C; + --bright-gold: #E8C060; + --border-dark: #2E201A; + --border-mid: #4A3028; + --shadow-red: rgba(139, 0, 0, 0.25); + --shadow-dark: rgba(0, 0, 0, 0.9); + --glow-red: rgba(196, 30, 58, 0.4); + --glow-amber: rgba(200, 114, 10, 0.35); +} + +body { + background: var(--black-coal); + color: var(--cream); + font-family: 'Crimson Text', serif; + overflow-x: hidden; + margin: 10px; +} + +/* ========================= + BACKGROUND +========================= */ +/* ========================= + HEADER +========================= */ + +.cms-header { + height: 70px; + + display: flex; + justify-content: space-between; + align-items: center; + + padding: 0 2rem; + + background: + linear-gradient(180deg, + rgba(20, 15, 12, .95), + rgba(10, 8, 7, .98)); + + border-bottom: 1px solid var(--border-dark); +} + +.container-xl { + width: 100%; + max-width: none; +} + +/* ========================= + BODY LAYOUT +========================= */ + +.admin-layout { + display: flex; + + min-height: calc(100vh - 70px); +} + +/* ========================= + SIDEBAR +========================= */ + +.sidebar { + width: 260px; + flex-shrink: 0; +} + +/* ========================= + CONTENT +========================= */ + +.content-wrapper { + flex: 1; + + padding: 2rem; +} + +.bg-image { + position: fixed; + inset: 0; + background: + linear-gradient(rgba(0, 0, 0, .35), + rgba(0, 0, 0, .55)), + url("/img/back.png") center/cover no-repeat; + + filter: brightness(.6) saturate(.85) sepia(.25); + z-index: -3; +} + +.bg-image::after { + content: ""; + position: absolute; + inset: 0; + + background: + radial-gradient(circle at top, + rgba(139, 0, 0, .12), + transparent 40%); +} + +/* ========================= + LAYOUT +========================= */ + +.layout { + display: flex; + min-height: 100vh; +} + +.content-wrapper { + flex: 1; + padding: 2.5rem; +} + +/* ========================= + MAIN CONTAINER +========================= */ + +.story-card { + position: relative; + + background: + linear-gradient(180deg, + rgba(28, 20, 16, .96), + rgba(10, 8, 7, .98)); + + border: 1px solid var(--border-dark); + + border-radius: 2px; + + backdrop-filter: blur(10px); + + overflow: hidden; + + box-shadow: + 0 0 40px rgba(0, 0, 0, .7), + 0 0 25px rgba(139, 0, 0, .08); + padding: 20px; +} + +.story-card::before { + content: ""; + + position: absolute; + top: 0; + left: 0; + right: 0; + + height: 2px; + + background: + linear-gradient(90deg, + transparent, + var(--crimson), + transparent); + + box-shadow: 0 0 10px var(--glow-red); +} + +/* ========================= + HEADER +========================= */ + +.section-header { + text-align: center; + margin-bottom: 3rem; +} + +.section-eyebrow { + font-family: 'Cormorant Garamond', serif; + + font-size: .75rem; + + letter-spacing: .35em; + + text-transform: uppercase; + + color: var(--crimson); + + margin-bottom: .8rem; +} + +.section-title { + font-family: 'Cormorant Garamond Decorative', serif; + + font-size: clamp(2rem, 4vw, 3.5rem); + + color: var(--cream); + + text-shadow: + 0 0 25px rgba(196, 30, 58, .25), + 2px 3px 15px rgba(0, 0, 0, .8); +} + +.section-divider { + width: 140px; + height: 1px; + + margin: 1rem auto; + + background: + linear-gradient(90deg, + transparent, + var(--crimson), + transparent); +} + +/* ========================= + STATS +========================= */ + +.stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; + margin-bottom: 3rem; +} + +.stat-card { + position: relative; + + background: + linear-gradient(180deg, + rgba(28, 20, 16, .95), + rgba(8, 6, 4, .95)); + + border: 1px solid var(--border-dark); + + padding: 2rem; + + overflow: hidden; + + transition: all .35s ease; +} + +.stat-card::before { + content: ""; + + position: absolute; + top: 0; + left: 0; + right: 0; + + height: 2px; + + background: + linear-gradient(90deg, + transparent, + var(--blood-red), + transparent); + + opacity: 0; + transition: .35s; +} + +.stat-card:hover { + transform: translateY(-5px); + + border-color: var(--border-mid); + + box-shadow: + 0 8px 35px rgba(0, 0, 0, .7), + 0 0 25px var(--shadow-red); +} + +.stat-card:hover::before { + opacity: 1; +} + +.stat-card h2 { + font-family: 'Cormorant Garamond Decorative', serif; + + font-size: 2.6rem; + + color: var(--crimson); + + text-shadow: 0 0 15px var(--glow-red); + + margin-bottom: .4rem; +} + +.stat-label { + font-size: .7rem; + + letter-spacing: .25em; + + text-transform: uppercase; + + color: var(--mid-grey); +} + +/* special */ + +.stat-card.active h2 { + color: #6aaf82; + text-shadow: 0 0 12px rgba(106, 175, 130, .45); +} + +.stat-card.pending h2 { + color: var(--amber); + text-shadow: 0 0 12px rgba(200, 114, 10, .35); +} + +/* ========================= + TABLE +========================= */ + +.table-section { + margin-top: 2rem; +} + +.table-title { + font-family: 'Cormorant Garamond', serif; + + font-size: 1.6rem; + + color: var(--parchment); + + margin-bottom: 1rem; +} + +.table-wrapper { + overflow: hidden; + + border: 1px solid var(--border-dark); + + background: + rgba(8, 6, 4, .65); + + border-radius: 14px; +} + +.dashboard-table { + width: 100%; + border-collapse: collapse; +} + +.dashboard-table thead { + background: + linear-gradient(180deg, + rgba(12, 8, 6, .95), + rgba(8, 6, 4, .98)); +} + +.dashboard-table th { + padding: 1rem; + + font-size: .7rem; + + letter-spacing: .18em; + + text-transform: uppercase; + + color: var(--parchment); + + border-bottom: 1px solid var(--border-dark); +} + +.dashboard-table td { + padding: 1rem; + + color: var(--light-grey); + + border-bottom: + 1px solid rgba(74, 48, 40, .35); +} + +.dashboard-table tbody tr { + transition: .25s; +} + +.dashboard-table tbody tr:hover { + background: + rgba(139, 0, 0, .08); + + box-shadow: + inset 3px 0 0 var(--blood-red); +} + +/* ========================= + BUTTON +========================= */ + +.btn { + position: relative; + + display: inline-flex; + align-items: center; + justify-content: center; + + padding: .8rem 1.6rem; + + font-size: .75rem; + font-weight: 700; + + letter-spacing: .18em; + text-transform: uppercase; + + border: 1px solid var(--crimson); + + color: var(--cream); + + background: + rgba(196, 30, 58, .08); + + overflow: hidden; + + transition: all .3s ease; +} + +.btn:hover { + background: var(--crimson); + + box-shadow: + 0 0 25px var(--glow-red); + + transform: translateY(-2px); +} + +.btn-secondary { + border-color: var(--amber); + + color: var(--amber); + + background: + rgba(200, 114, 10, .05); +} + +.btn-secondary:hover { + background: var(--amber); + color: var(--black-coal); + + box-shadow: + 0 0 25px var(--glow-amber); +} + +/* ========================================================== + ACCOUNT DETAIL + ========================================================== */ + +.detail-wrapper { + margin-top: 2rem; + + border: 1px solid var(--border-dark); + border-radius: 3px; + + overflow: hidden; + + background: + linear-gradient(180deg, + rgba(28, 20, 16, .85), + rgba(12, 10, 8, .95)); + padding: 20px; +} + +/* ========================================================== + DETAIL ROW + ========================================================== */ + +.detail-row { + display: flex; + align-items: center; + + min-height: 64px; + + border-bottom: + 1px solid rgba(74, 48, 40, .35); +} + +.detail-row:last-child { + border-bottom: none; +} + +.detail-label { + width: 220px; + flex-shrink: 0; + + padding: 1rem 1.5rem; + + font-size: .75rem; + letter-spacing: .18em; + text-transform: uppercase; + + color: var(--parchment); + + background: + rgba(255, 255, 255, .02); + + border-right: + 1px solid rgba(74, 48, 40, .35); + margin-right: 30px; +} + +.detail-value { + flex: 1; + + padding: 1rem 1.5rem; + + color: var(--cream); +} + +/* ========================================================== + STATUS + ========================================================== */ + +.status-active { + display: inline-flex; + align-items: center; + gap: .5rem; + + padding: .35rem .8rem; + + border-radius: 999px; + + color: #81c784; + + background: + rgba(129, 199, 132, .08); + + border: + 1px solid rgba(129, 199, 132, .2); + + font-weight: 600; +} + +.status-deleted { + display: inline-flex; + align-items: center; + gap: .5rem; + + padding: .35rem .8rem; + + border-radius: 999px; + + color: #ff8a80; + + background: + rgba(255, 138, 128, .08); + + border: + 1px solid rgba(255, 138, 128, .2); + + font-weight: 600; +} + +/* ========================================================== + MESSAGES + ========================================================== */ + +.success-message { + margin-bottom: 1.5rem; + + padding: 1rem 1.25rem; + + color: #81c784; + + background: + rgba(129, 199, 132, .08); + + border: + 1px solid rgba(129, 199, 132, .2); + + border-radius: 10px; +} + +.error-message { + margin-bottom: 1.5rem; + + padding: 1rem 1.25rem; + + color: #ff8a80; + + background: + rgba(255, 138, 128, .08); + + border: + 1px solid rgba(255, 138, 128, .2); + + border-radius: 10px; +} + +/* ========================================================== + ACTIONS + ========================================================== */ + +.action-buttons { + display: flex; + flex-wrap: wrap; + gap: 12px; + + margin-top: 2rem; +} + +.action-buttons form { + margin: 0; +} + +.action-buttons .btn { + min-width: 180px; + justify-content: center; +} + +/* ========================================================== + BUTTON COLORS + ========================================================== */ + +.btn-success { + border-color: #6aaf82; + color: #6aaf82; +} + +.btn-success:hover { + background: #6aaf82; + color: #111; + box-shadow: + 0 0 20px rgba(106, 175, 130, .35); +} + +.btn-danger { + border-color: #c94f4f; + color: #c94f4f; +} + +.btn-danger:hover { + background: #c94f4f; + color: #fff; + box-shadow: + 0 0 20px rgba(201, 79, 79, .35); +} + +/* ========================================================== + RESPONSIVE + ========================================================== */ + +/* ========================================================== + ACCOUNT TABLE + ========================================================== */ + +.account-table { + width: 100%; + border-collapse: collapse; +} + +.account-table thead { + background: + linear-gradient(180deg, + rgba(12, 8, 6, .95), + rgba(8, 6, 4, .98)); +} + +.account-table th { + padding: 1rem; + text-align: left; + font-size: .72rem; + letter-spacing: .15em; + text-transform: uppercase; + color: var(--parchment); + border-bottom: + 1px solid var(--border-dark); +} + +.account-table td { + padding: 1rem; + color: var(--light-grey); + border-bottom: + 1px solid rgba(74, 48, 40, .35); + vertical-align: middle; +} + +.account-table tbody tr { + transition: .25s ease; +} + +.account-table tbody tr:hover { + background: + rgba(139, 0, 0, .08); +} + +/* ========================================================== + STATUS + ========================================================== */ + +.status-active { + display: inline-flex; + align-items: center; + padding: .35rem .8rem; + border-radius: 999px; + background: + rgba(129, 199, 132, .08); + border: + 1px solid rgba(129, 199, 132, .2); + color: #81c784; + font-size: .85rem; + font-weight: 600; +} + +.status-deleted { + display: inline-flex; + align-items: center; + padding: .35rem .8rem; + border-radius: 999px; + background: + rgba(255, 138, 128, .08); + border: + 1px solid rgba(255, 138, 128, .2); + color: #ff8a80; + font-size: .85rem; + font-weight: 600; +} + +/* ========================================================== + SMALL BUTTON + ========================================================== */ + +.btn-sm { + min-width: 140px; + padding: .65rem 1rem; + font-size: .7rem; +} + +/* ========================================================== + ALERTS + ========================================================== */ + +.success-message { + margin-bottom: 1.5rem; + padding: 1rem 1.25rem; + border-radius: 10px; + background: + rgba(129, 199, 132, .08); + border: + 1px solid rgba(129, 199, 132, .2); + color: #81c784; +} + +.error-message { + margin-bottom: 1.5rem; + padding: 1rem 1.25rem; + border-radius: 10px; + background: + rgba(255, 138, 128, .08); + border: + 1px solid rgba(255, 138, 128, .2); + color: #ff8a80; +} + +/* ========================================================== + PAGINATION + ========================================================== */ + +.pagination { + margin-top: 2rem; + display: flex; + justify-content: center; + align-items: center; + gap: .5rem; + flex-wrap: wrap; +} + +.page-btn, +.page-current { + min-width: 42px; + height: 42px; + display: flex; + align-items: center; + justify-content: center; + text-decoration: none; + border-radius: 10px; + transition: .25s ease; +} + +.page-btn { + color: var(--cream); + border: + 1px solid var(--border-dark); + background: + rgba(255, 255, 255, .02); +} + +.page-btn:hover { + background: + rgba(196, 30, 58, .12); + border-color: + var(--crimson); + box-shadow: + 0 0 15px rgba(196, 30, 58, .2); +} + +.page-current { + color: #fff; + background: + var(--crimson); + border: + 1px solid var(--crimson); + box-shadow: + 0 0 15px rgba(196, 30, 58, .25); +} + +/* ========================================================== + PAGINATION + ========================================================== */ + +.pagination { + margin-top: 2rem; + display: flex; + justify-content: center; + align-items: center; + gap: .5rem; + flex-wrap: wrap; +} + +.page-btn, +.page-current { + min-width: 42px; + height: 42px; + display: flex; + align-items: center; + justify-content: center; + text-decoration: none; + border-radius: 10px; +} + +.page-btn { + color: var(--cream); + border: 1px solid var(--border-dark); + background: rgba(255, 255, 255, .02); +} + +.page-btn:hover { + background: rgba(196, 30, 58, .12); + border-color: var(--crimson); +} + +.page-current { + background: var(--crimson); + color: #fff; + border: 1px solid var(--crimson); +} + +/* ========================================================== + RESPONSIVE + ========================================================== */ + +@media (max-width:900px) { + + .table-wrapper { + overflow-x: auto; + } + + .account-table { + min-width: 900px; + } +} + +@media (max-width:768px) { + + .detail-row { + flex-direction: column; + align-items: flex-start; + } + + .detail-label { + width: 100%; + border-right: none; + border-bottom: + 1px solid rgba(74, 48, 40, .35); + } + + .detail-value { + width: 100%; + } + + .action-buttons { + flex-direction: column; + } + + .action-buttons .btn { + width: 100%; + } +} + +/* ========================= + EMPTY +========================= */ + +.empty-state { + padding: 3rem; + + text-align: center; + + color: var(--mid-grey); + + font-style: italic; +} + +/* ========================= + RESPONSIVE +========================= */ + +@media (max-width: 900px) { + + .stats-grid { + grid-template-columns: 1fr; + } + + .content-wrapper { + padding: 1rem; + } + + .story-card { + padding: 1.5rem; + } + + .section-title { + font-size: 2rem; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/error/error.css b/src/main/resources/static/css/error/error.css new file mode 100644 index 0000000000000000000000000000000000000000..1ac4721d2a4500fee4802861031145f091faf495 --- /dev/null +++ b/src/main/resources/static/css/error/error.css @@ -0,0 +1,99 @@ +/* ========================================================== + ERROR PAGE STYLES (Đồng bộ với Dashboard Hệ Thống) + ========================================================== */ + +body { + margin: 0; + padding: 0; + min-height: 100vh; + margin-top: 200px; + background: #0a0807; + font-family: system-ui, -apple-system, sans-serif; +} + + +h2, .error-description, .error-rollback { + display: block; + text-align: center; +} + + +h2 { + font-size: clamp(2rem, 5vw, 3.5rem); + margin-top: 0; + margin-bottom: 1.5rem; + text-transform: uppercase; + letter-spacing: 0.1em; + + /* Hiệu ứng đổ bóng chữ màu đỏ rực (Glow) */ + color: var(--cream, #fff); + text-shadow: 0 0 15px var(--glow-red, rgba(196, 30, 58, 0.6)); +} + +.error { + display: grid; + place-items: center; + +} + +.error-description { + max-width: 500px; + margin: 0 auto 2.5rem auto; + line-height: 1.6; + font-size: 1rem; + color: var(--light-grey, #a0a0a0); + padding: 0 1.5rem; +} + +.error-rollback { + display: inline-flex; + align-items: center; + justify-content: center; + margin: 0 auto; + + padding: 0.85rem 2rem; + text-decoration: none; + cursor: pointer; + width: fit-content; + + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.15em; + text-transform: uppercase; + + color: var(--cream, #fff); + background: linear-gradient(180deg, + rgba(196, 30, 58, .25), + rgba(139, 0, 0, .2)); + border: 1px solid var(--crimson, #c41e3a); + border-radius: 4px; + box-shadow: 0 0 15px rgba(196, 30, 58, .2); + + transition: all 0.3s ease; +} + +.error-rollback:hover { + background: var(--crimson, #c41e3a); + box-shadow: 0 0 25px var(--glow-red, rgba(196, 30, 58, 0.6)); + transform: translateY(-2px); +} + +.error-rollback:active { + transform: translateY(0); +} + + + +@media (max-width: 480px) { + body { + padding: 2rem; + } + + .error-description { + font-size: 0.9rem; + } + + .error-rollback { + width: 100%; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/faq/faq-create.css b/src/main/resources/static/css/faq/faq-create.css new file mode 100644 index 0000000000000000000000000000000000000000..86fb51cf341a432a3957616412d4a71fd5cf9524 --- /dev/null +++ b/src/main/resources/static/css/faq/faq-create.css @@ -0,0 +1,156 @@ +/* ========================================================== +FAQ CREATE PAGE - MOON BLIGHT EXTENSION +========================================================== */ + +/* FORM WRAPPER */ +.faq-form { + margin-top: 1.5rem; +} + +/* FORM GROUP */ +.form-group { + margin-bottom: 1.5rem; +} + +/* LABEL */ +.form-label { + display: block; + margin-bottom: .5rem; + font-size: .75rem; + letter-spacing: .18em; + text-transform: uppercase; + color: var(--parchment); +} + +/* INPUT / SELECT */ +.form-control { + width: 100%; + padding: .9rem 1rem; + background: rgba(8, 6, 4, .6); + border: 1px solid var(--border-dark); + color: var(--cream); + font-size: .95rem; + transition: .3s ease; + outline: none; +} + +.form-control:focus { + border-color: var(--crimson); + box-shadow: 0 0 15px rgba(196, 30, 58, .2); + background: rgba(28, 20, 16, .4); +} + +/* ERROR TEXT */ +.field-error { + margin-top: .4rem; + font-size: .75rem; + color: #ff8a80; + letter-spacing: .05em; +} + +/* ACTIONS */ +.faq-actions { + display: flex; + gap: 1rem; + justify-content: flex-end; + margin-top: 2rem; +} + +/* BUTTON OVERRIDE (ensure consistency) */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: .7rem 1.2rem; + font-size: .75rem; + letter-spacing: .15em; + text-transform: uppercase; + border: 1px solid var(--border-mid); + background: linear-gradient(180deg, + rgba(28, 20, 16, .9), + rgba(10, 8, 7, .95)); + color: var(--cream); + cursor: pointer; + transition: .3s ease; +} + +.btn:hover { + transform: translateY(-3px); + border-color: var(--crimson); + box-shadow: 0 0 20px rgba(196, 30, 58, .25); + color: #fff; +} + +.btn-primary { + border-color: var(--crimson); +} + +.btn-secondary { + border-color: var(--border-mid); +} + +/* ========================================================== +QUILL EDITOR THEME OVERRIDE +========================================================== */ + +.faq-editor { + min-height: 250px; + background: rgba(8, 6, 4, .6); + border: 1px solid var(--border-dark); + color: var(--cream); +} + +/* Quill toolbar */ +.ql-toolbar.ql-snow { + background: rgba(28, 20, 16, .95); + border: 1px solid var(--border-dark); +} + +.ql-toolbar button { + filter: brightness(.8); +} + +.ql-toolbar button:hover { + filter: brightness(1.2); +} + +/* editor area */ +.ql-container.ql-snow { + border: 1px solid var(--border-dark); + border-top: none; + background: rgba(8, 6, 4, .6); + color: var(--cream); + min-height: 200px; +} + +.ql-editor { + color: var(--cream); + font-family: "Crimson Text", serif; + font-size: 1rem; + line-height: 1.6; +} + +/* placeholder */ +.ql-editor.ql-blank::before { + color: var(--mid-grey); + font-style: italic; +} + +/* focus glow */ +.ql-container:focus-within { + box-shadow: 0 0 20px rgba(196, 30, 58, .15); +} + +/* ========================================================== +RESPONSIVE +========================================================== */ + +@media (max-width: 768px) { + .faq-actions { + flex-direction: column; + } + + .btn { + width: 100%; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/faq/faq-edit.css b/src/main/resources/static/css/faq/faq-edit.css new file mode 100644 index 0000000000000000000000000000000000000000..eed65c31a387c627209f66daeb2896fb6c41860f --- /dev/null +++ b/src/main/resources/static/css/faq/faq-edit.css @@ -0,0 +1,87 @@ +/* ========================================================== +FAQ EDIT PAGE - MOON BLIGHT EXTENSION +========================================================== */ + +/* EDIT MODE VISUAL ACCENT */ +.faq-card { + position: relative; +} + +/* subtle “editing mode” glow */ +.faq-card::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: + radial-gradient(circle at top right, + rgba(196, 30, 58, .08), + transparent 55%); + opacity: .8; +} + +/* HEADER tweak for edit state */ +.section-title { + position: relative; +} + +/* ID hidden field (still safe spacing) */ +input[type="hidden"] { + display: none; +} + +/* FORM ENHANCEMENT */ +.faq-form { + animation: fadeInUp .35s ease; +} + +/* subtle entry animation */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +/* QUILL EDIT MODE ENHANCEMENT */ +.faq-editor { + border: 1px solid var(--border-dark); + transition: .3s ease; +} + +/* stronger focus when editing content */ +.faq-editor:focus-within { + border-color: var(--crimson); + box-shadow: 0 0 25px rgba(196, 30, 58, .18); +} + +/* dirty state hint (optional JS can toggle .is-dirty) */ +.faq-form.is-dirty .btn-primary { + border-color: var(--amber); + box-shadow: 0 0 15px rgba(200, 114, 10, .25); +} + +/* ACTION AREA polish */ +.faq-actions { + position: sticky; + bottom: 0; + background: linear-gradient(180deg, + transparent, + rgba(10, 8, 7, .95)); + padding-top: 1rem; + backdrop-filter: blur(6px); +} + +/* MOBILE */ +@media (max-width: 768px) { + .section-title::after { + position: static; + display: block; + margin-top: .5rem; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/faq/faq.css b/src/main/resources/static/css/faq/faq.css new file mode 100644 index 0000000000000000000000000000000000000000..692bf3123d8068ee05ed16a3e3334b0fcf090372 --- /dev/null +++ b/src/main/resources/static/css/faq/faq.css @@ -0,0 +1,344 @@ + +@import "faq-edit.css"; +@import "faq-create.css"; +@import "../dashboard-user.css"; + +/* ========================================================== + FAQ LIST + ========================================================== */ +body { + margin: 10px; +} +.faq-card { + position: relative; + padding: 2rem; + + background: linear-gradient( + 180deg, + rgba(28, 20, 16, .96), + rgba(10, 8, 7, .98) + ); + + border: 1px solid var(--border-dark); + + overflow: hidden; + + box-shadow: + 0 0 40px rgba(0, 0, 0, .75), + 0 0 25px rgba(139, 0, 0, .08); +} + +.faq-card::before { + content: ""; + + position: absolute; + + top: 0; + left: 0; + right: 0; + + height: 2px; + + background: linear-gradient( + 90deg, + transparent, + var(--crimson), + transparent + ); + + box-shadow: 0 0 12px var(--glow-red); +} + +/* ========================================================== + MESSAGE + ========================================================== */ + +.success-message { + margin-bottom: 1rem; + padding: 1rem 1.25rem; + + color: #81c784; + + background: rgba(129, 199, 132, .08); + + border: 1px solid rgba(129, 199, 132, .2); +} + +.error-message { + margin-bottom: 1rem; + padding: 1rem 1.25rem; + + color: #ff8a80; + + background: rgba(255, 138, 128, .08); + + border: 1px solid rgba(255, 138, 128, .2); +} + +/* ========================================================== + TOP ACTION + ========================================================== */ + +.table-header { + display: flex; + justify-content: flex-end; + + margin-bottom: 1.5rem; +} + +/* ========================================================== + TABLE + ========================================================== */ + +.table-wrapper { + overflow-x: auto; + + border: 1px solid var(--border-dark); + + background: rgba(8, 6, 4, .65); +} + +.faq-table { + width: 100%; + border-collapse: collapse; +} + +.faq-table thead { + background: linear-gradient( + 180deg, + rgba(12, 8, 6, .95), + rgba(8, 6, 4, .98) + ); +} + +.faq-table th { + padding: 1rem; + + text-align: left; + + font-size: .72rem; + letter-spacing: .15em; + text-transform: uppercase; + + color: var(--parchment); + + border-bottom: 1px solid var(--border-dark); +} + +.faq-table td { + padding: 1rem; + + vertical-align: middle; + + color: var(--light-grey); + + border-bottom: 1px solid rgba(74, 48, 40, .35); +} + +.faq-table tbody tr { + transition: .25s ease; +} + +.faq-table tbody tr:hover { + background: rgba(139, 0, 0, .08); +} + +/* ========================================================== + STATUS + ========================================================== */ + +.status-active { + display: inline-flex; + align-items: center; + + padding: .35rem .8rem; + + border-radius: 999px; + + color: #81c784; + + background: rgba(129, 199, 132, .08); + + border: 1px solid rgba(129, 199, 132, .2); + + font-size: .85rem; + font-weight: 600; +} + +.status-deleted { + display: inline-flex; + align-items: center; + + padding: .35rem .8rem; + + border-radius: 999px; + + color: #ff8a80; + + background: rgba(255, 138, 128, .08); + + border: 1px solid rgba(255, 138, 128, .2); + + font-size: .85rem; + font-weight: 600; +} + +/* ========================================================== + ACTION BUTTONS + ========================================================== */ + +.action-buttons { + display: flex; + flex-wrap: wrap; + + gap: .5rem; +} + +.action-buttons form { + margin: 0; +} +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + + padding: 0.75rem 1.25rem; + + text-decoration: none; + cursor: pointer; + + font-size: 0.75rem; + font-weight: 700; + + letter-spacing: 0.15em; + text-transform: uppercase; + + transition: all 0.25s ease; +} + +.btn-primary { + color: var(--cream); + + background: linear-gradient( + 180deg, + rgba(196, 30, 58, .25), + rgba(139, 0, 0, .2) + ); + + border: 1px solid var(--crimson); + + box-shadow: + 0 0 12px rgba(196, 30, 58, .15); +} +.btn-secondary { + + border-color: var(--amber); + + color: var(--amber); + +} +.btn-primary:hover { + background: var(--crimson); + + color: #fff; + + box-shadow: + 0 0 20px rgba(196, 30, 58, .3); +} +/* ========================================================== + PAGINATION + ========================================================== */ + +.pagination { + display: flex; + + justify-content: center; + align-items: center; + + flex-wrap: wrap; + + gap: .5rem; + + margin-top: 2rem; +} + +.page-btn, +.page-current { + display: flex; + + align-items: center; + justify-content: center; + + min-width: 42px; + height: 42px; + + padding: 0 .8rem; + + text-decoration: none; + + border-radius: 8px; +} + +.page-btn { + color: var(--cream); + + border: 1px solid var(--border-dark); + + background: rgba(255,255,255,.02); + + transition: .25s ease; +} + +.page-btn:hover { + border-color: var(--crimson); + + background: rgba(196, 30, 58, .12); +} + +.page-current { + color: #fff; + + background: var(--crimson); + + border: 1px solid var(--crimson); + + font-weight: 600; +} + +/* ========================================================== + PAGE CONTENT + ========================================================== */ + +.section-dark { + width: 100%; +} + +/* ========================================================== + RESPONSIVE + ========================================================== */ + +@media (max-width: 1200px) { + + .faq-table { + min-width: 900px; + } +} + +@media (max-width: 768px) { + + .faq-card { + padding: 1.5rem; + } + + .table-header { + justify-content: center; + } + + .action-buttons { + flex-direction: column; + } + + .action-buttons .btn { + width: 100%; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/layouts/header.css b/src/main/resources/static/css/layouts/header.css new file mode 100644 index 0000000000000000000000000000000000000000..71e286669690a0c7317c03ade3da512da76fada6 --- /dev/null +++ b/src/main/resources/static/css/layouts/header.css @@ -0,0 +1,344 @@ +/* ========================================================== + CMS HEADER + ========================================================== */ + +.cms-header { + position: sticky; + top: 0; + z-index: 1000; + + display: flex; + justify-content: space-between; + align-items: center; + + padding: 16px 24px; + margin-bottom: 24px; + + background: + linear-gradient( + 180deg, + rgba(28,20,16,.98), + rgba(12,10,8,.98) + ); + + border: 1px solid var(--border-dark); + + border-radius: 16px; + + backdrop-filter: blur(14px); + + box-shadow: + 0 10px 35px rgba(0,0,0,.55); + + +} + +/* top glow line */ + +.cms-header::before { + content: ""; + + position: absolute; + top: 0; + left: 0; + right: 0; + + height: 2px; + + background: + linear-gradient( + 90deg, + transparent, + var(--crimson), + transparent + ); + + box-shadow: + 0 0 12px var(--glow-red); +} + +.user-dropdown { + position: relative; + display: inline-block; +} + +.user-summary { + list-style: none; + cursor: pointer; + user-select: none; + padding: 8px 12px; +} + +.user-summary::-webkit-details-marker { + display: none; +} + +.dropdown-menu { + position: absolute; + top: 100%; + right: 0; + min-width: 180px; + background: #fff; + border: 1px solid #ddd; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,.15); + padding: 8px 0; + z-index: 1000; +} + +.dropdown-menu a, +.dropdown-menu button { + display: block; + width: 100%; + padding: 10px 15px; + text-align: left; + background: none; + border: none; + text-decoration: none; + color: #333; + cursor: pointer; +} + +.dropdown-menu a:hover, +.dropdown-menu button:hover { + background: #f5f5f5; +} + +/* Xoay mũi tên khi mở */ +.arrow { + display: inline-block; + transition: transform .2s; +} + +.user-dropdown[open] .arrow { + transform: rotate(180deg); +} +/* ========================================================== + HEADER LEFT + ========================================================== */ + +.header-left { + display: flex; + align-items: center; +} + +.header-left h3 { + margin: 0; + + font-family: 'Cormorant Garamond Decorative', serif; + + font-size: 1.7rem; + font-weight: 700; + + color: var(--parchment); + + letter-spacing: .08em; + + text-shadow: + 0 0 18px rgba(196,30,58,.15); +} + +/* ========================================================== + HEADER RIGHT + ========================================================== */ + +.header-right { + display: flex; + align-items: center; + gap: 1rem; +} + +/* ========================================================== + USER DROPDOWN + ========================================================== */ + +.user-dropdown { + position: relative; +} + +/* remove browser marker */ + +.user-dropdown summary::-webkit-details-marker { + display: none; +} + +.user-summary { + cursor: pointer; + + display: flex; + align-items: center; + gap: 10px; + + padding: 10px 16px; + + border-radius: 12px; + + background: + rgba(255,255,255,.03); + + border: + 1px solid rgba(196,168,130,.15); + + color: var(--cream); + + user-select: none; + + transition: all .3s ease; + + list-style: none; +} + +.user-summary:hover { + background: + rgba(196,30,58,.08); + + border-color: + rgba(196,30,58,.25); + + transform: translateY(-1px); +} + +.user-dropdown[open] .user-summary { + background: + rgba(196,30,58,.12); + + border-color: + rgba(196,30,58,.35); +} + +/* ========================================================== + ARROW + ========================================================== */ + +.arrow { + font-size: .8rem; + opacity: .8; + + transition: .3s ease; +} + +.user-dropdown[open] .arrow { + transform: rotate(180deg); +} + +/* ========================================================== + DROPDOWN MENU + ========================================================== */ + +.dropdown-menu { + position: absolute; + + top: calc(100% + 10px); + right: 0; + + min-width: 220px; + + padding: 10px; + + display: flex; + flex-direction: column; + gap: 6px; + + background: + linear-gradient( + 180deg, + rgba(24,18,14,.98), + rgba(10,8,7,.99) + ); + + border: + 1px solid rgba(196,168,130,.15); + + border-radius: 14px; + + box-shadow: + 0 12px 35px rgba(0,0,0,.55); + + backdrop-filter: blur(12px); + + z-index: 999; +} + +/* ========================================================== + MENU LINKS + ========================================================== */ + +.dropdown-menu a { + text-decoration: none; + + color: var(--cream); + + padding: 12px 14px; + + border-radius: 10px; + + transition: .25s ease; +} + +.dropdown-menu a:hover { + background: + rgba(196,30,58,.08); + + color: var(--parchment); +} + +/* ========================================================== + LOGOUT BUTTON + ========================================================== */ + +.dropdown-menu form { + margin: 0; +} + +.dropdown-menu button { + width: 100%; + + padding: 12px 14px; + + background: transparent; + + border: + 1px solid rgba(255,255,255,.06); + + border-radius: 10px; + + color: #ff9d9d; + + cursor: pointer; + + transition: .25s ease; +} + +.dropdown-menu button:hover { + background: + rgba(139,0,0,.12); + + border-color: + rgba(139,0,0,.25); + + color: #ffd1d1; +} + +/* ========================================================== + RESPONSIVE + ========================================================== */ + +@media (max-width: 900px) { + + .cms-header { + flex-direction: column; + align-items: flex-start; + + gap: 12px; + } + + .header-right { + width: 100%; + justify-content: flex-end; + } + + .dropdown-menu { + right: auto; + left: 0; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/layouts/sidebar.css b/src/main/resources/static/css/layouts/sidebar.css new file mode 100644 index 0000000000000000000000000000000000000000..102829faeb5488c976a021ab2944bdfd7d986e86 --- /dev/null +++ b/src/main/resources/static/css/layouts/sidebar.css @@ -0,0 +1,307 @@ +/* ========================================= + SIDEBAR +========================================= */ + +.sidebar { + width: 280px; + min-height: 100vh; + + padding: 2rem 1.5rem; + + background: + linear-gradient( + 180deg, + rgba(18,12,10,.92), + rgba(10,8,7,.96) + ); + + backdrop-filter: blur(12px); + + border-right: 1px solid rgba(201,164,76,.15); + + box-shadow: + 4px 0 25px rgba(0,0,0,.45); + + position: sticky; + top: 0; +} + +/* ========================================= + TITLE +========================================= */ + +.sidebar-title { + text-align: center; + margin-bottom: 1.5rem; +} + +.sidebar-title h2 { + margin: 0; + + font-family: 'Cormorant Garamond Decorative', serif; + + font-size: 1.6rem; + + color: var(--parchment); + + text-shadow: + 0 0 15px rgba(196,30,58,.2); +} + +/* ========================================= + DIVIDER +========================================= */ + +.sidebar-divider { + border: none; + + height: 1px; + + margin: 1.5rem 0; + + background: + linear-gradient( + 90deg, + transparent, + var(--parchment), + transparent + ); +} + +/* ========================================= + MENU +========================================= */ + +.sidebar-menu { + list-style: none; + padding: 0; + margin: 0; + + display: flex; + flex-direction: column; + gap: .5rem; +} + +/* ========================================= + LINK +========================================= */ + +.sidebar-link { + position: relative; + + display: flex; + align-items: center; + + padding: .9rem 1rem; + + text-decoration: none; + + color: var(--cream); + + border-radius: 12px; + + transition: all .3s ease; + + font-size: .95rem; + + letter-spacing: .05em; +} + +.sidebar-link::before { + content: ""; + + position: absolute; + + left: 0; + top: 50%; + + width: 3px; + height: 0; + + transform: translateY(-50%); + + background: var(--crimson); + + transition: .3s; +} + +/* hover */ + +.sidebar-link:hover { + background: + rgba(196,30,58,.08); + + color: var(--parchment); + + transform: translateX(4px); +} + +.sidebar-link:hover::before { + height: 70%; +} + +/* ========================================= + ACTIVE +========================================= */ + +.sidebar-link.active { + background: + linear-gradient( + 90deg, + rgba(196,30,58,.15), + transparent + ); + + color: var(--parchment); + + border: 1px solid rgba(196,30,58,.15); +} + +.sidebar-link.active::before { + height: 80%; +} + +/* ========================================= + RESPONSIVE +========================================= */ +/* ========================= + SIDEBAR +========================= */ + +.sidebar{ + width:260px; + min-height:100vh; + + padding:2rem 1.5rem; + + background: + linear-gradient( + 180deg, + rgba(28,20,16,.95), + rgba(10,8,7,.98) + ); + + border-right:1px solid var(--border-dark); + + box-shadow: + 4px 0 25px rgba(0,0,0,.4); +} + +/* ========================= + TITLE +========================= */ + +.sidebar-title{ + text-align:center; +} + +.sidebar-title h2{ + margin:0; + + font-family:'Cormorant Garamond Decorative', serif; + + font-size:1.7rem; + + color:var(--parchment); + + letter-spacing:.08em; + + text-shadow: + 0 0 12px rgba(196,30,58,.2); +} + +/* ========================= + DIVIDER +========================= */ + +.sidebar-divider{ + border:none; + + height:1px; + + margin:1.5rem 0; + + background: + linear-gradient( + 90deg, + transparent, + var(--crimson), + transparent + ); +} + +/* ========================= + MENU +========================= */ + +.sidebar-menu{ + list-style:none; + margin:0; + padding:0; +} + +.sidebar-menu li{ + margin-bottom:.5rem; +} + +/* ========================= + LINK +========================= */ + +.sidebar-link{ + display:block; + + padding:.9rem 1rem; + + text-decoration:none; + + color:var(--cream); + + border-radius:12px; + + transition:all .3s ease; +} + +.sidebar-link:hover{ + background: + rgba(196,30,58,.08); + + color:var(--parchment); + + transform:translateX(4px); +} + +/* active */ + +.sidebar-link.active{ + background: + linear-gradient( + 90deg, + rgba(196,30,58,.15), + transparent + ); + + color:var(--parchment); + + border-left:3px solid var(--crimson); +} +@media (max-width: 992px) { + + .sidebar { + width: 100%; + min-height: auto; + + border-right: none; + border-bottom: 1px solid rgba(201,164,76,.15); + } + + .sidebar-menu { + flex-direction: row; + flex-wrap: wrap; + } + + .sidebar-link { + flex: 1; + justify-content: center; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/story/story-create.css b/src/main/resources/static/css/story/story-create.css new file mode 100644 index 0000000000000000000000000000000000000000..c90045a1d91cf789313361dfec86f4f30a9238a5 --- /dev/null +++ b/src/main/resources/static/css/story/story-create.css @@ -0,0 +1,212 @@ +/* ========================================================== +STORY CREATE FORM +========================================================== */ + +.story-form { + margin-top: 1.5rem; +} + +/* FORM GROUP */ +.form-group { + margin-bottom: 1.5rem; +} + +.form-label { + display: block; + margin-bottom: .5rem; + + font-size: .75rem; + letter-spacing: .15em; + text-transform: uppercase; + + color: var(--parchment); +} + +/* INPUTS */ +.form-control { + width: 100%; + padding: .85rem 1rem; + + border: 1px solid var(--border-mid); + outline: none; + + background: rgba(10, 8, 7, .85); + color: var(--cream); + + font-family: "Crimson Text", serif; + + transition: .3s ease; +} + +.form-control:focus { + border-color: var(--crimson); + box-shadow: 0 0 15px var(--glow-red); +} + +/* TEXTAREA */ +.story-textarea { + min-height: 120px; + resize: vertical; +} + +/* FILE INPUT */ +.file-input { + width: 100%; + padding: .6rem; + + background: rgba(10, 8, 7, .85); + border: 1px solid var(--border-mid); + + color: var(--cream); +} + +.file-input::file-selector-button { + background: var(--dark-card); + border: 1px solid var(--border-mid); + color: var(--cream); + + padding: .5rem .8rem; + margin-right: 1rem; + + cursor: pointer; + + transition: .3s; +} + +.file-input::file-selector-button:hover { + border-color: var(--crimson); + box-shadow: 0 0 10px var(--glow-red); +} + +/* ERROR TEXT */ +.field-error { + margin-top: .4rem; + font-size: .8rem; + + color: #ff8a80; +} + +/* ========================================================== +QUILL EDITOR +========================================================== */ + +.story-editor { + height: 320px; + + background: rgba(10, 8, 7, .9); + + border: 1px solid var(--border-mid); + + color: var(--cream); +} + +.ql-toolbar { + background: var(--dark-card); + border: 1px solid var(--border-mid) !important; +} + +.ql-toolbar button { + filter: invert(1); + opacity: .8; + transition: .2s; +} + +.ql-toolbar button:hover { + opacity: 1; + filter: invert(1) drop-shadow(0 0 6px var(--glow-red)); +} + +.ql-container { + border: none !important; + font-family: "Crimson Text", serif; + color: var(--cream); + font-size: 1rem; +} + +.ql-editor { + min-height: 260px; + color: var(--cream); +} + +/* placeholder */ +.ql-editor.ql-blank::before { + color: var(--mid-grey); + font-style: italic; +} + +/* ========================================================== +ACTIONS +========================================================== */ + +.story-actions { + display: flex; + gap: 1rem; + justify-content: flex-end; + margin-top: 2rem; +} + +/* BUTTON OVERRIDE (ensure consistency) */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + + padding: .8rem 1.3rem; + + border: 1px solid var(--border-mid); + + text-decoration: none; + cursor: pointer; + + font-weight: 700; + letter-spacing: .1em; + text-transform: uppercase; + + color: var(--cream); + + background: + linear-gradient( + 180deg, + rgba(28,20,16,.95), + rgba(8,6,4,.95) + ); + + transition: .3s ease; +} + +.btn:hover { + transform: translateY(-2px); +} + +.btn-primary { + border-color: var(--crimson); +} + +.btn-primary:hover { + box-shadow: 0 0 18px var(--glow-red); +} + +.btn-secondary:hover { + border-color: var(--amber); + box-shadow: 0 0 15px var(--glow-amber); +} + +/* ========================================================== +RESPONSIVE +========================================================== */ + +@media (max-width: 768px) { + + .story-actions { + flex-direction: column; + align-items: stretch; + } + + .btn { + width: 100%; + } + + .story-editor { + height: 250px; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/story/story-edit.css b/src/main/resources/static/css/story/story-edit.css new file mode 100644 index 0000000000000000000000000000000000000000..6514aaddc2f92b29dd2508a6dafb4f54097dbc8c --- /dev/null +++ b/src/main/resources/static/css/story/story-edit.css @@ -0,0 +1,142 @@ +/* ========================================================== +STORY EDIT PAGE (UPDATE) +========================================================== */ + +/* IMAGE PREVIEW */ +.story-image-preview { + display: block; + max-width: 220px; + max-height: 160px; + + margin-top: .5rem; + + border: 1px solid var(--border-mid); + + object-fit: cover; + + box-shadow: + 0 0 20px rgba(0,0,0,.5), + 0 0 10px rgba(139,0,0,.15); +} + +/* FORM TEXTAREA OVERRIDE */ +.textarea-control { + min-height: 120px; + resize: vertical; + + background: rgba(10, 8, 7, .85); + color: var(--cream); + + border: 1px solid var(--border-mid); + + padding: .85rem 1rem; + + font-family: "Crimson Text", serif; + + transition: .3s ease; +} + +.textarea-control:focus { + border-color: var(--crimson); + box-shadow: 0 0 15px var(--glow-red); + outline: none; +} + +/* DIFFERENTIATE EDIT PAGE HEADER (optional subtle accent) */ +.story-card .section-eyebrow { + letter-spacing: .4em; +} + +/* EDIT FORM DIFFERENCE: highlight update context */ +.story-card { + position: relative; +} + +.story-card::after { + content: ""; + position: absolute; + + bottom: 0; + left: 0; + right: 0; + + height: 2px; + + background: + linear-gradient( + 90deg, + transparent, + var(--amber), + transparent + ); + + opacity: .6; +} + +/* FILE INPUT (reuse consistency enhancement) */ +.file-input { + width: 100%; + padding: .6rem; + + background: rgba(10, 8, 7, .85); + border: 1px solid var(--border-mid); + + color: var(--cream); + + transition: .3s ease; +} + +.file-input::file-selector-button { + background: var(--dark-card); + border: 1px solid var(--border-mid); + color: var(--cream); + + padding: .5rem .8rem; + margin-right: 1rem; + + cursor: pointer; + + transition: .3s ease; +} + +.file-input::file-selector-button:hover { + border-color: var(--amber); + box-shadow: 0 0 12px var(--glow-amber); +} + +/* FORM SPACING TIGHTENING FOR EDIT MODE */ +.story-form .form-group { + margin-bottom: 1.3rem; +} + +/* ACTION BUTTON DIFFERENTIATION */ +.btn-primary { + border-color: var(--crimson); +} + +.btn-primary:hover { + box-shadow: 0 0 18px var(--glow-red); +} + +/* Make edit page slightly more "serious" tone */ +.story-card { + background: + linear-gradient( + 180deg, + rgba(28, 20, 16, .98), + rgba(8, 6, 4, .99) + ); +} + +/* RESPONSIVE IMPROVEMENT */ +@media (max-width: 768px) { + + .story-image-preview { + max-width: 100%; + height: auto; + } + + .textarea-control { + min-height: 100px; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/story/story.css b/src/main/resources/static/css/story/story.css new file mode 100644 index 0000000000000000000000000000000000000000..023b0a16160aeba381e447a3d40763f8c4a19197 --- /dev/null +++ b/src/main/resources/static/css/story/story.css @@ -0,0 +1,347 @@ +@import "../dashboard-user.css"; +@import "story-create.css"; +@import "story-edit.css"; +/* ========================================================== + STORY LIST + ========================================================== */ +body { + margin: 10px; +} +.story-card { + position: relative; + padding: 2rem; + + background: linear-gradient( + 180deg, + rgba(28, 20, 16, .96), + rgba(10, 8, 7, .98) + ); + + border: 1px solid var(--border-dark); + + overflow: hidden; + + box-shadow: + 0 0 40px rgba(0, 0, 0, .75), + 0 0 25px rgba(139, 0, 0, .08); +} + +.story-card::before { + content: ""; + + position: absolute; + + top: 0; + left: 0; + right: 0; + + height: 2px; + + background: linear-gradient( + 90deg, + transparent, + var(--crimson), + transparent + ); + + box-shadow: 0 0 12px var(--glow-red); +} + +/* ========================================================== + MESSAGE + ========================================================== */ + +.success-message { + margin-bottom: 1rem; + padding: 1rem 1.25rem; + + color: #81c784; + + background: rgba(129, 199, 132, .08); + + border: 1px solid rgba(129, 199, 132, .2); +} + +.error-message { + margin-bottom: 1rem; + padding: 1rem 1.25rem; + + color: #ff8a80; + + background: rgba(255, 138, 128, .08); + + border: 1px solid rgba(255, 138, 128, .2); +} + +/* ========================================================== + TOP ACTIONS + ========================================================== */ + +.top-actions { + display: flex; + justify-content: flex-end; + + margin-bottom: 1.5rem; +} + +/* ========================================================== + TABLE + ========================================================== */ + +.table-wrapper { + overflow-x: auto; + + border: 1px solid var(--border-dark); + + background: rgba(8, 6, 4, .65); +} + +.story-table { + width: 100%; + border-collapse: collapse; +} + +.story-table thead { + background: linear-gradient( + 180deg, + rgba(12, 8, 6, .95), + rgba(8, 6, 4, .98) + ); +} + +.story-table th { + padding: 1rem; + + text-align: left; + + font-size: .72rem; + letter-spacing: .15em; + text-transform: uppercase; + + color: var(--parchment); + + border-bottom: 1px solid var(--border-dark); +} + +.story-table td { + padding: 1rem; + + vertical-align: middle; + + color: var(--light-grey); + + border-bottom: 1px solid rgba(74, 48, 40, .35); +} + +.story-table tbody tr { + transition: .25s ease; +} + +.story-table tbody tr:hover { + background: rgba(139, 0, 0, .08); +} + +/* ========================================================== + IMAGE + ========================================================== */ + +.table-image { + width: 90px; + height: 56px; + + object-fit: cover; + + border-radius: 4px; + + border: 1px solid var(--border-dark); +} + +/* ========================================================== + STATUS + ========================================================== */ + +.status-active { + display: inline-flex; + align-items: center; + + padding: .35rem .8rem; + + border-radius: 999px; + + color: #81c784; + + background: rgba(129, 199, 132, .08); + + border: 1px solid rgba(129, 199, 132, .2); + + font-size: .85rem; + font-weight: 600; +} + +.status-deleted { + display: inline-flex; + align-items: center; + + padding: .35rem .8rem; + + border-radius: 999px; + + color: #ff8a80; + + background: rgba(255, 138, 128, .08); + + border: 1px solid rgba(255, 138, 128, .2); + + font-size: .85rem; + font-weight: 600; +} + +/* ========================================================== + ACTION BUTTONS + ========================================================== */ + +.action-group { + display: flex; + flex-wrap: wrap; + + gap: .5rem; +} + +.action-group form { + margin: 0; +} + +/* ========================================================== + PAGINATION + ========================================================== */ + +.pagination { + display: flex; + + justify-content: center; + align-items: center; + + flex-wrap: wrap; + + gap: .5rem; + + margin-top: 2rem; +} + +.page-btn, +.page-number { + display: flex; + + align-items: center; + justify-content: center; + + min-width: 42px; + height: 42px; + + padding: 0 .8rem; + + text-decoration: none; + + border-radius: 8px; +} +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + + padding: 0.75rem 1.25rem; + + text-decoration: none; + cursor: pointer; + + font-size: 0.75rem; + font-weight: 700; + + letter-spacing: 0.15em; + text-transform: uppercase; + + transition: all 0.25s ease; +} + +.btn-primary { + color: var(--cream); + + background: linear-gradient( + 180deg, + rgba(196, 30, 58, .25), + rgba(139, 0, 0, .2) + ); + + border: 1px solid var(--crimson); + + box-shadow: + 0 0 12px rgba(196, 30, 58, .15); +} +.btn-secondary { + + border-color: var(--amber); + + color: var(--amber); + +} +.page-btn, +.page-number { + color: var(--cream); + + border: 1px solid var(--border-dark); + + background: rgba(255, 255, 255, .02); +} + +.page-btn:hover, +.page-number:hover { + border-color: var(--crimson); + + background: rgba(196, 30, 58, .12); +} + +.page-number.active { + background: var(--crimson); + + border-color: var(--crimson); + + color: #fff; +} + +/* ========================================================== + PAGE CONTENT + ========================================================== */ + +.section-dark { + width: 100%; +} + +/* ========================================================== + RESPONSIVE + ========================================================== */ + +@media (max-width: 1200px) { + + .story-table { + min-width: 1200px; + } +} + +@media (max-width: 768px) { + + .story-card { + padding: 1.5rem; + } + + .top-actions { + justify-content: center; + } + + .action-group { + flex-direction: column; + } + + .action-group .btn { + width: 100%; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css new file mode 100644 index 0000000000000000000000000000000000000000..b48691a34b77fcaac5f1b57b9b9bbb496438ef4f --- /dev/null +++ b/src/main/resources/static/css/styles.css @@ -0,0 +1,2725 @@ +/* ============================================================ + DARK FANTASY / GOTHIC HORROR — VEIL OF PERDITION + Core Stylesheet + ============================================================ */ + +@import url('https://fonts.googleapis.com/css2?family=Cormorant Garamond:wght@400;600;700;900&family=Cormorant Garamond+Decorative:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400;1,600&family=IM+Fell+English:ital@0;1&display=swap'); + +/* ---- CSS Variables ---- */ +:root { + --blood-red: #8B0000; + --crimson: #C41E3A; + --deep-red: #4A0808; + --black-coal: #080604; + --dark-bg: #0D0A07; + --dark-surface: #160E0A; + --dark-card: #1C1410; + --burnt-orange: #A84800; + --amber: #C8720A; + --ash-grey: #4A4240; + --mid-grey: #8F7E76; + /* --mid-grey: #6B5E58; */ + + --light-grey: #C8C1BC; + --parchment: #C4A882; + --cream: #DDD0B8; + --gold: #C9A44C; + --bright-gold: #E8C060; + --border-dark: #2E201A; + --border-mid: #4A3028; + --shadow-red: rgba(139, 0, 0, 0.25); + --shadow-dark: rgba(0, 0, 0, 0.9); + --glow-red: rgba(196, 30, 58, 0.4); + --glow-amber: rgba(200, 114, 10, 0.35); +} + +/* ---- Reset & Base ---- */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + scroll-behavior: smooth; +} + +body { + background-color: var(--black-coal); + color: var(--cream); + font-family: 'Crimson Text', Georgia, serif; + font-size: 18px; + line-height: 1.7; + overflow-x: hidden; + background: #0f0f0f; + margin: 0; +} + +/* body{ + margin:0; + background:#0f0f0f; +} */ +#sandstorm { + position: fixed; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 10; + opacity: 0.4; +} + +.fog-container { + position: fixed; + inset: 0; + pointer-events: none; + overflow: hidden; + z-index: 10; +} + +.fog { + position: absolute; + width: 300vw; + height: 100vh; + top: 0; + left: 0; + filter: blur(120px); + /* blur mạnh = mềm mại, không thấy viền */ + will-change: transform; +} + +.fog1 { + background: + radial-gradient(ellipse 800px 500px at 200px 300px, rgba(120, 0, 0, 0.12), transparent 70%), + radial-gradient(ellipse 600px 400px at 1000px 100px, rgba(80, 0, 0, 0.10), transparent 70%), + radial-gradient(ellipse 700px 300px at 1800px 400px, rgba(100, 20, 0, 0.08), transparent 70%); + animation: fogMove 40s linear infinite; +} + +.fog2 { + background: + radial-gradient(ellipse 900px 600px at 500px 400px, rgba(60, 0, 0, 0.08), transparent 70%), + radial-gradient(ellipse 500px 400px at 1400px 200px, rgba(90, 10, 0, 0.10), transparent 70%); + animation: fogMoveReverse 65s linear infinite; + top: -10%; +} + +.fog3 { + background: + radial-gradient(ellipse 700px 500px at 800px 300px, rgba(100, 0, 0, 0.06), transparent 70%), + radial-gradient(ellipse 600px 300px at 200px 500px, rgba(80, 20, 0, 0.08), transparent 70%); + animation: fogMove 90s linear infinite; + top: 20%; +} + +@keyframes fogMove { + from { + transform: translateX(0); + } + + to { + transform: translateX(-100vw); + } +} + +@keyframes fogMoveReverse { + from { + transform: translateX(-100vw); + } + + to { + transform: translateX(0); + } +} + +a { + color: var(--parchment); + text-decoration: none; + transition: color 0.3s ease; +} + +a:hover { + color: var(--crimson); +} + +img { + max-width: 100%; + display: block; +} + +/* ================================================================================================= */ + + + + + +/* ==================================================================================================== */ +/* ---- Typography ---- */ +h1, h2, h3, h4 { + font-family: 'Cormorant Garamond', serif; + font-weight: 700; + letter-spacing: 0.08em; + line-height: 1.2; +} + +h1 { + font-size: clamp(2.2rem, 5vw, 4rem); +} + +h2 { + font-size: clamp(1.6rem, 3vw, 2.6rem); +} + +h3 { + font-size: clamp(1.2rem, 2vw, 1.8rem); +} + +h4 { + font-size: 1.1rem; +} + +.font-Cormorant Garamond { + font-family: 'Cormorant Garamond', serif; +} + +.font-Cormorant Garamond-deco { + font-family: 'Cormorant Garamond Decorative', serif; +} + +.font-im-fell { + font-family: 'Unna', serif; + font-style: italic; +} + +.font-crimson { + font-family: 'Crimson Text', serif; +} + +/* ---- Scrollbar ---- */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: var(--black-coal); +} + +::-webkit-scrollbar-thumb { + background: var(--blood-red); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--crimson); +} + + + +/* ============================================================ + video + ============================================================ */ +/* ========================= + VIDEO SECTION +========================= */ + +.video-section { + padding: 7rem 0; +} + +.trailer-card { + position: relative; + overflow: hidden; + border: 1px solid var(--border-dark); +} + +.trailer-bg { + position: relative; + height: 550px; + + background-size: cover; + background-position: center; + + display: flex; + align-items: center; + justify-content: center; +} + +.trailer-overlay { + position: absolute; + inset: 0; + + background: + linear-gradient(to bottom, + rgba(0, 0, 0, 0.3), + rgba(0, 0, 0, 0.8)); +} + +.trailer-content { + position: relative; + z-index: 2; + + text-align: center; +} + +.trailer-tag { + display: inline-block; + + margin-bottom: 1rem; + + font-size: 0.7rem; + letter-spacing: 0.3em; + text-transform: uppercase; + + color: var(--accent-gold); +} + +.trailer-title { + font-size: 4rem; + text-transform: uppercase; + letter-spacing: 0.1em; + + margin-bottom: 2rem; + + color: white; +} + +.watch-btn { + + padding: 1rem 2.5rem; + + border: 1px solid var(--accent-gold); + + background: transparent; + + color: white; + + text-transform: uppercase; + letter-spacing: 0.2em; + + cursor: pointer; + + transition: 0.3s; +} + +.watch-btn:hover { + + background: var(--accent-gold); + color: black; + + transform: translateY(-2px); +} + +/* ========================= + VIDEO MODAL +========================= */ + +.video-modal { + + position: fixed; + inset: 0; + + background: rgba(0, 0, 0, 0.92); + + backdrop-filter: blur(8px); + + display: none; + + justify-content: center; + align-items: center; + + z-index: 99999; +} + +.video-container { + + width: 900px; + + animation: fadeIn 0.3s ease; +} + +.video-container video { + + width: 100%; + + border: 1px solid var(--border-dark); + + box-shadow: 0 0 40px rgba(0, 0, 0, 0.8); +} + +/* ========================= + ANIMATION +========================= */ + +@keyframes fadeIn { + + from { + opacity: 0; + transform: scale(0.95); + } + + to { + opacity: 1; + transform: scale(1); + } +} + + +/* ============================================================ + NAVBAR + ============================================================ */ +.navbar { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1000; + background: linear-gradient(180deg, rgba(8, 6, 4, 0.98) 0%, rgba(8, 6, 4, 0.85) 100%); + border-bottom: 1px solid var(--border-dark); + padding: 0 2rem; + height: 68px; + display: flex; + align-items: center; + justify-content: space-between; + backdrop-filter: blur(8px); + box-shadow: 0 2px 30px rgba(0, 0, 0, 0.8), 0 1px 0 rgba(139, 0, 0, 0.3); +} + +.navbar-brand { + font-family: 'Cormorant Garamond Decorative', serif; + font-size: 1.8rem; + font-weight: 700; + color: var(--crimson); + letter-spacing: 0.05em; + text-transform: uppercase; + text-shadow: 0 0 20px var(--glow-red); + transition: all 0.3s; +} + +.navbar-brand:hover { + color: var(--bright-gold); + text-shadow: 0 0 25px var(--glow-amber); +} + +.navbar-nav { + display: flex; + list-style: none; + gap: 0; + align-items: center; +} + +.navbar-nav .nav-item a { + font-family: 'Cormorant Garamond', serif; + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--light-grey); + padding: 0.5rem 0.85rem; + display: block; + position: relative; + transition: color 0.3s ease; +} + +.navbar-nav .nav-item a::after { + content: ''; + position: absolute; + bottom: -2px; + left: 50%; + transform: translateX(-50%); + width: 0; + height: 1px; + background: var(--crimson); + transition: width 0.3s ease; + box-shadow: 0 0 8px var(--glow-red); +} + +.navbar-nav .nav-item a:hover, +.navbar-nav .nav-item a.active { + color: var(--cream); +} + +.navbar-nav .nav-item a:hover::after, +.navbar-nav .nav-item a.active::after { + width: 80%; +} + +.navbar-nav .nav-item a.active { + color: var(--parchment); +} + +.navbar-cta { + font-family: 'Cormorant Garamond', serif; + font-size: 0.7rem; + font-weight: 100; + letter-spacing: 0.15em; + text-transform: uppercase; + padding: 0.5rem 1.4rem; + background: transparent; + border: 1px solid var(--crimson); + color: var(--crimson); + cursor: pointer; + transition: all 0.3s ease; + text-decoration: none; + display: inline-block; + box-shadow: 0 0 10px rgba(139, 0, 0, 0.2); +} + +.navbar-cta:hover { + background: var(--crimson); + color: var(--cream); + box-shadow: 0 0 20px var(--glow-red); +} + +.hamburger { + display: none; + flex-direction: column; + gap: 5px; + cursor: pointer; + padding: 4px; +} + +.hamburger span { + width: 24px; + height: 2px; + background: var(--parchment); + transition: all 0.3s; +} + +/* ============================================================ + HERO SECTION + ============================================================ */ +.hero { + min-height: 100vh; + position: relative; + display: flex; + align-items: center; + overflow: hidden; + padding-top: 68px; +} + +.hero-bg { + position: absolute; + inset: 0; + background-image: url(../img/back-groud.png); + background-size: cover; + background-position: center 100%; + /* filter: brightness(1) saturate(0.9) sepia(0.8); */ +} + +.hero-overlay { + position: absolute; + inset: 0; + /* background: linear-gradient(135deg, + rgba(8, 6, 4, 0.95) 0%, + rgba(74, 8, 8, 0.6) 20%, + rgba(8, 6, 4, 0.85) 30%); */ +} + +.hero-vignette { + position: absolute; + inset: 0; + background: radial-gradient(ellipse at center, transparent 20%, rgba(0, 0, 0, 0.85) 80%); +} + +/* ================================================ */ + +.content-wrapper { + position: relative; + + background: var(--dark-bg); + min-height: 100vh; + /* background: var(--dark-bg); nền đặc để che hero phía sau */ +} + +.hero { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100vh; + /* đổi min-height thành height cố định */ + z-index: 1; + overflow: hidden; +} + +.hero-spacer { + height: 100vh; + pointer-events: none; + /* không chặn click */ + display: block; +} + +.section-stack { + position: relative; + z-index: 20; + margin-top: 0px; + /* background: var(--dark-bg); */ + border-radius: 20px 20px 0 0; + /* box-shadow: 0 -30px 80px rgba(0, 0, 0, 0.95); */ +} + +/* ============================================================= */ + +.hero-content { + position: relative; + z-index: 1; + width: 55%; + padding-left: 9%; + + + display: flex; + flex-direction: column; + justify-content: center; + max-width: 1800px; + + gap: 4rem; +} + +/* .hero-left {} */ + +.hero-eyebrow { + font-family: 'Cormorant Garamond', serif; + font-size: 0.75rem; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--crimson); + margin-bottom: 1.2rem; + display: flex; + align-items: center; + gap: 0.8rem; +} + +.hero-eyebrow::before, +.hero-eyebrow::after { + content: ''; + flex: 1; + max-width: 80px; + height: 1px; + background: var(--crimson); + opacity: 0.6; +} + +.hero-title { + font-family: 'Cormorant Garamond Decorative', serif; + font-size: clamp(3rem, 6.2vw, 5.7rem); + font-weight: 800; + line-height: 1.1; + color: var(--cream); + text-shadow: 0 0 60px rgba(172, 11, 11, 0.647), 3px 4px 25px rgba(77, 5, 5, 0.9); + margin-bottom: 0.4rem; + +} + +.hero-title-sub { + font-family: 'Lora', serif; + font-size: clamp(0.6rem, 1rem); + /* letter-spacing: 0.3em; */ + text-transform: uppercase; + color: var(--crimson); + margin-bottom: 1.8rem; + text-shadow: 0 0 15px var(--glow-red); +} + +.hero-divider { + width: 120px; + height: 2px; + background: linear-gradient(90deg, transparent, var(--crimson), transparent); + margin-bottom: 1.5rem; +} + +.hero-desc { + font-family: 'EB Garamond', serif; + font-style: italic; + font-size: 1.15rem; + color: var(--light-grey); + line-height: 1.8; + margin-bottom: 2.5rem; + max-width: 750px; +} + +.hero-buttons { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.hero-right { + position: relative; +} + +.hero-stat-card { + background: rgba(22, 14, 10, 0.9); + border: 1px solid var(--border-mid); + padding: 2rem; + position: relative; + overflow: hidden; +} + +.hero-stat-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--crimson), transparent); +} + +.stat-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; +} + +.stat-item { + text-align: center; + padding: 1rem; + border: 1px solid var(--border-dark); + background: rgba(8, 6, 4, 0.5); + transition: all 0.3s; +} + +.stat-item:hover { + border-color: var(--blood-red); + box-shadow: 0 0 20px var(--shadow-red); +} + +.stat-number { + font-family: 'Cormorant Garamond Decorative', serif; + font-size: 2.2rem; + font-weight: 700; + color: var(--crimson); + text-shadow: 0 0 15px var(--glow-red); + display: block; +} + +.stat-label { + font-family: 'Cormorant Garamond', serif; + font-size: 0.65rem; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--mid-grey); + margin-top: 0.3rem; +} + +/* ============================================================ + BUTTONS + ============================================================ */ +.btn { + font-family: 'Lora', serif; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.2em; + text-transform: uppercase; + padding: 0.85rem 2.2rem; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.6rem; + transition: all 0.3s ease; + text-decoration: none; + border: none; + position: relative; + overflow: hidden; +} + +.btn::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.08), transparent); + transition: left 0.5s; +} + +.btn:hover::before { + left: 100%; +} + +.btn-primary { + background: var(--blood-red); + color: var(--cream); + border: 1px solid var(--crimson); + box-shadow: 0 0 20px rgba(139, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +.btn-primary:hover { + background: var(--crimson); + box-shadow: 0 0 35px var(--glow-red), 0 4px 20px rgba(0, 0, 0, 0.5); + color: var(--cream); + transform: translateY(-1px); +} + +.btn-secondary { + background: transparent; + color: var(--parchment); + border: 1px solid var(--border-mid); +} + +.btn-secondary:hover { + border-color: var(--parchment); + color: var(--cream); + background: rgba(196, 168, 130, 0.08); + transform: translateY(-1px); +} + +.btn-outline-red { + background: transparent; + color: var(--crimson); + border: 1px solid var(--crimson); +} + +.btn-outline-red:hover { + background: var(--crimson); + color: var(--cream); + box-shadow: 0 0 25px var(--glow-red); +} + +.btn-gold { + background: linear-gradient(135deg, var(--gold), var(--amber)); + color: var(--black-coal); + border: 1px solid var(--bright-gold); + font-weight: 900; +} + +.btn-gold:hover { + background: linear-gradient(135deg, var(--bright-gold), var(--gold)); + box-shadow: 0 0 25px var(--glow-amber); + color: var(--black-coal); + transform: translateY(-1px); +} + +/* ============================================================ + PAGE HERO (Inner Pages) + ============================================================ */ +.page-hero { + padding: 8rem 2rem 4rem; + position: relative; + text-align: center; + overflow: hidden; + min-height: 360px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.page-hero-bg { + position: absolute; + inset: 0; + background-size: cover; + background-position: center; + filter: brightness(1.3); +} + +.page-hero-overlay { + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(8, 6, 4, 0.7) 0%, rgba(8, 6, 4, 0.5) 50%, rgba(8, 6, 4, 0.95) 100%); +} + +.page-hero-content { + position: relative; + z-index: 1; +} + +.page-hero-eyebrow { + font-family: 'Lora', serif; + font-size: 0.75rem; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--crimson); + margin-bottom: 1rem; +} + +.page-hero-title { + font-family: 'Cormorant Garamond Decorative', serif; + font-size: clamp(2.5rem, 5vw, 4.5rem); + color: var(--cream); + text-shadow: 0 0 40px rgba(139, 0, 0, 0.6), 2px 4px 20px rgba(0, 0, 0, 0.9); + margin-bottom: 1rem; +} + +.page-hero-subtitle { + font-family: 'Unna', serif; + font-style: italic; + font-size: 1.15rem; + color: var(--mid-grey); + max-width: 560px; +} + +/* ============================================================ + wrapper + ============================================================ */ + +/* CSS cho wrapper */ +.body-container {} + +.parallax-wrapper { + position: relative; + z-index: 2; +} + +.parallax-wrapper::before { + content: ""; + position: absolute; + inset: 0; + background-image: url('../img/backback.png'); + background-size: cover; + background-position: center; + background-attachment: fixed; + z-index: -1; +} + +/* Các section bên trong phải có nền bán trong suốt, KHÔNG dùng màu đặc */ +.parallax-wrapper .section-dark { + background: rgba(15, 12, 10, 0.60); +} + +.parallax-wrapper .section-darker { + background: rgba(15, 12, 10, 0.60); +} + + +.parallax-wrapper .section-world { + background: rgba(15, 12, 10, 0.60); +} + +/* ============================================================ + SECTION COMMON + ============================================================ */ +.section { + padding: 5rem 2rem; + position: relative; +} + +/* .section-dark { + background-color: var(--dark-bg); +} + +.section-darker { + background-color: var(--black-coal); +} + +.section-surface { + background-color: var(--dark-bg); +} */ + +.container { + max-width: 1300px; + margin: 0 auto; +} + +.container-sm { + max-width: 860px; + margin: 0 auto; +} + +.section-header { + text-align: center; + margin-bottom: 3.5rem; +} + +.section-eyebrow { + font-family: 'Cinzel', serif; + font-size: 1rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--crimson); + margin-bottom: 0.8rem; + display: block; +} + +.section-title { + font-family: 'Cormorant Garamond', serif; + font-size: clamp(2rem, 3.3vw, 3rem); + color: var(--cream); + text-shadow: 2px 2px 15px rgba(0, 0, 0, 0.8); + margin-bottom: 1rem; +} + +.section-subtitle { + font-family: 'Unna', serif; + font-style: italic; + font-size: 1i45rem; + color: var(--mid-grey); + max-width: 540px; + margin: 0 auto; +} + +.section-divider { + width: 120px; + height: 1px; + background: linear-gradient(90deg, transparent, var(--crimson), transparent); + margin: 1.2rem auto 0; +} + +/* ============================================================ + DECORATIVE ELEMENTS + ============================================================ */ +.ornament { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1.2rem; + justify-content: center; +} + +.ornament-line { + flex: 1; + max-width: 60px; + height: 1px; + background: linear-gradient(90deg, transparent, var(--crimson)); +} + +.ornament-line.reverse { + background: linear-gradient(90deg, var(--crimson), transparent); +} + +.ornament-symbol { + color: var(--crimson); + font-size: 1rem; +} + +.blood-drip-border { + border-top: 2px solid var(--blood-red); + position: relative; +} + +.blood-drip-border::after { + content: ''; + position: absolute; + top: -1px; + left: 0; + right: 0; + height: 40px; + background: linear-gradient(180deg, rgba(139, 0, 0, 0.15) 0%, transparent 100%); +} + +/* ============================================================ + CARDS + ============================================================ */ +.card { + background: var(--dark-card); + border: 1px solid var(--border-dark); + position: relative; + overflow: hidden; + transition: all 0.4s ease; +} + +.card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--blood-red), transparent); + opacity: 0; + transition: opacity 0.3s; +} + +.card:hover { + border-color: var(--border-mid); + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.7), 0 0 30px var(--shadow-red); + transform: translateY(-4px); +} + +.card:hover::before { + opacity: 1; +} + +.card-img { + width: 100%; + height: 220px; + object-fit: cover; + filter: brightness(0.75) saturate(0.7) sepia(0.2); + transition: filter 0.4s; +} + +.card:hover .card-img { + filter: brightness(0.9) saturate(0.85) sepia(0.1); +} + +.card-body { + padding: 1.5rem; +} + +.card-tag { + font-family: 'Lora', serif; + font-size: 0.8rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--crimson); + margin-bottom: 0.6rem; + display: block; +} + +.card-title { + font-family: 'Cormorant Garamond', serif; + font-size: 1.15rem; + font-weight: 700; + color: var(--cream); + margin-bottom: 0.6rem; + letter-spacing: 0.05em; +} + +.card-text { + font-family: 'Crimson Text', serif; + font-size: 0.95rem; + color: var(--mid-grey); + line-height: 1.6; +} + +.card-footer { + padding: 1rem 1.5rem; + border-top: 1px solid var(--border-dark); + display: flex; + align-items: center; + justify-content: space-between; +} + +/* ============================================================ + CHARACTER CARDS + ============================================================ */ +.character-card { + background: var(--dark-card); + border: 1px solid var(--border-dark); + overflow: hidden; + transition: all 0.4s; + position: relative; +} + +.character-card:hover { + border-color: var(--blood-red); + box-shadow: 0 0 40px var(--shadow-red), 0 8px 30px rgba(0, 0, 0, 0.8); + transform: translateY(-5px); +} + +.character-card-img { + width: 100%; + height: 300px; + object-fit: cover; + object-position: top; + filter: brightness(0.7) saturate(0.6) sepia(0.25); + transition: all 0.5s; +} + +.character-card:hover .character-card-img { + filter: brightness(0.9) saturate(0.85) sepia(0.1); + transform: scale(1.04); +} + +.character-card-body { + padding: 1.4rem 1.6rem 1.6rem; + background: linear-gradient(180deg, var(--dark-card) 0%, rgba(8, 6, 4, 0.95) 100%); +} + +.character-class { + font-family: 'Cormorant Garamond', serif; + font-size: 0.8rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--amber); + margin-bottom: 0.4rem; +} + +.character-name { + font-family: 'Cormorant Garamond', serif; + font-size: 1.5rem; + font-weight: 700; + color: var(--cream); + margin-bottom: 0.5rem; +} + +.character-quote { + font-family: 'Unna', serif; + font-style: italic; + font-size: 0.9rem; + color: var(--mid-grey); + margin-bottom: 1rem; + border-left: 2px solid var(--blood-red); + padding-left: 0.8rem; +} + +.stat-bars { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.stat-bar-row { + display: flex; + align-items: center; + gap: 0.6rem; +} + +.stat-bar-label { + font-family: 'Cormorant Garamond', serif; + font-size: 0.55rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--ash-grey); + width: 50px; + flex-shrink: 0; +} + +.stat-bar-track { + flex: 1; + height: 4px; + background: var(--border-dark); + position: relative; + overflow: hidden; +} + +.stat-bar-fill { + height: 100%; + background: linear-gradient(90deg, var(--blood-red), var(--crimson)); + box-shadow: 0 0 8px var(--glow-red); + transition: width 0.8s ease; +} + +.stat-bar-fill.amber { + background: linear-gradient(90deg, var(--burnt-orange), var(--amber)); + box-shadow: 0 0 8px var(--glow-amber); +} + +/* ============================================================ + ENEMY CARDS + ============================================================ */ +.enemy-card { + background: linear-gradient(180deg, rgba(28, 20, 16, 0.95) 0%, rgba(8, 6, 4, 0.98) 100%); + border: 1px solid var(--border-dark); + padding: 1.8rem; + position: relative; + overflow: hidden; + transition: all 0.4s; +} + +.enemy-card::after { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(ellipse at 50% 0%, rgba(139, 0, 0, 0.08) 0%, transparent 70%); + opacity: 0; + transition: opacity 0.4s; +} + +.enemy-card:hover { + border-color: var(--blood-red); + box-shadow: 0 0 40px rgba(139, 0, 0, 0.2), 0 10px 40px rgba(0, 0, 0, 0.8); + transform: translateY(-4px); +} + +.enemy-card:hover::after { + opacity: 1; +} + +.enemy-icon { + font-size: 3rem; + margin-bottom: 1rem; + display: block; + filter: drop-shadow(0 0 10px rgba(139, 0, 0, 0.5)); +} + +.enemy-type { + font-family: 'Cormorant Garamond', serif; + font-size: 0.58rem; + letter-spacing: 0.3em; + text-transform: uppercase; + color: var(--crimson); + margin-bottom: 0.4rem; +} + +.enemy-name { + font-family: 'Cormorant Garamond', serif; + font-size: 1.2rem; + font-weight: 700; + color: var(--cream); + margin-bottom: 0.5rem; +} + +.enemy-desc { + font-family: 'Crimson Text', serif; + font-size: 0.9rem; + color: var(--mid-grey); + line-height: 1.6; + margin-bottom: 1rem; +} + +.enemy-threat { + display: inline-block; + font-family: 'Cormorant Garamond', serif; + font-size: 0.55rem; + letter-spacing: 0.2em; + text-transform: uppercase; + padding: 0.2rem 0.7rem; + border: 1px solid; +} + +.threat-low { + border-color: #4a7c59; + color: #6aaf82; +} + +.threat-medium { + border-color: var(--amber); + color: var(--amber); +} + +.threat-high { + border-color: var(--crimson); + color: var(--crimson); +} + +.threat-extreme { + border-color: #FF0000; + color: #FF4444; + box-shadow: 0 0 10px rgba(255, 0, 0, 0.3); +} + +/* ============================================================ + back-ground img + ============================================================ */ + + +.backgroud-img { + position: relative; + z-index: 2; +} + +.backgroud-img::before { + content: ""; + position: absolute; + inset: 0; + background-image: url('../img/black.jpg'); + background-size: cover; + background-position: center; + background-attachment: fixed; + z-index: -1; +} + +/* ============================================================ + WEAPON CARDS + ============================================================ */ +.weapon-card { + display: flex; + gap: 1.5rem; + padding: 1.5rem; + background: var(--dark-card); + border: 1px solid var(--border-dark); + transition: all 0.4s; + align-items: flex-start; + position: relative; +} + +.weapon-card::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 2px; + background: linear-gradient(180deg, transparent, var(--blood-red), transparent); + opacity: 0; + transition: opacity 0.3s; +} + +.weapon-card:hover { + border-color: var(--border-mid); + box-shadow: 0 5px 30px rgba(0, 0, 0, 0.6), 0 0 20px var(--shadow-red); + transform: translateX(4px); +} + +.weapon-card:hover::before { + opacity: 1; +} + +.weapon-icon { + font-size: 2.5rem; + flex-shrink: 0; + width: 60px; + text-align: center; + filter: drop-shadow(0 0 8px var(--glow-amber)); +} + +.weapon-rarity { + font-family: 'Cormorant Garamond', serif; + font-size: 0.55rem; + letter-spacing: 0.25em; + text-transform: uppercase; + margin-bottom: 0.3rem; +} + +.rarity-common { + color: var(--mid-grey); +} + +.rarity-uncommon { + color: #6aaf82; +} + +.rarity-rare { + color: #5b8dd9; +} + +.rarity-legendary { + color: var(--bright-gold); + text-shadow: 0 0 10px var(--glow-amber); +} + +.rarity-cursed { + color: var(--crimson); + text-shadow: 0 0 10px var(--glow-red); +} + +.weapon-name { + font-family: 'Cormorant Garamond', serif; + font-size: 1.1rem; + font-weight: 700; + color: var(--cream); + margin-bottom: 0.4rem; +} + +.weapon-desc { + font-family: 'Crimson Text', serif; + font-size: 0.9rem; + color: var(--mid-grey); + line-height: 1.5; + font-style: italic; +} + +/* ============================================================ + NEWS CARDS + ============================================================ */ +.news-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 1.5rem; +} + +.news-card-featured { + grid-column: 1 / -1; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; + background: var(--dark-card); + border: 1px solid var(--border-dark); + overflow: hidden; + transition: all 0.4s; +} + +.news-card-featured:hover { + border-color: var(--blood-red); + box-shadow: 0 0 40px rgba(139, 0, 0, 0.2); +} + +.news-featured-img { + width: 100%; + height: 100%; + min-height: 280px; + object-fit: cover; + filter: brightness(0.7) saturate(0.7) sepia(0.2); +} + +.news-date { + font-family: 'Lora', serif; + font-size: 0.8rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--ash-grey); + margin-bottom: 0.5rem; +} + +.news-category { + font-family: 'Cormorant Garamond', serif; + font-size: 1.2rem; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--crimson); + margin-bottom: 0.5rem; + display: block; +} + +/* ============================================================ + SPLIT LAYOUT + ============================================================ */ +.split-section { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4rem; + align-items: center; + padding: 4rem 0; +} + +.split-section.reverse { + direction: rtl; +} + +.split-section.reverse>* { + direction: ltr; +} + +.split-img-wrapper { + position: relative; + overflow: hidden; +} + +.split-img-wrapper img { + width: 100%; + height: 400px; + object-fit: cover; + /* filter: brightness(0.75) saturate(0.65) sepia(0.2); */ + transition: filter 0.4s; +} + +.split-img-wrapper:hover img { + filter: brightness(0.9) saturate(0.85); +} + +.split-img-wrapper::after { + content: ''; + position: absolute; + inset: 0; + + border: 1px solid #c7a15a; + + box-shadow: + 0 0 8px rgba(199, 161, 90, 0.3), + inset 0 0 10px rgba(199, 161, 90, 0.15); + + pointer-events: none; +} + +.split-text { + position: relative; + + background: rgba(20, 16, 14, .75); + + padding: 2rem; + + border: 1px solid rgba(255, 255, 255, .05); + + box-shadow: + 0 15px 40px rgba(0, 0, 0, .6); +} + +.split-tag { + font-family: 'Cinzel', serif; + font-size: 0.92rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--crimson); + margin-bottom: 0.8rem; + display: flex; + align-items: center; + gap: 0.6rem; +} + +.split-tag::before { + content: '✦'; + font-size: 0.5rem; +} + +.split-title { + font-family: 'Cormorant Garamond', serif; + font-size: clamp(1.7rem, 2.7vw, 2.4rem); + font-weight: 700; + color: var(--cream); + margin-bottom: 1rem; + text-shadow: 1px 2px 10px rgba(0, 0, 0, 0.8); +} + +.split-body { + font-family: 'Crimson Text', serif; + font-size: 1rem; + color: var(--light-grey); + line-height: 1.8; + margin-bottom: 1.5rem; +} + +.split-divider { + width: 60px; + height: 1px; + background: var(--blood-red); + margin-bottom: 1.2rem; +} + +/* ============================================================ + QUOTE BLOCK + ============================================================ */ +.quote-block { + background: linear-gradient(135deg, rgba(28, 20, 16, 0.9), rgba(8, 6, 4, 0.95)); + border-left: 3px solid var(--blood-red); + padding: 2rem 2.5rem; + position: relative; + margin: 2rem 0; +} + +.quote-block::before { + content: '\201C'; + font-family: 'Cormorant Garamond Decorative', serif; + font-size: 6rem; + color: var(--blood-red); + opacity: 0.2; + position: absolute; + top: -1rem; + left: 0.8rem; + line-height: 1; +} + +.quote-text { + font-family: 'Unna', serif; + font-style: italic; + font-size: 1.2rem; + color: var(--parchment); + line-height: 1.7; + position: relative; + z-index: 1; +} + +.quote-author { + font-family: 'Cormorant Garamond', serif; + font-size: 0.65rem; + letter-spacing: 0.25em; + text-transform: uppercase; + color: var(--crimson); + margin-top: 0.8rem; + display: block; +} + +/* ============================================================ + GRID LAYOUTS + ============================================================ */ +.grid-2 { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1.5rem; +} + +.grid-3 { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; +} + +.grid-4 { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1.5rem; +} + +.grid-auto { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.5rem; +} + +/* ============================================================ + COMMUNITY SECTION + ============================================================ */ +.community-platform { + background: var(--dark-card); + border: 1px solid var(--border-dark); + padding: 2rem; + text-align: center; + transition: all 0.4s; + position: relative; + overflow: hidden; +} + +.community-platform::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, rgba(139, 0, 0, 0.05) 0%, transparent 50%); + opacity: 0; + transition: opacity 0.3s; +} + +.community-platform:hover { + border-color: var(--border-mid); + transform: translateY(-4px); + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.6); +} + +.community-platform:hover::before { + opacity: 1; +} + +.platform-icon { + font-size: 2.5rem; + margin-bottom: 1rem; + display: block; +} + +.platform-name { + font-family: 'Cormorant Garamond', serif; + font-size: 1rem; + font-weight: 700; + color: var(--cream); + margin-bottom: 0.4rem; +} + +.platform-count { + font-family: 'Cormorant Garamond Decorative', serif; + font-size: 1.5rem; + color: var(--crimson); + text-shadow: 0 0 15px var(--glow-red); +} + +.platform-label { + font-family: 'Lora', serif; + font-size: 0.85rem; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--mid-grey); +} + +/* ============================================================ + FOOTER + ============================================================ */ +footer { + background: var(--black-coal); + border-top: 1px solid var(--border-dark); + padding: 4rem 2rem 2rem; + position: relative; +} + +footer::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, var(--blood-red), transparent); + opacity: 0.5; +} + +.footer-grid { + max-width: 1300px; + margin: 0 auto; + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr; + gap: 3rem; + margin-bottom: 3rem; +} + +.footer-brand { + font-family: 'Cormorant Garamond Decorative', serif; + font-size: 1.4rem; + font-weight: 700; + color: var(--crimson); + text-shadow: 0 0 20px var(--glow-red); + margin-bottom: 1rem; + display: block; +} + +.footer-tagline { + font-family: 'Unna', serif; + font-style: italic; + font-size: 0.95rem; + color: var(--mid-grey); + line-height: 1.6; + margin-bottom: 1.5rem; +} + +.footer-heading { + font-family: 'Lora', serif; + font-size: 0.7rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--parchment); + margin-bottom: 1.2rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border-dark); +} + +.footer-links { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.footer-links a { + font-family: 'Crimson Text', serif; + font-size: 0.95rem; + color: var(--mid-grey); + transition: color 0.3s; +} + +.footer-links a:hover { + color: var(--parchment); +} + +.footer-bottom { + max-width: 1300px; + margin: 0 auto; + padding-top: 2rem; + border-top: 1px solid var(--border-dark); + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; +} + +.footer-copy { + font-family: 'Cormorant Garamond', serif; + font-size: 0.6rem; + letter-spacing: 0.15em; + color: var(--ash-grey); +} + +.footer-legal { + display: flex; + gap: 1.5rem; + list-style: none; +} + +.footer-legal a { + font-family: 'Cormorant Garamond', serif; + font-size: 0.6rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ash-grey); +} + +.footer-legal a:hover { + color: var(--mid-grey); +} + +/* ============================================================ + SUPPORT PAGE + ============================================================ */ +.support-accordion { + border: 1px solid var(--border-dark); + overflow: hidden; +} + +.accordion-item { + border-bottom: 1px solid var(--border-dark); +} + +.accordion-item:last-child { + border-bottom: none; +} + +.accordion-header { + background: var(--dark-card); + padding: 1.2rem 1.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + transition: background 0.3s; + user-select: none; +} + +.accordion-header:hover { + background: rgba(28, 20, 16, 0.95); +} + +.accordion-question { + font-family: 'Cormorant Garamond', serif; + font-size: 0.9rem; + font-weight: 600; + color: var(--cream); + letter-spacing: 0.05em; +} + +.accordion-icon { + color: var(--crimson); + font-size: 1.2rem; + transition: transform 0.3s; +} + +.accordion-body { + background: rgba(8, 6, 4, 0.7); + padding: 1.2rem 1.5rem; + display: none; +} + +.accordion-body.open { + display: block; +} + +.accordion-answer { + font-family: 'Crimson Text', serif; + font-size: 0.95rem; + color: var(--mid-grey); + line-height: 1.7; +} + +.contact-form { + background: var(--dark-card); + border: 1px solid var(--border-dark); + padding: 2.5rem; + position: relative; +} + +.contact-form::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--crimson), transparent); +} + +.form-group { + margin-bottom: 1.2rem; +} + +.form-label { + font-family: 'Cormorant Garamond', serif; + font-size: 0.62rem; + letter-spacing: 0.25em; + text-transform: uppercase; + color: var(--parchment); + display: block; + margin-bottom: 0.4rem; +} + +.form-control { + width: 100%; + padding: 0.75rem 1rem; + background: rgba(8, 6, 4, 0.8); + border: 1px solid var(--border-mid); + color: var(--cream); + font-family: 'Crimson Text', serif; + font-size: 1rem; + transition: all 0.3s; + outline: none; +} + +.form-control:focus { + border-color: var(--blood-red); + box-shadow: 0 0 15px rgba(139, 0, 0, 0.2); +} + +.form-control::placeholder { + color: var(--ash-grey); +} + +textarea.form-control { + resize: vertical; + min-height: 140px; +} + +select.form-control { + appearance: none; + cursor: pointer; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%238B0000' stroke-width='1.5' fill='none'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 1rem center; +} + +/* ============================================================ + WORLD PAGE — MAP SECTION + ============================================================ */ +.location-card { + background: linear-gradient(135deg, var(--dark-card) 0%, rgba(8, 6, 4, 0.95) 100%); + border: 1px solid var(--border-dark); + padding: 1.8rem; + position: relative; + overflow: hidden; + transition: all 0.4s; +} + +.location-card::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--blood-red), transparent); + opacity: 0; + transition: opacity 0.3s; +} + +.location-card:hover { + border-color: var(--blood-red); + box-shadow: 0 8px 35px rgba(0, 0, 0, 0.7), 0 0 25px var(--shadow-red); + transform: translateY(-4px); +} + +.location-card:hover::after { + opacity: 1; +} + +.location-danger { + display: flex; + gap: 3px; + margin-bottom: 0.8rem; +} + +.danger-skull { + color: var(--crimson); + font-size: 0.75rem; + opacity: 0.4; +} + +.danger-skull.active { + opacity: 1; + filter: drop-shadow(0 0 4px var(--glow-red)); +} + +/* ============================================================ + UTILITY + ============================================================ */ +.text-center { + text-align: center; +} + +.text-left { + text-align: left; +} + +.text-right { + text-align: right; +} + +.text-crimson { + color: var(--crimson); +} + +.text-parchment { + color: var(--parchment); +} + +.text-gold { + color: var(--gold); +} + +.text-grey { + color: var(--mid-grey); +} + +.text-cream { + color: var(--cream); +} + +.mt-1 { + margin-top: 0.5rem; +} + +.mt-2 { + margin-top: 1rem; +} + +.mt-3 { + margin-top: 1.5rem; +} + +.mt-4 { + margin-top: 2rem; +} + +.mb-1 { + margin-bottom: 0.5rem; +} + +.mb-2 { + margin-bottom: 1rem; +} + +.mb-3 { + margin-bottom: 1.5rem; +} + +.mb-4 { + margin-bottom: 2rem; +} + +.d-flex { + display: flex; +} + +.align-center { + align-items: center; +} + +.gap-1 { + gap: 0.5rem; +} + +.gap-2 { + gap: 1rem; +} + +.gap-3 { + gap: 1.5rem; +} + +.w-full { + width: 100%; +} + +.italic { + font-style: italic; +} + +.hidden { + display: none; +} + +/* ============================================================ + MOBILE NAV MENU + ============================================================ */ +.mobile-menu { + display: none; + position: fixed; + top: 68px; + left: 0; + right: 0; + background: rgba(8, 6, 4, 0.98); + border-bottom: 1px solid var(--border-dark); + backdrop-filter: blur(12px); + z-index: 999; + padding: 1rem 0; +} + +.mobile-menu.open { + display: block; +} + +.mobile-menu-list { + list-style: none; + padding: 0; +} + +.mobile-menu-list a { + display: block; + padding: 0.8rem 2rem; + font-family: 'Cormorant Garamond', serif; + font-size: 0.8rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--light-grey); + border-bottom: 1px solid rgba(46, 32, 26, 0.5); + transition: all 0.2s; +} + +.mobile-menu-list a:hover, +.mobile-menu-list a.active { + color: var(--cream); + background: rgba(139, 0, 0, 0.1); + padding-left: 2.5rem; +} + +/* ========================================================= + DARK FANTASY GOOGLE LOGIN + ========================================================= */ + +.login-page { + position: relative; + min-height: 100vh; + + display: flex; + align-items: center; + justify-content: center; + + overflow: hidden; + + background: + radial-gradient(circle at top, rgba(139, 0, 0, 0.15), transparent 40%), + linear-gradient(180deg, #080604 0%, #120908 100%); +} + +/* Background Image */ +.login-bg { + position: absolute; + inset: 0; + + background: + linear-gradient(rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0.82)), + url('img/login-bg.jpg'); + + background-size: cover; + background-position: center; + + filter: brightness(0.45) saturate(0.7) sepia(0.35); + + transform: scale(1.03); +} + +/* Dark vignette */ +.login-vignette { + position: absolute; + inset: 0; + + background: + radial-gradient(ellipse at center, + transparent 25%, + rgba(0, 0, 0, 0.82) 100%); +} + +/* Login Card */ +.login-card { + position: relative; + z-index: 2; + + width: 430px; + max-width: 92%; + + padding: 4rem 3rem; + + background: + linear-gradient(180deg, + rgba(28, 20, 16, 0.95), + rgba(8, 6, 4, 0.98)); + + border: 1px solid #3a1f18; + + box-shadow: + 0 0 50px rgba(0, 0, 0, 0.85), + 0 0 40px rgba(139, 0, 0, 0.18); + + backdrop-filter: blur(10px); + + text-align: center; + + overflow: hidden; +} + +/* top glow line */ +.login-card::before { + content: ''; + + position: absolute; + top: 0; + left: 0; + right: 0; + + height: 2px; + + background: + linear-gradient(90deg, + transparent, + #C41E3A, + transparent); + + box-shadow: 0 0 12px rgba(196, 30, 58, 0.4); +} + +/* symbol */ +.login-symbol { + font-size: 2rem; + color: #C41E3A; + + margin-bottom: 1rem; + + text-shadow: 0 0 18px rgba(196, 30, 58, 0.45); +} + +/* eyebrow */ +.login-eyebrow { + font-family: 'Cormorant Garamond', serif; + + font-size: 0.7rem; + letter-spacing: 0.35em; + + text-transform: uppercase; + + color: #A84800; + + margin-bottom: 1rem; +} + + +/* title */ +.login-title { + font-family: 'Cormorant Garamond Decorative', serif; + + font-size: 3rem; + font-weight: 700; + + color: #DDD0B8; + + line-height: 1.1; + + text-shadow: + 0 0 25px rgba(139, 0, 0, 0.35), + 2px 3px 10px rgba(0, 0, 0, 0.9); + + margin-bottom: 1rem; +} + +/* subtitle */ +.login-subtitle { + font-family: 'Unna', serif; + + font-style: italic; + + font-size: 1rem; + line-height: 1.8; + + color: #9A8E88; + + margin-bottom: 2rem; +} + +/* divider */ +.login-divider { + width: 90px; + height: 1px; + + margin: 0 auto 2rem; + + background: + linear-gradient(90deg, + transparent, + #8B0000, + transparent); +} + +/* GOOGLE BUTTON */ +.google-login-btn { + width: 100%; + + display: flex; + align-items: center; + justify-content: center; + gap: 0.9rem; + + padding: 1rem 1.4rem; + + background: + linear-gradient(180deg, + rgba(22, 14, 10, 0.95), + rgba(10, 8, 7, 0.98)); + + border: 1px solid #4A3028; + + color: #DDD0B8; + + font-family: 'Cormorant Garamond', serif; + font-size: 0.9rem; + font-weight: 700; + + letter-spacing: 0.08em; + text-transform: uppercase; + + cursor: pointer; + + transition: all 0.35s ease; + + position: relative; + overflow: hidden; +} + +/* shine effect */ +.google-login-btn::before { + content: ''; + + position: absolute; + top: 0; + left: -100%; + + width: 100%; + height: 100%; + + background: + linear-gradient(90deg, + transparent, + rgba(255, 255, 255, 0.06), + transparent); + + transition: 0.5s; +} + +.google-login-btn:hover::before { + left: 100%; +} + +.google-login-btn:hover { + border-color: #8B0000; + + transform: translateY(-2px); + + box-shadow: + 0 0 25px rgba(139, 0, 0, 0.3), + 0 6px 20px rgba(0, 0, 0, 0.7); +} + +.google-login-btn svg { + width: 22px; + height: 22px; + + flex-shrink: 0; +} + +/* footer quote */ +.login-footer { + margin-top: 2rem; + + font-family: 'Unna', serif; + font-style: italic; + + font-size: 0.9rem; + + color: #6B5E58; +} + + +/* ========================= + News +========================= */ + + + +/* ============================================================ + NEWS DETAIL + ============================================================ */ + +.news-layout { + max-width: 1400px; + margin: 6rem auto; + padding: 0 2rem; + + display: grid; + grid-template-columns: 2.2fr 0.5fr; + gap: 2rem; + align-items: start; +} + +/* ===================== + LEFT COLUMN + ===================== */ + +.news-left { + background: rgba(15, 12, 12, 0.9); + border: 1px solid rgba(180, 120, 80, 0.15); + border-radius: 20px; + overflow: hidden; + box-shadow: + 0 0 30px rgba(0, 0, 0, 0.45), + inset 0 0 0 1px rgba(255, 255, 255, 0.03); +} + +.news-left .card-img { + width: 100%; + height: 420px; + object-fit: cover; + display: block; +} + +.news-content { + padding: 2rem 2.5rem 0; +} + +.news-category { + color: var(--gold); + font-size: 1.8rem; + letter-spacing: 0.18em; + text-transform: uppercase; + margin-bottom: .5rem; +} + +.news-date { + color: var(--muted); + font-size: 0.9rem; +} + +.news-left>h2 { + padding: 1rem 2.5rem 0; +} + +.news-body { + padding: 0 2.5rem; +} + +.news-body:last-child { + padding-bottom: 2.5rem; +} + +.news-body p { + color: var(--light-grey); + line-height: 1.9; + margin-bottom: 1.4rem; + font-size: 1rem; +} + +/* ===================== + RIGHT SIDEBAR + ===================== */ + +.news-right { + position: sticky; + top: 100px; + + background: rgba(15, 12, 12, 0.92); + border: 1px solid rgba(180, 120, 80, 0.15); + border-radius: 20px; + overflow: hidden; + + box-shadow: + 0 0 30px rgba(0, 0, 0, .45), + inset 0 0 0 1px rgba(255, 255, 255, .03); +} + +/* background image */ +.news-bg { + height: 220px; + background-size: cover; + background-position: center; + position: relative; +} + +.news-bg::after { + content: ""; + position: absolute; + inset: 0; + background: + linear-gradient(to bottom, + rgba(0, 0, 0, 0.1), + rgba(10, 10, 10, 0.9)); +} + +.news-eyebrow, +.news-title, +.news-title-sub, +.news-right .btn { + position: relative; + z-index: 2; +} + +.news-eyebrow { + margin-top: -1rem; + padding: 0 1.8rem; + color: var(--gold); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: .18em; +} + +.news-title { + padding: .4rem 1.8rem 0; + font-size: 2rem; + margin: 0; + + font-family: 'Cormorant Garamond', serif; + color: var(--cream); +} + +.news-title-sub { + padding: .6rem 1.8rem 1.5rem; + color: var(--light-grey); + line-height: 1.7; + font-size: 0.95rem; +} + +.news-right .btn { + display: block; + margin: 0 1.8rem 2rem; + text-align: center; +} + +/* ===================== + EXTRA CARDS + ===================== */ + +.sidebar-card { + margin: 1.2rem 1.5rem; + padding: 1.2rem; + + border-radius: 14px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.sidebar-card h4 { + color: var(--cream); + margin-bottom: .6rem; +} + +.sidebar-card p { + color: var(--muted); + line-height: 1.7; + font-size: .9rem; +} + +/* ===================== + RESPONSIVE + ===================== */ + +@media (max-width: 992px) { + + .news-layout { + grid-template-columns: 1fr; + } + + .news-right { + position: relative; + top: auto; + } + + .news-left .card-img { + height: 280px; + } +} + + + +/* ========================= + RESPONSIVE +========================= */ + +@media(max-width: 600px) { + + .login-card { + padding: 3rem 2rem; + } + + .login-title { + font-size: 2.2rem; + } + +} + +/* ============================================================ + ANIMATIONS + ============================================================ */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes flicker { + 0%, 100% { + opacity: 1; + } + + 50% { + opacity: 0.85; + } + + 75% { + opacity: 0.95; + } +} + +@keyframes pulse-red { + 0%, 100% { + box-shadow: 0 0 10px rgba(139, 0, 0, 0.3); + } + + 50% { + box-shadow: 0 0 25px rgba(196, 30, 58, 0.6); + } +} + +.animate-fade-up { + animation: fadeInUp 0.7s ease forwards; +} + +.glow-red { + animation: pulse-red 3s ease-in-out infinite; +} + +/* ============================================================ + RESPONSIVE + ============================================================ */ +@media (max-width: 1100px) { + .footer-grid { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 900px) { + .hero-content { + grid-template-columns: 1fr; + text-align: center; + } + + .hero-eyebrow { + justify-content: center; + } + + .hero-buttons { + justify-content: center; + } + + .hero-divider { + margin-left: auto; + margin-right: auto; + } + + .hero-desc { + margin-left: auto; + margin-right: auto; + } + + .split-section { + grid-template-columns: 1fr; + gap: 2rem; + } + + .split-section.reverse { + direction: ltr; + } + + .grid-4 { + grid-template-columns: repeat(2, 1fr); + } + + .grid-3 { + grid-template-columns: repeat(2, 1fr); + } + + .news-card-featured { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + + .navbar-nav, + .navbar-cta { + display: none; + } + + .hamburger { + display: flex; + } + + .grid-2, + .grid-3, + .grid-4 { + grid-template-columns: 1fr; + } + + .footer-grid { + grid-template-columns: 1fr; + gap: 2rem; + } + + .footer-bottom { + flex-direction: column; + text-align: center; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/world/world-create.css b/src/main/resources/static/css/world/world-create.css new file mode 100644 index 0000000000000000000000000000000000000000..864d427fb49beb41c6c6e2858ac0dfd1738be436 --- /dev/null +++ b/src/main/resources/static/css/world/world-create.css @@ -0,0 +1,188 @@ +/* ========================================================== +CREATE WORLD PAGE - FORM STYLES +========================================================== */ + +.section-dark { + position: relative; +} + +/* ========================================================== +FORM CARD (reuse world-card nhưng tối ưu form spacing) +========================================================== */ + +.world-card { + background: linear-gradient(180deg, + rgba(28, 20, 16, .96), + rgba(8, 6, 4, .98)); + border: 1px solid var(--border-dark); + padding: 2.5rem; + box-shadow: 0 0 35px rgba(0, 0, 0, .65); + position: relative; + overflow: hidden; +} + +.world-card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--crimson), transparent); +} + +/* ========================================================== +FORM LAYOUT +========================================================== */ + +.world-form { + margin-top: 2rem; + display: flex; + flex-direction: column; + gap: 1.4rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: .5rem; +} + +.form-label { + font-size: .75rem; + letter-spacing: .15em; + text-transform: uppercase; + color: var(--parchment); +} + +/* ========================================================== +INPUTS +========================================================== */ + +.form-control { + padding: .8rem 1rem; + background: rgba(10, 8, 7, .8); + border: 1px solid var(--border-mid); + color: var(--cream); + font-size: .9rem; + outline: none; + transition: .25s ease; +} + +.form-control:focus { + border-color: var(--crimson); + box-shadow: 0 0 18px rgba(196, 30, 58, .15); +} + +.file-input { + padding: .6rem; + background: rgba(10, 8, 7, .6); + border: 1px solid var(--border-mid); + color: var(--mid-grey); +} + +/* ========================================================== +QUILL EDITOR +========================================================== */ + +.world-editor { + min-height: 300px; + background: rgba(10, 8, 7, .85); + border: 1px solid var(--border-mid); + color: var(--cream); +} + +.ql-toolbar { + background: rgba(28, 20, 16, .95) !important; + border: 1px solid var(--border-mid) !important; +} + +.ql-toolbar button, +.ql-toolbar .ql-picker { + color: var(--cream) !important; +} + +.ql-container { + border: 1px solid var(--border-mid) !important; + background: rgba(10, 8, 7, .9); + color: var(--cream); + min-height: 250px; +} + +.ql-editor { + min-height: 250px; + color: var(--cream); + font-size: .95rem; + line-height: 1.6; +} + +/* ========================================================== +ACTIONS +========================================================== */ + +.world-actions { + display: flex; + justify-content: flex-end; + gap: 1rem; + margin-top: 2rem; + flex-wrap: wrap; +} + +/* ========================================================== +BUTTONS (ensure consistency if not loaded from list page) +========================================================== */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: .65rem 1.2rem; + font-size: .75rem; + letter-spacing: .12em; + text-transform: uppercase; + font-weight: 700; + border: 1px solid var(--border-mid); + cursor: pointer; + text-decoration: none; + transition: .25s ease; + background: linear-gradient(180deg, + rgba(28, 20, 16, .9), + rgba(10, 8, 7, .95)); + color: var(--cream); +} + +.btn:hover { + transform: translateY(-2px); + border-color: var(--crimson); + box-shadow: 0 0 18px rgba(196, 30, 58, .25); + color: #fff; +} + +.btn-primary { + border-color: var(--crimson); +} + +.btn-secondary { + border-color: var(--amber); + color: var(--amber); +} + +/* ========================================================== +RESPONSIVE +========================================================== */ + +@media (max-width: 768px) { + .world-card { + padding: 1.5rem; + + } + + .world-actions { + flex-direction: column; + align-items: stretch; + } + + .btn { + width: 100%; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/world/world-edit.css b/src/main/resources/static/css/world/world-edit.css new file mode 100644 index 0000000000000000000000000000000000000000..121a74a3cc0303f6fd5b1fef4e3ff28d99127faa --- /dev/null +++ b/src/main/resources/static/css/world/world-edit.css @@ -0,0 +1,164 @@ +/* ========================================================== +UPDATE WORLD PAGE - EXTRA STYLES +========================================================== */ + +/* Card riêng cho trang update (giống create nhưng có thể reuse) */ +.world-create-card { + background: linear-gradient(180deg, + rgba(28, 20, 16, .96), + rgba(8, 6, 4, .98)); + border: 1px solid var(--border-dark); + padding: 2.5rem; + box-shadow: 0 0 35px rgba(0, 0, 0, .65); + position: relative; + overflow: hidden; +} + +.world-create-card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--crimson), transparent); +} + +/* ========================================================== +CURRENT IMAGE PREVIEW +========================================================== */ + +.current-image-wrapper { + margin-top: .5rem; + display: flex; + justify-content: flex-start; +} + +.current-image { + width: 160px; + height: 160px; + object-fit: cover; + border: 1px solid var(--border-mid); + box-shadow: 0 0 18px rgba(0, 0, 0, .6); + transition: .25s ease; +} + +.current-image:hover { + transform: scale(1.03); + box-shadow: 0 0 25px rgba(196, 30, 58, .2); +} + +/* ========================================================== +FORM (reuse but ensure consistency) +========================================================== */ + +.world-form { + margin-top: 2rem; + display: flex; + flex-direction: column; + gap: 1.4rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: .5rem; +} + +.form-label { + font-size: .75rem; + letter-spacing: .15em; + text-transform: uppercase; + color: var(--parchment); +} + +/* ========================================================== +INPUTS +========================================================== */ + +.form-control { + padding: .8rem 1rem; + background: rgba(10, 8, 7, .8); + border: 1px solid var(--border-mid); + color: var(--cream); + font-size: .9rem; + outline: none; + transition: .25s ease; +} + +.form-control:focus { + border-color: var(--crimson); + box-shadow: 0 0 18px rgba(196, 30, 58, .15); +} + +.file-input { + padding: .6rem; + background: rgba(10, 8, 7, .6); + border: 1px solid var(--border-mid); + color: var(--mid-grey); +} + +/* ========================================================== +QUILL EDITOR (reuse consistency) +========================================================== */ + +.world-editor { + min-height: 300px; + background: rgba(10, 8, 7, .85); + border: 1px solid var(--border-mid); + color: var(--cream); +} + +.ql-toolbar { + background: rgba(28, 20, 16, .95) !important; + border: 1px solid var(--border-mid) !important; +} + +.ql-container { + border: 1px solid var(--border-mid) !important; + background: rgba(10, 8, 7, .9); + color: var(--cream); + min-height: 250px; +} + +.ql-editor { + min-height: 250px; + color: var(--cream); + font-size: .95rem; + line-height: 1.6; +} + +/* ========================================================== +ACTIONS +========================================================== */ + +.world-actions { + display: flex; + justify-content: flex-end; + gap: 1rem; + margin-top: 2rem; + flex-wrap: wrap; +} + +/* ========================================================== +RESPONSIVE +========================================================== */ + +@media (max-width: 768px) { + .world-create-card { + padding: 1.5rem; + } + + .current-image { + width: 120px; + height: 120px; + } + + .world-actions { + flex-direction: column; + } + + .btn { + width: 100%; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/world/world.css b/src/main/resources/static/css/world/world.css new file mode 100644 index 0000000000000000000000000000000000000000..e9a9ec4a4d7da9f5670401cf21033262a252432f --- /dev/null +++ b/src/main/resources/static/css/world/world.css @@ -0,0 +1,333 @@ +@import "world-create.css"; +@import "world-edit.css"; +@import "../dashboard-user.css"; + + +/* ========================================================== + WORLD LIST + ========================================================== */ +body { + margin: 10px; +} + +.world-card { + position: relative; + padding: 2rem; + + background: linear-gradient(180deg, + rgba(28, 20, 16, .96), + rgba(10, 8, 7, .98)); + + border: 1px solid var(--border-dark); + + overflow: hidden; + + box-shadow: + 0 0 40px rgba(0, 0, 0, .75), + 0 0 25px rgba(139, 0, 0, .08); +} + +.world-card::before { + content: ""; + + position: absolute; + + top: 0; + left: 0; + right: 0; + + height: 2px; + + background: linear-gradient(90deg, + transparent, + var(--crimson), + transparent); + + box-shadow: 0 0 12px var(--glow-red); +} + +/* ========================================================== + MESSAGES + ========================================================== */ + +.success-message { + margin-bottom: 1rem; + padding: 1rem 1.25rem; + + color: #81c784; + + background: rgba(129, 199, 132, .08); + + border: 1px solid rgba(129, 199, 132, .2); +} + +.error-message { + margin-bottom: 1rem; + padding: 1rem 1.25rem; + + color: #ff8a80; + + background: rgba(255, 138, 128, .08); + + border: 1px solid rgba(255, 138, 128, .2); +} + +/* ========================================================== + ACTIONS + ========================================================== */ + +.list-actions { + display: flex; + justify-content: flex-end; + + margin-bottom: 1.5rem; +} + +/* ========================================================== + TABLE + ========================================================== */ + +.table-wrapper { + overflow-x: auto; + + border: 1px solid var(--border-dark); + + background: rgba(8, 6, 4, .65); +} + +.world-table { + width: 100%; + border-collapse: collapse; +} + +.world-table thead { + background: linear-gradient(180deg, + rgba(12, 8, 6, .95), + rgba(8, 6, 4, .98)); +} + +.world-table th { + padding: 1rem; + + text-align: left; + + font-size: .72rem; + letter-spacing: .15em; + text-transform: uppercase; + + color: var(--parchment); + + border-bottom: 1px solid var(--border-dark); +} + +.world-table td { + padding: 1rem; + + vertical-align: middle; + + color: var(--light-grey); + + border-bottom: 1px solid rgba(74, 48, 40, .35); +} + +.world-table tbody tr { + transition: .25s ease; +} + +.world-table tbody tr:hover { + background: rgba(139, 0, 0, .08); +} + +/* ========================================================== + IMAGE + ========================================================== */ + +.world-thumb { + width: 90px; + height: 56px; + + object-fit: cover; + + border-radius: 4px; + + border: 1px solid var(--border-dark); +} + +/* ========================================================== + STATUS + ========================================================== */ + +.status-active { + display: inline-flex; + align-items: center; + + padding: .35rem .8rem; + + border-radius: 999px; + + color: #81c784; + + background: rgba(129, 199, 132, .08); + + border: 1px solid rgba(129, 199, 132, .2); + + font-size: .85rem; + font-weight: 600; +} + +.status-deleted { + display: inline-flex; + align-items: center; + + padding: .35rem .8rem; + + border-radius: 999px; + + color: #ff8a80; + + background: rgba(255, 138, 128, .08); + + border: 1px solid rgba(255, 138, 128, .2); + + font-size: .85rem; + font-weight: 600; +} + +/* ========================================================== + ACTION BUTTONS + ========================================================== */ + +.action-group { + display: flex; + flex-wrap: wrap; + + gap: .5rem; +} + +.action-group form { + margin: 0; +} + +/* ========================================================== + PAGINATION + ========================================================== */ + +.pagination { + display: flex; + + justify-content: center; + align-items: center; + + flex-wrap: wrap; + + gap: .5rem; + + margin-top: 2rem; +} + +.pagination a, +.pagination span { + display: flex; + + align-items: center; + justify-content: center; + + min-width: 42px; + height: 42px; + + padding: 0 .8rem; + + text-decoration: none; + + border-radius: 8px; + + color: var(--cream); + + border: 1px solid var(--border-dark); + + background: rgba(255, 255, 255, .02); +} + +.pagination a:hover { + border-color: var(--crimson); + + background: rgba(196, 30, 58, .12); +} + +.pagination span[style*="bold"] { + background: var(--crimson); + + border-color: var(--crimson); + + color: #fff; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + + padding: 0.75rem 1.25rem; + + text-decoration: none; + cursor: pointer; + + font-size: 0.75rem; + font-weight: 700; + + letter-spacing: 0.15em; + text-transform: uppercase; + + transition: all 0.25s ease; +} + +.btn-primary { + color: var(--cream); + + background: linear-gradient(180deg, + rgba(196, 30, 58, .25), + rgba(139, 0, 0, .2)); + + border: 1px solid var(--crimson); + + box-shadow: + 0 0 12px rgba(196, 30, 58, .15); +} + +/* ========================================================== + PAGE CONTENT + ========================================================== */ + +.section-dark { + width: 100%; +} + +/* ========================================================== + RESPONSIVE + ========================================================== */ + +@media (max-width: 1200px) { + + .world-table { + min-width: 1200px; + } +} + +@media (max-width: 768px) { + + .world-card { + padding: 1.5rem; + } + + .list-actions { + justify-content: center; + } + + .action-group { + flex-direction: column; + } + + .action-group .btn { + width: 100%; + } +} \ No newline at end of file diff --git a/src/main/resources/static/img/Dieutravien.png b/src/main/resources/static/img/Dieutravien.png new file mode 100644 index 0000000000000000000000000000000000000000..91bd599b5e1134ddb5d90c2d51e85a59d9b3907f --- /dev/null +++ b/src/main/resources/static/img/Dieutravien.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:67c8c72ce534e60ad3de8c22b062bbd88a7df3b39da130ca1ed5d7a4920276b0 +size 246615 diff --git a/src/main/resources/static/img/Giaochu.png b/src/main/resources/static/img/Giaochu.png new file mode 100644 index 0000000000000000000000000000000000000000..01613a6eba9fe2a8c578c4e58c74c99f029c0e04 --- /dev/null +++ b/src/main/resources/static/img/Giaochu.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec7b4cb20f6ab45d732f5d3922bee0d9d041dc44d5a9127a18c3c7c606ee63a8 +size 253275 diff --git a/src/main/resources/static/img/Kethanhtrung.png b/src/main/resources/static/img/Kethanhtrung.png new file mode 100644 index 0000000000000000000000000000000000000000..0eff6fe1fdb92024c72dbb143fe2c9d70759f0b5 --- /dev/null +++ b/src/main/resources/static/img/Kethanhtrung.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b30cff62549005f37cda1a8a2812eeb28a30fdb8c13bb702784e8609131d76e0 +size 237619 diff --git a/src/main/resources/static/img/Thomo.png b/src/main/resources/static/img/Thomo.png new file mode 100644 index 0000000000000000000000000000000000000000..b3cc136f8d252ff13ac680637918d31155f0cfc4 --- /dev/null +++ b/src/main/resources/static/img/Thomo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0cbd3cf12c423e1fdf46c6ec2a642e98e67526cd94ecc2d2b32b5f7a5b138fd7 +size 226875 diff --git a/src/main/resources/static/img/a.png b/src/main/resources/static/img/a.png new file mode 100644 index 0000000000000000000000000000000000000000..b3007970c068aab09d0a6c2716c39a18f18d8915 --- /dev/null +++ b/src/main/resources/static/img/a.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:978d5a866822b428e04aa672deb9c2bfefec210ae4a144782342d001e817e2c9 +size 2222267 diff --git a/src/main/resources/static/img/aa.png b/src/main/resources/static/img/aa.png new file mode 100644 index 0000000000000000000000000000000000000000..bc682c3ec551171b1f5e5ea620eefaf845789f3a --- /dev/null +++ b/src/main/resources/static/img/aa.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:47defc4ce3d141fef8086572e94561ca495c722b700ec67bd77cee67e53caafb +size 2297287 diff --git a/src/main/resources/static/img/back-groud.png b/src/main/resources/static/img/back-groud.png new file mode 100644 index 0000000000000000000000000000000000000000..2cc01097ae0fd9992f6743101d992e3f0baf96da --- /dev/null +++ b/src/main/resources/static/img/back-groud.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e8accb0a25272dc32dc4d7070d9b22e6ffdcc42d13fe876f1ad3f68a06b1ac37 +size 768753 diff --git a/src/main/resources/static/img/back.png b/src/main/resources/static/img/back.png new file mode 100644 index 0000000000000000000000000000000000000000..35d6673e6efb06b5ef56fafa5b446fd6c7cabf74 --- /dev/null +++ b/src/main/resources/static/img/back.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:348111eeab57c583425feedd90f878a2201a453fcadd04775edaa0e7165d1789 +size 1279099 diff --git a/src/main/resources/static/img/backback.png b/src/main/resources/static/img/backback.png new file mode 100644 index 0000000000000000000000000000000000000000..630fb4d0f6fe773f235421a77264e7576b18f517 --- /dev/null +++ b/src/main/resources/static/img/backback.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4e28295a67107959baad4a71b6e4a4144ce030affcb5c6d00464cc3ddb3f1db4 +size 2000991 diff --git a/src/main/resources/static/img/backgord.png b/src/main/resources/static/img/backgord.png new file mode 100644 index 0000000000000000000000000000000000000000..139cf74ba2168e7de0df759e789ec80858c1b0be --- /dev/null +++ b/src/main/resources/static/img/backgord.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eba6998ad9f9ffaf867a488d03d54380521845ab2c9fcefcb8ed950d5c35f168 +size 2203524 diff --git a/src/main/resources/static/img/backgroud.png b/src/main/resources/static/img/backgroud.png new file mode 100644 index 0000000000000000000000000000000000000000..22af4829c6c152eda09c1b1086bd1a4347099707 --- /dev/null +++ b/src/main/resources/static/img/backgroud.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:69b2fe3694a56170bc37e57fcf018cef9096b7d85dd21dbb8e8a7052cb4f4c19 +size 2055324 diff --git a/src/main/resources/static/img/black.jpg b/src/main/resources/static/img/black.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d1c52dbbcf3a6a9f0b3e81f207a1881fd0852b89 --- /dev/null +++ b/src/main/resources/static/img/black.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3a78a51099ac879acc2de8d512438e5c9f1a8e7a4724f4d82cdd26971074c1b6 +size 2497 diff --git a/src/main/resources/static/img/img1.png b/src/main/resources/static/img/img1.png new file mode 100644 index 0000000000000000000000000000000000000000..8086ba715bc08fb064d4120018f41bcfb852ce19 --- /dev/null +++ b/src/main/resources/static/img/img1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d4e0a194577beb3fbee354e0e5a373de02348bc4339c34d69ceab921ae31bd8a +size 404017 diff --git a/src/main/resources/static/img/img2.png b/src/main/resources/static/img/img2.png new file mode 100644 index 0000000000000000000000000000000000000000..abe54ddd85aa7bc3ffc5215e673846fce81bca0a --- /dev/null +++ b/src/main/resources/static/img/img2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b8141301c60ec9556ff442217be702c180a76475ebcd3c14b8637608f578bf50 +size 220055 diff --git a/src/main/resources/static/img/img3.png b/src/main/resources/static/img/img3.png new file mode 100644 index 0000000000000000000000000000000000000000..93b8e95c306941e8add00d6181e784b0dd568f4b --- /dev/null +++ b/src/main/resources/static/img/img3.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:431c6a7ab3ec73dfba75b5fe42ab498bcb49e80b58c17c7f1c44f35af2563089 +size 84340 diff --git a/src/main/resources/static/img/img4.png b/src/main/resources/static/img/img4.png new file mode 100644 index 0000000000000000000000000000000000000000..912e91013465008ea7be1fca6d33dfe452019033 --- /dev/null +++ b/src/main/resources/static/img/img4.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8acd3a8b13f5f41ec9425a56f40bfbc3101f5a8cb6069494689a2d4569289ff2 +size 104059 diff --git a/src/main/resources/static/img/img5.png b/src/main/resources/static/img/img5.png new file mode 100644 index 0000000000000000000000000000000000000000..1539c57be1365ca9a7525f4d6996ea41566e25c2 --- /dev/null +++ b/src/main/resources/static/img/img5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:768f764b7cf0f5d38391fa3da936413c3818dfa52152143a45d311740e0b02f7 +size 78019 diff --git a/src/main/resources/static/img/img6.png b/src/main/resources/static/img/img6.png new file mode 100644 index 0000000000000000000000000000000000000000..f1bfe80493d80745a3a884a4f8010993d39e80e0 --- /dev/null +++ b/src/main/resources/static/img/img6.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2a8c1b54038a88130dd96ea24227be7797c7f25986b1c3704c173962de5bc36b +size 95373 diff --git a/src/main/resources/static/img/nhanvat-back.png b/src/main/resources/static/img/nhanvat-back.png new file mode 100644 index 0000000000000000000000000000000000000000..ed88f92e13df33e8614dc99f133efe0794b84502 --- /dev/null +++ b/src/main/resources/static/img/nhanvat-back.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1e626a6a9fc2ce357983a7c222050ae57f88b38d08f163c25c7f33a185eabd15 +size 1627455 diff --git a/src/main/resources/static/img/section2-back.png b/src/main/resources/static/img/section2-back.png new file mode 100644 index 0000000000000000000000000000000000000000..445d9d272c8be1f5e68799bfada09e6f661560f2 --- /dev/null +++ b/src/main/resources/static/img/section2-back.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:350fb0215122fd3e270d0fe3a72f1ba9506055b97309fc3f163577c0c8828196 +size 1820723 diff --git a/src/main/resources/static/img/start-story.png b/src/main/resources/static/img/start-story.png new file mode 100644 index 0000000000000000000000000000000000000000..dce4c94d4e66aaf12414212584a828b252480c3c --- /dev/null +++ b/src/main/resources/static/img/start-story.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:20f6c2ab890298f9c48c9bd63ebeaa2c825471ccfe78427dd9ccaf972ba43b3e +size 2981937 diff --git a/src/main/resources/static/img/story.png b/src/main/resources/static/img/story.png new file mode 100644 index 0000000000000000000000000000000000000000..d3851ba9833c91bd87c2e21d2a4c8e02792b3101 --- /dev/null +++ b/src/main/resources/static/img/story.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d01a11d70a43edcad811737a031570fd7517f35b2d02d57e1285684a9df08e63 +size 742328 diff --git a/src/main/resources/static/img/thamsat.png b/src/main/resources/static/img/thamsat.png new file mode 100644 index 0000000000000000000000000000000000000000..3559127653cfbd786eb9161c24cf78b61d7af0d5 --- /dev/null +++ b/src/main/resources/static/img/thamsat.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0dc6186371961890bc3a0375a6766825bfa1b9cb7892663559a30f1cbf48d1a9 +size 2847054 diff --git a/src/main/resources/static/img/traitimthegioi.png b/src/main/resources/static/img/traitimthegioi.png new file mode 100644 index 0000000000000000000000000000000000000000..43ff7261ef3dadbf5a9127be7e80dd9964893077 --- /dev/null +++ b/src/main/resources/static/img/traitimthegioi.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d091360b4eafc1fb3935a3b3d85cf2b955e1fed4d8d1f9bfa6e7f2037b8450a0 +size 2482961 diff --git a/src/main/resources/static/img/world-back.png b/src/main/resources/static/img/world-back.png new file mode 100644 index 0000000000000000000000000000000000000000..2c853aceace181ecf8667c0aa4bab1528ffea53c --- /dev/null +++ b/src/main/resources/static/img/world-back.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2cfa973094871e5f6ea5b2a48b1cc8fdf7370f406201fa2d494344947bb26da7 +size 1728958 diff --git a/src/main/resources/static/js/arccodion.js b/src/main/resources/static/js/arccodion.js new file mode 100644 index 0000000000000000000000000000000000000000..69f0c85141e153e6edbc4854ec1d58f40bf9fa59 --- /dev/null +++ b/src/main/resources/static/js/arccodion.js @@ -0,0 +1,23 @@ +document.addEventListener('DOMContentLoaded', function () { + document.querySelectorAll('.accordion-header').forEach(function (header) { + header.addEventListener('click', function () { + var body = this.nextElementSibling; + var icon = this.querySelector('.accordion-icon'); + var isOpen = body.classList.contains('open'); + + document.querySelectorAll('.accordion-body').forEach(function (accordionBody) { + accordionBody.classList.remove('open'); + }); + document.querySelectorAll('.accordion-icon').forEach(function (accordionIcon) { + accordionIcon.style.transform = ''; + }); + + if (!isOpen) { + body.classList.add('open'); + if (icon) { + icon.style.transform = 'rotate(45deg)'; + } + } + }); + }); +}); diff --git a/src/main/resources/static/js/editor.js b/src/main/resources/static/js/editor.js new file mode 100644 index 0000000000000000000000000000000000000000..80c5caa145541c82551f1f2f9801628cc7986a53 --- /dev/null +++ b/src/main/resources/static/js/editor.js @@ -0,0 +1,99 @@ +// ========================================== +// 1. KHỞI TẠO QUILL VÀ CẤU HÌNH UPLOAD ẢNH +// ========================================== +const quill = new Quill("#quill-editor", { + theme: "snow", + modules: { + toolbar: { + container: [ + [{ header: [1, 2, 3, false] }], + + [{ size: ["small", false, "large", "huge"] }], + + [{ font: [] }], + + ["bold", "italic", "underline", "strike"], + + [{ color: [] }, { background: [] }], + + [{ list: "ordered" }, { list: "bullet" }], + + [{ indent: "-1" }, { indent: "+1" }], + + [{ align: [] }], + + ["blockquote", "code-block"], + + ["link", "image", "video"], + + ["clean"], + ], + handlers: { + image: selectLocalImage, + }, + }, + }, +}); + +function selectLocalImage() { + const input = document.createElement("input"); + input.setAttribute("type", "file"); + input.setAttribute("accept", "image/*"); + input.click(); + + input.onchange = () => { + const file = input.files[0]; + if (file) { + uploadToServer(file); + } + }; +} + +function uploadToServer(file) { + const formData = new FormData(); + formData.append("image", file); + + fetch("/api/upload", { + method: "POST", + body: formData, + }) + .then((response) => response.json()) + .then((data) => { + const range = quill.getSelection(true); // Thêm 'true' để chắc chắn lấy được vị trí + quill.insertEmbed(range.index, "image", data.url); + }) + .catch((error) => { + console.error("Lỗi upload ảnh:", error); + alert("Upload ảnh thất bại!"); + }); +} + +// ========================================== +// 2. XỬ LÝ ĐỒNG BỘ DỮ LIỆU KHI SUBMIT FORM +// ========================================== +const contentTextarea = document.getElementById("content"); + +// Đổ dữ liệu cũ vào Editor nếu đang ở trang Sửa (Edit) +if (contentTextarea.value) { + quill.clipboard.dangerouslyPasteHTML(contentTextarea.value); +} + +// Tìm form bao quanh cái textarea +const form = contentTextarea.closest("form"); + +// Bắt sự kiện khi người dùng bấm nút Submit +form.addEventListener("submit", function (event) { + // Lấy nội dung text thuần để kiểm tra rỗng + const rawText = quill.getText().trim(); + + // Nếu rỗng thì chặn submit + if (rawText.length === 0 && quill.getLength() <= 1) { + // getLength() <= 1 để xử lý case chỉ có 1 khoảng trắng hoặc enter + event.preventDefault(); + alert("Vui lòng nhập nội dung!"); + return; + } + + // NẾU CÓ NỘI DUNG -> Copy toàn bộ HTML (bao gồm chữ, thẻ , thẻ ) nhét vào textarea + contentTextarea.value = quill.getSemanticHTML(); +}); diff --git a/src/main/resources/static/js/index.js b/src/main/resources/static/js/index.js new file mode 100644 index 0000000000000000000000000000000000000000..58252a593d8ecbf574e8ba2438d4ef232bd68729 --- /dev/null +++ b/src/main/resources/static/js/index.js @@ -0,0 +1,125 @@ + + function closeVideo(){ + + const modal = document.getElementById("videoModal"); + const video = document.getElementById("videoPlayer"); + + modal.style.display = "none"; + + video.pause(); + video.currentTime = 0; +} + function openVideo(){ + document.getElementById("videoModal").style.display = "flex"; + } + + function closeVideo(){ + + const modal = document.getElementById("videoModal"); + const video = document.getElementById("trailerVideo"); + + modal.style.display = "none"; + + video.pause(); + video.currentTime = 0; + } +document.addEventListener('DOMContentLoaded', function () { + + // Navbar scroll + var navbar = document.querySelector('.navbar'); + window.addEventListener('scroll', function () { + if (window.scrollY > 60) { + navbar.style.background = 'rgba(8,6,4,0.99)'; + } else { + navbar.style.background = ''; + } + }); + + // Hamburger menu + var hamburger = document.getElementById('hamburger'); + var mobileMenu = document.getElementById('mobileMenu'); + if (hamburger && mobileMenu) { + hamburger.addEventListener('click', function () { + var isOpen = mobileMenu.classList.toggle('open'); + var spans = hamburger.querySelectorAll('span'); + if (isOpen) { + spans[0].style.transform = 'rotate(45deg) translate(5px, 5px)'; + spans[1].style.opacity = '0'; + spans[2].style.transform = 'rotate(-45deg) translate(5px, -5px)'; + } else { + spans[0].style.transform = ''; + spans[1].style.opacity = ''; + spans[2].style.transform = ''; + } + }); + document.addEventListener('click', function (e) { + if (!hamburger.contains(e.target) && !mobileMenu.contains(e.target)) { + mobileMenu.classList.remove('open'); + hamburger.querySelectorAll('span').forEach(function(s){ s.style.transform=''; s.style.opacity=''; }); + } + }); + } + + // Accordion + document.querySelectorAll('.accordion-header').forEach(function (header) { + header.addEventListener('click', function () { + var body = this.nextElementSibling; + var icon = this.querySelector('.accordion-icon'); + var isOpen = body.classList.contains('open'); + document.querySelectorAll('.accordion-body').forEach(function (b) { b.classList.remove('open'); }); + document.querySelectorAll('.accordion-icon').forEach(function (i) { i.style.transform = ''; }); + if (!isOpen) { + body.classList.add('open'); + if (icon) icon.style.transform = 'rotate(45deg)'; + } + }); + }); + + // Stat bar animation + if ('IntersectionObserver' in window) { + var statObs = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (entry.isIntersecting) { + var fill = entry.target; + var target = fill.getAttribute('data-width'); + fill.style.width = '0'; + setTimeout(function () { fill.style.width = target; }, 100); + statObs.unobserve(fill); + } + }); + }, { threshold: 0.3 }); + + document.querySelectorAll('.stat-bar-fill').forEach(function (fill) { + fill.setAttribute('data-width', fill.style.width); + fill.style.width = '0'; + statObs.observe(fill); + }); + + // Fade in cards + var fadeObs = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (entry.isIntersecting) { + entry.target.style.opacity = '1'; + entry.target.style.transform = 'translateY(0)'; + fadeObs.unobserve(entry.target); + } + }); + }, { threshold: 0.08, rootMargin: '0px 0px -30px 0px' }); + + document.querySelectorAll('.card, .character-card, .enemy-card, .weapon-card, .location-card, .community-platform, .split-section').forEach(function (el) { + el.style.opacity = '0'; + el.style.transition = 'opacity 0.65s ease, transform 0.65s ease'; + el.style.transform = 'translateY(22px)'; + fadeObs.observe(el); + }); + } + + // Parallax hero + var heroBg = document.querySelector('.hero-bg'); + if (heroBg) { + window.addEventListener('scroll', function () { + heroBg.style.transform = 'translateY(' + (window.scrollY * 0.28) + 'px)'; + }, { passive: true }); + } + +}); diff --git a/src/main/resources/static/js/index2.js b/src/main/resources/static/js/index2.js new file mode 100644 index 0000000000000000000000000000000000000000..c9cf242caff17d68e76c40545e0d1aa6545da6bc --- /dev/null +++ b/src/main/resources/static/js/index2.js @@ -0,0 +1,107 @@ +// ============================================================ +// MOON BLIGHT — index.js +// ============================================================ + +document.addEventListener("DOMContentLoaded", function () { + + // -------------------------------------------------------- + // 1. NAVBAR SCROLL (event delegation — không bị null) + // -------------------------------------------------------- + document.addEventListener("click", function (e) { + const link = e.target.closest(".nav-down"); + if (!link) return; + + const targetId = link.dataset.target; + if (!targetId) return; + + const section = document.getElementById(targetId); + + if (section) { + // Đang ở trang chủ → scroll mượt + e.preventDefault(); + section.scrollIntoView({ behavior: "smooth" }); + + // Đóng mobile menu nếu đang mở + closeMobileMenu(); + } else { + // Ở trang khác → redirect về trang chủ kèm hash + window.location.href = "/#" + targetId; + } + }); + + // Xử lý hash khi vừa redirect về trang chủ + if (window.location.hash) { + const targetId = window.location.hash.substring(1); + const target = document.getElementById(targetId); + if (target) { + setTimeout(() => target.scrollIntoView({ behavior: "smooth" }), 200); + // Xóa hash trên URL cho sạch + history.replaceState(null, "", window.location.pathname); + } + } + + // -------------------------------------------------------- + // 2. HAMBURGER MENU + // -------------------------------------------------------- + const hamburger = document.getElementById("hamburger"); + const mobileMenu = document.getElementById("mobileMenu"); + + if (hamburger && mobileMenu) { + hamburger.addEventListener("click", function () { + const isOpen = mobileMenu.classList.toggle("open"); + hamburger.setAttribute("aria-expanded", isOpen); + mobileMenu.setAttribute("aria-hidden", !isOpen); + hamburger.classList.toggle("active", isOpen); + }); + + // Đóng mobile menu khi click ra ngoài + document.addEventListener("click", function (e) { + if (!hamburger.contains(e.target) && !mobileMenu.contains(e.target)) { + closeMobileMenu(); + } + }); + } + + function closeMobileMenu() { + if (!mobileMenu) return; + mobileMenu.classList.remove("open"); + mobileMenu.setAttribute("aria-hidden", "true"); + if (hamburger) { + hamburger.setAttribute("aria-expanded", "false"); + hamburger.classList.remove("active"); + } + } + + // -------------------------------------------------------- + // 3. NAVBAR — đổi style khi scroll + // -------------------------------------------------------- + const navbar = document.querySelector(".navbar"); + if (navbar) { + window.addEventListener("scroll", function () { + navbar.classList.toggle("scrolled", window.scrollY > 50); + }, { passive: true }); + } + + // -------------------------------------------------------- + // 4. VIDEO MODAL + // -------------------------------------------------------- + window.openVideo = function () { + const modal = document.getElementById("videoModal"); + const player = document.getElementById("videoPlayer"); + if (modal) modal.style.display = "flex"; + if (player) player.play(); + }; + + window.closeVideo = function () { + const modal = document.getElementById("videoModal"); + const player = document.getElementById("videoPlayer"); + if (modal) modal.style.display = "none"; + if (player) player.pause(); + }; + + // Đóng video bằng phím Escape + document.addEventListener("keydown", function (e) { + if (e.key === "Escape") window.closeVideo(); + }); + +}); \ No newline at end of file diff --git a/src/main/resources/static/js/main.txt b/src/main/resources/static/js/main.txt new file mode 100644 index 0000000000000000000000000000000000000000..b03c73de30e4f4e674f57c957c9a0216690cee1d --- /dev/null +++ b/src/main/resources/static/js/main.txt @@ -0,0 +1,118 @@ +/* Dark Fantasy — Main JavaScript */ +/* Rename this file to main.js after downloading the project */ + +document.addEventListener('DOMContentLoaded', function () { + + // ---- Navbar scroll effect ---- + const navbar = document.querySelector('.navbar'); + window.addEventListener('scroll', function () { + if (window.scrollY > 60) { + navbar.style.background = 'rgba(8,6,4,0.99)'; + } else { + navbar.style.background = ''; + } + }); + + // ---- Mobile hamburger ---- + const hamburger = document.getElementById('hamburger'); + const mobileMenu = document.getElementById('mobileMenu'); + + if (hamburger && mobileMenu) { + hamburger.addEventListener('click', function () { + const isOpen = mobileMenu.classList.toggle('open'); + const spans = hamburger.querySelectorAll('span'); + if (isOpen) { + spans[0].style.transform = 'rotate(45deg) translate(5px, 5px)'; + spans[1].style.opacity = '0'; + spans[2].style.transform = 'rotate(-45deg) translate(5px, -5px)'; + } else { + spans[0].style.transform = ''; + spans[1].style.opacity = ''; + spans[2].style.transform = ''; + } + }); + document.addEventListener('click', function (e) { + if (!hamburger.contains(e.target) && !mobileMenu.contains(e.target)) { + mobileMenu.classList.remove('open'); + hamburger.querySelectorAll('span').forEach(function(s){ s.style.transform=''; s.style.opacity=''; }); + } + }); + } + + // ---- Accordion ---- + document.querySelectorAll('.accordion-header').forEach(function (header) { + header.addEventListener('click', function () { + const body = this.nextElementSibling; + const icon = this.querySelector('.accordion-icon'); + const isOpen = body.classList.contains('open'); + document.querySelectorAll('.accordion-body').forEach(function (b) { b.classList.remove('open'); }); + document.querySelectorAll('.accordion-icon').forEach(function (i) { i.style.transform = ''; }); + if (!isOpen) { + body.classList.add('open'); + if (icon) icon.style.transform = 'rotate(45deg)'; + } + }); + }); + + // ---- Stat bar animation ---- + const observer = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (entry.isIntersecting) { + const fill = entry.target; + const target = fill.getAttribute('data-width'); + fill.style.width = '0'; + setTimeout(function () { fill.style.width = target; }, 100); + observer.unobserve(fill); + } + }); + }, { threshold: 0.3 }); + + document.querySelectorAll('.stat-bar-fill').forEach(function (fill) { + fill.setAttribute('data-width', fill.style.width); + fill.style.width = '0'; + observer.observe(fill); + }); + + // ---- Fade in on scroll ---- + const fadeObserver = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (entry.isIntersecting) { + entry.target.style.opacity = '1'; + entry.target.style.transform = 'translateY(0)'; + fadeObserver.unobserve(entry.target); + } + }); + }, { threshold: 0.1, rootMargin: '0px 0px -40px 0px' }); + + document.querySelectorAll('.card, .character-card, .enemy-card, .weapon-card, .location-card, .community-platform').forEach(function (el) { + el.style.opacity = '0'; + el.style.transition = 'opacity 0.6s ease, transform 0.6s ease'; + el.style.transform = 'translateY(20px)'; + fadeObserver.observe(el); + }); + + // ---- Parallax hero ---- + const heroBg = document.querySelector('.hero-bg'); + if (heroBg) { + window.addEventListener('scroll', function () { + heroBg.style.transform = 'translateY(' + (window.scrollY * 0.3) + 'px)'; + }, { passive: true }); + } + + // ---- Contact form ---- + const contactForm = document.getElementById('contactForm'); + if (contactForm) { + contactForm.addEventListener('submit', function (e) { + e.preventDefault(); + const btn = contactForm.querySelector('[type="submit"]'); + const orig = btn.textContent; + btn.textContent = 'Message Sent — The Darkness Receives Your Words...'; + btn.disabled = true; + setTimeout(function () { + btn.textContent = orig; + btn.disabled = false; + contactForm.reset(); + }, 3500); + }); + } +}); diff --git a/src/main/resources/static/video/trailer.mp4 b/src/main/resources/static/video/trailer.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..ec8742a9cfbe0d6ffb272d47d62322f30b9cce25 --- /dev/null +++ b/src/main/resources/static/video/trailer.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b6d8f8fe4952822cb4347a4bab9d8650d7269a9467e4219c45ef74ed015fae21 +size 5042222 diff --git a/src/main/resources/templates/cms/admin/account-detail.html b/src/main/resources/templates/cms/admin/account-detail.html new file mode 100644 index 0000000000000000000000000000000000000000..7315ce1d596cf676cd124fa2d9e312329bdd657e --- /dev/null +++ b/src/main/resources/templates/cms/admin/account-detail.html @@ -0,0 +1,110 @@ + + + + + + Chi tiết tài khoản + + + + + +
+
+ +
+
+ +
+ + +
+
+ +
+ +
+ Moonblight CMS +

Chi tiết tài khoản

+
+
+ +

+

+

+

+ +
+ +
+ ID + +
+ +
+ Username + +
+ +
+ Email + +
+ +
+ Họ tên + +
+ +
+ Vai trò + +
+ +
+ Trạng thái + + + Đang hoạt động + + + + Đã khóa + +
+ +
+ +
+ +
+ + + +
+ +
+ + + +
+ + + Quay lại danh sách + + +
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/templates/cms/admin/account-list.html b/src/main/resources/templates/cms/admin/account-list.html new file mode 100644 index 0000000000000000000000000000000000000000..6731823ebe62ee6f3dfed8a1a84f5f4327242f85 --- /dev/null +++ b/src/main/resources/templates/cms/admin/account-list.html @@ -0,0 +1,112 @@ + + + + + + Danh sách tài khoản + + + + + +
+
+ +
+
+ +
+ +
+
+ +
+ +
+ Moonblight CMS +

Danh sách tài khoản

+
+
+ +

+

+

+

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/templates/cms/admin/admin-dashboard.html b/src/main/resources/templates/cms/admin/admin-dashboard.html new file mode 100644 index 0000000000000000000000000000000000000000..66971057ae0c7042614ac527be9a81d04dbb6498 --- /dev/null +++ b/src/main/resources/templates/cms/admin/admin-dashboard.html @@ -0,0 +1,52 @@ + + + + + Dashboard Quản trị + + + + + +
+
+
+
+ +
+
+
+
+
+ Moonblight CMS + +

Dashboard Quản Trị

+ +
+
+ + + +
+
+ Tổng tài khoản +

0

+
+ +
+ Đang hoạt động +

0

+
+ +
+ Chờ duyệt +

0

+
+
+
+
+
+
+
+ + diff --git a/src/main/resources/templates/cms/admin/audit-log.html b/src/main/resources/templates/cms/admin/audit-log.html new file mode 100644 index 0000000000000000000000000000000000000000..af94a3de6ffa7077ce94119fbf91af6ec3ce8287 --- /dev/null +++ b/src/main/resources/templates/cms/admin/audit-log.html @@ -0,0 +1,122 @@ + + + + + Audit Logs + + + + + +
+ +
+ +
+
+ +
+
+
+
+ + +
+ Moonblight CMS + +

Audit Logs

+ +
+
+ + + +
+

Danh sách log hệ thống

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDNgười thực hiệnLoại đối tượngID đối tượngHành độngMô tảThời gian
+ + +
+ Không có dữ liệu log +
+
+
+ + + + +
+
+
+
+
+ + diff --git a/src/main/resources/templates/cms/article/article-create.html b/src/main/resources/templates/cms/article/article-create.html new file mode 100644 index 0000000000000000000000000000000000000000..211eba79796cbf59f9d1154140bd2190abad25ff --- /dev/null +++ b/src/main/resources/templates/cms/article/article-create.html @@ -0,0 +1,136 @@ + + + + + + Tạo bài viết + + + + + + + + + +
+
+ +
+
+ +
+ +
+ +
+
+ +
+ + Moonblight CMS + + +

+ Tạo bài viết mới +

+ +
+
+ +

+

+

+

+ +
+ + +
+ + + +
+
+ +
+ + +
+ + + + + +
+
+ +
+ + +
+ + + + + +
+ + +
+ + + + + +
+
+ +
+
+ +
+ + +
+ + + + + Quay lại + + +
+
+
+
+
+
+
+ + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/cms/article/article-edit.html b/src/main/resources/templates/cms/article/article-edit.html new file mode 100644 index 0000000000000000000000000000000000000000..d405bb2d4bb10eb072b1cf5babf45977b385f972 --- /dev/null +++ b/src/main/resources/templates/cms/article/article-edit.html @@ -0,0 +1,141 @@ + + + + + + Cập nhật bài viết + + + + + + + + + + + +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + Moonblight CMS + + +

+ Cập nhật bài viết +

+ +
+
+ +

+

+

+

+ +
+ + +
+ + + +
+
+ +
+ + +
+ + + + +
+
+ +
+ + +
+ + Thumbnail +
+ + +
+ + +
+ + +
+ + + +
+
+
+
+ +
+ + +
+ + + + + Quay lại + + +
+
+
+
+
+
+
+ + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/cms/article/article-list.html b/src/main/resources/templates/cms/article/article-list.html new file mode 100644 index 0000000000000000000000000000000000000000..fff2052c6ecca3eb9570636541de2b955d2b72e0 --- /dev/null +++ b/src/main/resources/templates/cms/article/article-list.html @@ -0,0 +1,176 @@ + + + + + + + Danh sách bài viết + + + + + + + +
+
+ +
+ +
+ + +
+ +
+ +
+ +
+ +
+ + + Moonblight CMS + + +

+ Danh sách bài viết +

+ +
+ +
+ +

+

+ +

+

+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDThumbnailTiêu đềTác giảLoạiNgày tạoTrạng tháiHành động
+ + + + + + + Đang hiển thị + + + + Đang ẩn + + + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ +
+ +
+ + +
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/templates/cms/auth/change-password.html b/src/main/resources/templates/cms/auth/change-password.html new file mode 100644 index 0000000000000000000000000000000000000000..ca4b725aa5b8a58eac5f6c43c79d63b4d296d924 --- /dev/null +++ b/src/main/resources/templates/cms/auth/change-password.html @@ -0,0 +1,86 @@ + + + + + + Đổi mật khẩu + + + + + +
+ +
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+ +

Đổi mật khẩu

+ + +

+

+ +

+

+ + +
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + + +
+ +
+ +
+
+
+ + + + + \ No newline at end of file diff --git a/src/main/resources/templates/cms/auth/forgot-password.html b/src/main/resources/templates/cms/auth/forgot-password.html new file mode 100644 index 0000000000000000000000000000000000000000..e0aecb088593bccddded55e9beec545069526160 --- /dev/null +++ b/src/main/resources/templates/cms/auth/forgot-password.html @@ -0,0 +1,64 @@ + + + + + + Quên mật khẩu + + + + + + + + +
+ +
+
+ +
+ MOONBLIGHT ACCOUNT +
+ +

Quên mật khẩu

+ +
+ + +

+ + +
+ +
+ + + +
+
+
+ + +
+ +
+ + + Đã có tài khoản? Đăng nhập + + + + Tạo tài khoản mới + + +
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/cms/auth/login.html b/src/main/resources/templates/cms/auth/login.html new file mode 100644 index 0000000000000000000000000000000000000000..3d20d9a316b4c7a250cc2a9fe54e024e0c96af98 --- /dev/null +++ b/src/main/resources/templates/cms/auth/login.html @@ -0,0 +1,75 @@ + + + + + + Login + + + + + + + + +
+ + +
+
+ +
+ MOONBLIGHT ACCOUNT +
+ +

Login

+ +
+ + +

+

+ +
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ +
+ + + Tạo tài khoản mới + + + + Quên mật khẩu + + +
+
+ + + \ No newline at end of file diff --git a/src/main/resources/templates/cms/auth/register.html b/src/main/resources/templates/cms/auth/register.html new file mode 100644 index 0000000000000000000000000000000000000000..657bc8f608629d8f851b51027e62c2523b2083c3 --- /dev/null +++ b/src/main/resources/templates/cms/auth/register.html @@ -0,0 +1,135 @@ + + + + + Register + + + + + + + +
+
+
+
MOONBLIGHT ACCOUNT
+ +

Đăng ký

+ +
+ + +

+ +

+ + +
+ +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ +
+ + + Đã có tài khoản? Đăng nhập + + + + Quên mật khẩu + +
+
+ + diff --git a/src/main/resources/templates/cms/auth/reset-password.html b/src/main/resources/templates/cms/auth/reset-password.html new file mode 100644 index 0000000000000000000000000000000000000000..a6ab4e463e9bd8e3c824046846226d397eb6434a --- /dev/null +++ b/src/main/resources/templates/cms/auth/reset-password.html @@ -0,0 +1,69 @@ + + + + + + Đặt lại mật khẩu + + + + + + + +
+
+ +
+ +
+ MOONBLIGHT ACCOUNT +
+ +

+ Đặt lại mật khẩu +

+ +
+ +

+

+ +

+

+ +
+ +
+ + + +
+
+
+ +
+ + + +
+
+
+ + + + + + + +
+ +
+
+ + + \ No newline at end of file diff --git a/src/main/resources/templates/cms/auth/verify-otp.html b/src/main/resources/templates/cms/auth/verify-otp.html new file mode 100644 index 0000000000000000000000000000000000000000..4acab2c2bf6d2267f40f6d89ad56ecfe01f2e565 --- /dev/null +++ b/src/main/resources/templates/cms/auth/verify-otp.html @@ -0,0 +1,62 @@ + + + + + + Xác nhận OTP + + + + + + + +
+ +
+
+
MOONBLIGHT ACCOUNT
+ +

Xác nhận OTP

+ +
+
+ +
+ + + +
+ + + +
+
+ + +
+ +
+ + + Quay lại đăng nhập + +
+
+ + diff --git a/src/main/resources/templates/cms/character/character-create.html b/src/main/resources/templates/cms/character/character-create.html new file mode 100644 index 0000000000000000000000000000000000000000..76b596d82e3f1517461e7f9b7c45bcc4e583a637 --- /dev/null +++ b/src/main/resources/templates/cms/character/character-create.html @@ -0,0 +1,143 @@ + + + + + + Tạo Nhân Vật + + + + + + + + + + +
+
+ +
+
+ +
+ + +
+ +
+ +
+ +
+ + Moonblight CMS + + +

+ Tạo Nhân Vật Mới +

+ +
+
+ +

+

+ +
+ +
+ + + + +
+
+
+ +
+ + + + +
+
+
+ +
+ + + + +
+
+ +
+
+
+ +
+ + + + +
+
+
+ +
+ + + + +
+
+
+ +
+ + + +
+ +
+ + + + + Quay lại + + +
+ +
+ +
+ +
+ +
+
+
+ + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/cms/character/character-edit.html b/src/main/resources/templates/cms/character/character-edit.html new file mode 100644 index 0000000000000000000000000000000000000000..e0671d733cfe8a7433ea020f0cf1ad24ee80c05c --- /dev/null +++ b/src/main/resources/templates/cms/character/character-edit.html @@ -0,0 +1,108 @@ + + + + + + Cập nhật nhân vật + + + + + + + + + +
+
+ +
+
+ +
+ +
+
+ +
+ +
+ Moonblight CMS +

Cập Nhật Nhân Vật

+
+
+ +

+

+ +
+ + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ Character Image +
+
+ +
+ + +
+ +
+ + + + +
+
+
+ +
+ + + + Quay lại + + +
+ +
+ +
+ +
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/templates/cms/character/character-list.html b/src/main/resources/templates/cms/character/character-list.html new file mode 100644 index 0000000000000000000000000000000000000000..96ad04497a3fa551189da61875a923cd0987b947 --- /dev/null +++ b/src/main/resources/templates/cms/character/character-list.html @@ -0,0 +1,147 @@ + + + + + + Danh sách nhân vật + + + + + + +
+
+ +
+
+ +
+ +
+
+ +
+ +
+ Moonblight CMS +

Danh Sách Nhân Vật

+
+
+ +

+

+ +

+

+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDẢnhTênChủng tộcƯu tiênTrạng tháiHành động
+ Character + + + Đang hiển thị + + + + Đang ẩn + + +
+ +
+ +
+ +
+ + + +
+ +
+ + + +
+
+
+ +
+ + +
+
+
+
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/cms/contact/contact-detail.html b/src/main/resources/templates/cms/contact/contact-detail.html new file mode 100644 index 0000000000000000000000000000000000000000..184f2b6b33d7caabe4a8d4e7588a1958d302a63e --- /dev/null +++ b/src/main/resources/templates/cms/contact/contact-detail.html @@ -0,0 +1,125 @@ + + + + + + Chi tiết liên hệ + + + + + + +
+
+ +
+
+ +
+ +
+
+ +
+ +
+ + Moonblight CMS + + +

+ Chi tiết liên hệ +

+ +
+
+ +

+

+ +

+

+ +
+ +
+ ID + +
+ +
+ Người gửi + +
+ +
+ Email + + +
+ +
+ Danh mục + + + +
+ +
+ Nền tảng + + + +
+ +
+ Trạng thái + + + Đã xử lý + + + + Chưa xử lý + +
+ +
+
+ Nội dung +
+ +

+              
+ +
+ +
+ +
+ + + +
+ + + Quay lại danh sách + + +
+ +
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/cms/contact/contact-list.html b/src/main/resources/templates/cms/contact/contact-list.html new file mode 100644 index 0000000000000000000000000000000000000000..5546463b41023911f54aca83cc42eaff345fb628 --- /dev/null +++ b/src/main/resources/templates/cms/contact/contact-list.html @@ -0,0 +1,160 @@ + + + + + + Danh sách liên hệ + + + + + + +
+
+ +
+
+ +
+ + +
+
+ +
+ +
+ + Moonblight CMS + + +

+ Danh sách liên hệ +

+ +
+
+ +

+

+ +

+

+ +
+ Chưa có tin nhắn liên hệ nào. +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDNgười gửiEmailDanh mụcNền tảngTrạng tháiHành động
+ + + + + + + Đã xử lý + + + + Chưa xử lý + + + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ +
+ +
+ + + +
+ +
+
+ +
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/cms/contributor/contributor-create.html b/src/main/resources/templates/cms/contributor/contributor-create.html new file mode 100644 index 0000000000000000000000000000000000000000..c66c9d7835a98c3564c97d09f29d3f90754cadf9 --- /dev/null +++ b/src/main/resources/templates/cms/contributor/contributor-create.html @@ -0,0 +1,122 @@ + + + + + + Tạo người đóng góp + + ``` + + ``` + + + + ``` +
+ +
+ +
+
+ +
+
+
+
+
+ Moonblight CMS + +

Tạo Người Đóng Góp Mới

+ +
+
+ +

+ +
+
+ + + + +
+
+ +
+ + + + +
+
+ +
+ + + + +
+
+ +
+ + + +
+ +
+ + + + Quay lại + +
+
+
+
+
+
+
+ ``` + + diff --git a/src/main/resources/templates/cms/contributor/contributor-edit.html b/src/main/resources/templates/cms/contributor/contributor-edit.html new file mode 100644 index 0000000000000000000000000000000000000000..fb035dfa0b925c70decbda2c1367233781594563 --- /dev/null +++ b/src/main/resources/templates/cms/contributor/contributor-edit.html @@ -0,0 +1,121 @@ + + + + + + Cập nhật người đóng góp + + + + + +
+ +
+ +
+ ``` +
+ +
+
+
+
+
+ Moonblight CMS + +

Cập Nhật Người Đóng Góp

+ +
+
+ +

+ +
+ + +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + +
+ Contributor Avatar + +
Chưa có ảnh
+
+
+ +
+ + + +
+ +
+ + + + Quay lại + +
+
+
+
+
+
+ ``` +
+ + diff --git a/src/main/resources/templates/cms/contributor/contributor-list.html b/src/main/resources/templates/cms/contributor/contributor-list.html new file mode 100644 index 0000000000000000000000000000000000000000..0017b2a9aa538bff66270c62fe36e5a91a95bac6 --- /dev/null +++ b/src/main/resources/templates/cms/contributor/contributor-list.html @@ -0,0 +1,194 @@ + + + + + + Danh sách người đóng góp + + + + +
+ +
+ +
+ ``` +
+ +
+
+
+
+
+ Moonblight CMS +

Danh Sách Người Đóng Góp

+
+
+ +

+ +

+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDẢnh đại diệnTênChức danhƯu tiênTrạng tháiHành động
+ Contributor + + + - + + + + Đang hiển thị + + + + Đang ẩn + + +
+
+ +
+ +
+ +
+ +
+ +
+
+
+
+ + +
+
+
+
+ ``` +
+ + diff --git a/src/main/resources/templates/cms/dashboard.html b/src/main/resources/templates/cms/dashboard.html new file mode 100644 index 0000000000000000000000000000000000000000..df4d3110ae48c3624e2bce5ebcf3e785a2650c16 --- /dev/null +++ b/src/main/resources/templates/cms/dashboard.html @@ -0,0 +1,133 @@ + + + + + Dashboard + + + + + + +
+ +
+ +
+
+ +
+
+
+
+ +
+
Moon Blight CMS
+ +

Dashboard

+ +
+ +

Chào mừng đến với Moon Blight CMS.

+
+ + +
+

+ +

+
+ + +
+
+

0

+

Articles

+
+ +
+

0

+

Stories

+
+ +
+

0

+

Worlds

+
+ +
+

0

+

Characters

+
+ +
+

0

+

FAQs

+
+
+

0

+

Contributors

+
+ +
+

0

+

Chưa xử lý

+
+
+ + +
+ Cần chú ý: + + Có + 0 + tin nhắn liên hệ chưa được xử lý. +
+ + + +
+
+
+
+
+ + diff --git a/src/main/resources/templates/cms/faq/faq-create.html b/src/main/resources/templates/cms/faq/faq-create.html new file mode 100644 index 0000000000000000000000000000000000000000..7e1cd90afecbac2d35ccb29853fefe1249d4bc0f --- /dev/null +++ b/src/main/resources/templates/cms/faq/faq-create.html @@ -0,0 +1,74 @@ + + + + + + Tạo FAQ + + + + + + + + +
+
+ +
+
+ +
+ +
+
+
+
+ Moonblight CMS +

Tạo FAQ mới

+
+ +
+ + +

+

+ +
+ +
+ + +
+
+ +
+ + +
+
+ + +
+ + +
+
+
+ +
+ + Quay lại +
+
+ +
+
+ +
+
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/cms/faq/faq-edit.html b/src/main/resources/templates/cms/faq/faq-edit.html new file mode 100644 index 0000000000000000000000000000000000000000..75b7fe55e5cbe226757ed5138ac311e8f3ba879d --- /dev/null +++ b/src/main/resources/templates/cms/faq/faq-edit.html @@ -0,0 +1,74 @@ + + + + + + Chỉnh sửa FAQ + + + + + + + + +
+
+ +
+
+ +
+ +
+
+
+ +
+ Moonblight CMS +

Chỉnh FAQ

+
+
+

+ +

+ +
+ + +
+ + +
+
+ +
+ + +
+
+ +
+ + + +
+
+
+ +
+ + Quay lại +
+
+
+
+ +
+
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/cms/faq/faq-list.html b/src/main/resources/templates/cms/faq/faq-list.html new file mode 100644 index 0000000000000000000000000000000000000000..240109005023dbdf51356712d769f0c55945d411 --- /dev/null +++ b/src/main/resources/templates/cms/faq/faq-list.html @@ -0,0 +1,109 @@ + + + + + + Danh sách FAQ + + + + + +
+
+ +
+
+ +
+ +
+
+
+ +
+ Moonblight CMS +

Danh sách FAQ

+
+
+ +

+

+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
IDTiêu đềPriorityTrạng tháiHành động
+ + Đang hiển thị + + Đang ẩn + +
+
+ +
+
+ +
+
+ +
+
+
+
+ + +
+
+
+ +
+
+ + + \ No newline at end of file diff --git a/src/main/resources/templates/cms/fragments/admin-sidebar.html b/src/main/resources/templates/cms/fragments/admin-sidebar.html new file mode 100644 index 0000000000000000000000000000000000000000..87edc619ff3fd4fe4aa268fb0628d36855d45f38 --- /dev/null +++ b/src/main/resources/templates/cms/fragments/admin-sidebar.html @@ -0,0 +1,30 @@ + \ No newline at end of file diff --git a/src/main/resources/templates/cms/fragments/header.html b/src/main/resources/templates/cms/fragments/header.html new file mode 100644 index 0000000000000000000000000000000000000000..9ca827993b74d8e6a30a50c328c185a0adbc1b33 --- /dev/null +++ b/src/main/resources/templates/cms/fragments/header.html @@ -0,0 +1,38 @@ +
+ +
+

🌙 Moon Blight CMS

+
+ +
+ +
+ + + + + + +
+ +
+ +
\ No newline at end of file diff --git a/src/main/resources/templates/cms/fragments/sidebar.html b/src/main/resources/templates/cms/fragments/sidebar.html new file mode 100644 index 0000000000000000000000000000000000000000..1a46f4b6bd1be98a4cdb5ca704ad10ac72435596 --- /dev/null +++ b/src/main/resources/templates/cms/fragments/sidebar.html @@ -0,0 +1,63 @@ + \ No newline at end of file diff --git a/src/main/resources/templates/cms/note-about-layout-list.txt b/src/main/resources/templates/cms/note-about-layout-list.txt new file mode 100644 index 0000000000000000000000000000000000000000..5c2283ee1f309e4909479eb3c56591a34f010dc6 --- /dev/null +++ b/src/main/resources/templates/cms/note-about-layout-list.txt @@ -0,0 +1,13 @@ +
+ + + +
+ + + +
+ +
+ +
\ No newline at end of file diff --git a/src/main/resources/templates/cms/story/story-create.html b/src/main/resources/templates/cms/story/story-create.html new file mode 100644 index 0000000000000000000000000000000000000000..62721f17ce474e3a30841ffd236db590f00abfdc --- /dev/null +++ b/src/main/resources/templates/cms/story/story-create.html @@ -0,0 +1,139 @@ + + + + + + Tạo câu chuyện + + + + + + + + +
+
+ +
+
+ +
+ +
+ + +
+
+ +
+ Moonblight CMS +

Tạo Câu Chuyện Mới

+
+
+ + +

+

+ + +

+

+ + +
+ +
+ + +
+
+
+ +
+ + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + +
+
+ +
+ +
+ + + + +
+ + +
+ + + + + +
+
+
+
+ +
+ +
+ + + + + Quay Lại + + +
+
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/cms/story/story-edit.html b/src/main/resources/templates/cms/story/story-edit.html new file mode 100644 index 0000000000000000000000000000000000000000..53f54868255c208f9d2d4b5fdc3e66b468910c5d --- /dev/null +++ b/src/main/resources/templates/cms/story/story-edit.html @@ -0,0 +1,149 @@ + + + + + + + Cập nhật câu chuyện + + + + + + + + +
+
+ +
+
+
+ +
+
+
+
+ + Moonblight CMS + +

+ Cập Nhật Câu Chuyện +

+
+
+ +

+

+ +

+

+ +
+
+ + + +
+
+ +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + Story Image + +
+ + + +
+ + + + +
+ +
+ + + +
+
+ +
+ +
+ + + + +
+ + + +
+ + + +
+
+
+
+ +
+ +
+ + + + Quay lại + + +
+
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/cms/story/story-list.html b/src/main/resources/templates/cms/story/story-list.html new file mode 100644 index 0000000000000000000000000000000000000000..94bf6331a43dd7c5db38e7ef9bf0a6267c16264d --- /dev/null +++ b/src/main/resources/templates/cms/story/story-list.html @@ -0,0 +1,122 @@ + + + + + + Danh sách câu chuyện + + + + +
+
+ +
+
+ +
+ +
+
+
+
+ Moonblight CMS +

Danh Sách Câu Chuyện

+
+
+ +

+

+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDẢnhƯu tiênTiêu đềTagQuote AuthorTrạng tháiHành động
+ Story Image + + Đang hiển thị + Đang ẩn + + +
+
+ +
+ +
+ +
+ +
+ +
+
+ +
+ + + + +
+
+
+ +
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/templates/cms/world/world-create.html b/src/main/resources/templates/cms/world/world-create.html new file mode 100644 index 0000000000000000000000000000000000000000..64123628e2a0350951b76558cef4b436f9053cfc --- /dev/null +++ b/src/main/resources/templates/cms/world/world-create.html @@ -0,0 +1,91 @@ + + + + + + + Tạo thế giới + + + + + + +
+
+ +
+
+ +
+ +
+
+
+ +
+ Moonblight CMS +

Tạo Thế Giới Mới

+
+
+ +

+ +

+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+
+
+ +
+ + + + Quay Lại + +
+
+ +
+
+
+
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/cms/world/world-edit.html b/src/main/resources/templates/cms/world/world-edit.html new file mode 100644 index 0000000000000000000000000000000000000000..19e047dd80845123f2af110edb13bfe0c2ce3d22 --- /dev/null +++ b/src/main/resources/templates/cms/world/world-edit.html @@ -0,0 +1,135 @@ + + + + + + Cập nhật thế giới + + + + + + + + +
+
+ +
+
+
+
+
+
+
+ Moonblight CMS +

Cập Nhật Thế Giới

+
+
+ +

+ +

+ +
+
+ + +
+ +
+ + +
+ +
+ + + +
+ +
+ + +
+ World Image +
+
+ +
+ + +
+ +
+ + +
+
+ +
+ + + + Quay Lại + +
+
+
+
+
+
+
+ + + diff --git a/src/main/resources/templates/cms/world/world-list.html b/src/main/resources/templates/cms/world/world-list.html new file mode 100644 index 0000000000000000000000000000000000000000..81f84d2414b7753b0173b9307a41660bb79402d8 --- /dev/null +++ b/src/main/resources/templates/cms/world/world-list.html @@ -0,0 +1,133 @@ + + + + + + + Danh sách thế giới + + + + + +
+
+ +
+
+ +
+ +
+ +
+
+
+ Moonblight CMS +

Danh Sách Thế Giới

+
+
+ +

+ +

+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDẢnhƯu tiênTagTiêu đềTrạng tháiHành động
+ World Image + + + Đang hiển thị + + + + Đang ẩn + + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+ + + +
+
+
+ +
+ + + \ No newline at end of file diff --git a/src/main/resources/templates/error/400.html b/src/main/resources/templates/error/400.html new file mode 100644 index 0000000000000000000000000000000000000000..eaf0399c82e9e2e71c2b27dd29d4ee6ec57f504f --- /dev/null +++ b/src/main/resources/templates/error/400.html @@ -0,0 +1,20 @@ + + + + + + 400 - Yêu cầu không hợp lệ + + + +

400 - Yêu cầu không hợp lệ

+ +

+ Yêu cầu của bạn không hợp lệ hoặc dữ liệu gửi lên không đúng định dạng. + Vui lòng kiểm tra lại và thử lại. +

+ + + diff --git a/src/main/resources/templates/error/403.html b/src/main/resources/templates/error/403.html new file mode 100644 index 0000000000000000000000000000000000000000..33062783ec6ee92022a4d016a892886745e40556 --- /dev/null +++ b/src/main/resources/templates/error/403.html @@ -0,0 +1,23 @@ + + + + + + + 403 - Truy Cập Bị Từ Chối + + + + +

403 - Quyền Truy Cập Bị Hạn Chế

+ +

+ Tài nguyên bạn đang cố gắng truy cập bị hạn chế. Bạn không có + đủ quyền để xem trang này. +

+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/error/404.html b/src/main/resources/templates/error/404.html new file mode 100644 index 0000000000000000000000000000000000000000..2c863217abc0f653a04176ed95e19a8bf5964bf9 --- /dev/null +++ b/src/main/resources/templates/error/404.html @@ -0,0 +1,23 @@ + + + + + + + 404 - Không Tìm Thấy Trang + + + + +

404 - Không Tìm Thấy Trang

+ +

+ Tài nguyên bạn đang tìm kiếm có thể đã bị xóa, thay đổi tên + hoặc tạm thời không khả dụng. +

+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/error/500.html b/src/main/resources/templates/error/500.html new file mode 100644 index 0000000000000000000000000000000000000000..4e0ec3eaee0d2283d14c503bc1d2b391ee920aa3 --- /dev/null +++ b/src/main/resources/templates/error/500.html @@ -0,0 +1,23 @@ + + + + + + + 500 - Lỗi Máy Chủ Nội Bộ + + + + +

500 - Lỗi Máy Chủ Nội Bộ

+ +

+ Tài nguyên bạn đang tìm kiếm có thể đã bị xóa, thay đổi tên + hoặc tạm thời không khả dụng. +

+ + + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/characters/characters-card.jsp b/src/main/webapp/WEB-INF/views/characters/characters-card.jsp new file mode 100644 index 0000000000000000000000000000000000000000..36c55e721fadff8a91b61c058ced9667455731cd --- /dev/null +++ b/src/main/webapp/WEB-INF/views/characters/characters-card.jsp @@ -0,0 +1,28 @@ +
+
+ ${character.name} + +
+
+ +
+ + ${character.race} + + +

+ ${character.name} +

+ +

+ "${character.quote}" +

+ + + View Profile + +
+
\ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/characters/characters.jsp b/src/main/webapp/WEB-INF/views/characters/characters.jsp new file mode 100644 index 0000000000000000000000000000000000000000..0a4ac836bff6afd27ad617caa98e480f427b7b3e --- /dev/null +++ b/src/main/webapp/WEB-INF/views/characters/characters.jsp @@ -0,0 +1,108 @@ +<%@ page contentType="text/html;charset=UTF-8" language="java" %> + <%@ include file="/WEB-INF/views/layout/header.jsp" %> + +
+
+
+
+
+

◆ Huyết Chiến Danh Lục ◆

+

Nhân vật

+

Mỗi anh hùng đều mang theo những thương tích — cả hữu hình lẫn ẩn sâu + trong tâm trí. + +

+
+
+ + +
+
+

+ + Mỗi kẻ mang theo những kỹ năng riêng biệt cho cuộc viễn chinh bị nguyền rủa — và những cách thức + riêng để sụp đổ dưới áp lực. + + Ta là phật ác không còn nơi chốn, ta là ác phật làm gì được ta. +

+
+
+ + +
+
+
+ +
+
+ ${character.name} + +
+
+ +
+ + + ${character.race} + + +

+ ${character.name} +

+ +

+ "${character.quote}" +

+ + +
+
+
+
+
+
+ + +
+
+
+
+ Affliction +
+
+
The Breaking Point
+

Virtues & Afflictions

+
+

+ Không một anh hùng nào có thể đối mặt với vực thẳm mà không mang thương tổn. Khi áp lực dần + chồng chất, họ sẽ chạm tới giới hạn tinh thần. + Một số tìm thấy ý chí sâu thẳm bên trong — thức tỉnh những Đức Hạnh + mạnh mẽ, biến tuyệt vọng thành sức mạnh. +

+

+ Những kẻ khác thì gục ngã. Các Chứng Loạn Tâm + như Hoang Tưởng, + Ích Kỷ hay Khổ Dâm bắt đầu chiếm lấy tâm trí —khiến họ chống lại đồng đội, phá hoại hành + động của cả tổ đội, + và đẩy chính bản thân đến diệt vong nhanh hơn. +

+

+ Ngay cả một chiến binh dày dạn trận mạc với đầy đủ trang bị + cũng có thể trở thành mối hiểm họa lớn nhất của ngươi + nếu tinh thần của họ sụp đổ trước tiên. +

+
+
+
+
+ + <%@ include file="/WEB-INF/views/layout/footer.jsp" %> \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/community/aboutUs.jsp b/src/main/webapp/WEB-INF/views/community/aboutUs.jsp new file mode 100644 index 0000000000000000000000000000000000000000..8412731a4f9f9a8f99a3970dfad4c4ad28f4c65b --- /dev/null +++ b/src/main/webapp/WEB-INF/views/community/aboutUs.jsp @@ -0,0 +1,177 @@ +<%@ page contentType="text/html;charset=UTF-8" language="java" %> + <%@ include file="/WEB-INF/views/layout/header.jsp" %> + +
+
+
+
+
+

◆About Us ◆

+

Đội ngũ phát triển

+

Moon Blight là một dự án dark fantasy được xây dựng với mong muốn tạo ra + một thế giới nơi lời nguyền của mặt trăng đỏ định hình số phận của mọi sinh linh. Chúng tôi tin rằng + mỗi lựa chọn đều để lại dấu ấn lên thế giới đang dần lụi tàn.

+
+
+ + +
+
+
+ Tầm nhìn +

Game muốn mang lại trải nghiệm cho người chơi.

+ +
+
+
+ + + +

Thế giới dark fantasy có chiều sâu.

+
+ +
+
+
+ +

Cốt truyện nhiều lựa chọn.

+
+ +
+
+
+ +

Người chơi ảnh hưởng đến diễn biến thế giới.

+
+ +
+
+
+ +

Không khí u ám, bí ẩn và đầy bi kịch.

+
+ +
+
+
+
+
+ + + + +
+
+
+
+ +

Câu chuyện hình thành dự án

+
+

+ Dự án được ấp ủ năm 2026 do nhóm bạn thân đam mê game dark fantasy thành lập. Ý tưởng nảy + sinh từ những buổi tối chơi Dungeons & Dragons, khi chúng tôi tạo ra một thế giới bị nguyền + rủa bởi mặt trăng đỏ. Chúng tôi muốn xây dựng một trò chơi nơi mỗi lựa chọn của người chơi + đều có hậu quả sâu sắc, ảnh hưởng đến số phận của thế giới và những sinh linh đang dần lụi + tàn. +

+

+ Qua nhiều năm phát triển, chúng tôi đã tạo ra một thế giới phong phú với cốt truyện đa dạng, + nơi người chơi có thể khám phá những bí ẩn của mặt trăng đỏ và chứng kiến sự biến đổi của + thế giới qua từng quyết định. Chúng tôi hy vọng Moon Blight sẽ mang đến cho người chơi một + trải nghiệm đậm chất dark fantasy, nơi họ có thể tự do khám phá và tạo nên câu chuyện của + riêng mình. +

+ +
+
+ Contest +
+
+
+
+ + + + +
+
+
+

Các thành viên của dự án

+

+ Các nhà phát triển đã đóng góp vào sự thành công của Moon Blight. +

+
+
+
+ +
+ ${contributor.name} +
+ + Thành viên + +

+ ${contributor.name} +

+

+ ${contributor.position} +

+
+
+
+
+ + + +
+
+ + + +
+
+
+ Người điều hành +

Trần Bình Dương

+

Thầy là một giáo viên của trường Đại học FPT cơ sở Hà Nội. Cũng là người + trực tiếp điều hành và quản lý nhóm phát triển.

+
+
+
+
+ 🎓 +

Người hướng dẫn dự án

+

Theo sát tiến độ và hỗ trợ nhóm giải quyết các vấn đề phát sinh trong quá trình phát triển.

+
+
+ 📚 +

Hỗ trợ chuyên môn

+

Đưa ra những góp ý và đánh giá nhằm nâng cao chất lượng sản phẩm.

+
+
+ 🤝 +

Đồng hành cùng nhóm

+

Góp phần kết nối ý tưởng, định hướng phát triển và hoàn thiện dự án.

+
+
+ +
+
+ + <%@ include file="/WEB-INF/views/layout/footer.jsp" %> \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/enemies/enemies.jsp b/src/main/webapp/WEB-INF/views/enemies/enemies.jsp new file mode 100644 index 0000000000000000000000000000000000000000..7e4f5fa54a453312aafbb1a9077ec331669586a0 --- /dev/null +++ b/src/main/webapp/WEB-INF/views/enemies/enemies.jsp @@ -0,0 +1,246 @@ +<%@ page contentType="text/html;charset=UTF-8" language="java" %> + <%@ include file="/WEB-INF/views/layout/header.jsp" %> + +
+
+
+
+
+

◆ Sinh Vật Trong Bóng Tố ◆

+

Quái vật

+

Chúng không hận bạn, cũng chẳng sợ bạn. Chúng chỉ đơn giản là tàn sát và + ngấu nghiến — từ thể xác, lý trí, cho đến cả sợi hy vọng mong manh nhất.

+
+
+ + +
+
+
+
+ + Thây + Ma +
+
+ 🐛 + Quái + Dị +
+
+ 🐠 + Dã + Thú +
+
+ + Kẻ + Hút Máu +
+
+ 🐈 + Đạo + Tặc +
+
+
+
+ + +
+
+
+ Danh Sách Quái Vật +

Thông tin quái vật

+
+
+
+ +
+ + Thây Ma — Đồi Mộ +

Đám Xương Lang Thang

+

+ Tàn tích sống dậy của những kẻ từng phục vụ vùng lãnh địa này. + Vô thức, vô tận, và hoàn toàn không biết đến lòng thương xót. + Số lượng áp đảo chính là vũ khí nguy hiểm nhất của chúng. +

+ ◆ MỐI ĐE DỌA THẤP +
+ +
+ 🐛 + Quái Dị — Darkest Dungeon +

Kẻ Xáo Trộn Hư Không

+

+ Một sinh vật thuần túy của vực không, được triệu hồi từ những nghi thức cấm kỵ. + Nó tồn tại chỉ để xóa bỏ mọi thứ. + Bất kỳ ai nhìn thẳng vào nó đều sẽ vĩnh viễn mất đi một phần lý trí. +

+ ◆ MỐI ĐE DỌA CỰC ĐẠI +
+ +
+ 🐠 + Dã Thú — Weald +

Người Sói

+

+ Từng là con người — giờ chỉ còn lại chút ít. + Lời nguyền đã ăn sâu vào máu thịt nó. + Tiếng tru gây áp lực tinh thần lên cả tổ đội. + Móng vuốt của nó sẽ lo phần còn lại. +

+ ◆ MỐI ĐE DỌA CAO +
+ +
+ 🐙 + Kẻ Hút Máu — Crimson Court +

Đao Phủ Huyết Giáo

+

+ Một thành viên méo mó của Crimson Court đã uống quá sâu thứ máu bị nguyền rủa. + Kháng cự hầu hết các loại sát thương. + Chỉ một lần tiếp xúc cũng đủ truyền Crimson Curse cho cả tổ đội. +

+ ◆ MỐI ĐE DỌA CAO +
+ +
+ 🐰 + Dã Thú — Warrens +

Thần Heo

+

+ Dị thể cổ xưa được lũ người-heo ở Warrens tôn thờ. + Thực thể cấp Boss. + Kích thước khổng lồ của nó khiến những đòn đánh vào hàng sau gần như vô dụng. +

+ ◆ MỐI ĐE DỌA CỰC ĐẠI +
+ +
+ + Quái Dị — Cove +

Chúa Sao Sâu Thẳm

+

+ Nó trôi dạt từ đáy đại dương đen tối bên ngoài lãnh địa. + Và mang theo cả vực không cùng nó. + Chỉ nhìn nó quá lâu cũng đủ khiến tâm trí lập tức sụp đổ. +

+ ◆ MỐI ĐE DỌA CỰC ĐẠI +
+ +
+ 👺 + Thây Ma — Đồi Mộ +

Thủy Thủ Chìm Xác

+

+ Những thủy thủ bỏ mạng trong các hầm mộ ngập nước bên dưới lãnh địa. + Chúng vẫn mang theo những vũ khí cổ xưa đủ sắc bén để kết liễu một mạng người. + Không thể thương lượng với chúng. + Và thật ra, chưa bao giờ có thể. +

+ ◆ MỐI ĐE DỌA TRUNG BÌNH +
+ +
+ 🏭 + Đạo Tặc — Weald +

Xạ Thủ Lữ Đoàn

+

+ Những tên tội phạm được thuê tới để cướp bóc vùng lãnh địa đổ nát. + Nhưng thứ chúng tìm thấy lại là sự điên loạn. + Giờ đây, chúng canh giữ những hành lang này chẳng khác gì lũ quái vật thật sự. +

+ ◆ MỐI ĐE DỌA TRUNG BÌNH +
+ +
+ 🐍 + Quái Dị — Darkest Dungeon +

Kẻ Sưu Tầm

+

+ Một thực thể lang thang đã thu thập đầu của những anh hùng đã ngã xuống + rồi tiếp tục điều khiển họ — bất tử, trống rỗng, chống lại chính đồng đội cũ. + Nó vẫn nhớ điểm yếu của họ. +

+ ◆ MỐI ĐE DỌA CỰC ĐẠI +
+ +
+
+
+ + +
+
+ +
+ Điểm Cuối Của Hành Trình +

Trùm Hầm Ngục

+

+ Mỗi hầm ngục đều cất giấu một thực thể sở hữu sức mạnh khủng khiếp. + Chúng không phải chướng ngại vật. + Chúng là hậu quả. +

+
+
+ +
+ +
+
+ Necromancer Lord +
+ +
+
Boss Khu Tàn Tích
+

Chúa Tể Triệu Hồn

+
+ +

+ Hắn từng là học trò trung thành nhất của Tổ Tiên. + Giờ đây, hắn cai trị khu Tàn Tích bằng ma thuật tử linh và lòng hận thù vô tận. + Trong chiến đấu, hắn có thể hồi sinh chính thuộc hạ của mình. +

+ + + ◆ MỐI ĐE DỌA CỰC ĐẠI + +
+
+ +
+
+ Hag +
+ +
+
Boss Khu Rừng Weald
+

Mụ Phù Thủy

+
+ +

+ Một sinh vật cổ xưa tồn tại trước cả vùng lãnh địa này. + Mụ nhét các anh hùng vào chiếc vạc khổng lồ của mình — + loại họ hoàn toàn khỏi trận chiến trong khi thiêu sống họ bên trong. +

+ + + ◆ MỐI ĐE DỌA CỰC ĐẠI + +
+
+ +
+
+
+ + <%@ include file="/WEB-INF/views/layout/footer.jsp" %> \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/enemies/enemy-card.jsp b/src/main/webapp/WEB-INF/views/enemies/enemy-card.jsp new file mode 100644 index 0000000000000000000000000000000000000000..c745f9dac095e1a7868f3cbcc91d736cd27d508c --- /dev/null +++ b/src/main/webapp/WEB-INF/views/enemies/enemy-card.jsp @@ -0,0 +1,56 @@ + + +
+ +
+ + + ${enemy.icon} + + + + ◆ ${enemy.threatLevel} + + +
+ +
+ + + ${enemy.type} + + +

+ ${enemy.name} +

+ +

+ ${enemy.description} +

+ +
+ +
+ HP + ${enemy.hp} +
+ +
+ ATK + ${enemy.attack} +
+ +
+ DEF + ${enemy.defense} +
+ +
+ + + View Entity + + +
+ +
\ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/index.jsp b/src/main/webapp/WEB-INF/views/index.jsp new file mode 100644 index 0000000000000000000000000000000000000000..be8d640bcb547d58db27dfa2934d37b83051041e --- /dev/null +++ b/src/main/webapp/WEB-INF/views/index.jsp @@ -0,0 +1,262 @@ +<%@ page contentType="text/html;charset=UTF-8" language="java" %> + <%@ include file="/WEB-INF/views/layout/header.jsp" %> + + + + + +
+ +
+
+
+
+ + +
+
+
Dark Fantasy RPG
+

MOON BLIGHT

+

Chìm vào thế giới hư vô bị nguyền rủa

+
+ +
+ Begin the Journey + + ◆ Tải xuống + + +
+
+ +
+
+
+ +
+ +
+ +
+ + + +
+ +
+ + + + +
+ + +
+ +
+
+
+ ${topStory.title} +
+
+
+ ${topStory.tag} +
+

+ ${topStory.title} +

+
+

+ ${topStory.summary} +

+ + Đọc toàn bộ + +
+
+
+
+
+ + + + + +
+ +
+
+
+
+ ${topWorld.tag} +
+

+ ${topWorld.title} +

+
+

+ ${topWorld.summary} +

+ + Khám phá thế giới + +
+
+ ${topWorld.title} +
+
+
+
+
+ + + + + + +
+
+
+ Tội Nhân +

Kẻ cầm quyền tàn ác

+

Những tàn hồn liên kết cùng nhau bởi tuyệt vọng và hắc ý.

+
+
+
+ +
+ ${character.name} +
+ ${character.race} +

${character.name}

+
"${character.quote}"
+
+
+
+
+
+ Xem thêm +
+
+
+ +
+ + + +
+ +
+
+
+

+ Hãy ghi nhớ: đích đến vốn chẳng phải là điều trọng yếu. Xưa nay chưa từng là như vậy. + Sự trầm giáng mới là mục đích. Hắc ám mới là đáp án. + Ngươi hoặc sẽ tái xuất sau khi chinh phục cơn ác mộng — + hoặc sẽ vĩnh viễn không tái xuất nữa. +

+ — The Ancestor, Final Testament +
+
+
+ + + + +
+
+
+ Thông điệp từ vực thẳm +

Tin tức

+
+
+
+ + + +
+ + ${article.title} + +
+ + + ${article.type} + + +

+ ${article.title} +

+ + + +
+ + + +
+ +
+ +
+ +
+
+ + + + <%@ include file="/WEB-INF/views/layout/footer.jsp" %> +
+
diff --git a/src/main/webapp/WEB-INF/views/layout/footer.jsp b/src/main/webapp/WEB-INF/views/layout/footer.jsp new file mode 100644 index 0000000000000000000000000000000000000000..3ede0db0b01a2ae0c45f702eb9d1cc78f8c2027d --- /dev/null +++ b/src/main/webapp/WEB-INF/views/layout/footer.jsp @@ -0,0 +1,187 @@ +<%@ page contentType="text/html;charset=UTF-8" language="java" %> + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/layout/header.jsp b/src/main/webapp/WEB-INF/views/layout/header.jsp new file mode 100644 index 0000000000000000000000000000000000000000..dbadc6a314f004e3a863d54cb14819f9f1e69a67 --- /dev/null +++ b/src/main/webapp/WEB-INF/views/layout/header.jsp @@ -0,0 +1,59 @@ +<%@ page contentType="text/html;charset=UTF-8" language="java" %> + <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> + + + + + + + ${pageTitle} + + + + + + + +
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/news/newDetail.jsp b/src/main/webapp/WEB-INF/views/news/newDetail.jsp new file mode 100644 index 0000000000000000000000000000000000000000..68f0841b6932828bc48e1208ff1338e6f7913a3a --- /dev/null +++ b/src/main/webapp/WEB-INF/views/news/newDetail.jsp @@ -0,0 +1,98 @@ +<%@ page contentType="text/html;charset=UTF-8" language="java" %> + <%@ include file="/WEB-INF/views/layout/header.jsp" %> + +
+
+
+
+
+

Tin tức

+

Cập nhật tình hình từ tiền tuyến khai phá. Đại nghiệp diễn tiến. Hắc ám + diên miên.

+
+
+ +
+ +
+ + ${article.title} + +
+ +

+ ${article.type} +

+ + + Ngày đăng: ${article.createdAt} + + +
+ +

+ ${article.title} +

+ +
+

+ ${article.content} +

+
+ +
+ + + +
+ + + <%@ include file="/WEB-INF/views/layout/footer.jsp" %> \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/news/news.jsp b/src/main/webapp/WEB-INF/views/news/news.jsp new file mode 100644 index 0000000000000000000000000000000000000000..8b163033a469ece9035ac8137c26bf2ca923b116 --- /dev/null +++ b/src/main/webapp/WEB-INF/views/news/news.jsp @@ -0,0 +1,259 @@ +<%@ page contentType="text/html;charset=UTF-8" language="java" %> +<%@ include file="/WEB-INF/views/layout/header.jsp" %> + + +
+
+
+
+

◆ Viễn thám ký ◆

+

Tin tức

+

+ Cập nhật tình hình từ tiền tuyến khai phá. Đại nghiệp diễn tiến. Hắc ám + diên miên. +

+
+
+ + +
+
+
+ Tinh Tuyển +

Tối Tân Thư Tín

+
+
+ + + + +
+
+ + +
+
+
+ Tất cả các bản tin +

Tin tức gần đây

+
+
+ + + + +
+ + + +
+ + ${article.title} + +
+ + + ${article.type} + + +

+ ${article.title} +

+ + + +
+ + + +
+ + +
+ + +
+ + + +
+
+ + + +<%@ include file="/WEB-INF/views/layout/footer.jsp" %> diff --git a/src/main/webapp/WEB-INF/views/storys/story.jsp b/src/main/webapp/WEB-INF/views/storys/story.jsp new file mode 100644 index 0000000000000000000000000000000000000000..e6e6484a76f0dbfb18e751a52bca73d57297fd32 --- /dev/null +++ b/src/main/webapp/WEB-INF/views/storys/story.jsp @@ -0,0 +1,160 @@ +<%@ page contentType="text/html;charset=UTF-8" language="java" %> + <%@ include file="/WEB-INF/views/layout/header.jsp" %> + + +
+
+
+
+
+

◆ Truyền kỳ ◆

+

Cốt truyện

+

Một giai thoại về sự ngạo mạn, hủ bại, và bóng tối đang rập rình những kẻ + mưu cầu cấm lực.

+
+
+ + + +
+
+
+
+ ${story.title} +
+
+
+ ${story.tag} +
+

+ ${story.title} +

+
+

+ ${story.content} +

+
+
+
+
+ +
+
+
+

+ ${story.quoteContent} +

+ + + — ${story.quoteAuthor} + + +
+
+
+
+
+ + + + <%@ include file="/WEB-INF/views/layout/footer.jsp" %> \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/supports/support.jsp b/src/main/webapp/WEB-INF/views/supports/support.jsp new file mode 100644 index 0000000000000000000000000000000000000000..782be3bb02489b7d7227b54652fd6503820c5532 --- /dev/null +++ b/src/main/webapp/WEB-INF/views/supports/support.jsp @@ -0,0 +1,218 @@ +<%@ page contentType="text/html;charset=UTF-8" language="java" %> +<%@ include file="/WEB-INF/views/layout/header.jsp" %> + +
+
+
+ +
+

◆ Nơi Nương Náu Cuối Cùng ◆

+

Hỗ Trợ

+

+ Chúng tôi không thể gửi anh hùng tới cứu ngươi. + Nhưng chúng tôi có thể trả lời câu hỏi của ngươi. +

+
+
+ + +
+
+ +
+ +
+ + +

+ Sự Cố Kỹ Thuật +

+ +

+ Crash game, hiệu năng, và các lỗi riêng theo từng nền tảng. +

+
+ +
+ + +

+ Tài Khoản & Mua Hàng +

+ +

+ Thanh toán, hoàn tiền, và các vấn đề quản lý tài khoản. +

+
+ +
+ 🐛 + +

+ Báo Cáo Lỗi +

+ +

+ Báo lỗi trong game, exploit, và các hành vi ngoài ý muốn. +

+
+ +
+ + +

+ Khả Năng Tiếp Cận +

+ +

+ Tùy chọn hỗ trợ tiếp cận, phản hồi, và yêu cầu hỗ trợ đặc biệt. +

+
+ +
+
+
+ + +
+
+ +
+ +
+ +
FAQ
+ +

+ Câu Hỏi Thường Gặp +

+ +
+ +
+
+ + ${faq.title} + + + +
+
+

+ ${faq.content} +

+
+
+
+
+
+ +
+
Liên Hệ Trực Tiếp
+

+ Gửi Tin Nhắn +

+
+ +

${successMessage}

+
+ + +

${errorMessage}

+
+ +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+
+ +<%@ include file="/WEB-INF/views/layout/footer.jsp" %> diff --git a/src/main/webapp/WEB-INF/views/weapons/weapon-card.jsp b/src/main/webapp/WEB-INF/views/weapons/weapon-card.jsp new file mode 100644 index 0000000000000000000000000000000000000000..d17a23351ab79020a3396e28c7e6526aa7c30240 --- /dev/null +++ b/src/main/webapp/WEB-INF/views/weapons/weapon-card.jsp @@ -0,0 +1,64 @@ + + +
+ +
+ + + ${weapon.icon} + + +
+ +
+ +
+ + + ◆ ${weapon.rarity} + + + + ${weapon.type} + + +
+ +

+ ${weapon.name} +

+ +

+ ${weapon.description} +

+ +
+ ${weapon.effects} +
+ +
+ +
+ DMG + ${weapon.damage} +
+ +
+ SPD + ${weapon.speed} +
+ +
+ CRIT + ${weapon.crit} +
+ +
+ + + Inspect Relic + + +
+ +
\ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/weapons/weapons.jsp b/src/main/webapp/WEB-INF/views/weapons/weapons.jsp new file mode 100644 index 0000000000000000000000000000000000000000..6b8f9efb3776ec49c9c449f768ce2791189420dd --- /dev/null +++ b/src/main/webapp/WEB-INF/views/weapons/weapons.jsp @@ -0,0 +1,206 @@ +<%@ page contentType="text/html;charset=UTF-8" language="java" %> + <%@ include file="/WEB-INF/views/layout/header.jsp" %> + +
+
+
+
+
+

◆ Kho Vũ Khí ◆

+

Vũ khí

+

Mỗi lưỡi kiếm đều đã nhuốm máu. Mỗi cổ vật đều đi kèm một cái giá. Câu hỏi + là bạn có sẵn sàng trả cái giá đó hay không.

+
+
+ + +
+
+

+ Hơn 400 loại vũ khí, phụ kiện và cổ vật đang nằm rải rác khắp các ngục tối bị nguyền rủa. + Một số mang lại sức mạnh. Một số mang lại sự điên loạn. Một số mang lại cả hai, và để bạn tự lựa + chọn thứ mình muốn. +

+
+
+ + +
+
+
+ Kho Vũ Khí +

Vũ khí của những kẻ bị nguyền rủa

+
+
+
+ +
+ +
+ ◆ Legendary +

The Ancestor's Knapsack

+

"He carried it through every expedition. What it contains has never + been fully catalogued. Perhaps for good reason." +
+15% Crit, +2 Speed, -10 + Stress Resistance +

+
+
+ +
+ 🗡 +
+ ◆ Cursed +

Blade of the Unmaking

+

"It does not cut through armor. It cuts through the concept of armor. + Heroes who wield it report hearing it speak to them in the dark." +
+25 Damage, +30% Blight + Chance, Stress +5 on equip +

+
+
+ +
+ 🐍 +
+ ◆ Rare +

Tome of Eldritch Whispers

+

"The text rearranges itself when you're not looking. The notes in the + margins are not yours, but they are in your handwriting." +
+20% Skill Damage, Eldritch + attacks marked +

+
+
+ +
+ +
+ ◆ Uncommon +

Hallowed Cudgel

+

"Blessed by a priest who later fled the estate screaming. The + blessing held, though. That's what matters." +
+8 Damage vs Undead, +5 + Stress Resistance +

+
+
+ +
+ 🏭 +
+ ◆ Cursed +

The Crimson Chalice

+

"Drink from it and you are healed. Drink too often and you begin to + need it. The Court watches those who need it." +
Heal 8 HP on kill. After 5 + kills: Contract Crimson Curse +

+
+
+ +
+ 📐 +
+ ◆ Rare +

Vestal's Reliquary

+

"It is said to contain the finger-bone of a saint. Whether that saint + would approve of how it is being used is another matter entirely." +
+15% Healing Skills, -5 + Damage Taken from Eldritch +

+
+
+ +
+ +
+ ◆ Legendary +

Signet of the Ancestor

+

"He wore this to every summoning. It still smells of whatever he was + burning on those evenings. The heroes who wear it hear his voice on long expeditions." +
+15% All Resistances, + Ancestor's Commentary unlocked in camp +

+
+
+ +
+ 🎿 +
+ ◆ Common +

Torch of Vigil

+

"Ánh sáng nó tỏa ra chưa bao giờ chạm tới được những góc tối sâu + nhất. + Nhưng có nó… vẫn tốt hơn là không có. Dù chỉ đôi chút." +
+15 Ánh Sáng. Không thể bị + dập tắt bởi hành động của kẻ địch. +

+
+
+ +
+
+
+ + +
+
+
+ Phân Loại + +

Độ Hiếm & Rủi Ro

+

Sức mạnh càng lớn, cái giá phải trả càng nặng nề. + Hầm ngục này chưa từng ban phát lòng nhân từ.

+
+
+
+
+ ◆ Thường Gặp +

Trang bị đáng tin cậy với những chỉ số cộng thêm vừa phải. + Không mang bất kỳ tác dụng phụ nào. + Nền tảng cho mọi chuyến viễn chinh.

+
+
+ ◆ Không Phổ Biến +

Chỉ số được cường hóa cùng những hiệu ứng lợi thế theo tình huống. + Có vài điểm kỳ lạ nhỏ, nhưng nhìn chung đều mang lại lợi ích. + Rất đáng để săn tìm.

+
+
+ ◆ Hiếm +

Những năng lực mạnh mẽ đi kèm với cái giá tương xứng. + Hãy xây dựng tổ đội xoay quanh chúng khi có thể.

+
+
+ ◆ Truyền Thuyết +

Sức mạnh có thể thay đổi toàn bộ cuộc chơi. + Định hình vận mệnh của cả tổ đội. + Mỗi món đều là độc nhất, và chỉ có thể đạt được ở giai đoạn cuối hành trình.

+
+
+ ◆ Bị Nguyền Rủa +

Những cổ vật nguy hiểm nhất trong hầm ngục. + Sức mạnh vượt ngoài giới hạn — nhưng cái giá phải trả cũng vượt ngoài sức tưởng tượng. + Chỉ sử dụng khi ngươi thật sự hiểu được hậu quả của nó..

+
+
+ ◆ + Độc Nhất +

Những con trùm không chỉ là kẻ cản đường — mỗi trận chiến đều mang ý nghĩa + riêng trong cốt truyện. + Mỗi boss đều kể một câu chuyện, và một số sẽ thay đổi cách tương tác tùy theo lịch sử cùng + lựa chọn của tổ đội ngươi.

+
+
+
+
+ + <%@ include file="/WEB-INF/views/layout/footer.jsp" %> \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/worlds/world.jsp b/src/main/webapp/WEB-INF/views/worlds/world.jsp new file mode 100644 index 0000000000000000000000000000000000000000..efa73303354e5aa30ece86809672750d87fc5fc7 --- /dev/null +++ b/src/main/webapp/WEB-INF/views/worlds/world.jsp @@ -0,0 +1,267 @@ +<%@ page contentType="text/html;charset=UTF-8" language="java" %> + <%@ include file="/WEB-INF/views/layout/header.jsp" %> + +
+
+
+
+
+

◆ Vùng Đất Bị Nguyền Rủa ◆

+

Thế giới

+

Mỗi góc tàn trang đều ẩn tàng một loại kinh hãi dị biệt. + Mỗi kinh hãi đều mang theo một bài học khác nhau. + Và ngươi sẽ phải lĩnh hội toàn bộ chúng.

+
+
+ +
+ +
+
+
+
+ +
+

+ +

+
+

+ +

+
+
+ ${world.title} +
+
+
+
+
+ + + + + + + + <%@ include file="/WEB-INF/views/layout/footer.jsp" %> \ No newline at end of file diff --git a/src/main/webapp/files/DARK_MOON.zip b/src/main/webapp/files/DARK_MOON.zip new file mode 100644 index 0000000000000000000000000000000000000000..c4fd0a12779795392393ebc19be75ff3413547eb --- /dev/null +++ b/src/main/webapp/files/DARK_MOON.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9c2a94aceca1375586f8b635b4116909ab6ace4ab48b7b0d3eebb70f038a0bbc +size 24400027