user avatar in edit dialogs (#312)

This commit was merged in pull request #312.
This commit is contained in:
2026-04-15 15:58:46 -06:00
committed by GitHub
parent b4d31b13af
commit b65868b4d5
10 changed files with 295 additions and 0 deletions

View File

@@ -4,12 +4,21 @@ import com.petshop.backend.entity.User;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.service.AvatarStorageService;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
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.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/users")
@@ -40,4 +49,70 @@ public class UserAvatarController {
return ResponseEntity.notFound().build();
}
}
@PostMapping("/{userId}/avatar")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> uploadUserAvatar(@PathVariable Long userId, @RequestParam("avatar") MultipartFile file) {
User user = userRepository.findById(userId).orElse(null);
if (user == null) {
return ResponseEntity.notFound().build();
}
if (file.isEmpty()) {
Map<String, String> error = new HashMap<>();
error.put("message", "Please select a file to upload");
return ResponseEntity.badRequest().body(error);
}
if (file.getSize() > 5 * 1024 * 1024) {
Map<String, String> error = new HashMap<>();
error.put("message", "File size must not exceed 5MB");
return ResponseEntity.badRequest().body(error);
}
String contentType = file.getContentType();
if (contentType == null || (!contentType.equals("image/jpeg") && !contentType.equals("image/png") && !contentType.equals("image/gif"))) {
Map<String, String> error = new HashMap<>();
error.put("message", "Only JPG, PNG, and GIF images are allowed");
return ResponseEntity.badRequest().body(error);
}
try {
avatarStorageService.deleteAvatar(user);
String avatarPath = avatarStorageService.storeAvatar(file);
user.setAvatarUrl(avatarPath);
userRepository.save(user);
Map<String, String> result = new HashMap<>();
result.put("avatarUrl", avatarStorageService.toOwnerAvatarUrl(user));
result.put("message", "Avatar uploaded successfully");
return ResponseEntity.ok(result);
} catch (IOException e) {
Map<String, String> error = new HashMap<>();
error.put("message", "Failed to upload avatar: " + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
}
@DeleteMapping("/{userId}/avatar")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> deleteUserAvatar(@PathVariable Long userId) {
User user = userRepository.findById(userId).orElse(null);
if (user == null) {
return ResponseEntity.notFound().build();
}
try {
avatarStorageService.deleteAvatar(user);
user.setAvatarUrl(null);
userRepository.save(user);
Map<String, String> result = new HashMap<>();
result.put("message", "Avatar removed successfully");
return ResponseEntity.ok(result);
} catch (IOException e) {
Map<String, String> error = new HashMap<>();
error.put("message", "Failed to remove avatar: " + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
}
}

View File

@@ -16,6 +16,7 @@ public class UserResponse {
private Long primaryStoreId;
private Integer loyaltyPoints;
private Boolean active;
private String avatarUrl;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@@ -118,6 +119,14 @@ public class UserResponse {
this.active = active;
}
public String getAvatarUrl() {
return avatarUrl;
}
public void setAvatarUrl(String avatarUrl) {
this.avatarUrl = avatarUrl;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}

View File

@@ -205,6 +205,7 @@ public class UserService {
response.setPrimaryStoreId(user.getPrimaryStore() != null ? user.getPrimaryStore().getStoreId() : null);
response.setLoyaltyPoints(user.getLoyaltyPoints());
response.setActive(user.getActive());
response.setAvatarUrl(user.getAvatarUrl() != null ? "/api/v1/users/" + user.getId() + "/avatar/file" : null);
response.setCreatedAt(user.getCreatedAt());
response.setUpdatedAt(user.getUpdatedAt());
return response;

View File

@@ -16,6 +16,7 @@ public class EmployeeResponse {
private String staffRole;
private Long primaryStoreId;
private Boolean active;
private String avatarUrl;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@@ -45,6 +46,8 @@ public class EmployeeResponse {
public void setPrimaryStoreId(Long primaryStoreId) { this.primaryStoreId = primaryStoreId; }
public Boolean getActive() { return active; }
public void setActive(Boolean active) { this.active = active; }
public String getAvatarUrl() { return avatarUrl; }
public void setAvatarUrl(String avatarUrl) { this.avatarUrl = avatarUrl; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }

View File

@@ -11,6 +11,7 @@ public class UserResponse {
private String role;
private Boolean active;
private Integer loyaltyPoints;
private String avatarUrl;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@@ -81,6 +82,14 @@ public class UserResponse {
this.loyaltyPoints = loyaltyPoints;
}
public String getAvatarUrl() {
return avatarUrl;
}
public void setAvatarUrl(String avatarUrl) {
this.avatarUrl = avatarUrl;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}

View File

@@ -8,6 +8,7 @@ import org.example.petshopdesktop.api.dto.user.UserResponse;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.List;
public class UserApi {
@@ -45,4 +46,12 @@ public class UserApi {
public UserResponse updateUser(Long id, UserRequest request) throws Exception {
return apiClient.put("/api/v1/users/" + id, request, UserResponse.class);
}
public void uploadUserAvatar(Long userId, Path filePath) throws Exception {
apiClient.postMultipart("/api/v1/users/" + userId + "/avatar", "avatar", filePath, Object.class);
}
public void deleteUserAvatar(Long userId) throws Exception {
apiClient.delete("/api/v1/users/" + userId + "/avatar");
}
}

View File

@@ -9,15 +9,21 @@ import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.PasswordField;
import javafx.scene.control.TextField;
import javafx.scene.image.ImageView;
import javafx.stage.Stage;
import org.example.petshopdesktop.Validator;
import org.example.petshopdesktop.api.dto.user.UserRequest;
import org.example.petshopdesktop.api.dto.user.UserResponse;
import org.example.petshopdesktop.api.endpoints.CustomerApi;
import org.example.petshopdesktop.api.endpoints.UserApi;
import org.example.petshopdesktop.auth.UserSession;
import org.example.petshopdesktop.util.ActivityLogger;
import org.example.petshopdesktop.util.DesktopImageSupport;
import org.example.petshopdesktop.util.FilePickerSupport;
import org.example.petshopdesktop.util.TextFieldFormatSupport;
import java.io.File;
public class CustomerEditDialogController {
@FXML private TextField txtFirstName;
@@ -32,7 +38,15 @@ public class CustomerEditDialogController {
@FXML private Label lblError;
@FXML private Button btnSave;
@FXML private ImageView imgAvatarPreview;
@FXML private Label lblAvatarStatus;
@FXML private Button btnChangeAvatar;
@FXML private Button btnRemoveAvatar;
private UserResponse customer;
private File selectedAvatarFile;
private String currentAvatarUrl;
private boolean removeAvatarRequested;
@FXML
void initialize() {
@@ -41,6 +55,9 @@ public class CustomerEditDialogController {
boolean isAdmin = UserSession.getInstance().isAdmin();
txtLoyaltyPoints.setDisable(!isAdmin);
btnChangeAvatar.setOnMouseClicked(e -> handleChangeAvatar());
btnRemoveAvatar.setOnMouseClicked(e -> handleRemoveAvatar());
}
public void setCustomer(UserResponse user) {
@@ -55,6 +72,65 @@ public class CustomerEditDialogController {
cbActive.setValue(Boolean.TRUE.equals(user.getActive()) ? "Active" : "Inactive");
int pts = user.getLoyaltyPoints() != null ? user.getLoyaltyPoints() : 0;
txtLoyaltyPoints.setText(String.valueOf(pts));
currentAvatarUrl = user.getAvatarUrl();
refreshAvatarPreview();
}
private void handleChangeAvatar() {
File file = FilePickerSupport.pickImageFile(btnSave.getScene().getWindow());
if (file == null) return;
selectedAvatarFile = file;
removeAvatarRequested = false;
lblAvatarStatus.setText("Selected: " + file.getName());
DesktopImageSupport.loadImageInto(imgAvatarPreview, file.toURI().toString(), 90, 90);
btnRemoveAvatar.setDisable(false);
}
private void handleRemoveAvatar() {
selectedAvatarFile = null;
removeAvatarRequested = true;
currentAvatarUrl = null;
refreshAvatarPreview();
}
private void applyAvatarChanges(Long userId) throws Exception {
String previousAvatarUrl = currentAvatarUrl;
if (removeAvatarRequested) {
try {
UserApi.getInstance().deleteUserAvatar(userId);
} catch (Exception ignored) {
}
}
if (selectedAvatarFile != null) {
UserApi.getInstance().uploadUserAvatar(userId, selectedAvatarFile.toPath());
currentAvatarUrl = "/api/v1/users/" + userId + "/avatar/file";
} else if (removeAvatarRequested) {
currentAvatarUrl = null;
}
DesktopImageSupport.evict(previousAvatarUrl);
DesktopImageSupport.evict(currentAvatarUrl);
selectedAvatarFile = null;
removeAvatarRequested = false;
}
private void refreshAvatarPreview() {
if (imgAvatarPreview == null || lblAvatarStatus == null || btnRemoveAvatar == null) return;
imgAvatarPreview.setImage(null);
if (selectedAvatarFile != null) {
lblAvatarStatus.setText("Selected: " + selectedAvatarFile.getName());
DesktopImageSupport.loadImageInto(imgAvatarPreview, selectedAvatarFile.toURI().toString(), 90, 90);
btnRemoveAvatar.setDisable(false);
return;
}
if (currentAvatarUrl != null && !currentAvatarUrl.isBlank()) {
lblAvatarStatus.setText("Current avatar loaded");
DesktopImageSupport.loadImageInto(imgAvatarPreview, currentAvatarUrl, 90, 90);
btnRemoveAvatar.setDisable(false);
return;
}
lblAvatarStatus.setText("No avatar");
btnRemoveAvatar.setDisable(true);
}
private String[] splitFullName(String fullName) {
@@ -148,6 +224,7 @@ public class CustomerEditDialogController {
if (finalLoyaltyPoints != null) request.setLoyaltyPoints(finalLoyaltyPoints);
CustomerApi.getInstance().updateCustomer(customer.getId(), request);
applyAvatarChanges(customer.getId());
Platform.runLater(this::close);
} catch (Exception e) {

View File

@@ -10,6 +10,7 @@ import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.PasswordField;
import javafx.scene.control.TextField;
import javafx.scene.image.ImageView;
import javafx.stage.Stage;
import org.example.petshopdesktop.Validator;
import org.example.petshopdesktop.api.dto.common.DropdownOption;
@@ -17,9 +18,13 @@ import org.example.petshopdesktop.api.dto.employee.EmployeeRequest;
import org.example.petshopdesktop.api.dto.employee.EmployeeResponse;
import org.example.petshopdesktop.api.endpoints.DropdownApi;
import org.example.petshopdesktop.api.endpoints.EmployeeApi;
import org.example.petshopdesktop.api.endpoints.UserApi;
import org.example.petshopdesktop.util.ActivityLogger;
import org.example.petshopdesktop.util.DesktopImageSupport;
import org.example.petshopdesktop.util.FilePickerSupport;
import org.example.petshopdesktop.util.TextFieldFormatSupport;
import java.io.File;
import java.util.List;
public class StaffEditDialogController {
@@ -37,8 +42,16 @@ public class StaffEditDialogController {
@FXML private Label lblError;
@FXML private Button btnSave;
@FXML private ImageView imgAvatarPreview;
@FXML private Label lblAvatarStatus;
@FXML private Button btnChangeAvatar;
@FXML private Button btnRemoveAvatar;
private EmployeeResponse employee;
private Long pendingStoreId = null;
private File selectedAvatarFile;
private String currentAvatarUrl;
private boolean removeAvatarRequested;
@FXML
void initialize() {
@@ -60,6 +73,9 @@ public class StaffEditDialogController {
}
});
btnChangeAvatar.setOnMouseClicked(e -> handleChangeAvatar());
btnRemoveAvatar.setOnMouseClicked(e -> handleRemoveAvatar());
loadStores();
}
@@ -110,6 +126,65 @@ public class StaffEditDialogController {
pendingStoreId = emp.getPrimaryStoreId();
applyPendingStore();
currentAvatarUrl = emp.getAvatarUrl();
refreshAvatarPreview();
}
private void handleChangeAvatar() {
File file = FilePickerSupport.pickImageFile(btnSave.getScene().getWindow());
if (file == null) return;
selectedAvatarFile = file;
removeAvatarRequested = false;
lblAvatarStatus.setText("Selected: " + file.getName());
DesktopImageSupport.loadImageInto(imgAvatarPreview, file.toURI().toString(), 90, 90);
btnRemoveAvatar.setDisable(false);
}
private void handleRemoveAvatar() {
selectedAvatarFile = null;
removeAvatarRequested = true;
currentAvatarUrl = null;
refreshAvatarPreview();
}
private void applyAvatarChanges(Long userId) throws Exception {
String previousAvatarUrl = currentAvatarUrl;
if (removeAvatarRequested) {
try {
UserApi.getInstance().deleteUserAvatar(userId);
} catch (Exception ignored) {
}
}
if (selectedAvatarFile != null) {
UserApi.getInstance().uploadUserAvatar(userId, selectedAvatarFile.toPath());
currentAvatarUrl = "/api/v1/users/" + userId + "/avatar/file";
} else if (removeAvatarRequested) {
currentAvatarUrl = null;
}
DesktopImageSupport.evict(previousAvatarUrl);
DesktopImageSupport.evict(currentAvatarUrl);
selectedAvatarFile = null;
removeAvatarRequested = false;
}
private void refreshAvatarPreview() {
if (imgAvatarPreview == null || lblAvatarStatus == null || btnRemoveAvatar == null) return;
imgAvatarPreview.setImage(null);
if (selectedAvatarFile != null) {
lblAvatarStatus.setText("Selected: " + selectedAvatarFile.getName());
DesktopImageSupport.loadImageInto(imgAvatarPreview, selectedAvatarFile.toURI().toString(), 90, 90);
btnRemoveAvatar.setDisable(false);
return;
}
if (currentAvatarUrl != null && !currentAvatarUrl.isBlank()) {
lblAvatarStatus.setText("Current avatar loaded");
DesktopImageSupport.loadImageInto(imgAvatarPreview, currentAvatarUrl, 90, 90);
btnRemoveAvatar.setDisable(false);
return;
}
lblAvatarStatus.setText("No avatar");
btnRemoveAvatar.setDisable(true);
}
private String[] splitFullName(String fullName) {
@@ -193,6 +268,7 @@ public class StaffEditDialogController {
request.setPrimaryStoreId(storeId);
EmployeeApi.getInstance().updateEmployee(employee.getId(), request);
applyAvatarChanges(employee.getUserId());
Platform.runLater(this::close);
} catch (Exception e) {

View File

@@ -6,6 +6,7 @@
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.PasswordField?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.image.ImageView?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.Region?>
<?import javafx.scene.layout.VBox?>
@@ -24,6 +25,23 @@
<Label fx:id="lblError" text="" textFill="#FF6B6B" wrapText="true" />
<HBox alignment="CENTER_LEFT" spacing="15.0">
<children>
<ImageView fx:id="imgAvatarPreview" fitHeight="90.0" fitWidth="90.0" pickOnBounds="true" preserveRatio="true" />
<VBox spacing="8.0">
<children>
<Label fx:id="lblAvatarStatus" text="No avatar" textFill="#2c3e50" />
<HBox spacing="8.0">
<children>
<Button fx:id="btnChangeAvatar" mnemonicParsing="false" text="Change Avatar" />
<Button fx:id="btnRemoveAvatar" mnemonicParsing="false" text="Remove Avatar" />
</children>
</HBox>
</children>
</VBox>
</children>
</HBox>
<HBox spacing="10.0">
<children>
<VBox spacing="6.0" HBox.hgrow="ALWAYS">

View File

@@ -6,6 +6,7 @@
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.PasswordField?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.image.ImageView?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.Region?>
<?import javafx.scene.layout.VBox?>
@@ -24,6 +25,23 @@
<Label fx:id="lblError" text="" textFill="#FF6B6B" wrapText="true" />
<HBox alignment="CENTER_LEFT" spacing="15.0">
<children>
<ImageView fx:id="imgAvatarPreview" fitHeight="90.0" fitWidth="90.0" pickOnBounds="true" preserveRatio="true" />
<VBox spacing="8.0">
<children>
<Label fx:id="lblAvatarStatus" text="No avatar" textFill="#2c3e50" />
<HBox spacing="8.0">
<children>
<Button fx:id="btnChangeAvatar" mnemonicParsing="false" text="Change Avatar" />
<Button fx:id="btnRemoveAvatar" mnemonicParsing="false" text="Remove Avatar" />
</children>
</HBox>
</children>
</VBox>
</children>
</HBox>
<HBox spacing="10.0">
<children>
<VBox spacing="6.0" HBox.hgrow="ALWAYS">