Refactor tutorial management and add AI image model functionality
- Updated application.properties for local database configuration. - Introduced DateTimeHelper for timestamp parsing. - Added MyAIImageModel and MyImageOptions for AI image processing. - Enhanced Tutorial model to include created and modified timestamps. - Implemented tutorial editing component with improved UI and functionality. - Updated user API service to handle tutorial retrieval and updates. - Refactored tutorials list component to support selection and batch deletion.
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
package com.jambotronGroup.jambotron.ZhiPuAi;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.ai.image.*;
|
||||
import org.springframework.ai.model.ModelOptionsUtils;
|
||||
import org.springframework.ai.retry.RetryUtils;
|
||||
import org.springframework.ai.zhipuai.ZhiPuAiImageModel;
|
||||
import org.springframework.ai.zhipuai.ZhiPuAiImageOptions;
|
||||
import org.springframework.ai.zhipuai.api.ZhiPuAiImageApi;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.retry.support.RetryTemplate;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class MyAIImageModel implements ImageModel {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ZhiPuAiImageModel.class);
|
||||
|
||||
public final RetryTemplate retryTemplate;
|
||||
|
||||
private final MyImageOptions defaultOptions;
|
||||
|
||||
private final ZhiPuAiImageApi zhiPuAiImageApi;
|
||||
|
||||
public MyAIImageModel(ZhiPuAiImageApi zhiPuAiImageApi) {
|
||||
this(zhiPuAiImageApi, MyImageOptions.builder().build(), RetryUtils.DEFAULT_RETRY_TEMPLATE);
|
||||
}
|
||||
|
||||
public MyAIImageModel(ZhiPuAiImageApi zhiPuAiImageApi, MyImageOptions defaultOptions,
|
||||
RetryTemplate retryTemplate) {
|
||||
Assert.notNull(zhiPuAiImageApi, "ZhiPuAiImageApi must not be null");
|
||||
Assert.notNull(defaultOptions, "defaultOptions must not be null");
|
||||
Assert.notNull(retryTemplate, "retryTemplate must not be null");
|
||||
this.zhiPuAiImageApi = zhiPuAiImageApi;
|
||||
this.defaultOptions = defaultOptions;
|
||||
this.retryTemplate = retryTemplate;
|
||||
}
|
||||
|
||||
public MyImageOptions getDefaultOptions() {
|
||||
return this.defaultOptions;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ImageResponse call(ImagePrompt imagePrompt) {
|
||||
return this.retryTemplate.execute(ctx -> {
|
||||
|
||||
String instructions = imagePrompt.getInstructions().get(0).getText();
|
||||
|
||||
ZhiPuAiImageApi.ZhiPuAiImageRequest imageRequest = new ZhiPuAiImageApi.ZhiPuAiImageRequest(instructions,
|
||||
ZhiPuAiImageApi.DEFAULT_IMAGE_MODEL);
|
||||
|
||||
if (this.defaultOptions != null) {
|
||||
imageRequest = ModelOptionsUtils.merge(this.defaultOptions, imageRequest,
|
||||
ZhiPuAiImageApi.ZhiPuAiImageRequest.class);
|
||||
}
|
||||
|
||||
if (imagePrompt.getOptions() != null) {
|
||||
imageRequest = ModelOptionsUtils.merge(toZhiPuAiImageOptions(imagePrompt.getOptions()), imageRequest,
|
||||
ZhiPuAiImageApi.ZhiPuAiImageRequest.class);
|
||||
}
|
||||
|
||||
// Make the request
|
||||
ResponseEntity<ZhiPuAiImageApi.ZhiPuAiImageResponse> imageResponseEntity = this.zhiPuAiImageApi
|
||||
.createImage(imageRequest);
|
||||
|
||||
// Convert to org.springframework.ai.model derived ImageResponse data type
|
||||
return convertResponse(imageResponseEntity, imageRequest);
|
||||
});
|
||||
}
|
||||
|
||||
private ImageResponse convertResponse(ResponseEntity<ZhiPuAiImageApi.ZhiPuAiImageResponse> imageResponseEntity,
|
||||
ZhiPuAiImageApi.ZhiPuAiImageRequest zhiPuAiImageRequest) {
|
||||
ZhiPuAiImageApi.ZhiPuAiImageResponse imageApiResponse = imageResponseEntity.getBody();
|
||||
if (imageApiResponse == null) {
|
||||
logger.warn("No image response returned for request: {}", zhiPuAiImageRequest);
|
||||
return new ImageResponse(List.of());
|
||||
}
|
||||
|
||||
List<ImageGeneration> imageGenerationList = imageApiResponse.data()
|
||||
.stream()
|
||||
.map(entry -> new ImageGeneration(new Image(entry.url(), null)))
|
||||
.toList();
|
||||
|
||||
return new ImageResponse(imageGenerationList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the {@link ImageOptions} into {@link ZhiPuAiImageOptions}.
|
||||
* @param runtimeImageOptions the image options to use.
|
||||
* @return the converted {@link ZhiPuAiImageOptions}.
|
||||
*/
|
||||
private MyImageOptions toZhiPuAiImageOptions(ImageOptions runtimeImageOptions) {
|
||||
MyImageOptions.Builder myImageOptionsBuilder = MyImageOptions.builder();
|
||||
if (runtimeImageOptions != null) {
|
||||
if (runtimeImageOptions.getModel() != null) {
|
||||
myImageOptionsBuilder.model(runtimeImageOptions.getModel());
|
||||
}
|
||||
if (runtimeImageOptions instanceof MyImageOptions myImageOptions) {
|
||||
if (myImageOptions.getUser() != null) {
|
||||
myImageOptionsBuilder.user(myImageOptions.getUser());
|
||||
}
|
||||
}
|
||||
}
|
||||
return myImageOptionsBuilder.build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.jambotronGroup.jambotron.ZhiPuAi;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import org.springframework.ai.image.ImageOptions;
|
||||
import org.springframework.ai.zhipuai.ZhiPuAiImageOptions;
|
||||
import org.springframework.ai.zhipuai.api.ZhiPuAiImageApi;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public class MyImageOptions implements ImageOptions {
|
||||
/**
|
||||
* The model to use for image generation.
|
||||
*/
|
||||
@JsonProperty("model")
|
||||
private String model = ZhiPuAiImageApi.DEFAULT_IMAGE_MODEL;
|
||||
|
||||
/**
|
||||
* A unique identifier representing your end-user, which can help ZhiPuAI to monitor
|
||||
* and detect abuse. User ID length requirement: minimum of 6 characters, maximum of
|
||||
* 128 characters
|
||||
*/
|
||||
@JsonProperty("user_id")
|
||||
private String user;
|
||||
|
||||
public static MyImageOptions.Builder builder() {
|
||||
|
||||
return new MyImageOptions.Builder();
|
||||
}
|
||||
|
||||
@Override
|
||||
@JsonIgnore
|
||||
public Integer getN() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getModel() {
|
||||
return this.model;
|
||||
}
|
||||
|
||||
public void setModel(String model) {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
@Override
|
||||
@JsonIgnore
|
||||
public Integer getWidth() {
|
||||
return 300;
|
||||
}
|
||||
|
||||
@Override
|
||||
@JsonIgnore
|
||||
public Integer getHeight() {
|
||||
|
||||
return 200;
|
||||
}
|
||||
|
||||
@Override
|
||||
@JsonIgnore
|
||||
public String getResponseFormat() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@JsonIgnore
|
||||
public String getStyle() {
|
||||
return null;
|
||||
}
|
||||
|
||||
public String getUser() {
|
||||
return this.user;
|
||||
}
|
||||
|
||||
public void setUser(String user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
// if (!(o instanceof ZhiPuAiImageOptions that)) {
|
||||
// return false;
|
||||
// }
|
||||
// return Objects.equals(this.model, that.model) && Objects.equals(this.user, that.user);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(this.model, this.user);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ZhiPuAiImageOptions{model='" + this.model + '\'' + ", user='" + this.user + '\'' + '}';
|
||||
}
|
||||
|
||||
public static final class Builder {
|
||||
|
||||
private final MyImageOptions options;
|
||||
|
||||
Builder() {
|
||||
this.options = new MyImageOptions();
|
||||
}
|
||||
|
||||
public MyImageOptions.Builder model(String model) {
|
||||
this.options.setModel(model);
|
||||
return this;
|
||||
}
|
||||
|
||||
public MyImageOptions.Builder user(String user) {
|
||||
this.options.setUser(user);
|
||||
return this;
|
||||
}
|
||||
|
||||
public MyImageOptions build() {
|
||||
|
||||
return this.options;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
package com.jambotronGroup.jambotron.ZhiPuAi;
|
||||
|
||||
|
||||
import org.springframework.ai.image.ImageOptions;
|
||||
import org.springframework.ai.image.ImageOptionsBuilder;
|
||||
import org.springframework.ai.image.ImagePrompt;
|
||||
import org.springframework.ai.image.ImageResponse;
|
||||
import org.springframework.ai.retry.RetryUtils;
|
||||
import org.springframework.ai.zhipuai.ZhiPuAiImageModel;
|
||||
import org.springframework.ai.zhipuai.ZhiPuAiImageOptions;
|
||||
import org.springframework.ai.zhipuai.api.ZhiPuAiImageApi;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -21,18 +25,48 @@ public class ZhiPuAiImageService {
|
||||
|
||||
private ZhiPuAiImageApi _zhiPuAiImageApi;
|
||||
|
||||
private MyAIImageModel _myAIImageModel;
|
||||
|
||||
private ZhiPuAiImageModel _zhiPuAiImageModel;
|
||||
|
||||
private MyImageOptions _myImageOptions;
|
||||
|
||||
public ZhiPuAiImageService() {
|
||||
|
||||
_zhiPuAiImageApi = new ZhiPuAiImageApi("628447c8c65845a48a7226391464a2ea.Dw8ci6TiW0BRF5LI");
|
||||
|
||||
ImageOptionsBuilder imageOptionsBuilder = ImageOptionsBuilder.builder();
|
||||
|
||||
ImageOptions imageOptions = imageOptionsBuilder.model("Cogview-3-Flash")//ZhiPuAiImageApi.DEFAULT_IMAGE_MODEL)
|
||||
.height(100)
|
||||
.width(200)
|
||||
|
||||
.build();
|
||||
// _myImageOptions = new MyImageOptions.Builder()
|
||||
// .model(ZhiPuAiImageApi.DEFAULT_IMAGE_MODEL)
|
||||
// .user("jambotron")
|
||||
//
|
||||
// .build();
|
||||
|
||||
|
||||
_zhiPuAiImageModel = new ZhiPuAiImageModel(_zhiPuAiImageApi);
|
||||
_myAIImageModel = new MyAIImageModel(_zhiPuAiImageApi);
|
||||
|
||||
}
|
||||
|
||||
public ImageResponse generateImage(String prompt) {
|
||||
ImageOptionsBuilder imageOptionsBuilder = ImageOptionsBuilder.builder();
|
||||
ImageOptions imageOptions = imageOptionsBuilder.model("Cogview-3-Flash")//ZhiPuAiImageApi.DEFAULT_IMAGE_MODEL)
|
||||
.height(1440)
|
||||
.width(720)
|
||||
|
||||
.build();
|
||||
// Create an ImagePrompt object with the desired prompt
|
||||
ImagePrompt imagePrompt = new ImagePrompt(prompt);
|
||||
ImagePrompt imagePrompt = new ImagePrompt(prompt,imageOptions);
|
||||
|
||||
/*// Call the generate method to get the image response
|
||||
ImageResponse imageResponse = _myAIImageModel.call(imagePrompt);
|
||||
*/
|
||||
|
||||
// Call the generate method to get the image response
|
||||
ImageResponse imageResponse = _zhiPuAiImageModel.call(imagePrompt);
|
||||
|
||||
@@ -7,21 +7,17 @@ import com.jambotronGroup.jambotron.repository.TutorialRepository;
|
||||
import com.jambotronGroup.jambotron.repository.UserRepository;
|
||||
import com.jambotronGroup.jambotron.security.AuthenticationFacade;
|
||||
import com.jambotronGroup.jambotron.security.services.UserDetailsImpl;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
|
||||
//@CrossOrigin(origins = "http://localhost:4200", maxAge = 3600, allowCredentials="true")
|
||||
@@ -106,18 +102,7 @@ public class TutorialController {
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/tutorials/{id}")
|
||||
public ResponseEntity<Tutorial> getTutorialById(@PathVariable("id") long id) {
|
||||
Optional<Tutorial> tutorialData = tutorialRepository.findById(id);
|
||||
|
||||
if (tutorialData.isPresent()) {
|
||||
return new ResponseEntity<>(tutorialData.get(), HttpStatus.OK);
|
||||
} else {
|
||||
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("user/tutorial-add")
|
||||
@PostMapping("user/tutorial-add")
|
||||
public ResponseEntity<Tutorial> createTutorial(@RequestBody Tutorial tutorial) {
|
||||
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
@@ -127,29 +112,49 @@ public class TutorialController {
|
||||
|
||||
try {
|
||||
Tutorial _tutorial = tutorialRepository
|
||||
.save(new Tutorial(tutorial.getTitle(), tutorial.getDescription(), false, user));
|
||||
.save(new Tutorial(tutorial.getTitle(), tutorial.getDescription(), false, user, Timestamp.valueOf(LocalDateTime.now()), Timestamp.valueOf(LocalDateTime.now())));
|
||||
return new ResponseEntity<>(_tutorial, HttpStatus.CREATED);
|
||||
} catch (Exception e) {
|
||||
return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@PutMapping("/tutorials/{id}")
|
||||
public ResponseEntity<Tutorial> updateTutorial(@PathVariable("id") long id, @RequestBody Tutorial tutorial) {
|
||||
@GetMapping("user/tutorial-get/{id}")
|
||||
public ResponseEntity<Tutorial> getTutorial(@PathVariable("id") long id) {
|
||||
Optional<Tutorial> tutorialData = tutorialRepository.findById(id);
|
||||
|
||||
if (tutorialData.isPresent()) {
|
||||
Tutorial _tutorial = tutorialData.get();
|
||||
_tutorial.setTitle(tutorial.getTitle());
|
||||
_tutorial.setDescription(tutorial.getDescription());
|
||||
_tutorial.setPublished(tutorial.isPublished());
|
||||
return new ResponseEntity<>(tutorialRepository.save(_tutorial), HttpStatus.OK);
|
||||
|
||||
return new ResponseEntity<Tutorial>(tutorialData.get(), HttpStatus.OK);
|
||||
} else {
|
||||
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/tutorials/{id}")
|
||||
@PutMapping("user/tutorial-update/{id}")
|
||||
public ResponseEntity<?> updateTutorial(@PathVariable("id") long id, @RequestBody Tutorial tutorial) {
|
||||
Optional<Tutorial> tutorialData = tutorialRepository.findById(id);
|
||||
Map<String, Object> map = new LinkedHashMap<String, Object>();
|
||||
if (tutorialData.isPresent()) {
|
||||
Tutorial servTutorial = tutorialData.get();
|
||||
servTutorial.setTitle(tutorial.getTitle());
|
||||
servTutorial.setDescription(tutorial.getDescription());
|
||||
servTutorial.setPublished(tutorial.isPublished());
|
||||
try {
|
||||
servTutorial = tutorialRepository.save(servTutorial);
|
||||
} catch (Exception e) {
|
||||
|
||||
map.put("status", 0);
|
||||
map.put("message", e.getMessage());
|
||||
return new ResponseEntity<>(map,HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
return new ResponseEntity<>(servTutorial, HttpStatus.OK);
|
||||
} else {
|
||||
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("user/tutorials/{id}")
|
||||
public ResponseEntity<HttpStatus> deleteTutorial(@PathVariable("id") long id) {
|
||||
try {
|
||||
tutorialRepository.deleteById(id);
|
||||
|
||||
@@ -25,15 +25,25 @@ public class Tutorial {
|
||||
@JoinColumn(name = "userID", nullable = false)
|
||||
private User user;
|
||||
|
||||
@Column(name = "created")
|
||||
private java.sql.Timestamp created;
|
||||
|
||||
@Column(name = "modified")
|
||||
private java.sql.Timestamp modified;
|
||||
|
||||
|
||||
|
||||
public Tutorial() {
|
||||
|
||||
}
|
||||
|
||||
public Tutorial(String title, String description, boolean published, User user) {
|
||||
public Tutorial(String title, String description, boolean published, User user, java.sql.Timestamp created, java.sql.Timestamp modified) {
|
||||
this.title = title;
|
||||
this.description = description;
|
||||
this.published = published;
|
||||
this.user = user;
|
||||
this.created = created;
|
||||
this.modified = modified;
|
||||
}
|
||||
|
||||
public long getId() {
|
||||
@@ -64,6 +74,22 @@ public class Tutorial {
|
||||
this.published = isPublished;
|
||||
}
|
||||
|
||||
public void setCreated(java.sql.Timestamp created) {
|
||||
this.created = created;
|
||||
}
|
||||
|
||||
public java.sql.Timestamp getCreated() {
|
||||
return created;
|
||||
}
|
||||
|
||||
public void setModified(java.sql.Timestamp modified) {
|
||||
this.modified = modified;
|
||||
}
|
||||
|
||||
public java.sql.Timestamp getModified() {
|
||||
return modified;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Tutorial [id=" + id + ", title=" + title + ", desc=" + description + ", published=" + published + "]";
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.jambotronGroup.jambotron.utils;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.text.ParseException;
|
||||
import java.text.SimpleDateFormat;
|
||||
|
||||
public class DateTimeHelper {
|
||||
|
||||
public static SimpleDateFormat DATE_TIME_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
public static java.sql.Timestamp parseTimestamp(String timestamp) {
|
||||
try {
|
||||
return new Timestamp(DATE_TIME_FORMAT.parse(timestamp).getTime());
|
||||
} catch (ParseException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,32 +2,32 @@ spring.application.name=jambotron
|
||||
|
||||
#============Localhost Configurations========================
|
||||
|
||||
#spring.datasource.url= jdbc:postgresql://localhost:5432/jambotronDB
|
||||
#spring.datasource.username= admin
|
||||
#spring.datasource.password= postgrespw
|
||||
|
||||
#spring.flyway.baseline-on-migrate=true
|
||||
#spring.flyway.validate-on-migrate=true
|
||||
#
|
||||
#spring.flyway.url=jdbc:postgresql://localhost:5432/jambotronDB
|
||||
#spring.flyway.user=admin
|
||||
#spring.flyway.password=postgrespw
|
||||
|
||||
|
||||
#============Koyeb Configurations========================
|
||||
|
||||
spring.datasource.url= jdbc:postgresql://ep-red-breeze-a2cxhq5f.eu-central-1.pg.koyeb.app/jambotronDB
|
||||
spring.datasource.username= koyeb-adm
|
||||
spring.datasource.password= npg_HfFEUA7bay1i
|
||||
spring.datasource.url= jdbc:postgresql://localhost:5432/jambotronDB
|
||||
spring.datasource.username= admin
|
||||
spring.datasource.password= postgrespw
|
||||
|
||||
spring.flyway.baseline-on-migrate=true
|
||||
spring.flyway.validate-on-migrate=true
|
||||
|
||||
spring.flyway.url=jdbc:postgresql://ep-red-breeze-a2cxhq5f.eu-central-1.pg.koyeb.app/jambotronDB
|
||||
spring.flyway.user=koyeb-adm
|
||||
spring.flyway.password=npg_HfFEUA7bay1i
|
||||
spring.flyway.url=jdbc:postgresql://localhost:5432/jambotronDB
|
||||
spring.flyway.user=admin
|
||||
spring.flyway.password=postgrespw
|
||||
|
||||
|
||||
#============Koyeb Configurations========================
|
||||
|
||||
#spring.datasource.url= jdbc:postgresql://ep-red-breeze-a2cxhq5f.eu-central-1.pg.koyeb.app/jambotronDB
|
||||
#spring.datasource.username= koyeb-adm
|
||||
#spring.datasource.password= npg_HfFEUA7bay1i
|
||||
#
|
||||
#spring.flyway.baseline-on-migrate=true
|
||||
#spring.flyway.validate-on-migrate=true
|
||||
#
|
||||
#spring.flyway.url=jdbc:postgresql://ep-red-breeze-a2cxhq5f.eu-central-1.pg.koyeb.app/jambotronDB
|
||||
#spring.flyway.user=koyeb-adm
|
||||
#spring.flyway.password=npg_HfFEUA7bay1i
|
||||
#
|
||||
|
||||
#spring.datasource.url=${SPRING_DATASOURCE_URL}
|
||||
#spring.datasource.username=${SPRING_DATASOURCE_USERNAME}
|
||||
#spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
|
||||
|
||||
Reference in New Issue
Block a user