UI to share user to other users

This commit is contained in:
2026-02-01 23:23:53 +01:00
parent 9bf36599fb
commit 5b8c83f972
6 changed files with 509 additions and 8 deletions
+5
View File
@@ -16,6 +16,11 @@ func RegisterRoutes(r *gin.RouterGroup) {
charGrp.PUT("/:id/image", UpdateCharacterImage)
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
charGrp.GET("/:id/experience-wealth", GetCharacterExperienceAndWealth) // 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
@@ -2544,7 +2544,7 @@ a:focus {
}
.remove-btn {
position: absolute;
/*position: absolute;*/
right: 0;
top: 50%;
transform: translateY(-50%);
+337 -5
View File
@@ -1,6 +1,6 @@
<template>
<div v-if="showDialog" class="modal-overlay" @click.self="closeDialog">
<div class="modal-content">
<div class="modal-content modal-large">
<div class="modal-header">
<h3>{{ $t('visibility.title') }}</h3>
<button @click="closeDialog" class="close-button">&times;</button>
@@ -10,6 +10,8 @@
<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">
@@ -39,12 +41,80 @@
</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="updateVisibility" class="btn-primary" :disabled="isUpdating">
<button @click="updateVisibilityAndShares" class="btn-primary" :disabled="isUpdating">
<span v-if="!isUpdating">{{ $t('visibility.save') }}</span>
<span v-else>{{ $t('visibility.saving') }}</span>
</button>
@@ -75,7 +145,25 @@ export default {
data() {
return {
isPublic: false,
isUpdating: 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: {
@@ -88,21 +176,80 @@ export default {
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 updateVisibility() {
async updateVisibilityAndShares() {
this.isUpdating = true
try {
const token = localStorage.getItem('token')
// Update visibility
await API.patch(`/api/characters/${this.characterId}`,
{ public: this.isPublic },
{
@@ -110,10 +257,18 @@ export default {
}
)
// 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:', 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
@@ -178,4 +333,181 @@ export default {
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>
+10 -1
View File
@@ -527,7 +527,16 @@ export default {
save: 'Speichern',
saving: 'Wird gespeichert...',
updating: 'Sichtbarkeit wird aktualisiert...',
updateError: 'Fehler beim Aktualisieren der Sichtbarkeit'
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: {
title: 'Benutzerverwaltung',
+10 -1
View File
@@ -523,7 +523,16 @@ export default {
save: 'Save',
saving: 'Saving...',
updating: 'Updating visibility...',
updateError: 'Failed to update 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: {
title: 'User Management',