Uploading a character image

This commit is contained in:
2025-12-29 13:37:55 +01:00
parent b41b06763c
commit 3a12b613d9
7 changed files with 586 additions and 2 deletions
+45
View File
@@ -0,0 +1,45 @@
package character
import (
"bamort/database"
"bamort/logger"
"bamort/models"
"net/http"
"github.com/gin-gonic/gin"
)
type ImageUpdateRequest struct {
Image string `json:"image" binding:"required"`
}
func UpdateCharacterImage(c *gin.Context) {
id := c.Param("id")
logger.Debug("UpdateCharacterImage called for character ID: %s", id)
var character models.Char
err := character.FirstID(id)
if err != nil {
logger.Error("Character not found: %s", err.Error())
respondWithError(c, http.StatusNotFound, "Character not found")
return
}
var request ImageUpdateRequest
if err := c.ShouldBindJSON(&request); err != nil {
logger.Error("Invalid request data: %s", err.Error())
respondWithError(c, http.StatusBadRequest, "Invalid request data")
return
}
character.Image = request.Image
if err := database.DB.Save(&character).Error; err != nil {
logger.Error("Failed to update character image: %s", err.Error())
respondWithError(c, http.StatusInternalServerError, "Failed to update character image")
return
}
logger.Info("Character image updated successfully for ID: %s", id)
c.JSON(http.StatusOK, gin.H{"message": "Image updated successfully", "image": character.Image})
}
+99
View File
@@ -0,0 +1,99 @@
package character
import (
"bamort/database"
"bamort/models"
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func TestUpdateCharacterImage(t *testing.T) {
setupTestEnvironment(t)
database.SetupTestDB()
router := gin.Default()
protected := router.Group("/api")
protected.Use(func(c *gin.Context) {
c.Set("userID", uint(1))
c.Next()
})
RegisterRoutes(protected)
// Get existing character
var char models.Char
err := char.FirstID("18")
assert.NoError(t, err, "Test character 18 should exist")
// Prepare image data
imageData := "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
requestBody := map[string]string{
"image": imageData,
}
jsonData, _ := json.Marshal(requestBody)
// Update character image
req, _ := http.NewRequest("PUT", "/api/characters/18/image", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, "Should successfully update image")
// Verify image was saved
var updatedChar models.Char
err = updatedChar.FirstID("18")
assert.NoError(t, err)
assert.Equal(t, imageData, updatedChar.Image, "Image should be updated in database")
}
func TestUpdateCharacterImageInvalidID(t *testing.T) {
setupTestEnvironment(t)
database.SetupTestDB()
router := gin.Default()
protected := router.Group("/api")
protected.Use(func(c *gin.Context) {
c.Set("userID", uint(1))
c.Next()
})
RegisterRoutes(protected)
requestBody := map[string]string{
"image": "data:image/png;base64,test",
}
jsonData, _ := json.Marshal(requestBody)
req, _ := http.NewRequest("PUT", "/api/characters/99999/image", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code, "Should return 404 for non-existent character")
}
func TestUpdateCharacterImageInvalidData(t *testing.T) {
setupTestEnvironment(t)
database.SetupTestDB()
router := gin.Default()
protected := router.Group("/api")
protected.Use(func(c *gin.Context) {
c.Set("userID", uint(1))
c.Next()
})
RegisterRoutes(protected)
req, _ := http.NewRequest("PUT", "/api/characters/18/image", bytes.NewBuffer([]byte("invalid json")))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code, "Should return 400 for invalid JSON")
}
+1
View File
@@ -11,6 +11,7 @@ func RegisterRoutes(r *gin.RouterGroup) {
charGrp.GET("/:id", GetCharacter)
charGrp.PUT("/:id", UpdateCharacter)
charGrp.DELETE("/:id", DeleteCharacter)
charGrp.PUT("/:id/image", UpdateCharacterImage)
// Erfahrung und Vermögen
charGrp.GET("/:id/experience-wealth", GetCharacterExperienceAndWealth) // NewSystem
+22
View File
@@ -4,6 +4,10 @@
<div class="character-overview">
<div class="character-image">
<img :src="imageSrc" alt="Character Image"/>
<ImageUploadCropper
:characterId="character.id"
@image-updated="handleImageUpdate"
/>
</div>
<div class="character-stats">
<div class="stat" v-for="(stat, index) in characterStats" :key="index">
@@ -81,6 +85,16 @@
margin-top: 0; /* Kein zusätzlicher oberer Margin */
}
.character-image {
position: relative;
}
.character-image .image-upload-container {
position: absolute;
bottom: 10px;
right: 10px;
}
.character-info {
margin-top: 20px;
}
@@ -88,8 +102,13 @@
<script>
import ImageUploadCropper from './ImageUploadCropper.vue'
export default {
name: "DatasheetView",
components: {
ImageUploadCropper
},
props: {
character: {
type: Object,
@@ -125,6 +144,9 @@ export default {
}
},
methods: {
handleImageUpdate(newImage) {
this.$emit('character-updated')
},
getStat(path) {
if (path === 'git' ){
return '64!'
@@ -0,0 +1,393 @@
<template>
<div class="image-upload-container">
<button @click="showDialog = true" class="btn-upload">
{{ $t('character.uploadImage') }}
</button>
<div v-if="showDialog" class="modal-overlay" @click.self="closeDialog">
<div class="modal-content image-upload-modal">
<div class="modal-header">
<h3>{{ $t('character.uploadImage') }}</h3>
<button @click="closeDialog" class="close-button">&times;</button>
</div>
<div class="modal-body">
<div v-if="!imageLoaded" class="upload-area">
<input
type="file"
ref="fileInput"
@change="handleFileSelect"
accept="image/*"
style="display: none"
/>
<button @click="$refs.fileInput.click()" class="btn-select-file">
{{ $t('character.selectImage') }}
</button>
</div>
<div v-else class="crop-area">
<div class="crop-controls">
<label>
<input type="radio" v-model="cropShape" value="rect" />
{{ $t('character.cropRect') }}
</label>
<label>
<input type="radio" v-model="cropShape" value="round" />
{{ $t('character.cropRound') }}
</label>
</div>
<div class="canvas-container">
<canvas
ref="canvas"
@mousedown="startDrag"
@mousemove="drag"
@mouseup="endDrag"
@mouseleave="endDrag"
></canvas>
</div>
<div class="preview-container">
<h4>{{ $t('character.preview') }}</h4>
<canvas ref="previewCanvas" width="400" height="400"></canvas>
</div>
</div>
</div>
<div class="modal-footer">
<button @click="closeDialog" class="btn-cancel">{{ $t('cancel') }}</button>
<button v-if="imageLoaded" @click="resetImage" class="btn-secondary">
{{ $t('character.changeImage') }}
</button>
<button
v-if="imageLoaded"
@click="uploadImage"
class="btn-primary"
:disabled="isUploading"
>
<span v-if="!isUploading">{{ $t('character.saveImage') }}</span>
<span v-else>{{ $t('uploading') }}...</span>
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.btn-upload {
padding: 8px 16px;
background-color: var(--primary-color);
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-upload:hover {
opacity: 0.9;
}
.image-upload-modal {
min-width: 700px;
max-width: 90vw;
}
.upload-area {
text-align: center;
padding: 40px;
}
.btn-select-file {
padding: 12px 24px;
background-color: var(--primary-color);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
.crop-area {
display: flex;
flex-direction: column;
gap: 20px;
}
.crop-controls {
display: flex;
gap: 20px;
justify-content: center;
}
.crop-controls label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.canvas-container {
display: flex;
justify-content: center;
border: 1px solid #ccc;
background-color: #f5f5f5;
overflow: auto;
max-height: 500px;
}
.canvas-container canvas {
cursor: crosshair;
}
.preview-container {
text-align: center;
}
.preview-container h4 {
margin-bottom: 10px;
}
.preview-container canvas {
border: 1px solid #ccc;
background-color: white;
}
.btn-secondary {
padding: 10px 20px;
background-color: #6c757d;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-secondary:hover {
background-color: #5a6268;
}
</style>
<script>
import API from '../utils/api'
export default {
name: 'ImageUploadCropper',
props: {
characterId: {
type: [String, Number],
required: true
}
},
data() {
return {
showDialog: false,
imageLoaded: false,
isUploading: false,
cropShape: 'rect',
image: null,
cropStart: null,
cropEnd: null,
isDragging: false,
cropWidth: 400,
cropHeight: 400
}
},
methods: {
handleFileSelect(event) {
const file = event.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
const img = new Image()
img.onload = () => {
this.image = img
this.imageLoaded = true
this.$nextTick(() => {
this.initCanvas()
})
}
img.src = e.target.result
}
reader.readAsDataURL(file)
},
initCanvas() {
const canvas = this.$refs.canvas
const ctx = canvas.getContext('2d')
canvas.width = this.image.width
canvas.height = this.image.height
ctx.drawImage(this.image, 0, 0)
// Initialize crop area in center
this.cropStart = {
x: Math.max(0, (this.image.width - this.cropWidth) / 2),
y: Math.max(0, (this.image.height - this.cropHeight) / 2)
}
this.cropEnd = {
x: this.cropStart.x + this.cropWidth,
y: this.cropStart.y + this.cropHeight
}
this.drawCropOverlay()
this.updatePreview()
},
drawCropOverlay() {
const canvas = this.$refs.canvas
const ctx = canvas.getContext('2d')
// Redraw image
ctx.drawImage(this.image, 0, 0)
// Draw semi-transparent overlay
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'
ctx.fillRect(0, 0, canvas.width, canvas.height)
const width = this.cropEnd.x - this.cropStart.x
const height = this.cropEnd.y - this.cropStart.y
// Clear crop area
ctx.clearRect(this.cropStart.x, this.cropStart.y, width, height)
ctx.drawImage(
this.image,
this.cropStart.x, this.cropStart.y, width, height,
this.cropStart.x, this.cropStart.y, width, height
)
// Draw border
ctx.strokeStyle = '#00ff00'
ctx.lineWidth = 2
if (this.cropShape === 'round') {
ctx.beginPath()
const centerX = this.cropStart.x + width / 2
const centerY = this.cropStart.y + height / 2
const radius = Math.min(width, height) / 2
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI)
ctx.stroke()
} else {
ctx.strokeRect(this.cropStart.x, this.cropStart.y, width, height)
}
},
updatePreview() {
const previewCanvas = this.$refs.previewCanvas
const ctx = previewCanvas.getContext('2d')
const width = this.cropEnd.x - this.cropStart.x
const height = this.cropEnd.y - this.cropStart.y
ctx.clearRect(0, 0, 400, 400)
if (this.cropShape === 'round') {
ctx.save()
ctx.beginPath()
ctx.arc(200, 200, 200, 0, 2 * Math.PI)
ctx.clip()
}
ctx.drawImage(
this.image,
this.cropStart.x, this.cropStart.y, width, height,
0, 0, 400, 400
)
if (this.cropShape === 'round') {
ctx.restore()
}
},
startDrag(event) {
const canvas = this.$refs.canvas
const rect = canvas.getBoundingClientRect()
// Calculate mouse position on the actual canvas (accounting for scaling)
const scaleX = canvas.width / rect.width
const scaleY = canvas.height / rect.height
this.cropStart = {
x: (event.clientX - rect.left) * scaleX,
y: (event.clientY - rect.top) * scaleY
}
this.isDragging = true
},
drag(event) {
if (!this.isDragging) return
const canvas = this.$refs.canvas
const rect = canvas.getBoundingClientRect()
// Calculate mouse position on the actual canvas (accounting for scaling)
const scaleX = canvas.width / rect.width
const scaleY = canvas.height / rect.height
this.cropEnd = {
x: (event.clientX - rect.left) * scaleX,
y: (event.clientY - rect.top) * scaleY
}
// Ensure minimum size
const minSize = 50
if (Math.abs(this.cropEnd.x - this.cropStart.x) < minSize ||
Math.abs(this.cropEnd.y - this.cropStart.y) < minSize) {
return
}
this.drawCropOverlay()
this.updatePreview()
},
endDrag() {
this.isDragging = false
},
async uploadImage() {
this.isUploading = true
try {
// Get cropped image as base64
const previewCanvas = this.$refs.previewCanvas
const croppedImage = previewCanvas.toDataURL('image/png')
await API.put(`/api/characters/${this.characterId}/image`, {
image: croppedImage
})
this.$emit('image-updated', croppedImage)
this.closeDialog()
//alert(this.$t('character.imageUploadSuccess'))
} catch (error) {
console.error('Failed to upload image:', error)
alert(this.$t('character.imageUploadError') + ': ' + (error.response?.data?.error || error.message))
} finally {
this.isUploading = false
}
},
resetImage() {
this.imageLoaded = false
this.image = null
this.cropStart = null
this.cropEnd = null
if (this.$refs.fileInput) {
this.$refs.fileInput.value = ''
}
},
closeDialog() {
this.showDialog = false
this.resetImage()
}
},
watch: {
cropShape() {
if (this.imageLoaded) {
this.drawCropOverlay()
this.updatePreview()
}
}
}
}
</script>
+13 -1
View File
@@ -470,5 +470,17 @@ export default {
passwordUpdateSuccess: 'Passwort erfolgreich aktualisiert',
passwordUpdateError: 'Fehler beim Aktualisieren des Passworts',
currentPasswordIncorrect: 'Das aktuelle Passwort ist falsch'
}
},
character: {
uploadImage: 'Bild hochladen',
selectImage: 'Bild auswählen',
cropRect: 'Rechteckig',
cropRound: 'Rund',
preview: 'Vorschau',
changeImage: 'Bild ändern',
saveImage: 'Bild speichern',
imageUploadSuccess: 'Bild erfolgreich hochgeladen',
imageUploadError: 'Fehler beim Hochladen des Bildes'
},
uploading: 'Hochladen'
}
+13 -1
View File
@@ -468,5 +468,17 @@ export default {
passwordUpdateSuccess: 'Password updated successfully',
passwordUpdateError: 'Failed to update password',
currentPasswordIncorrect: 'Current password is incorrect'
}
},
character: {
uploadImage: 'Upload Image',
selectImage: 'Select Image',
cropRect: 'Rectangular',
cropRound: 'Round',
preview: 'Preview',
changeImage: 'Change Image',
saveImage: 'Save Image',
imageUploadSuccess: 'Image uploaded successfully',
imageUploadError: 'Failed to upload image'
},
uploading: 'Uploading'
}