Merge pull request #23 from Bardioc26/public_chars

Show Public chars to everyone
users can change a char from private to public and vice versa
user can share a private char with other users
show own chars in left list and shared chars in right list
ensure user can only change own chars
This commit is contained in:
Bardioc26
2026-02-02 13:48:25 +01:00
committed by GitHub
30 changed files with 1478 additions and 81 deletions
+62
View File
@@ -30,6 +30,17 @@ func respondWithError(c *gin.Context, status int, message string) {
c.JSON(status, gin.H{"error": message}) c.JSON(status, gin.H{"error": message})
} }
// checkCharacterOwnership verifies that the logged-in user owns the character
func checkCharacterOwnership(c *gin.Context, character *models.Char) bool {
userID := c.GetUint("userID")
if character.UserID != userID {
logger.Warn("Unauthorized access attempt: user %d tried to modify character %d owned by user %d", userID, character.ID, character.UserID)
respondWithError(c, http.StatusForbidden, "You are not authorized to modify this character")
return false
}
return true
}
func ListCharacters(c *gin.Context) { func ListCharacters(c *gin.Context) {
logger.Debug("ListCharacters aufgerufen") logger.Debug("ListCharacters aufgerufen")
@@ -59,6 +70,14 @@ func ListCharacters(c *gin.Context) {
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve public characters") respondWithError(c, http.StatusInternalServerError, "Failed to retrieve public characters")
return return
} }
listShared, err := models.FindSharedCharList(c.GetUint("userID"))
if err != nil {
logger.Error("Fehler beim Laden der geteilten Charaktere: %s", err.Error())
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve shared characters")
return
}
listPublic = append(listPublic, listShared...)
allCharacters.Others = listPublic allCharacters.Others = listPublic
logger.Info("Charakterliste erfolgreich geladen: %d Charaktere", len(listOfChars)) logger.Info("Charakterliste erfolgreich geladen: %d Charaktere", len(listOfChars))
@@ -101,6 +120,11 @@ func UpdateCharacter(c *gin.Context) {
return return
} }
// Check ownership
if !checkCharacterOwnership(c, &character) {
return
}
// Store the original ID to preserve it // Store the original ID to preserve it
originalID := character.ID originalID := character.ID
originalGameSystem := character.GameSystem originalGameSystem := character.GameSystem
@@ -133,6 +157,12 @@ func DeleteCharacter(c *gin.Context) {
respondWithError(c, http.StatusNotFound, "Character not found") respondWithError(c, http.StatusNotFound, "Character not found")
return return
} }
// Check ownership
if !checkCharacterOwnership(c, &character) {
return
}
err = character.Delete() err = character.Delete()
if err != nil { if err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to delete character") respondWithError(c, http.StatusInternalServerError, "Failed to delete character")
@@ -267,6 +297,11 @@ func UpdateCharacterExperience(c *gin.Context) {
return return
} }
// Check ownership
if !checkCharacterOwnership(c, &character) {
return
}
// Parse Request // Parse Request
var req UpdateExperienceRequest var req UpdateExperienceRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -357,6 +392,11 @@ func UpdateCharacterWealth(c *gin.Context) {
return return
} }
// Check ownership
if !checkCharacterOwnership(c, &character) {
return
}
// Parse Request // Parse Request
var req UpdateWealthRequest var req UpdateWealthRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -567,6 +607,11 @@ func LearnSkill(c *gin.Context) {
return return
} }
// Check ownership
if !checkCharacterOwnership(c, &character) {
return
}
// Verwende gsmaster.LernCostRequest direkt // Verwende gsmaster.LernCostRequest direkt
var request gsmaster.LernCostRequest var request gsmaster.LernCostRequest
if err := c.ShouldBindJSON(&request); err != nil { if err := c.ShouldBindJSON(&request); err != nil {
@@ -1052,6 +1097,11 @@ func ImproveSkill(c *gin.Context) {
return return
} }
// Check ownership
if !checkCharacterOwnership(c, char) {
return
}
// 2. Skill validieren und Level ermitteln // 2. Skill validieren und Level ermitteln
characterClass, skillInfo, currentLevel, err := validateSkillForImprovement(char, &request) characterClass, skillInfo, currentLevel, err := validateSkillForImprovement(char, &request)
if err != nil { if err != nil {
@@ -1225,6 +1275,18 @@ func LearnSpell(c *gin.Context) {
} }
charID := uint(charIDInt) charID := uint(charIDInt)
// Load character to check ownership
var character models.Char
if err := character.FirstID(char_ID); err != nil {
respondWithError(c, http.StatusNotFound, "Charakter nicht gefunden")
return
}
// Check ownership
if !checkCharacterOwnership(c, &character) {
return
}
var lernRequest gsmaster.LernCostRequest var lernRequest gsmaster.LernCostRequest
if err := c.ShouldBindJSON(&lernRequest); err != nil { if err := c.ShouldBindJSON(&lernRequest); err != nil {
respondWithError(c, http.StatusBadRequest, "Ungültige Anfrageparameter: "+err.Error()) respondWithError(c, http.StatusBadRequest, "Ungültige Anfrageparameter: "+err.Error())
+5
View File
@@ -25,6 +25,11 @@ func UpdateCharacterImage(c *gin.Context) {
return return
} }
// Check ownership
if !checkCharacterOwnership(c, &character) {
return
}
var request ImageUpdateRequest var request ImageUpdateRequest
if err := c.ShouldBindJSON(&request); err != nil { if err := c.ShouldBindJSON(&request); err != nil {
logger.Error("Invalid request data: %s", err.Error()) logger.Error("Invalid request data: %s", err.Error())
+339
View File
@@ -0,0 +1,339 @@
package character
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"bamort/database"
"bamort/models"
"bamort/testutils"
"bamort/user"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestOwnershipGuardsBlockMutations(t *testing.T) {
testutils.SetupTestEnvironment(t)
gin.SetMode(gin.TestMode)
database.SetupTestDB(true, true)
t.Cleanup(database.ResetTestDB)
require.NoError(t, models.MigrateStructure())
t.Run("UpdateCharacter blocks non-owner", func(t *testing.T) {
owner := ensureUserExists(t, 101)
char := createCharacterOwnedBy(t, owner.UserID)
originalName := char.Name
ctx, w := buildJSONContext(t, http.MethodPut, map[string]any{"name": "New Name"}, char.UserID+1, map[string]string{"id": fmt.Sprint(char.ID)})
UpdateCharacter(ctx)
require.Equal(t, http.StatusForbidden, w.Code)
reloaded := reloadCharacter(t, char.ID)
require.Equal(t, originalName, reloaded.Name)
})
t.Run("DeleteCharacter blocks non-owner", func(t *testing.T) {
owner := ensureUserExists(t, 102)
char := createCharacterOwnedBy(t, owner.UserID)
ctx, w := buildJSONContext(t, http.MethodDelete, nil, char.UserID+1, map[string]string{"id": fmt.Sprint(char.ID)})
DeleteCharacter(ctx)
require.Equal(t, http.StatusForbidden, w.Code)
var exists models.Char
err := database.DB.First(&exists, char.ID).Error
require.NoError(t, err)
})
t.Run("UpdateCharacterExperience blocks non-owner", func(t *testing.T) {
owner := ensureUserExists(t, 103)
char := createCharacterOwnedBy(t, owner.UserID)
seedExperience(t, char, 25)
ctx, w := buildJSONContext(t, http.MethodPost, map[string]any{"experience_points": 50}, char.UserID+1, map[string]string{"id": fmt.Sprint(char.ID)})
UpdateCharacterExperience(ctx)
require.Equal(t, http.StatusForbidden, w.Code)
reloaded := reloadCharacterWithPreloads(t, char.ID)
require.Equal(t, 25, reloaded.Erfahrungsschatz.EP)
})
t.Run("UpdateCharacterWealth blocks non-owner", func(t *testing.T) {
owner := ensureUserExists(t, 104)
char := createCharacterOwnedBy(t, owner.UserID)
seedWealth(t, char, 5, 4, 3)
ctx, w := buildJSONContext(t, http.MethodPost, map[string]any{"goldstücke": 50}, char.UserID+1, map[string]string{"id": fmt.Sprint(char.ID)})
UpdateCharacterWealth(ctx)
require.Equal(t, http.StatusForbidden, w.Code)
reloaded := reloadCharacterWithPreloads(t, char.ID)
require.Equal(t, 5, reloaded.Vermoegen.Goldstuecke)
require.Equal(t, 4, reloaded.Vermoegen.Silberstuecke)
require.Equal(t, 3, reloaded.Vermoegen.Kupferstuecke)
})
t.Run("LearnSkill blocks non-owner", func(t *testing.T) {
owner := ensureUserExists(t, 105)
char := createCharacterOwnedBy(t, owner.UserID)
before := countSkills(t, char.ID)
ctx, w := buildJSONContext(t, http.MethodPost, map[string]any{"name": "Test Skill", "target_level": 1, "type": "skill"}, char.UserID+1, map[string]string{"id": fmt.Sprint(char.ID)})
LearnSkill(ctx)
require.Equal(t, http.StatusForbidden, w.Code)
after := countSkills(t, char.ID)
require.Equal(t, before, after)
})
t.Run("ImproveSkill blocks non-owner", func(t *testing.T) {
owner := ensureUserExists(t, 106)
char := createCharacterOwnedBy(t, owner.UserID)
seedSkill(t, char, "Athletik", 1, 0)
before := countSkills(t, char.ID)
payload := map[string]any{
"char_id": char.ID,
"name": "Athletik",
"current_level": 1,
"target_level": 2,
"type": "skill",
"action": "improve",
"reward": "default",
}
ctx, w := buildJSONContext(t, http.MethodPost, payload, char.UserID+1, nil)
ImproveSkill(ctx)
require.Equal(t, http.StatusForbidden, w.Code)
after := countSkills(t, char.ID)
require.Equal(t, before, after)
})
t.Run("LearnSpell blocks non-owner", func(t *testing.T) {
owner := ensureUserExists(t, 107)
char := createCharacterOwnedBy(t, owner.UserID)
before := countSpells(t, char.ID)
ctx, w := buildJSONContext(t, http.MethodPost, map[string]any{"name": "Test Spell"}, char.UserID+1, map[string]string{"id": fmt.Sprint(char.ID)})
LearnSpell(ctx)
require.Equal(t, http.StatusForbidden, w.Code)
after := countSpells(t, char.ID)
require.Equal(t, before, after)
})
t.Run("UpdatePracticePoints blocks non-owner", func(t *testing.T) {
owner := ensureUserExists(t, 108)
char := createCharacterOwnedBy(t, owner.UserID)
seedSkill(t, char, "Menschenkenntnis", 1, 2)
before := fetchSkillPp(t, char.ID, "Menschenkenntnis")
payload := []map[string]any{{"skill_name": "Menschenkenntnis", "amount": 0}}
ctx, w := buildJSONContext(t, http.MethodPost, payload, char.UserID+1, map[string]string{"id": fmt.Sprint(char.ID)})
UpdatePracticePoints(ctx)
require.Equal(t, http.StatusForbidden, w.Code)
after := fetchSkillPp(t, char.ID, "Menschenkenntnis")
require.Equal(t, before, after)
})
t.Run("AddPracticePoint blocks non-owner", func(t *testing.T) {
owner := ensureUserExists(t, 109)
char := createCharacterOwnedBy(t, owner.UserID)
seedSkill(t, char, "Athletik", 1, 1)
before := fetchSkillPp(t, char.ID, "Athletik")
payload := map[string]any{"skill_name": "Athletik", "amount": 3}
ctx, w := buildJSONContext(t, http.MethodPost, payload, char.UserID+1, map[string]string{"id": fmt.Sprint(char.ID)})
AddPracticePoint(ctx)
require.Equal(t, http.StatusForbidden, w.Code)
after := fetchSkillPp(t, char.ID, "Athletik")
require.Equal(t, before, after)
})
t.Run("UsePracticePoint blocks non-owner", func(t *testing.T) {
owner := ensureUserExists(t, 110)
char := createCharacterOwnedBy(t, owner.UserID)
seedSkill(t, char, "Athletik", 1, 2)
before := fetchSkillPp(t, char.ID, "Athletik")
payload := map[string]any{"skill_name": "Athletik", "amount": 1}
ctx, w := buildJSONContext(t, http.MethodPost, payload, char.UserID+1, map[string]string{"id": fmt.Sprint(char.ID)})
UsePracticePoint(ctx)
require.Equal(t, http.StatusForbidden, w.Code)
after := fetchSkillPp(t, char.ID, "Athletik")
require.Equal(t, before, after)
})
t.Run("UpdateCharacterShares blocks non-owner", func(t *testing.T) {
owner := ensureUserExists(t, 111)
shareTarget := ensureUserExists(t, 112)
char := createCharacterOwnedBy(t, owner.UserID)
payload := map[string]any{"user_ids": []uint{shareTarget.UserID}}
ctx, w := buildJSONContext(t, http.MethodPost, payload, char.UserID+1, map[string]string{"id": fmt.Sprint(char.ID)})
UpdateCharacterShares(ctx)
require.Equal(t, http.StatusForbidden, w.Code)
var shares []models.CharShare
err := database.DB.Where("character_id = ?", char.ID).Find(&shares).Error
require.NoError(t, err)
require.Empty(t, shares)
})
t.Run("UpdateCharacterImage blocks non-owner", func(t *testing.T) {
owner := ensureUserExists(t, 113)
char := createCharacterOwnedBy(t, owner.UserID)
char.Image = "initial.png"
require.NoError(t, database.DB.Save(&char).Error)
ctx, w := buildJSONContext(t, http.MethodPost, map[string]any{"image": "new.png"}, char.UserID+1, map[string]string{"id": fmt.Sprint(char.ID)})
UpdateCharacterImage(ctx)
require.Equal(t, http.StatusForbidden, w.Code)
reloaded := reloadCharacter(t, char.ID)
require.Equal(t, "initial.png", reloaded.Image)
})
}
func ensureUserExists(t *testing.T, id uint) user.User {
var existing user.User
err := database.DB.First(&existing, "user_id = ?", id).Error
if err == nil {
return existing
}
if err != nil && err != gorm.ErrRecordNotFound {
require.NoError(t, err)
}
newUser := user.User{
UserID: id,
Username: fmt.Sprintf("user-%d-%d", id, time.Now().UnixNano()),
Email: fmt.Sprintf("user-%d@example.com", id),
Role: user.RoleStandardUser,
}
require.NoError(t, database.DB.Create(&newUser).Error)
return newUser
}
func createCharacterOwnedBy(t *testing.T, ownerID uint) models.Char {
char := models.Char{
BamortBase: models.BamortBase{Name: fmt.Sprintf("Char-%d", time.Now().UnixNano())},
UserID: ownerID,
Typ: "Krieger",
Rasse: "Mensch",
Grad: 1,
}
require.NoError(t, database.DB.Create(&char).Error)
return char
}
func seedExperience(t *testing.T, char models.Char, ep int) {
exp := models.Erfahrungsschatz{
BamortCharTrait: models.BamortCharTrait{CharacterID: char.ID, UserID: char.UserID},
EP: ep,
}
require.NoError(t, database.DB.Create(&exp).Error)
}
func seedWealth(t *testing.T, char models.Char, gold, silver, copper int) {
wealth := models.Vermoegen{
BamortCharTrait: models.BamortCharTrait{CharacterID: char.ID, UserID: char.UserID},
Goldstuecke: gold,
Silberstuecke: silver,
Kupferstuecke: copper,
}
require.NoError(t, database.DB.Create(&wealth).Error)
}
func seedSkill(t *testing.T, char models.Char, name string, level, pp int) {
skill := models.SkFertigkeit{
BamortCharTrait: models.BamortCharTrait{
BamortBase: models.BamortBase{Name: name},
CharacterID: char.ID,
UserID: char.UserID,
},
Fertigkeitswert: level,
Pp: pp,
Improvable: true,
Category: "Test",
}
require.NoError(t, database.DB.Create(&skill).Error)
}
func countSkills(t *testing.T, charID uint) int {
var skills []models.SkFertigkeit
require.NoError(t, database.DB.Where("character_id = ?", charID).Find(&skills).Error)
return len(skills)
}
func countSpells(t *testing.T, charID uint) int {
var spells []models.SkZauber
require.NoError(t, database.DB.Where("character_id = ?", charID).Find(&spells).Error)
return len(spells)
}
func fetchSkillPp(t *testing.T, charID uint, name string) int {
var skill models.SkFertigkeit
err := database.DB.Where("character_id = ? AND name = ?", charID, name).First(&skill).Error
require.NoError(t, err)
return skill.Pp
}
func reloadCharacter(t *testing.T, id uint) models.Char {
var char models.Char
require.NoError(t, database.DB.First(&char, id).Error)
return char
}
func reloadCharacterWithPreloads(t *testing.T, id uint) models.Char {
var char models.Char
require.NoError(t, database.DB.Preload("Erfahrungsschatz").Preload("Vermoegen").First(&char, id).Error)
return char
}
func buildJSONContext(t *testing.T, method string, body any, userID uint, params map[string]string) (*gin.Context, *httptest.ResponseRecorder) {
var buf bytes.Buffer
if body != nil {
require.NoError(t, json.NewEncoder(&buf).Encode(body))
}
req, err := http.NewRequest(method, "/", &buf)
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(w)
ctx.Request = req
ctx.Set("userID", userID)
for k, v := range params {
ctx.Params = append(ctx.Params, gin.Param{Key: k, Value: v})
}
return ctx, w
}
@@ -63,6 +63,11 @@ func UpdatePracticePoints(c *gin.Context) {
return return
} }
// Check ownership
if !checkCharacterOwnership(c, &character) {
return
}
// Request-Parameter abrufen // Request-Parameter abrufen
var practicePoints []PracticePointResponse var practicePoints []PracticePointResponse
if err := c.ShouldBindJSON(&practicePoints); err != nil { if err := c.ShouldBindJSON(&practicePoints); err != nil {
@@ -120,6 +125,11 @@ func AddPracticePoint(c *gin.Context) {
return return
} }
// Check ownership
if !checkCharacterOwnership(c, &character) {
return
}
// Request-Parameter abrufen // Request-Parameter abrufen
type AddPPRequest struct { type AddPPRequest struct {
SkillName string `json:"skill_name" binding:"required"` SkillName string `json:"skill_name" binding:"required"`
@@ -218,6 +228,11 @@ func UsePracticePoint(c *gin.Context) {
return return
} }
// Check ownership
if !checkCharacterOwnership(c, &character) {
return
}
// Request-Parameter abrufen // Request-Parameter abrufen
type UsePPRequest struct { type UsePPRequest struct {
SkillName string `json:"skill_name" binding:"required"` SkillName string `json:"skill_name" binding:"required"`
+7
View File
@@ -10,10 +10,17 @@ func RegisterRoutes(r *gin.RouterGroup) {
charGrp.POST("", CreateCharacter) charGrp.POST("", CreateCharacter)
charGrp.GET("/:id", GetCharacter) charGrp.GET("/:id", GetCharacter)
charGrp.PUT("/:id", UpdateCharacter) charGrp.PUT("/:id", UpdateCharacter)
charGrp.PATCH("/:id", UpdateCharacter)
charGrp.DELETE("/:id", DeleteCharacter) charGrp.DELETE("/:id", DeleteCharacter)
charGrp.PUT("/:id/image", UpdateCharacterImage) charGrp.PUT("/:id/image", UpdateCharacterImage)
charGrp.GET("/:id/datasheet-options", GetDatasheetOptions) charGrp.GET("/:id/datasheet-options", GetDatasheetOptions)
// Character Sharing
charGrp.GET("/:id/shares", GetCharacterShares)
charGrp.PUT("/:id/shares", UpdateCharacterShares)
charGrp.GET("/:id/available-users", GetAvailableUsersForSharing)
// Erfahrung und Vermögen // Erfahrung und Vermögen
charGrp.GET("/:id/experience-wealth", GetCharacterExperienceAndWealth) // NewSystem charGrp.GET("/:id/experience-wealth", GetCharacterExperienceAndWealth) // NewSystem
charGrp.PUT("/:id/experience", UpdateCharacterExperience) // NewSystem charGrp.PUT("/:id/experience", UpdateCharacterExperience) // NewSystem
+146
View File
@@ -0,0 +1,146 @@
package character
import (
"bamort/database"
"bamort/models"
"bamort/user"
"net/http"
"github.com/gin-gonic/gin"
)
// GetCharacterShares returns the list of users a character is shared with
func GetCharacterShares(c *gin.Context) {
charID := c.Param("id")
var character models.Char
if err := character.FirstID(charID); err != nil {
respondWithError(c, http.StatusNotFound, "Character not found")
return
}
// Check ownership
if !checkCharacterOwnership(c, &character) {
return
}
var shares []models.CharShare
if err := database.DB.Where("character_id = ?", character.ID).Find(&shares).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve shares")
return
}
// Get user details for each share
type ShareWithUser struct {
models.CharShare
Username string `json:"username"`
Email string `json:"email"`
}
var sharesWithUsers []ShareWithUser
for _, share := range shares {
var u user.User
if err := u.FirstId(share.UserID); err == nil {
sharesWithUsers = append(sharesWithUsers, ShareWithUser{
CharShare: share,
Username: u.Username,
Email: u.Email,
})
}
}
c.JSON(http.StatusOK, sharesWithUsers)
}
// UpdateCharacterShares updates the list of users a character is shared with
func UpdateCharacterShares(c *gin.Context) {
charID := c.Param("id")
var character models.Char
if err := character.FirstID(charID); err != nil {
respondWithError(c, http.StatusNotFound, "Character not found")
return
}
// Check ownership
if !checkCharacterOwnership(c, &character) {
return
}
type UpdateSharesRequest struct {
UserIDs []uint `json:"user_ids" binding:"required"`
}
var request UpdateSharesRequest
if err := c.ShouldBindJSON(&request); err != nil {
respondWithError(c, http.StatusBadRequest, err.Error())
return
}
// Delete existing shares
if err := database.DB.Where("character_id = ?", character.ID).Delete(&models.CharShare{}).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to delete existing shares")
return
}
// Create new shares
for _, userID := range request.UserIDs {
// Don't share with yourself
if userID == character.UserID {
continue
}
share := models.CharShare{
CharacterID: character.ID,
UserID: userID,
Permission: "read",
}
if err := database.DB.Create(&share).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to create share")
return
}
}
c.JSON(http.StatusOK, gin.H{"message": "Shares updated successfully"})
}
// GetAvailableUsersForSharing returns a list of users (excluding the owner)
func GetAvailableUsersForSharing(c *gin.Context) {
charID := c.Param("id")
var character models.Char
if err := character.FirstID(charID); err != nil {
respondWithError(c, http.StatusNotFound, "Character not found")
return
}
// Check ownership
if !checkCharacterOwnership(c, &character) {
return
}
var users []user.User
if err := database.DB.Where("user_id != ?", character.UserID).Find(&users).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve users")
return
}
// Remove sensitive data
type UserInfo struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
Email string `json:"email"`
}
var userInfos []UserInfo
for _, u := range users {
userInfos = append(userInfos, UserInfo{
UserID: u.UserID,
Username: u.Username,
Email: u.Email,
})
}
c.JSON(http.StatusOK, userInfos)
}
+1 -1
View File
@@ -1,7 +1,7 @@
package config package config
// Version is the application version // Version is the application version
const Version = "0.2.0" const Version = "0.2.1"
var ( var (
// GitCommit will be set by build flags or detected at runtime // GitCommit will be set by build flags or detected at runtime
+61 -2
View File
@@ -19,6 +19,21 @@ func respondWithError(c *gin.Context, status int, message string) {
c.JSON(status, gin.H{"error": message}) c.JSON(status, gin.H{"error": message})
} }
// checkEquipmentOwnership verifies that the logged-in user owns the equipment's character
func checkEquipmentOwnership(c *gin.Context, characterID uint) bool {
userID := c.GetUint("userID")
var character models.Char
if err := database.DB.Select("id", "user_id").First(&character, characterID).Error; err != nil {
respondWithError(c, http.StatusNotFound, "Character not found")
return false
}
if character.UserID != userID {
respondWithError(c, http.StatusForbidden, "You are not authorized to modify this character's equipment")
return false
}
return true
}
func CreateAusruestung(c *gin.Context) { func CreateAusruestung(c *gin.Context) {
var ausruestung models.EqAusruestung var ausruestung models.EqAusruestung
if err := c.ShouldBindJSON(&ausruestung); err != nil { if err := c.ShouldBindJSON(&ausruestung); err != nil {
@@ -26,6 +41,11 @@ func CreateAusruestung(c *gin.Context) {
return return
} }
// Check ownership
if !checkEquipmentOwnership(c, ausruestung.CharacterID) {
return
}
if err := database.DB.Create(&ausruestung).Error; err != nil { if err := database.DB.Create(&ausruestung).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to create Ausruestung") respondWithError(c, http.StatusInternalServerError, "Failed to create Ausruestung")
return return
@@ -55,6 +75,11 @@ func UpdateAusruestung(c *gin.Context) {
return return
} }
// Check ownership
if !checkEquipmentOwnership(c, ausruestung.CharacterID) {
return
}
if err := c.ShouldBindJSON(&ausruestung); err != nil { if err := c.ShouldBindJSON(&ausruestung); err != nil {
respondWithError(c, http.StatusBadRequest, err.Error()) respondWithError(c, http.StatusBadRequest, err.Error())
return return
@@ -70,7 +95,19 @@ func UpdateAusruestung(c *gin.Context) {
func DeleteAusruestung(c *gin.Context) { func DeleteAusruestung(c *gin.Context) {
ausruestungID := c.Param("ausruestung_id") ausruestungID := c.Param("ausruestung_id")
if err := database.DB.Delete(&models.EqAusruestung{}, ausruestungID).Error; err != nil {
var ausruestung models.EqAusruestung
if err := database.DB.First(&ausruestung, ausruestungID).Error; err != nil {
respondWithError(c, http.StatusNotFound, "Ausruestung not found")
return
}
// Check ownership
if !checkEquipmentOwnership(c, ausruestung.CharacterID) {
return
}
if err := database.DB.Delete(&ausruestung).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to delete Ausruestung") respondWithError(c, http.StatusInternalServerError, "Failed to delete Ausruestung")
return return
} }
@@ -89,6 +126,11 @@ func CreateWaffe(c *gin.Context) {
return return
} }
// Check ownership
if !checkEquipmentOwnership(c, waffe.CharacterID) {
return
}
if err := database.DB.Create(&waffe).Error; err != nil { if err := database.DB.Create(&waffe).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to create Waffe") respondWithError(c, http.StatusInternalServerError, "Failed to create Waffe")
return return
@@ -118,6 +160,11 @@ func UpdateWaffe(c *gin.Context) {
return return
} }
// Check ownership
if !checkEquipmentOwnership(c, waffe.CharacterID) {
return
}
if err := c.ShouldBindJSON(&waffe); err != nil { if err := c.ShouldBindJSON(&waffe); err != nil {
respondWithError(c, http.StatusBadRequest, err.Error()) respondWithError(c, http.StatusBadRequest, err.Error())
return return
@@ -133,7 +180,19 @@ func UpdateWaffe(c *gin.Context) {
func DeleteWaffe(c *gin.Context) { func DeleteWaffe(c *gin.Context) {
waffeID := c.Param("waffe_id") waffeID := c.Param("waffe_id")
if err := database.DB.Delete(&models.EqWaffe{}, waffeID).Error; err != nil {
var waffe models.EqWaffe
if err := database.DB.First(&waffe, waffeID).Error; err != nil {
respondWithError(c, http.StatusNotFound, "Waffe not found")
return
}
// Check ownership
if !checkEquipmentOwnership(c, waffe.CharacterID) {
return
}
if err := database.DB.Delete(&waffe).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to delete Waffe") respondWithError(c, http.StatusInternalServerError, "Failed to delete Waffe")
return return
} }
+21 -12
View File
@@ -82,8 +82,8 @@ func TestCreateAusruestung(t *testing.T) {
BamortBase: models.BamortBase{ BamortBase: models.BamortBase{
Name: "Test Sword", Name: "Test Sword",
}, },
CharacterID: 1, CharacterID: 21,
UserID: 1, UserID: 4,
}, },
Magisch: models.Magisch{ Magisch: models.Magisch{
IstMagisch: false, IstMagisch: false,
@@ -111,13 +111,17 @@ func TestCreateAusruestung(t *testing.T) {
{ {
name: "Empty JSON", name: "Empty JSON",
payload: map[string]interface{}{}, payload: map[string]interface{}{},
expectedStatus: http.StatusCreated, expectedStatus: http.StatusNotFound,
shouldContain: "", shouldContain: "",
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// u := user.User{}
// u.FirstId(1)
// token := user.GenerateToken(&u)
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)
@@ -129,9 +133,12 @@ func TestCreateAusruestung(t *testing.T) {
body = *bytes.NewBuffer(jsonData) body = *bytes.NewBuffer(jsonData)
} }
c.Set("userID", uint(4)) // Simulate logged-in user with ID 4
c.Request = httptest.NewRequest("POST", "/ausruestung", &body) c.Request = httptest.NewRequest("POST", "/ausruestung", &body)
c.Request.Header.Set("Content-Type", "application/json") c.Request.Header.Set("Content-Type", "application/json")
//c.Request.Header.Set("Authorization", "Bearer "+token)
CreateAusruestung(c) CreateAusruestung(c)
assert.Equal(t, tt.expectedStatus, w.Code) assert.Equal(t, tt.expectedStatus, w.Code)
@@ -235,8 +242,8 @@ func TestUpdateAusruestung(t *testing.T) {
BamortBase: models.BamortBase{ BamortBase: models.BamortBase{
Name: "Original Equipment", Name: "Original Equipment",
}, },
CharacterID: 123, CharacterID: 21,
UserID: 1, UserID: 4,
}, },
Magisch: models.Magisch{ Magisch: models.Magisch{
IstMagisch: false, IstMagisch: false,
@@ -297,6 +304,8 @@ func TestUpdateAusruestung(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)
c.Set("userID", uint(4))
c.Params = gin.Params{ c.Params = gin.Params{
{Key: "ausruestung_id", Value: tt.ausruestungID}, {Key: "ausruestung_id", Value: tt.ausruestungID},
} }
@@ -332,8 +341,8 @@ func TestDeleteAusruestung(t *testing.T) {
BamortBase: models.BamortBase{ BamortBase: models.BamortBase{
Name: "Equipment to Delete", Name: "Equipment to Delete",
}, },
CharacterID: 123, CharacterID: 21,
UserID: 1, UserID: 4,
}, },
Magisch: models.Magisch{ Magisch: models.Magisch{
IstMagisch: false, IstMagisch: false,
@@ -368,14 +377,14 @@ func TestDeleteAusruestung(t *testing.T) {
{ {
name: "Non-existent Ausruestung", name: "Non-existent Ausruestung",
ausruestungID: "999", ausruestungID: "999",
expectedStatus: http.StatusOK, // GORM doesn't fail on deleting non-existent records expectedStatus: http.StatusNotFound, // GORM doesn't fail on deleting non-existent records
shouldContain: "deleted successfully", shouldContain: "Ausruestung not found",
}, },
{ {
name: "Invalid Ausruestung ID", name: "Invalid Ausruestung ID",
ausruestungID: "invalid", ausruestungID: "invalid",
expectedStatus: http.StatusInternalServerError, expectedStatus: http.StatusNotFound,
shouldContain: "error", shouldContain: "Ausruestung not found",
}, },
} }
@@ -383,11 +392,11 @@ func TestDeleteAusruestung(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)
c.Set("userID", uint(4))
c.Params = gin.Params{ c.Params = gin.Params{
{Key: "ausruestung_id", Value: tt.ausruestungID}, {Key: "ausruestung_id", Value: tt.ausruestungID},
} }
DeleteAusruestung(c) DeleteAusruestung(c)
assert.Equal(t, tt.expectedStatus, w.Code) assert.Equal(t, tt.expectedStatus, w.Code)
+21 -19
View File
@@ -8,50 +8,52 @@ import (
func RegisterRoutes(r *gin.RouterGroup) { func RegisterRoutes(r *gin.RouterGroup) {
maintGrp := r.Group("/maintenance") maintGrp := r.Group("/maintenance")
maintGrp.Use(user.RequireMaintainer())
{
maintGrp.GET("", GetMasterData) maintGrp.GET("", GetMasterData)
maintGrp.GET("/skills", GetMDSkills) maintGrp.GET("/skills", GetMDSkills)
maintGrp.GET("/skills-enhanced", GetEnhancedMDSkills) // New enhanced endpoint maintGrp.GET("/skills-enhanced", GetEnhancedMDSkills) // New enhanced endpoint
maintGrp.POST("/skills-enhanced", CreateEnhancedMDSkill) // Create new skill
maintGrp.GET("/skills/:id", GetMDSkill) maintGrp.GET("/skills/:id", GetMDSkill)
maintGrp.GET("/skills-enhanced/:id", GetEnhancedMDSkill) // New enhanced endpoint maintGrp.GET("/skills-enhanced/:id", GetEnhancedMDSkill) // New enhanced endpoint
maintGrp.GET("/weaponskills", GetMDWeaponSkills)
maintGrp.GET("/weaponskills-enhanced", GetEnhancedMDWeaponSkills) // New enhanced endpoint
maintGrp.GET("/weaponskills/:id", GetMDWeaponSkill)
maintGrp.GET("/weaponskills-enhanced/:id", GetEnhancedMDWeaponSkill) // New enhanced endpoint
maintGrp.GET("/spells", GetMDSpells)
maintGrp.GET("/spells-enhanced", GetEnhancedMDSpells) // New enhanced endpoint
maintGrp.GET("/spells/:id", GetMDSpell)
maintGrp.GET("/spells-enhanced/:id", GetEnhancedMDSpell) // New enhanced endpoint
maintGrp.GET("/equipment", GetMDEquipments)
maintGrp.GET("/equipment-enhanced", GetEnhancedMDEquipment) // New enhanced endpoint
maintGrp.GET("/equipment/:id", GetMDEquipment)
maintGrp.GET("/equipment-enhanced/:id", GetEnhancedMDEquipmentItem) // New enhanced endpoint
maintGrp.GET("/weapons", GetMDWeapons)
maintGrp.GET("/weapons-enhanced", GetEnhancedMDWeapons) // New enhanced endpoint
maintGrp.GET("/weapons/:id", GetMDWeapon)
maintGrp.GET("/weapons-enhanced/:id", GetEnhancedMDWeapon) // New enhanced endpoint
maintGrp.Use(user.RequireMaintainer())
{
maintGrp.POST("/skills-enhanced", CreateEnhancedMDSkill) // Create new skill
maintGrp.PUT("/skills/:id", UpdateMDSkill) maintGrp.PUT("/skills/:id", UpdateMDSkill)
maintGrp.PUT("/skills-enhanced/:id", UpdateEnhancedMDSkill) // New enhanced endpoint maintGrp.PUT("/skills-enhanced/:id", UpdateEnhancedMDSkill) // New enhanced endpoint
maintGrp.POST("/skills", AddSkill) maintGrp.POST("/skills", AddSkill)
maintGrp.DELETE("/skills/:id", DeleteMDSkill) maintGrp.DELETE("/skills/:id", DeleteMDSkill)
maintGrp.GET("/weaponskills", GetMDWeaponSkills)
maintGrp.GET("/weaponskills-enhanced", GetEnhancedMDWeaponSkills) // New enhanced endpoint
maintGrp.GET("/weaponskills/:id", GetMDWeaponSkill)
maintGrp.GET("/weaponskills-enhanced/:id", GetEnhancedMDWeaponSkill) // New enhanced endpoint
maintGrp.PUT("/weaponskills/:id", UpdateMDWeaponSkill) maintGrp.PUT("/weaponskills/:id", UpdateMDWeaponSkill)
maintGrp.PUT("/weaponskills-enhanced/:id", UpdateEnhancedMDWeaponSkill) // New enhanced endpoint maintGrp.PUT("/weaponskills-enhanced/:id", UpdateEnhancedMDWeaponSkill) // New enhanced endpoint
maintGrp.POST("/weaponskills", AddWeaponSkill) maintGrp.POST("/weaponskills", AddWeaponSkill)
maintGrp.DELETE("/weaponskills/:id", DeleteMDWeaponSkill) maintGrp.DELETE("/weaponskills/:id", DeleteMDWeaponSkill)
maintGrp.GET("/spells", GetMDSpells)
maintGrp.GET("/spells-enhanced", GetEnhancedMDSpells) // New enhanced endpoint
maintGrp.GET("/spells/:id", GetMDSpell)
maintGrp.GET("/spells-enhanced/:id", GetEnhancedMDSpell) // New enhanced endpoint
maintGrp.PUT("/spells/:id", UpdateMDSpell) maintGrp.PUT("/spells/:id", UpdateMDSpell)
maintGrp.PUT("/spells-enhanced/:id", UpdateEnhancedMDSpell) // New enhanced endpoint maintGrp.PUT("/spells-enhanced/:id", UpdateEnhancedMDSpell) // New enhanced endpoint
maintGrp.POST("/spells", AddSpell) maintGrp.POST("/spells", AddSpell)
maintGrp.DELETE("/spells/:id", DeleteMDSpell) maintGrp.DELETE("/spells/:id", DeleteMDSpell)
maintGrp.GET("/equipment", GetMDEquipments)
maintGrp.GET("/equipment-enhanced", GetEnhancedMDEquipment) // New enhanced endpoint
maintGrp.GET("/equipment/:id", GetMDEquipment)
maintGrp.GET("/equipment-enhanced/:id", GetEnhancedMDEquipmentItem) // New enhanced endpoint
maintGrp.PUT("/equipment/:id", UpdateMDEquipment) maintGrp.PUT("/equipment/:id", UpdateMDEquipment)
maintGrp.PUT("/equipment-enhanced/:id", UpdateEnhancedMDEquipmentItem) // New enhanced endpoint maintGrp.PUT("/equipment-enhanced/:id", UpdateEnhancedMDEquipmentItem) // New enhanced endpoint
maintGrp.POST("/equipment", AddEquipment) maintGrp.POST("/equipment", AddEquipment)
maintGrp.DELETE("/equipment/:id", DeleteMDEquipment) maintGrp.DELETE("/equipment/:id", DeleteMDEquipment)
maintGrp.GET("/weapons", GetMDWeapons)
maintGrp.GET("/weapons-enhanced", GetEnhancedMDWeapons) // New enhanced endpoint
maintGrp.GET("/weapons/:id", GetMDWeapon)
maintGrp.GET("/weapons-enhanced/:id", GetEnhancedMDWeapon) // New enhanced endpoint
maintGrp.PUT("/weapons/:id", UpdateMDWeapon) maintGrp.PUT("/weapons/:id", UpdateMDWeapon)
maintGrp.PUT("/weapons-enhanced/:id", UpdateEnhancedMDWeapon) // New enhanced endpoint maintGrp.PUT("/weapons-enhanced/:id", UpdateEnhancedMDWeapon) // New enhanced endpoint
maintGrp.POST("/weapons", AddWeapon) maintGrp.POST("/weapons", AddWeapon)
+3
View File
@@ -295,6 +295,9 @@ func copyMariaDBToSQLite(mariaDB, sqliteDB *gorm.DB) error {
// Audit Logging (abhängig von Char) // Audit Logging (abhängig von Char)
&models.AuditLogEntry{}, &models.AuditLogEntry{},
// Char Shares (abhängig von Char und User)
&models.CharShare{},
// View-Strukturen ohne eigene Tabellen werden nicht kopiert: // View-Strukturen ohne eigene Tabellen werden nicht kopiert:
// SkillLearningInfo, SpellLearningInfo, CharList, FeChar, etc. // SkillLearningInfo, SpellLearningInfo, CharList, FeChar, etc.
} }
+3
View File
@@ -49,6 +49,7 @@ func MigrateStructure(db ...*gorm.DB) error {
return nil return nil
} }
func gameSystemMigrateStructure(db ...*gorm.DB) error { func gameSystemMigrateStructure(db ...*gorm.DB) error {
// Use provided DB or default to database.DB // Use provided DB or default to database.DB
var targetDB *gorm.DB var targetDB *gorm.DB
@@ -66,6 +67,7 @@ func gameSystemMigrateStructure(db ...*gorm.DB) error {
} }
return nil return nil
} }
func gsMasterMigrateStructure(db ...*gorm.DB) error { func gsMasterMigrateStructure(db ...*gorm.DB) error {
// Use provided DB or default to database.DB // Use provided DB or default to database.DB
var targetDB *gorm.DB var targetDB *gorm.DB
@@ -112,6 +114,7 @@ func characterMigrateStructure(db ...*gorm.DB) error {
&Bennies{}, &Bennies{},
&Vermoegen{}, &Vermoegen{},
&CharacterCreationSession{}, &CharacterCreationSession{},
&CharShare{},
) )
if err != nil { if err != nil {
return err return err
+15
View File
@@ -255,6 +255,21 @@ func (object *Char) FindByUserID(userID uint) ([]Char, error) {
return chars, nil return chars, nil
} }
func FindSharedCharList(userID uint) ([]CharList, error) {
var chars []CharList
gs := GetGameSystem(0, "midgard")
err := database.DB.Table("char_chars").
Select("char_chars.id, char_chars.name, char_chars.user_id, char_chars.rasse, char_chars.typ, char_chars.grad, char_chars.public, char_chars.game_system, char_chars.game_system_id, users.username as owner").
Joins("LEFT JOIN users ON char_chars.user_id = users.user_id").
Joins("INNER JOIN char_shares ON char_shares.character_id = char_chars.id").
Where("char_shares.user_id = ? AND (char_chars.game_system = ? OR char_chars.game_system_id = ?)", userID, gs.Name, gs.ID).
Find(&chars).Error
if err != nil {
return nil, err
}
return chars, nil
}
func FindPublicCharList() ([]CharList, error) { func FindPublicCharList() ([]CharList, error) {
var chars []CharList var chars []CharList
gs := GetGameSystem(0, "midgard") gs := GetGameSystem(0, "midgard")
+32
View File
@@ -0,0 +1,32 @@
package models
import (
"bamort/database"
"fmt"
)
type CharShare struct {
ID uint `gorm:"primaryKey" json:"id"`
CharacterID uint `gorm:"index" json:"character_id"` // ID of the character being shared
UserID uint `gorm:"index" json:"user_id"` // ID of the user with whom the character is shared
Permission string `json:"permission"` // Permission level (e.g., "read", "write")
}
func (object *CharShare) TableName() string {
dbPrefix := "char"
return dbPrefix + "_" + "shares"
}
func (object *CharShare) FirstByChar(id uint) error {
if id == 0 {
return fmt.Errorf("invalid character ID")
}
return database.DB.First(object, "character_id = ?", id).Error
}
func (object *CharShare) FirstByUser(id uint) error {
if id == 0 {
return fmt.Errorf("invalid user ID")
}
return database.DB.First(object, "user_id = ?", id).Error
}
+1 -1
View File
@@ -21,7 +21,7 @@ func SetupGin(r *gin.Engine) {
r.Use(cors.New(cors.Config{ r.Use(cors.New(cors.Config{
//AllowOrigins: []string{"http://localhost:3000"}, // Replace with your frontend's URL //AllowOrigins: []string{"http://localhost:3000"}, // Replace with your frontend's URL
AllowOrigins: allowedOrigins, AllowOrigins: allowedOrigins,
AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"}, AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
ExposeHeaders: []string{"Content-Length"}, ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true, AllowCredentials: true,
+1 -1
View File
@@ -1,6 +1,6 @@
# Frontend Version Management # Frontend Version Management
## Current Version: 0.2.0 ## Current Version: 0.2.1
The frontend version is managed independently from the backend. The frontend version is managed independently from the backend.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "bamort-frontend", "name": "bamort-frontend",
"version": "0.2.0", "version": "0.2.1",
"private": true, "private": true,
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
"type": "module", "type": "module",
+11 -4
View File
@@ -210,12 +210,14 @@ a:focus {
} }
/* Global fullwidth page class for view components */ /* Global fullwidth page class for view components */
/*
.fullwidth-page { .fullwidth-page {
/*width: 100%; width: 100%;
min-height: calc(100vh - 120px); min-height: calc(100vh - 120px);
padding: 20px; padding: 20px;
box-sizing: border-box;*/ box-sizing: border-box;
} }
*/
/* Common Card Layouts */ /* Common Card Layouts */
.card { .card {
@@ -2544,7 +2546,7 @@ a:focus {
} }
.remove-btn { .remove-btn {
position: absolute; /*position: absolute;*/
right: 0; right: 0;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
@@ -3999,10 +4001,15 @@ a:focus {
padding: 2rem; padding: 2rem;
text-align: center; text-align: center;
color: white; color: white;
display: grid;
grid-template-rows: 1fr auto;
align-items: center;
justify-items: center;
row-gap: 1.5rem;
} }
.dragon-container { .dragon-container {
margin-bottom: 2rem; margin-bottom: 0;
} }
.dragon-image { .dragon-image {
+29 -1
View File
@@ -6,6 +6,9 @@
<button @click="showExportDialog = true" class="export-button-small" :title="$t('export.title')"> <button @click="showExportDialog = true" class="export-button-small" :title="$t('export.title')">
📄 📄
</button> </button>
<button v-if="isOwner" @click="showVisibilityDialog = true" class="export-button-small" :title="$t('visibility.title')">
{{ character.public ? '🌐' : '🔒' }}
</button>
<h2>{{ $t('char') }}: {{ character.name }} ({{ $t(currentView) }})</h2> <h2>{{ $t('char') }}: {{ character.name }} ({{ $t(currentView) }})</h2>
</div> </div>
</div> </div>
@@ -18,9 +21,18 @@
@export-success="handleExportSuccess" @export-success="handleExportSuccess"
/> />
<!-- Visibility Dialog -->
<VisibilityDialog
:characterId="id"
:currentVisibility="character.public"
:showDialog="showVisibilityDialog"
@update:showDialog="showVisibilityDialog = $event"
@visibility-updated="handleVisibilityUpdated"
/>
<!-- Submenu Content --> <!-- Submenu Content -->
<!-- <div class="character-aspect"> --> <!-- <div class="character-aspect"> -->
<component :is="currentView" :character="character" @character-updated="refreshCharacter"/> <component :is="currentView" :character="character" :isOwner="isOwner" @character-updated="refreshCharacter"/>
<!-- </div> --> <!-- </div> -->
<!-- Submenu --> <!-- Submenu -->
@@ -52,7 +64,9 @@
<script> <script>
import API from '../utils/api' import API from '../utils/api'
import { useUserStore } from '../stores/userStore'
import ExportDialog from "./ExportDialog.vue"; import ExportDialog from "./ExportDialog.vue";
import VisibilityDialog from "./VisibilityDialog.vue";
import DatasheetView from "./DatasheetView.vue"; // Component for character stats import DatasheetView from "./DatasheetView.vue"; // Component for character stats
import SkillView from "./SkillView.vue"; // Component for character history import SkillView from "./SkillView.vue"; // Component for character history
import WeaponView from "./WeaponView.vue"; // Component for character history import WeaponView from "./WeaponView.vue"; // Component for character history
@@ -67,6 +81,7 @@ export default {
props: ["id"], // Receive the route parameter as a prop props: ["id"], // Receive the route parameter as a prop
components: { components: {
ExportDialog, ExportDialog,
VisibilityDialog,
DatasheetView, DatasheetView,
SkillView, SkillView,
WeaponView, WeaponView,
@@ -81,6 +96,7 @@ export default {
currentView: "DatasheetView", // Default view currentView: "DatasheetView", // Default view
lastView: "DatasheetView", lastView: "DatasheetView",
showExportDialog: false, showExportDialog: false,
showVisibilityDialog: false,
menus: [ menus: [
{ id: 1, name: "Datasheet", component: "DatasheetView" }, { id: 1, name: "Datasheet", component: "DatasheetView" },
{ id: 2, name: "Skill", component: "SkillView" }, { id: 2, name: "Skill", component: "SkillView" },
@@ -96,6 +112,12 @@ export default {
], ],
}; };
}, },
computed: {
isOwner() {
const userStore = useUserStore()
return userStore.currentUser && this.character.user_id === userStore.currentUser.id
}
},
async created() { async created() {
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
const response = await API.get(`/api/characters/${this.id}`, { const response = await API.get(`/api/characters/${this.id}`, {
@@ -108,6 +130,12 @@ export default {
console.log('PDF exported successfully') console.log('PDF exported successfully')
}, },
handleVisibilityUpdated(isPublic) {
this.character.public = isPublic
this.showVisibilityDialog = false
console.log('Character visibility updated to:', isPublic ? 'public' : 'private')
},
changeView(view) { changeView(view) {
this.lastView = this.currentView; this.lastView = this.currentView;
this.currentView = view; this.currentView = view;
+56 -7
View File
@@ -16,13 +16,32 @@
@delete-session="handleDeleteSession" @delete-session="handleDeleteSession"
/> />
<div v-if="characters.length === 0" class="empty-state"> <div v-if="ownedCharacters.length === 0" class="empty-state">
<h3>{{ $t('characters.list.no_characters') }}</h3> <h3>{{ $t('characters.list.no_characters') }}</h3>
<p>{{ $t('characters.list.no_characters_description') }}</p> <p>{{ $t('characters.list.no_characters_description') }}</p>
</div> </div>
<div v-else class="list-container"> <div v-else class="list-container horizontal-placement">
<div v-for="character in characters" :key="character.character_id" class="list-item"> <div class="charlist">
<div class="charlist-header">{{ $t('characters.list.owned_characters_title') }}</div>
<div v-for="character in ownedCharacters" :key="character.character_id" class="list-item">
<router-link :to="`/character/${character.id}`" class="list-item-content">
<h4 class="list-item-title">{{ character.name }}</h4>
<div class="list-item-details">
{{ character.rasse }} <span class="list-item-separator">|</span>
{{ character.typ }} <span class="list-item-separator">|</span>
{{ $t('characters.list.grade') }}: {{ character.grad }} <span class="list-item-separator">|</span>
{{ $t('characters.list.owner') }}: {{ character.owner }} <span class="list-item-separator">|</span>
<span class="badge" :class="character.public ? 'badge-success' : 'badge-secondary'">
{{ character.public ? $t('characters.list.public') : $t('characters.list.private') }}
</span>
</div>
</router-link>
</div>
</div>
<div class="charlist">
<div class="charlist-header">{{ $t('characters.list.shared_characters_title') }}</div>
<div v-for="character in sharedCharacters" :key="character.character_id" class="list-item">
<router-link :to="`/character/${character.id}`" class="list-item-content"> <router-link :to="`/character/${character.id}`" class="list-item-content">
<h4 class="list-item-title">{{ character.name }}</h4> <h4 class="list-item-title">{{ character.name }}</h4>
<div class="list-item-details"> <div class="list-item-details">
@@ -38,7 +57,9 @@
</div> </div>
</div> </div>
</div> </div>
</template><script> </div>
</template>
<script>
import API from '../utils/api' import API from '../utils/api'
import { formatDate } from '@/utils/dateUtils' import { formatDate } from '@/utils/dateUtils'
import CharacterCreationSessions from './CharacterCreationSessions.vue' import CharacterCreationSessions from './CharacterCreationSessions.vue'
@@ -49,7 +70,8 @@ export default {
}, },
data() { data() {
return { return {
characters: [], ownedCharacters: [],
sharedCharacters: [],
creationSessions: [], creationSessions: [],
} }
}, },
@@ -64,7 +86,8 @@ export default {
const response = await API.get('/api/characters', { const response = await API.get('/api/characters', {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}) })
this.characters = response.data.self_owned this.ownedCharacters = response.data.self_owned
this.sharedCharacters = response.data.others
} catch (error) { } catch (error) {
console.error('Error loading characters:', error) console.error('Error loading characters:', error)
} }
@@ -132,7 +155,7 @@ export default {
} }
</script> </script>
<style> <style scoped>
/* All common styles moved to main.css */ /* All common styles moved to main.css */
.create-character-section { .create-character-section {
@@ -166,6 +189,32 @@ export default {
color: #007bff; color: #007bff;
} }
.horizontal-placement {
display: flex;
gap: 10px;
align-items: flex-start;
}
.charlist {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
max-height: calc(100vh - 15px - 300px);
overflow-y: auto;
}
.charlist-header {
font-size: 1.25rem;
font-weight: 600;
color: #333;
padding: 12px 20px;
background: #f8f9fa;
border-bottom: 2px solid #dee2e6;
margin-bottom: 0;
flex-shrink: 0;
}
/* Responsive Design */ /* Responsive Design */
@media (max-width: 768px) { @media (max-width: 768px) {
.list-item { .list-item {
@@ -1,9 +1,14 @@
<template> <template>
<div class="cd-view"> <div class="cd-view">
<div v-if="!isOwner" class="error-message">
<p>{{ $t('deleteChar.notAuthorized') }}</p>
</div>
<div v-else>
<p>Are you sure you want to delete {{ character.name }}?</p> <p>Are you sure you want to delete {{ character.name }}?</p>
<button @click="deleteCharacter" class="btn btn-danger">Yes, Delete</button> <button @click="deleteCharacter" class="btn btn-danger">Yes, Delete</button>
<button @click="$emit('cancel')" class="btn btn-secondary">Cancel</button> <button @click="$emit('cancel')" class="btn btn-secondary">Cancel</button>
</div> </div>
</div>
</template> </template>
<script> <script>
@@ -16,6 +21,10 @@ export default {
character: { character: {
type: Object, type: Object,
required: true required: true
},
isOwner: {
type: Boolean,
default: false
} }
}, },
+25 -3
View File
@@ -2,7 +2,7 @@
<div class="fullwidth-container"> <div class="fullwidth-container">
<div class="header-section"> <div class="header-section">
<h2>{{ $t('EquipmentView') }}</h2> <h2>{{ $t('EquipmentView') }}</h2>
<button @click="openAddEquipmentDialog" class="btn-add-equipment"> <button v-if="isOwner" @click="openAddEquipmentDialog" class="btn-add-equipment">
{{ $t('equipment.add') }} {{ $t('equipment.add') }}
</button> </button>
</div> </div>
@@ -18,7 +18,7 @@
<th>{{ $t('equipment.amount') }}</th> <th>{{ $t('equipment.amount') }}</th>
<th>{{ $t('equipment.contained_in') }}</th> <th>{{ $t('equipment.contained_in') }}</th>
<th>{{ $t('equipment.bonus') }}</th> <th>{{ $t('equipment.bonus') }}</th>
<th>{{ $t('equipment.actions') }}</th> <th v-if="isOwner">{{ $t('equipment.actions') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -31,7 +31,7 @@
<td>{{ equipment.anzahl || '-' }}</td> <td>{{ equipment.anzahl || '-' }}</td>
<td>{{ equipment.beinhaltet_in || '-' }}</td> <td>{{ equipment.beinhaltet_in || '-' }}</td>
<td>{{ equipment.bonus || '-' }}</td> <td>{{ equipment.bonus || '-' }}</td>
<td class="action-cell"> <td v-if="isOwner" class="action-cell">
<button @click="deleteEquipment(equipment)" class="btn-delete" title="Löschen"> <button @click="deleteEquipment(equipment)" class="btn-delete" title="Löschen">
🗑 🗑
</button> </button>
@@ -126,6 +126,24 @@
.cd-table th { .cd-table th {
background-color: #1da766; background-color: #1da766;
} }
/* Fix modal footer visibility */
.modal-fullscreen {
display: flex;
flex-direction: column;
max-height: calc(100vh - 50px);
}
.modal-fullscreen .modal-body {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.modal-fullscreen .modal-header,
.modal-fullscreen .modal-footer {
flex-shrink: 0;
}
</style> </style>
<script> <script>
@@ -137,6 +155,10 @@ name: "EquipmentView",
character: { character: {
type: Object, type: Object,
required: true required: true
},
isOwner: {
type: Boolean,
default: false
} }
}, },
data() { data() {
+6 -2
View File
@@ -14,7 +14,7 @@
<div class="resource-amount">{{ character.erfahrungsschatz?.ep || 0 }} EP</div> <div class="resource-amount">{{ character.erfahrungsschatz?.ep || 0 }} EP</div>
</div> </div>
</div> </div>
<div class="form-row control-row"> <div v-if="isOwner" class="form-row control-row">
<div class="form-group"> <div class="form-group">
<input <input
v-model.number="experienceAmount" v-model.number="experienceAmount"
@@ -51,7 +51,7 @@
<div class="resource-amount">{{ character.vermoegen?.goldstücke || 0 }} GS</div> <div class="resource-amount">{{ character.vermoegen?.goldstücke || 0 }} GS</div>
</div> </div>
</div> </div>
<div class="form-row control-row"> <div v-if="isOwner" class="form-row control-row">
<div class="form-group"> <div class="form-group">
<input <input
v-model.number="goldAmount" v-model.number="goldAmount"
@@ -147,6 +147,10 @@ export default {
character: { character: {
type: Object, type: Object,
required: true required: true
},
isOwner: {
type: Boolean,
default: false
} }
}, },
data() { data() {
+9 -3
View File
@@ -5,7 +5,7 @@
<div class="table-wrapper-left"> <div class="table-wrapper-left">
<div class="header-section"> <div class="header-section">
<!-- Lernmodus Toggle Button --> <!-- Lernmodus Toggle Button -->
<div class="learning-mode-controls"> <div v-if="isOwner" class="learning-mode-controls">
<!-- Ressourcen-Anzeige (nur sichtbar wenn Lernmodus aktiv) --> <!-- Ressourcen-Anzeige (nur sichtbar wenn Lernmodus aktiv) -->
<div v-if="learningMode" class="resources-display"> <div v-if="learningMode" class="resources-display">
<div class="resource-item"> <div class="resource-item">
@@ -68,7 +68,7 @@
<td>{{ skill.fertigkeitswert || '-' }}</td> <td>{{ skill.fertigkeitswert || '-' }}</td>
<td>{{ skill.bonus || '0' }}</td> <td>{{ skill.bonus || '0' }}</td>
<td class="pp-cell"> <td class="pp-cell">
<div class="pp-container"> <div v-if="isOwner" class="pp-container">
<button <button
@click="decreasePP(skill)" @click="decreasePP(skill)"
class="pp-btn pp-btn-minus" class="pp-btn pp-btn-minus"
@@ -86,6 +86,7 @@
+ +
</button> </button>
</div> </div>
<span v-else>{{ skill.pp || '0' }}</span>
</td> </td>
<td>{{ skill.bemerkung || '-' }}</td> <td>{{ skill.bemerkung || '-' }}</td>
<td v-if="learningMode" class="action-cell"> <td v-if="learningMode" class="action-cell">
@@ -109,7 +110,7 @@
<td>{{ skill.fertigkeitswert || '-' }}</td> <td>{{ skill.fertigkeitswert || '-' }}</td>
<td>{{ skill.bonus || '0' }}</td> <td>{{ skill.bonus || '0' }}</td>
<td class="pp-cell"> <td class="pp-cell">
<div class="pp-container"> <div v-if="isOwner" class="pp-container">
<button <button
@click="decreaseWeaponPP(skill)" @click="decreaseWeaponPP(skill)"
class="pp-btn pp-btn-minus" class="pp-btn pp-btn-minus"
@@ -127,6 +128,7 @@
+ +
</button> </button>
</div> </div>
<span v-else>{{ skill.pp || '0' }}</span>
</td> </td>
<td>{{ skill.bemerkung || '-' }}</td> <td>{{ skill.bemerkung || '-' }}</td>
<td v-if="learningMode" class="action-cell"> <td v-if="learningMode" class="action-cell">
@@ -290,6 +292,10 @@ export default {
character: { character: {
type: Object, type: Object,
required: true required: true
},
isOwner: {
type: Boolean,
default: false
} }
}, },
data() { data() {
+5 -1
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="fullwidth-container"> <div class="fullwidth-container">
<!-- Header mit Lernmodus-Kontrollen --> <!-- Header mit Lernmodus-Kontrollen -->
<div class="page-header header-section"> <div v-if="isOwner" class="page-header header-section">
<div class="learning-mode-controls"> <div class="learning-mode-controls">
<!-- Lernmodus Toggle Button --> <!-- Lernmodus Toggle Button -->
<button <button
@@ -99,6 +99,10 @@ export default {
character: { character: {
type: Object, type: Object,
required: true required: true
},
isOwner: {
type: Boolean,
default: false
} }
}, },
data() { data() {
@@ -0,0 +1,513 @@
<template>
<div v-if="showDialog" class="modal-overlay" @click.self="closeDialog">
<div class="modal-content modal-large">
<div class="modal-header">
<h3>{{ $t('visibility.title') }}</h3>
<button @click="closeDialog" class="close-button">&times;</button>
</div>
<div class="modal-body">
<div v-if="isUpdating" class="loading-overlay">
<div class="spinner"></div>
<p>{{ $t('visibility.updating') }}</p>
</div>
<!-- Visibility Options -->
<div class="form-group">
<p>{{ $t('visibility.description') }}</p>
<div class="visibility-options">
<label class="radio-option">
<input
type="radio"
:value="false"
v-model="isPublic"
:disabled="isUpdating"
>
<div class="option-content">
<span class="option-label">{{ $t('visibility.private') }}</span>
<span class="option-description">{{ $t('visibility.privateDescription') }}</span>
</div>
</label>
<label class="radio-option">
<input
type="radio"
:value="true"
v-model="isPublic"
:disabled="isUpdating"
>
<div class="option-content">
<span class="option-label">{{ $t('visibility.public') }}</span>
<span class="option-description">{{ $t('visibility.publicDescription') }}</span>
</div>
</label>
</div>
</div>
<!-- Share with Specific Users Section -->
<div class="form-group">
<h4>{{ $t('visibility.shareWithUsers') }}</h4>
<p class="section-description">{{ $t('visibility.shareDescription') }}</p>
<div class="share-sections-container">
<!-- Add Users Section -->
<div class="add-users-section">
<h5>{{ $t('visibility.addUsers') }}</h5>
<div class="user-search">
<input
v-model="searchQuery"
type="text"
:placeholder="$t('visibility.searchUsers')"
class="form-control"
:disabled="isUpdating"
/>
</div>
<div v-if="isLoadingUsers" class="loading">{{ $t('visibility.loadingUsers') }}</div>
<div v-else-if="filteredAvailableUsers.length > 0" class="available-users-list">
<div
v-for="user in filteredAvailableUsers"
:key="user.user_id"
class="user-item"
@click="toggleUser(user.user_id)"
>
<div class="user-info">
<span class="user-name">{{ user.username }}</span>
<span class="user-email">{{ user.email }}</span>
</div>
</div>
</div>
<div v-else-if="!isLoadingUsers && availableUsers.length === 0" class="no-users">
{{ $t('visibility.noOtherUsers') }}
</div>
<div v-else class="no-users">
{{ $t('visibility.noMatchingUsers') }}
</div>
</div>
<!-- Currently Shared Users -->
<div class="shared-users-list">
<h5>{{ $t('visibility.currentlySharedWith') }}</h5>
<div v-if="sharedUserIds.length > 0" class="shared-users-items">
<div
v-for="userId in sharedUserIds"
:key="userId"
class="user-item shared-user"
>
<div class="user-info">
<span class="user-name">{{ getUserName(userId) }}</span>
<span class="user-email">{{ getUserEmail(userId) }}</span>
</div>
<button @click="removeUser(userId)" class="remove-btn" :disabled="isUpdating">&times;</button>
</div>
</div>
<div v-else class="no-users">
{{ $t('visibility.noSharedUsers') }}
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button @click="closeDialog" class="btn-cancel" :disabled="isUpdating">
{{ $t('visibility.cancel') }}
</button>
<button @click="updateVisibilityAndShares" class="btn-primary" :disabled="isUpdating">
<span v-if="!isUpdating">{{ $t('visibility.save') }}</span>
<span v-else>{{ $t('visibility.saving') }}</span>
</button>
</div>
</div>
</div>
</template>
<script>
import API from '../utils/api'
export default {
name: "VisibilityDialog",
props: {
characterId: {
type: [String, Number],
required: true
},
currentVisibility: {
type: Boolean,
default: false
},
showDialog: {
type: Boolean,
default: false
}
},
data() {
return {
isPublic: false,
isUpdating: false,
isLoadingUsers: false,
availableUsers: [],
sharedUserIds: [],
searchQuery: ''
}
},
computed: {
filteredAvailableUsers() {
let users = this.availableUsers.filter(user => !this.sharedUserIds.includes(user.user_id))
if (!this.searchQuery) {
return users
}
const query = this.searchQuery.toLowerCase()
return users.filter(user =>
user.username.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query)
)
}
},
watch: {
currentVisibility: {
immediate: true,
handler(newValue) {
this.isPublic = newValue
}
},
showDialog(newValue) {
if (newValue) {
this.isPublic = this.currentVisibility
this.loadAvailableUsers()
this.loadCurrentShares()
}
}
},
methods: {
async loadAvailableUsers() {
this.isLoadingUsers = true
try {
const token = localStorage.getItem('token')
const response = await API.get(`/api/characters/${this.characterId}/available-users`, {
headers: { Authorization: `Bearer ${token}` }
})
this.availableUsers = response.data || []
} catch (error) {
console.error('Failed to load available users:', error)
} finally {
this.isLoadingUsers = false
}
},
async loadCurrentShares() {
try {
const token = localStorage.getItem('token')
const response = await API.get(`/api/characters/${this.characterId}/shares`, {
headers: { Authorization: `Bearer ${token}` }
})
this.sharedUserIds = (response.data || []).map(share => share.user_id)
} catch (error) {
console.error('Failed to load current shares:', error)
this.sharedUserIds = []
}
},
toggleUser(userId) {
const index = this.sharedUserIds.indexOf(userId)
if (index > -1) {
this.sharedUserIds.splice(index, 1)
} else {
this.sharedUserIds.push(userId)
}
},
removeUser(userId) {
const index = this.sharedUserIds.indexOf(userId)
if (index > -1) {
this.sharedUserIds.splice(index, 1)
}
},
getUserName(userId) {
const user = this.availableUsers.find(u => u.user_id === userId)
return user ? user.username : 'Unknown'
},
getUserEmail(userId) {
const user = this.availableUsers.find(u => u.user_id === userId)
return user ? user.email : ''
},
closeDialog() {
if (!this.isUpdating) {
this.searchQuery = ''
this.$emit('update:showDialog', false)
}
},
async updateVisibilityAndShares() {
this.isUpdating = true
try {
const token = localStorage.getItem('token')
// Update visibility
await API.patch(`/api/characters/${this.characterId}`,
{ public: this.isPublic },
{
headers: { Authorization: `Bearer ${token}` }
}
)
// Update shares
await API.put(`/api/characters/${this.characterId}/shares`,
{ user_ids: this.sharedUserIds },
{
headers: { Authorization: `Bearer ${token}` }
}
)
this.$emit('visibility-updated', this.isPublic)
this.closeDialog()
} catch (error) {
console.error('Failed to update character visibility/shares:', error)
alert(this.$t('visibility.updateError') + ': ' + (error.response?.data?.error || error.message))
} finally {
this.isUpdating = false
}
}
}
}
</script>
<style scoped>
.visibility-options {
display: flex;
flex-direction: column;
gap: 15px;
margin-top: 15px;
}
.radio-option {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 15px;
border: 2px solid #dee2e6;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.radio-option:hover {
border-color: #007bff;
background-color: #f8f9fa;
}
.radio-option input[type="radio"] {
margin-top: 3px;
cursor: pointer;
}
.option-content {
display: flex;
flex-direction: column;
gap: 5px;
flex: 1;
}
.option-label {
font-weight: 600;
font-size: 1.1rem;
color: #333;
}
.option-description {
font-size: 0.9rem;
color: #666;
}
.radio-option input[type="radio"]:checked + .option-content .option-label {
color: #007bff;
}
.radio-option:has(input[type="radio"]:checked) {
border-color: #007bff;
background-color: #e7f3ff;
}
.modal-large {
max-width: 700px;
max-height: 90vh;
overflow-y: auto;
}
.section-description {
color: #666;
font-size: 0.9rem;
margin-bottom: 15px;
}
.share-sections-container {
display: flex;
gap: 20px;
align-items: flex-start;
}
.shared-users-list {
flex: 1;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
min-height: 400px;
}
.shared-users-list h5 {
margin: 0 0 10px 0;
color: #333;
font-size: 1rem;
}
.shared-users-items {
border: 1px solid #dee2e6;
border-radius: 8px;
max-height: 300px;
overflow-y: auto;
background: white;
}
.user-item.shared-user {
background: white;
cursor: default;
}
.user-item.shared-user:hover {
background: #fff5f5;
}
.user-item.shared-user .remove-btn {
background: none;
border: none;
color: #dc3545;
font-size: 1.5rem;
cursor: pointer;
padding: 0 8px;
line-height: 1;
transition: color 0.2s ease;
font-weight: bold;
flex-shrink: 0;
}
.user-item.shared-user .remove-btn:hover {
color: #a71d2a;
}
.user-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.user-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: white;
border: 1px solid #dee2e6;
border-radius: 20px;
font-size: 0.9rem;
}
.user-chip .remove-btn {
background: none;
border: none;
color: #dc3545;
font-size: 1.2rem;
cursor: pointer;
padding: 0;
line-height: 1;
transition: color 0.2s ease;
}
.user-chip .remove-btn:hover {
color: #a71d2a;
}
.add-users-section {
flex: 1;
}
.add-users-section h5 {
margin: 0 0 10px 0;
color: #333;
font-size: 1rem;
}
.user-search {
margin-bottom: 15px;
}
.available-users-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid #dee2e6;
border-radius: 8px;
}
.user-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 15px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.2s ease;
}
.user-item:last-child {
border-bottom: none;
}
.user-item:hover {
background: #f8f9fa;
}
.user-item.selected {
background: #e7f3ff;
border-left: 3px solid #007bff;
}
.user-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.user-name {
font-weight: 600;
color: #333;
}
.user-email {
font-size: 0.85rem;
color: #666;
}
.check-icon {
color: #28a745;
font-size: 1.2rem;
font-weight: bold;
}
.no-users {
text-align: center;
padding: 40px 20px;
color: #999;
font-style: italic;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
</style>
+7 -3
View File
@@ -2,7 +2,7 @@
<div class="cd-view"> <div class="cd-view">
<div class="header-section"> <div class="header-section">
<h2>{{ $t('WeaponView') }}</h2> <h2>{{ $t('WeaponView') }}</h2>
<button @click="openAddWeaponDialog" class="btn-add-weapon"> <button v-if="isOwner" @click="openAddWeaponDialog" class="btn-add-weapon">
{{ $t('weapon.add') }} {{ $t('weapon.add') }}
</button> </button>
</div> </div>
@@ -18,7 +18,7 @@
<th>{{ $t('weapon.amount') }}</th> <th>{{ $t('weapon.amount') }}</th>
<th>{{ $t('weapon.contained_in') }}</th> <th>{{ $t('weapon.contained_in') }}</th>
<th>{{ $t('weapon.bonus') }}</th> <th>{{ $t('weapon.bonus') }}</th>
<th>{{ $t('weapon.actions') }}</th> <th v-if="isOwner">{{ $t('weapon.actions') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -31,7 +31,7 @@
<td>{{ weapon.anzahl || '-' }}</td> <td>{{ weapon.anzahl || '-' }}</td>
<td>{{ weapon.beinhaltet_in || '-' }}</td> <td>{{ weapon.beinhaltet_in || '-' }}</td>
<td>{{ weapon.anb || '-' }}/{{ weapon.abwb || '-' }}</td> <td>{{ weapon.anb || '-' }}/{{ weapon.abwb || '-' }}</td>
<td class="action-cell"> <td v-if="isOwner" class="action-cell">
<button @click="editWeapon(weapon)" class="btn-edit" title="Bearbeiten"> <button @click="editWeapon(weapon)" class="btn-edit" title="Bearbeiten">
</button> </button>
@@ -378,6 +378,10 @@ name: "WeaponView",
character: { character: {
type: Object, type: Object,
required: true required: true
},
isOwner: {
type: Boolean,
default: false
} }
}, },
data() { data() {
+28 -1
View File
@@ -86,6 +86,9 @@ export default {
Characters:'Charaktere', Characters:'Charaktere',
Admin:'Administration', Admin:'Administration',
}, },
deleteChar: {
notAuthorized: 'Sie sind nicht berechtigt, diesen Charakter zu löschen. Nur der Besitzer kann diese Aktion durchführen.'
},
landing:{ landing:{
title:'BaMoRT - Charakterverwaltung für mein Lieblingsrollenspielsystem', title:'BaMoRT - Charakterverwaltung für mein Lieblingsrollenspielsystem',
description:'BaMoRT ist ein Werkzeug zur Charakterverwaltung für Rollenspiele. Es bietet Funktionen zur Charaktererstellung, -entwicklung und -verwaltung mit Unterstützung für Fertigkeiten, Zauber, Ausrüstung und mehr. Viele Ausrüstungsteile, Fertikeiten und Zauber fehlen noch, da das Projekt noch in der Entwicklung ist.', description:'BaMoRT ist ein Werkzeug zur Charakterverwaltung für Rollenspiele. Es bietet Funktionen zur Charaktererstellung, -entwicklung und -verwaltung mit Unterstützung für Fertigkeiten, Zauber, Ausrüstung und mehr. Viele Ausrüstungsteile, Fertikeiten und Zauber fehlen noch, da das Projekt noch in der Entwicklung ist.',
@@ -313,7 +316,9 @@ export default {
public: 'Öffentlich', public: 'Öffentlich',
private: 'Privat', private: 'Privat',
grade: 'Grad', grade: 'Grad',
owner: 'Besitzer' owner: 'Besitzer',
shared_characters_title: 'Geteilte Charaktere',
owned_characters_title: 'Eigene Charaktere'
}, },
create: { create: {
spells: { spells: {
@@ -511,6 +516,28 @@ export default {
pleaseWait: 'Bitte warten, dies kann einen Moment dauern', pleaseWait: 'Bitte warten, dies kann einen Moment dauern',
popupBlocked: 'Popup wurde blockiert. Bitte erlauben Sie Popups für diese Seite.' popupBlocked: 'Popup wurde blockiert. Bitte erlauben Sie Popups für diese Seite.'
}, },
visibility: {
title: 'Sichtbarkeit ändern',
description: 'Legen Sie fest, wer diesen Charakter sehen kann.',
private: 'Privat',
privateDescription: 'Nur Sie können diesen Charakter sehen',
public: 'Öffentlich',
publicDescription: 'Alle Benutzer können diesen Charakter sehen',
cancel: 'Abbrechen',
save: 'Speichern',
saving: 'Wird gespeichert...',
updating: 'Sichtbarkeit wird aktualisiert...',
updateError: 'Fehler beim Aktualisieren der Sichtbarkeit',
shareWithUsers: 'Mit bestimmten Benutzern teilen',
shareDescription: 'Wählen Sie Benutzer aus, die diesen Charakter sehen können',
currentlySharedWith: 'Derzeit geteilt mit',
addUsers: 'Benutzer hinzufügen',
searchUsers: 'Benutzer suchen...',
loadingUsers: 'Lade Benutzer...',
noOtherUsers: 'Keine anderen Benutzer verfügbar',
noMatchingUsers: 'Keine Benutzer entsprechen Ihrer Suche',
noSharedUsers: 'Noch nicht mit Benutzern geteilt'
},
userManagement: { userManagement: {
title: 'Benutzerverwaltung', title: 'Benutzerverwaltung',
loading: 'Lade Benutzer...', loading: 'Lade Benutzer...',
+28 -1
View File
@@ -85,6 +85,9 @@ export default {
Characters:'Characters', Characters:'Characters',
Admin:'Administration', Admin:'Administration',
}, },
deleteChar: {
notAuthorized: 'You are not authorized to delete this character. Only the owner can perform this action.'
},
landing:{ landing:{
title:'BaMoRT - Character Management for Role-Playing Games', title:'BaMoRT - Character Management for Role-Playing Games',
description:'BaMoRT is a modern character management tool for role-playing games. It provides comprehensive features for character creation, development, and management with support for skills, spells, equipment, and more.', description:'BaMoRT is a modern character management tool for role-playing games. It provides comprehensive features for character creation, development, and management with support for skills, spells, equipment, and more.',
@@ -309,7 +312,9 @@ export default {
public: 'Public', public: 'Public',
private: 'Private', private: 'Private',
grade: 'Grade', grade: 'Grade',
owner: 'Owner' owner: 'Owner',
shared_characters_title: 'Geteilte Charaktere',
owned_characters_title: 'Eigene Charaktere'
}, },
create: { create: {
spells: { spells: {
@@ -507,6 +512,28 @@ export default {
pleaseWait: 'Please wait, this may take a moment', pleaseWait: 'Please wait, this may take a moment',
popupBlocked: 'Popup was blocked. Please allow popups for this site.' popupBlocked: 'Popup was blocked. Please allow popups for this site.'
}, },
visibility: {
title: 'Change Visibility',
description: 'Set who can see this character.',
private: 'Private',
privateDescription: 'Only you can see this character',
public: 'Public',
publicDescription: 'All users can see this character',
cancel: 'Cancel',
save: 'Save',
saving: 'Saving...',
updating: 'Updating visibility...',
updateError: 'Failed to update visibility',
shareWithUsers: 'Share with specific users',
shareDescription: 'Select users who can view this character',
currentlySharedWith: 'Currently shared with',
addUsers: 'Add users',
searchUsers: 'Search users...',
loadingUsers: 'Loading users...',
noOtherUsers: 'No other users available',
noMatchingUsers: 'No users match your search',
noSharedUsers: 'Not shared with any users yet'
},
userManagement: { userManagement: {
title: 'User Management', title: 'User Management',
loading: 'Loading users...', loading: 'Loading users...',
+1 -1
View File
@@ -1,5 +1,5 @@
// Frontend version information // Frontend version information
export const VERSION = '0.2.0' export const VERSION = '0.2.1'
// Git commit will be injected at build time or detected from env // Git commit will be injected at build time or detected from env
export const GIT_COMMIT = import.meta.env.VITE_GIT_COMMIT || 'unknown' export const GIT_COMMIT = import.meta.env.VITE_GIT_COMMIT || 'unknown'