665 lines
24 KiB
Vue
665 lines
24 KiB
Vue
|
|
<script setup>
|
||
|
|
import { ref, onMounted, computed, watch } from 'vue'
|
||
|
|
import { useAuth } from '~/composables/useAuth.js'
|
||
|
|
|
||
|
|
const route = useRoute()
|
||
|
|
const pid = route.params.pid
|
||
|
|
|
||
|
|
const {
|
||
|
|
isAuthenticated,
|
||
|
|
isInitialized,
|
||
|
|
userProfile,
|
||
|
|
userRoles,
|
||
|
|
token,
|
||
|
|
initializeAuth,
|
||
|
|
logout
|
||
|
|
} = useAuth()
|
||
|
|
|
||
|
|
const user = ref(null)
|
||
|
|
const editForm = ref({})
|
||
|
|
const isEditing = ref(false)
|
||
|
|
const isLoading = ref(true)
|
||
|
|
const isSaving = ref(false)
|
||
|
|
const errorMessage = ref('')
|
||
|
|
const successMessage = ref('')
|
||
|
|
|
||
|
|
const violationCategories = ref([])
|
||
|
|
const selectedCategory = ref('')
|
||
|
|
const selectedGameId = ref('')
|
||
|
|
const selectedSubRule = ref('')
|
||
|
|
const modNote = ref('')
|
||
|
|
const changeReasonText = ref('')
|
||
|
|
|
||
|
|
const validCountries = [
|
||
|
|
"GB", "US", "IT", "NL", "DE", "CA", "FR", "HU", "CR",
|
||
|
|
"AU", "BR", "RO", "CL", "MX", "RU", "ES", "JP", "CZ",
|
||
|
|
"PT", "MT", "AR", "SE", "PL", "IE", "BE", "HT", "NO",
|
||
|
|
"FI", "GR", "BO", "AT", "VE", "PA", "PE", "GF", "SA",
|
||
|
|
"CO", "LT", "NA", "CH", "CY", "RS", "KY", "GP", "DK",
|
||
|
|
"KR", "LU", "SV", "VA", "GT", "SK", "HR", "ZA", "DO",
|
||
|
|
"UY", "LV", "HN", "JM", "TR", "IN", "ER", "AW", "NZ",
|
||
|
|
"EC", "TW", "EE", "CN", "SI", "AI", "BG", "NI", "IS",
|
||
|
|
"MQ", "BZ", "BA", "MY", "AZ", "ZW", "AL", "IM", "VG",
|
||
|
|
"VI", "BM", "GY", "SR", "MS", "TC", "BB", "TT"
|
||
|
|
]
|
||
|
|
|
||
|
|
const validLanguages = [
|
||
|
|
"en", "it", "de", "fr", "es", "us", "pt", "ru", "ja", "nl", "ko", "zh", "tw"
|
||
|
|
]
|
||
|
|
|
||
|
|
const accountLevels = {
|
||
|
|
'-3': { title: 'Omey Banned', realmRole: 'role-banned', textColor: '#fff', bgStart: '#2e2e2e', bgEnd: '#727272' },
|
||
|
|
'-2': { title: 'Inf Eula Banned', realmRole: 'role-banned', textColor: '#fff', bgStart: '#2e2e2e', bgEnd: '#727272' },
|
||
|
|
'-1': { title: 'Banned', realmRole: 'role-banned', textColor: '#fff', bgStart: '#2e2e2e', bgEnd: '#727272' },
|
||
|
|
'0': { title: 'Member', realmRole: 'role-member', textColor: '#fff', bgStart: '#047205', bgEnd: '#00a003' },
|
||
|
|
'1': { title: 'Tester', realmRole: 'role-tester', textColor: '#fff', bgStart: '#0c98a2', bgEnd: '#01c9d7' },
|
||
|
|
'2': { title: 'Moderator', realmRole: 'role-moderator', textColor: '#000', bgStart: '#00dc04', bgEnd: '#067708' },
|
||
|
|
'3': { title: 'Admin / Developer', realmRole: 'role-admin', textColor: '#fff', bgStart: '#920606', bgEnd: '#c80404' },
|
||
|
|
'4': { title: 'Manager', realmRole: 'role-manager', textColor: '#fff', bgStart: '#0830b4', bgEnd: '#1e51f8' }
|
||
|
|
}
|
||
|
|
|
||
|
|
const FIELD_ROLES = {
|
||
|
|
email: ['can-edit-email'],
|
||
|
|
account_level: ['can-edit-account_level'],
|
||
|
|
country: ['can-edit-country'],
|
||
|
|
language: ['can-edit-language'],
|
||
|
|
timezone: ['can-edit-timezone'],
|
||
|
|
gender: ['can-edit-gender'],
|
||
|
|
marketing_allowed: ['can-edit-marketing_allowed'],
|
||
|
|
off_device_allowed: ['can-edit-off_device_allowed']
|
||
|
|
}
|
||
|
|
|
||
|
|
const isNewBanSelected = computed(() => {
|
||
|
|
const originalLevel = Number(user.value?.account_level)
|
||
|
|
const newLevel = Number(editForm.value.account_level)
|
||
|
|
return originalLevel >= 0 && newLevel < 0
|
||
|
|
})
|
||
|
|
|
||
|
|
const activeCategoryObj = computed(() => {
|
||
|
|
return violationCategories.value.find(c => c.id === selectedCategory.value)
|
||
|
|
})
|
||
|
|
|
||
|
|
const activeGameObj = computed(() => {
|
||
|
|
if (activeCategoryObj.value?.type !== 'game') return null
|
||
|
|
return activeCategoryObj.value?.games?.find(g => g.id === selectedGameId.value)
|
||
|
|
})
|
||
|
|
|
||
|
|
const availableSubRules = computed(() => {
|
||
|
|
if (!activeCategoryObj.value) return []
|
||
|
|
if (activeCategoryObj.value.type === 'general') return activeCategoryObj.value.rules || []
|
||
|
|
if (activeCategoryObj.value.type === 'game') return activeGameObj.value?.rules || []
|
||
|
|
return []
|
||
|
|
})
|
||
|
|
|
||
|
|
const constructedBanReason = computed(() => {
|
||
|
|
if (!isNewBanSelected.value) return changeReasonText.value
|
||
|
|
if (!activeCategoryObj.value) return ''
|
||
|
|
|
||
|
|
let reasonStr = activeCategoryObj.value.label
|
||
|
|
|
||
|
|
if (activeCategoryObj.value.type === 'game' && activeGameObj.value) {
|
||
|
|
reasonStr += ` [Game: ${activeGameObj.value.name}]`
|
||
|
|
}
|
||
|
|
|
||
|
|
if (selectedSubRule.value) {
|
||
|
|
reasonStr += ` - ${selectedSubRule.value}`
|
||
|
|
}
|
||
|
|
|
||
|
|
return reasonStr
|
||
|
|
})
|
||
|
|
|
||
|
|
const fetchRules = async () => {
|
||
|
|
try {
|
||
|
|
const rulesData = await $fetch('/api/admin/rules')
|
||
|
|
violationCategories.value = rulesData
|
||
|
|
} catch (err) {
|
||
|
|
console.error('Failed to load violation rules:', err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const formatCountry = (countryCode) => {
|
||
|
|
if (!countryCode) return 'N/A'
|
||
|
|
try {
|
||
|
|
return new Intl.DisplayNames(['en'], { type: 'region' }).of(countryCode.toUpperCase())
|
||
|
|
} catch {
|
||
|
|
return countryCode
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const formatLanguage = (langCode) => {
|
||
|
|
if (!langCode) return 'N/A'
|
||
|
|
try {
|
||
|
|
return new Intl.DisplayNames(['en'], { type: 'language' }).of(langCode.toLowerCase())
|
||
|
|
} catch {
|
||
|
|
return langCode
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const formatGender = (genderCode) => {
|
||
|
|
if (!genderCode) return 'N/A'
|
||
|
|
const code = String(genderCode).toUpperCase()
|
||
|
|
return code === 'M' ? 'Male' : code === 'F' ? 'Female' : genderCode
|
||
|
|
}
|
||
|
|
|
||
|
|
const formatTimezone = (tzString) => {
|
||
|
|
if (!tzString) return 'N/A'
|
||
|
|
try {
|
||
|
|
const cityName = tzString.split('/').pop()?.replace(/_/g, ' ') || tzString
|
||
|
|
const formatter = new Intl.DateTimeFormat('en-US', { timeZone: tzString, timeZoneName: 'shortOffset' })
|
||
|
|
const parts = formatter.formatToParts(new Date())
|
||
|
|
const offset = parts.find(p => p.type === 'timeZoneName')?.value || ''
|
||
|
|
return `${cityName} (${offset.replace('GMT', 'UTC')})`
|
||
|
|
} catch {
|
||
|
|
return tzString.replace(/_/g, ' ')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const canEditField = (fieldName) => {
|
||
|
|
if (!userRoles.value || !userRoles.value.length) return false
|
||
|
|
const allowedRoles = FIELD_ROLES[fieldName]
|
||
|
|
if (!allowedRoles) return false
|
||
|
|
return userRoles.value.some(role => allowedRoles.includes(String(role).toLowerCase()))
|
||
|
|
}
|
||
|
|
|
||
|
|
const canEditProfile = computed(() => {
|
||
|
|
if (!userRoles.value || !userRoles.value.length) return false
|
||
|
|
return Object.keys(FIELD_ROLES).some(field => canEditField(field))
|
||
|
|
})
|
||
|
|
|
||
|
|
const userAccountLevel = computed(() => {
|
||
|
|
if (!user.value || user.value.account_level === null || user.value.account_level === undefined) return null
|
||
|
|
const levelKey = String(user.value.account_level)
|
||
|
|
return accountLevels[levelKey] || {
|
||
|
|
title: `Level ${user.value.account_level}`,
|
||
|
|
textColor: '#fff',
|
||
|
|
bgStart: '#30363d',
|
||
|
|
bgEnd: '#21262d'
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
const handleLogout = () => logout()
|
||
|
|
const handleNavigate = (path) => navigateTo(path)
|
||
|
|
|
||
|
|
const fetchUserData = async () => {
|
||
|
|
if (!isEditing.value) isLoading.value = true
|
||
|
|
errorMessage.value = ''
|
||
|
|
try {
|
||
|
|
const data = await $fetch(`/api/admin/users/${pid}`)
|
||
|
|
user.value = data
|
||
|
|
if (!isEditing.value) {
|
||
|
|
editForm.value = { ...data }
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
errorMessage.value = err.data?.statusMessage || 'Failed to load user profile.'
|
||
|
|
} finally {
|
||
|
|
isLoading.value = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const startEditing = () => {
|
||
|
|
selectedCategory.value = ''
|
||
|
|
selectedGameId.value = ''
|
||
|
|
selectedSubRule.value = ''
|
||
|
|
changeReasonText.value = ''
|
||
|
|
modNote.value = ''
|
||
|
|
editForm.value = { ...user.value }
|
||
|
|
isEditing.value = true
|
||
|
|
}
|
||
|
|
|
||
|
|
const saveChanges = async () => {
|
||
|
|
isSaving.value = true
|
||
|
|
errorMessage.value = ''
|
||
|
|
successMessage.value = ''
|
||
|
|
|
||
|
|
if (isNewBanSelected.value) {
|
||
|
|
if (!selectedCategory.value) {
|
||
|
|
errorMessage.value = 'Please select a Violation Category.'
|
||
|
|
isSaving.value = false
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if (activeCategoryObj.value?.type === 'game' && !selectedGameId.value) {
|
||
|
|
errorMessage.value = 'Please select the Target Game for this violation.'
|
||
|
|
isSaving.value = false
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if (availableSubRules.value.length > 0 && !selectedSubRule.value) {
|
||
|
|
errorMessage.value = 'Please select a Specific Rule Violated.'
|
||
|
|
isSaving.value = false
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const payload = {
|
||
|
|
change_reason: constructedBanReason.value,
|
||
|
|
mod_note: modNote.value
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const field of Object.keys(FIELD_ROLES)) {
|
||
|
|
if (canEditField(field) && editForm.value[field] !== undefined) {
|
||
|
|
payload[field] = editForm.value[field]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const accessToken = token?.value || ''
|
||
|
|
|
||
|
|
await $fetch(`/api/admin/users/${pid}`, {
|
||
|
|
method: 'PUT',
|
||
|
|
headers: {
|
||
|
|
Authorization: accessToken ? `Bearer ${accessToken}` : ''
|
||
|
|
},
|
||
|
|
body: payload
|
||
|
|
})
|
||
|
|
|
||
|
|
await fetchUserData()
|
||
|
|
|
||
|
|
isEditing.value = false
|
||
|
|
successMessage.value = 'Profile updated successfully!'
|
||
|
|
setTimeout(() => { successMessage.value = '' }, 4000)
|
||
|
|
} catch (err) {
|
||
|
|
console.error('Update profile error:', err)
|
||
|
|
errorMessage.value = err.data?.statusMessage || err.message || 'Failed to update profile.'
|
||
|
|
} finally {
|
||
|
|
isSaving.value = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
watch(userProfile, async (newProfile) => {
|
||
|
|
if (newProfile && isAuthenticated.value) {
|
||
|
|
await fetchUserData()
|
||
|
|
}
|
||
|
|
}, { deep: true })
|
||
|
|
|
||
|
|
onMounted(async () => {
|
||
|
|
await initializeAuth({ redirectTo: '/oauth' })
|
||
|
|
if (isAuthenticated.value) {
|
||
|
|
await fetchUserData()
|
||
|
|
await fetchRules()
|
||
|
|
}
|
||
|
|
})
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<template>
|
||
|
|
<div>
|
||
|
|
<div v-if="!isInitialized" class="global-loader-wrapper">
|
||
|
|
<div class="spinner lg"></div>
|
||
|
|
<p style="color: #8b949e; margin-top: 1rem; font-size: 0.95rem;">Connecting to Keycloak...</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-else-if="isAuthenticated" class="settings-container">
|
||
|
|
<Header
|
||
|
|
:user-profile="userProfile"
|
||
|
|
active-tab="search"
|
||
|
|
@logout="handleLogout"
|
||
|
|
@navigate="handleNavigate"
|
||
|
|
/>
|
||
|
|
|
||
|
|
<main class="settings-main-content">
|
||
|
|
<section class="settings-panel" style="width: 100%;">
|
||
|
|
<div class="panel-header archivo-black" style="display: flex; justify-content: space-between; align-items: center;">
|
||
|
|
<span>USER PROFILE</span>
|
||
|
|
<div style="display: flex; gap: 0.5rem;">
|
||
|
|
<button
|
||
|
|
v-if="canEditProfile && !isEditing"
|
||
|
|
type="button"
|
||
|
|
class="btn-action primary"
|
||
|
|
@click="startEditing"
|
||
|
|
>
|
||
|
|
✎ Edit Profile
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
class="btn-action secondary"
|
||
|
|
@click="handleNavigate('/search')"
|
||
|
|
>
|
||
|
|
← Back to Search
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="panel-body" style="padding: 1.5rem;">
|
||
|
|
<div v-if="isLoading" style="text-align: center; padding: 2rem; color: #8b949e;">
|
||
|
|
<div class="spinner sm" style="margin: 0 auto 0.5rem auto;"></div>
|
||
|
|
Loading profile details...
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-else-if="errorMessage" style="text-align: center; padding: 1rem; color: #f85149; background: #221515; border-radius: 6px; margin-bottom: 1rem;">
|
||
|
|
{{ errorMessage }}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-if="successMessage" style="text-align: center; padding: 1rem; color: #3fb950; background: #0d2818; border-radius: 6px; margin-bottom: 1rem;">
|
||
|
|
{{ successMessage }}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-if="user" style="display: flex; flex-direction: column; gap: 1.5rem;">
|
||
|
|
<div style="display: flex; align-items: center; justify-content: space-between; padding: 1.25rem; background: #0d1117; border: 1px solid #30363d; border-radius: 8px;">
|
||
|
|
<div style="display: flex; align-items: center; gap: 1.25rem;">
|
||
|
|
<img
|
||
|
|
:src="user.pid ? `https://mii.spfn.net/${user.pid}/main.png` : '/img_unknown_MiiIcon.png'"
|
||
|
|
:alt="user.username || user.sfid || 'Mii Avatar'"
|
||
|
|
style="width: 72px; height: 72px; border-radius: 50%; background: #161b22; object-fit: contain; flex-shrink: 0;"
|
||
|
|
@error="(e) => e.target.src = '/img_unknown_MiiIcon.png'"
|
||
|
|
/>
|
||
|
|
|
||
|
|
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
|
||
|
|
<h2 style="font-size: 1.25rem; font-weight: 700; color: #f0f6fc; margin: 0;">
|
||
|
|
{{ user.sfid || user.username || 'No Username' }}
|
||
|
|
</h2>
|
||
|
|
<span style="font-size: 0.85rem; color: #8b949e;">PID: {{ user.pid }}</span>
|
||
|
|
<span v-if="user.email" style="font-size: 0.85rem; color: #8b949e;">Email: {{ user.email }}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-if="userAccountLevel">
|
||
|
|
<span
|
||
|
|
class="role-badge"
|
||
|
|
:style="{
|
||
|
|
color: userAccountLevel.textColor,
|
||
|
|
background: `linear-gradient(135deg, ${userAccountLevel.bgStart}, ${userAccountLevel.bgEnd})`
|
||
|
|
}"
|
||
|
|
>
|
||
|
|
{{ userAccountLevel.title }}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-if="!isEditing" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem;">
|
||
|
|
<div class="info-card">
|
||
|
|
<span class="info-label">Account Level</span>
|
||
|
|
<span class="info-value">{{ userAccountLevel ? userAccountLevel.title : 'N/A' }} ({{ user.account_level ?? 'N/A' }})</span>
|
||
|
|
</div>
|
||
|
|
<div class="info-card">
|
||
|
|
<span class="info-label">Country</span>
|
||
|
|
<span class="info-value">{{ formatCountry(user.country) }}</span>
|
||
|
|
</div>
|
||
|
|
<div class="info-card">
|
||
|
|
<span class="info-label">Language</span>
|
||
|
|
<span class="info-value">{{ formatLanguage(user.language) }}</span>
|
||
|
|
</div>
|
||
|
|
<div class="info-card">
|
||
|
|
<span class="info-label">Timezone</span>
|
||
|
|
<span class="info-value">{{ formatTimezone(user.timezone) }}</span>
|
||
|
|
</div>
|
||
|
|
<div class="info-card">
|
||
|
|
<span class="info-label">Gender</span>
|
||
|
|
<span class="info-value">{{ formatGender(user.gender) }}</span>
|
||
|
|
</div>
|
||
|
|
<div class="info-card">
|
||
|
|
<span class="info-label">Email Verified Since</span>
|
||
|
|
<span class="info-value">{{ user.email_verified_since ? new Date(user.email_verified_since).toLocaleString() : 'Unverified' }}</span>
|
||
|
|
</div>
|
||
|
|
<div class="info-card">
|
||
|
|
<span class="info-label">Marketing Allowed</span>
|
||
|
|
<span class="info-value">{{ user.marketing_allowed ? 'Yes' : 'No' }}</span>
|
||
|
|
</div>
|
||
|
|
<div class="info-card">
|
||
|
|
<span class="info-label">Off-Device Allowed</span>
|
||
|
|
<span class="info-value">{{ user.off_device_allowed ? 'Yes' : 'No' }}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<form v-else @submit.prevent="saveChanges" style="display: flex; flex-direction: column; gap: 1rem;">
|
||
|
|
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem;">
|
||
|
|
|
||
|
|
<div class="info-card" :class="{ 'disabled-card': !canEditField('email') }">
|
||
|
|
<label class="info-label">Email</label>
|
||
|
|
<input
|
||
|
|
v-model="editForm.email"
|
||
|
|
type="email"
|
||
|
|
class="input-field"
|
||
|
|
:disabled="!canEditField('email')"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="info-card" :class="{ 'disabled-card': !canEditField('account_level') }">
|
||
|
|
<label class="info-label">Account Level</label>
|
||
|
|
<select
|
||
|
|
v-model.number="editForm.account_level"
|
||
|
|
class="input-field"
|
||
|
|
:disabled="!canEditField('account_level')"
|
||
|
|
>
|
||
|
|
<option v-for="(val, key) in accountLevels" :key="key" :value="Number(key)">
|
||
|
|
{{ val.title }} ({{ key }})
|
||
|
|
</option>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="info-card" :class="{ 'disabled-card': !canEditField('country') }">
|
||
|
|
<label class="info-label">Country</label>
|
||
|
|
<select
|
||
|
|
v-model="editForm.country"
|
||
|
|
class="input-field"
|
||
|
|
:disabled="!canEditField('country')"
|
||
|
|
>
|
||
|
|
<option v-for="code in validCountries" :key="code" :value="code">
|
||
|
|
{{ formatCountry(code) }} ({{ code }})
|
||
|
|
</option>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="info-card" :class="{ 'disabled-card': !canEditField('language') }">
|
||
|
|
<label class="info-label">Language</label>
|
||
|
|
<select
|
||
|
|
v-model="editForm.language"
|
||
|
|
class="input-field"
|
||
|
|
:disabled="!canEditField('language')"
|
||
|
|
>
|
||
|
|
<option v-for="lang in validLanguages" :key="lang" :value="lang">
|
||
|
|
{{ formatLanguage(lang) }} ({{ lang }})
|
||
|
|
</option>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="info-card" :class="{ 'disabled-card': !canEditField('timezone') }">
|
||
|
|
<label class="info-label">Timezone</label>
|
||
|
|
<input
|
||
|
|
v-model="editForm.timezone"
|
||
|
|
type="text"
|
||
|
|
class="input-field"
|
||
|
|
:disabled="!canEditField('timezone')"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="info-card" :class="{ 'disabled-card': !canEditField('gender') }">
|
||
|
|
<label class="info-label">Gender</label>
|
||
|
|
<select
|
||
|
|
v-model="editForm.gender"
|
||
|
|
class="input-field"
|
||
|
|
:disabled="!canEditField('gender')"
|
||
|
|
>
|
||
|
|
<option value="M">Male (M)</option>
|
||
|
|
<option value="F">Female (F)</option>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="info-card" :class="{ 'disabled-card': !canEditField('marketing_allowed') }">
|
||
|
|
<label class="info-label">Marketing Allowed</label>
|
||
|
|
<select
|
||
|
|
v-model="editForm.marketing_allowed"
|
||
|
|
class="input-field"
|
||
|
|
:disabled="!canEditField('marketing_allowed')"
|
||
|
|
>
|
||
|
|
<option :value="true">Yes</option>
|
||
|
|
<option :value="false">No</option>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="info-card" :class="{ 'disabled-card': !canEditField('off_device_allowed') }">
|
||
|
|
<label class="info-label">Off-Device Allowed</label>
|
||
|
|
<select
|
||
|
|
v-model="editForm.off_device_allowed"
|
||
|
|
class="input-field"
|
||
|
|
:disabled="!canEditField('off_device_allowed')"
|
||
|
|
>
|
||
|
|
<option :value="true">Yes</option>
|
||
|
|
<option :value="false">No</option>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div style="display: flex; flex-direction: column; gap: 1rem; background: #0d1117; border: 1px solid #30363d; padding: 1rem; border-radius: 6px;">
|
||
|
|
|
||
|
|
<template v-if="isNewBanSelected">
|
||
|
|
<div style="display: flex; flex-direction: column; gap: 0.35rem;">
|
||
|
|
<label class="info-label">Violation Category *</label>
|
||
|
|
<select v-model="selectedCategory" class="input-field" required @change="selectedGameId = ''; selectedSubRule = ''">
|
||
|
|
<option value="" disabled>Select a rule category...</option>
|
||
|
|
<option v-for="cat in violationCategories" :key="cat.id" :value="cat.id">
|
||
|
|
{{ cat.label }}
|
||
|
|
</option>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-if="activeCategoryObj?.type === 'game'" style="display: flex; flex-direction: column; gap: 0.35rem;">
|
||
|
|
<label class="info-label">Target Game *</label>
|
||
|
|
<select v-model="selectedGameId" class="input-field" required @change="selectedSubRule = ''">
|
||
|
|
<option value="" disabled>Select game...</option>
|
||
|
|
<option v-for="game in activeCategoryObj.games" :key="game.id" :value="game.id">
|
||
|
|
{{ game.name }}
|
||
|
|
</option>
|
||
|
|
</select>
|
||
|
|
|
||
|
|
<div v-if="activeGameObj" style="display: flex; align-items: center; gap: 0.75rem; margin-top: 0.5rem; background: #161b22; padding: 0.5rem; border-radius: 6px; border: 1px solid #30363d;">
|
||
|
|
<img v-if="activeGameObj.iconUrl" :src="activeGameObj.iconUrl" :alt="activeGameObj.name" style="width: 32px; height: 32px; object-fit: contain; border-radius: 4px;" />
|
||
|
|
<span style="font-weight: 600; font-size: 0.9rem; color: #f0f6fc;">{{ activeGameObj.name }}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-if="availableSubRules.length > 0" style="display: flex; flex-direction: column; gap: 0.35rem;">
|
||
|
|
<label class="info-label">Specific Rule Violated *</label>
|
||
|
|
<select v-model="selectedSubRule" class="input-field" required>
|
||
|
|
<option value="" disabled>Select specific rule...</option>
|
||
|
|
<option v-for="rule in availableSubRules" :key="rule" :value="rule">
|
||
|
|
{{ rule }}
|
||
|
|
</option>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div style="display: flex; flex-direction: column; gap: 0.35rem;">
|
||
|
|
<label class="info-label">Moderator Note & Evidence (Optional)</label>
|
||
|
|
<textarea
|
||
|
|
v-model="modNote"
|
||
|
|
class="input-field"
|
||
|
|
rows="3"
|
||
|
|
placeholder="Provide optional details and evidence links (e.g., ticket #, screenshots, logs)..."
|
||
|
|
></textarea>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<template v-else>
|
||
|
|
<div style="display: flex; flex-direction: column; gap: 0.35rem;">
|
||
|
|
<label class="info-label">Reason for Change</label>
|
||
|
|
<input
|
||
|
|
v-model="changeReasonText"
|
||
|
|
type="text"
|
||
|
|
class="input-field"
|
||
|
|
placeholder="Optional reason for making these changes..."
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div style="display: flex; justify-content: flex-end; gap: 0.75rem; margin-top: 1rem;">
|
||
|
|
<button type="button" class="btn-action secondary" @click="isEditing = false">
|
||
|
|
Cancel
|
||
|
|
</button>
|
||
|
|
<button type="submit" class="btn-action primary" :disabled="isSaving">
|
||
|
|
{{ isSaving ? 'Saving...' : 'Save Changes' }}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
</main>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<style scoped>
|
||
|
|
.info-card {
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
gap: 0.35rem;
|
||
|
|
padding: 0.85rem 1rem;
|
||
|
|
background: #0d1117;
|
||
|
|
border: 1px solid #30363d;
|
||
|
|
border-radius: 6px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.disabled-card {
|
||
|
|
opacity: 0.5;
|
||
|
|
}
|
||
|
|
|
||
|
|
.info-label {
|
||
|
|
font-size: 0.75rem;
|
||
|
|
color: #8b949e;
|
||
|
|
text-transform: uppercase;
|
||
|
|
letter-spacing: 0.05em;
|
||
|
|
}
|
||
|
|
|
||
|
|
.info-value {
|
||
|
|
font-size: 0.95rem;
|
||
|
|
font-weight: 600;
|
||
|
|
color: #c9d1d9;
|
||
|
|
}
|
||
|
|
|
||
|
|
.input-field {
|
||
|
|
background: #161b22;
|
||
|
|
border: 1px solid #30363d;
|
||
|
|
border-radius: 4px;
|
||
|
|
color: #f0f6fc;
|
||
|
|
padding: 0.4rem 0.6rem;
|
||
|
|
font-size: 0.9rem;
|
||
|
|
outline: none;
|
||
|
|
font-family: inherit;
|
||
|
|
}
|
||
|
|
|
||
|
|
.input-field:focus {
|
||
|
|
border-color: #58a6ff;
|
||
|
|
}
|
||
|
|
|
||
|
|
.input-field:disabled {
|
||
|
|
background: #0d1117;
|
||
|
|
color: #484f58;
|
||
|
|
border-color: #21262d;
|
||
|
|
cursor: not-allowed;
|
||
|
|
}
|
||
|
|
|
||
|
|
.role-badge {
|
||
|
|
display: inline-block;
|
||
|
|
padding: 0.4rem 0.85rem;
|
||
|
|
font-size: 0.85rem;
|
||
|
|
font-weight: 700;
|
||
|
|
border-radius: 20px;
|
||
|
|
text-transform: uppercase;
|
||
|
|
letter-spacing: 0.03em;
|
||
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-action {
|
||
|
|
border-radius: 6px;
|
||
|
|
padding: 0.4rem 0.85rem;
|
||
|
|
font-size: 0.8rem;
|
||
|
|
font-weight: 600;
|
||
|
|
cursor: pointer;
|
||
|
|
transition: opacity 0.2s ease;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-action.primary {
|
||
|
|
background: #238636;
|
||
|
|
border: 1px solid #2ea043;
|
||
|
|
color: #ffffff;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-action.secondary {
|
||
|
|
background: transparent;
|
||
|
|
border: 1px solid #30363d;
|
||
|
|
color: #c9d1d9;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-action:hover {
|
||
|
|
opacity: 0.85;
|
||
|
|
}
|
||
|
|
</style>
|