546 lines
20 KiB
Vue
546 lines
20 KiB
Vue
<template>
|
|
<div class="system-settings-page min-vh-100 bg-light pb-5">
|
|
<!-- Header -->
|
|
<header style="margin-top:5vh" class=" bg-dark text-white py-3 shadow-lg border-bottom border-primary border-4 sticky-top-header">
|
|
<div class="container py-2">
|
|
<div class="d-flex align-items-center justify-content-between flex-wrap gap-3">
|
|
<div class="d-flex align-items-center gap-4 animate-fade-in">
|
|
<div class="display-container position-relative d-none d-md-block">
|
|
<i class="fas fa-cogs fa-2x text-primary-gradient"></i>
|
|
<div class="pulse-ring position-absolute top-50 start-50 translate-middle"></div>
|
|
</div>
|
|
<div>
|
|
<h1 class="h5 fw-black text-white mb-0 ls-tight">Global <span class="text-primary-gradient">Settings</span></h1>
|
|
<p class="text-muted fw-medium small text-uppercase ls-wide mt-1 mb-0 d-none d-sm-block">
|
|
Platform-wide configuration & Branding
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="d-flex gap-2">
|
|
<button
|
|
@click="saveAll"
|
|
class="btn btn-primary-gradient rounded-pill px-3 px-sm-4 fw-bold shadow-lg d-flex align-items-center gap-2"
|
|
:disabled="isLoading"
|
|
>
|
|
<i v-if="isLoading" class="fas fa-spinner fa-spin"></i>
|
|
<i v-else class="fas fa-save"></i>
|
|
<span class="d-none d-sm-inline">Save Changes</span>
|
|
<span class="d-sm-none">Save</span>
|
|
</button>
|
|
<button
|
|
@click="goBack"
|
|
class="btn btn-outline-light rounded-pill px-3 px-sm-4 fw-bold border-2"
|
|
>
|
|
<i class="fas fa-arrow-left me-1 me-sm-2"></i> <span class="d-none d-sm-inline">Back</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="container pt-2" style="margin-top:10vh">
|
|
<div v-if="error" class="alert alert-danger rounded-4 shadow-sm border-0 d-flex align-items-center gap-3">
|
|
<i class="fas fa-exclamation-triangle fa-lg"></i>
|
|
<div class="fw-bold">{{ error }}</div>
|
|
<button @click="error = null" class="btn-close ms-auto"></button>
|
|
</div>
|
|
|
|
<div class="row g-4">
|
|
<!-- Sidebar Groups -->
|
|
<div class="col-lg-3">
|
|
<div class="card border-0 shadow-sm rounded-4 p-3 bg-white sticky-top" style="top: 7rem; z-index: 10;">
|
|
<div class="d-flex flex-column gap-2">
|
|
<button
|
|
v-for="(groupSettings, groupName) in visibleGroups"
|
|
:key="groupName"
|
|
@click="activeGroup = groupName"
|
|
class="btn rounded-4 border-0 text-start px-3 py-2 fw-bold d-flex align-items-center gap-3 transition-all"
|
|
:class="activeGroup === groupName ? 'btn-soft-primary text-primary' : 'btn-light text-muted hover-bg-light'"
|
|
>
|
|
<i :class="getGroupIcon(groupName)"></i>
|
|
<span class="text-capitalize">{{ groupName }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Settings Editor -->
|
|
<div class="col-lg-9">
|
|
<div v-show="activeGroup" class="animate-slide-up">
|
|
<div class="card border-0 shadow-lg rounded-5 overflow-hidden bg-white mb-4">
|
|
<div class="card-header bg-white border-0 p-4 pb-0">
|
|
<h4 class="fw-black mb-0 text-capitalize d-flex align-items-center gap-3">
|
|
<i :class="getGroupIcon(activeGroup)" class="text-primary"></i>
|
|
{{ activeGroup }} Settings
|
|
</h4>
|
|
<hr class="mt-4 opacity-10">
|
|
</div>
|
|
|
|
<div class="card-body p-4 pt-2">
|
|
<div class="row g-4">
|
|
<div v-for="setting in currentGroupSettings" :key="setting.key" class="col-12">
|
|
<div class="setting-item p-3 rounded-4 border border-light-subtle hover-bg-light transition-all">
|
|
<div class="row align-items-center">
|
|
<div class="col-md-5">
|
|
<label class="fw-black mb-1 d-block">{{ setting.label }}</label>
|
|
<small class="text-muted d-block">{{ setting.description }}</small>
|
|
</div>
|
|
<div class="col-md-7 mt-3 mt-md-0">
|
|
<!-- Boolean Input -->
|
|
<div v-if="setting.type === 'boolean'" class="form-check form-switch custom-switch">
|
|
<input
|
|
class="form-check-input"
|
|
type="checkbox"
|
|
v-model="formData[setting.key]"
|
|
role="switch"
|
|
>
|
|
<span class="ms-2 fw-bold" :class="formData[setting.key] ? 'text-primary' : 'text-muted'">
|
|
{{ formData[setting.key] ? 'Enabled' : 'Disabled' }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- JSON Array Input -->
|
|
<div v-else-if="setting.type === 'json'" class="d-flex flex-column gap-3">
|
|
<div class="d-flex flex-wrap gap-2 mb-2">
|
|
<div
|
|
v-for="(item, index) in formData[setting.key]"
|
|
:key="index"
|
|
class="badge rounded-pill bg-soft-primary text-primary border border-primary-subtle d-flex align-items-center gap-2 py-2 px-3 animate-fade-in"
|
|
>
|
|
<span class="fw-bold text-uppercase small">{{ item }}</span>
|
|
<i
|
|
@click="removeItemFromJson(setting.key, index)"
|
|
class="fas fa-times-circle cursor-pointer hover-text-danger transition-all"
|
|
></i>
|
|
</div>
|
|
<div v-if="!formData[setting.key] || formData[setting.key].length === 0" class="text-muted small italic opacity-50">
|
|
No items added yet.
|
|
</div>
|
|
</div>
|
|
<div class="input-group">
|
|
<input
|
|
type="text"
|
|
v-model="newItemInputs[setting.key]"
|
|
class="form-control rounded-start-pill border-2 h-48px px-3 shadow-none"
|
|
placeholder="Add new item..."
|
|
@keyup.enter="addItemToJson(setting.key)"
|
|
>
|
|
<button
|
|
@click="addItemToJson(setting.key)"
|
|
class="btn btn-primary-gradient rounded-end-pill px-4"
|
|
type="button"
|
|
>
|
|
<i class="fas fa-plus me-2"></i> Add
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Image Input -->
|
|
<div v-else-if="setting.type === 'image'" class="d-flex align-items-center gap-3">
|
|
<div class="logo-preview skeleton-loading rounded-4 border p-2 bg-light d-flex align-items-center justify-content-center" style="width: 80px; height: 80px;">
|
|
<img v-if="logoPreviews[setting.key] || setting.value_url" :src="logoPreviews[setting.key] || setting.value_url" class="img-fluid rounded-3" alt="Logo">
|
|
<i v-else class="fas fa-image fa-2x text-muted opacity-25"></i>
|
|
</div>
|
|
<div class="flex-grow-1">
|
|
<input
|
|
type="file"
|
|
@change="(e) => handleLogoUpload(e, setting.key)"
|
|
class="form-control form-control-sm rounded-pill border-2"
|
|
accept="image/*"
|
|
>
|
|
<small class="text-muted mt-1 d-block">Recommended size: 512x512px. PNG or SVG preferred.</small>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Select Input -->
|
|
<select
|
|
v-else-if="setting.type === 'select'"
|
|
v-model="formData[setting.key]"
|
|
class="form-select rounded-pill border-2 px-3 fw-medium h-48px"
|
|
>
|
|
<option
|
|
v-for="opt in (setting.options_parsed || [])"
|
|
:key="opt"
|
|
:value="opt"
|
|
>{{ formatOption(opt) }}</option>
|
|
</select>
|
|
|
|
<!-- Organization Select -->
|
|
<div v-else-if="setting.type === 'organization'" class="d-flex flex-column gap-2">
|
|
<select
|
|
v-model="formData[setting.key]"
|
|
class="form-select rounded-pill border-2 px-3 fw-medium h-48px"
|
|
:disabled="loadingOrganizations"
|
|
>
|
|
<option :value="null">— None —</option>
|
|
<option
|
|
v-for="org in organizations"
|
|
:key="org.hashkey"
|
|
:value="org.hashkey"
|
|
>{{ org.name }} ({{ org.type }})</option>
|
|
</select>
|
|
<small v-if="loadingOrganizations" class="text-muted">
|
|
<i class="fas fa-spinner fa-spin me-1"></i> Loading organizations…
|
|
</small>
|
|
<small v-else-if="organizations.length === 0" class="text-muted">
|
|
No active organizations available.
|
|
</small>
|
|
</div>
|
|
|
|
<!-- Textarea Input -->
|
|
<textarea
|
|
v-else-if="setting.type === 'textarea'"
|
|
v-model="formData[setting.key]"
|
|
class="form-control rounded-4 border-2 px-3 fw-medium"
|
|
rows="4"
|
|
:placeholder="'Enter ' + setting.label"
|
|
></textarea>
|
|
|
|
<!-- Text Input -->
|
|
<input
|
|
v-else
|
|
type="text"
|
|
v-model="formData[setting.key]"
|
|
class="form-control rounded-pill border-2 px-3 fw-medium h-48px"
|
|
:placeholder="'Enter ' + setting.label"
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div v-if="!activeGroup" class="text-center py-5">
|
|
<div class="opacity-25 mb-4">
|
|
<i class="fas fa-cog fa-5x animate-spin-slow"></i>
|
|
</div>
|
|
<h4 class="fw-black text-muted">Select a setting group to begin</h4>
|
|
<p class="text-muted small">Configurations changes take effect immediately across all user sessions.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Feedback Modal -->
|
|
<div v-if="successMsg" class="toast-container position-fixed top-0 end-0 p-4 mt-5" style="z-index: 10050;">
|
|
<div class="toast show border-0 shadow-lg bg-primary text-white rounded-4 animate-slide-down">
|
|
<div class="d-flex p-3 px-4 align-items-center gap-3">
|
|
<i class="fas fa-check-circle fa-lg"></i>
|
|
<div class="fw-bold">{{ successMsg }}</div>
|
|
<button @click="successMsg = ''" class="btn-close btn-close-white ms-auto"></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, reactive } from 'vue';
|
|
import { useSystemSettings } from '@/composables/useSystemSettings';
|
|
import { useUIStore } from '@/stores/ui';
|
|
|
|
const {
|
|
groupedSettings, fetchAdminSettings, updateSettings, uploadLogo, isLoading, error
|
|
} = useSystemSettings();
|
|
|
|
const uiStore = useUIStore();
|
|
|
|
const activeGroup = ref('general');
|
|
const formData = reactive({});
|
|
const newItemInputs = reactive({});
|
|
const logoPreviews = reactive({});
|
|
const successMsg = ref('');
|
|
const organizations = ref([]);
|
|
const loadingOrganizations = ref(false);
|
|
|
|
const formatOption = (opt) => {
|
|
if (!opt) return '';
|
|
return String(opt)
|
|
.replace(/_/g, ' ')
|
|
.replace(/\b\w/g, c => c.toUpperCase());
|
|
};
|
|
|
|
const fetchOrganizations = async () => {
|
|
loadingOrganizations.value = true;
|
|
try {
|
|
const response = await fetch('/admin/ultimate/system-settings/organizations', {
|
|
headers: { 'Accept': 'application/json' },
|
|
credentials: 'same-origin',
|
|
});
|
|
const result = await response.json();
|
|
if (result.success && Array.isArray(result.data)) {
|
|
organizations.value = result.data;
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load organizations', e);
|
|
} finally {
|
|
loadingOrganizations.value = false;
|
|
}
|
|
};
|
|
|
|
const addItemToJson = (key) => {
|
|
const value = newItemInputs[key]?.trim();
|
|
if (!value) return;
|
|
|
|
if (!formData[key]) formData[key] = [];
|
|
if (!formData[key].includes(value)) {
|
|
formData[key].push(value);
|
|
}
|
|
newItemInputs[key] = '';
|
|
};
|
|
|
|
const removeItemFromJson = (key, index) => {
|
|
if (formData[key]) {
|
|
formData[key].splice(index, 1);
|
|
}
|
|
};
|
|
|
|
const HIDDEN_GROUPS = ['modules'];
|
|
|
|
const visibleGroups = computed(() => {
|
|
const all = groupedSettings.value || {};
|
|
return Object.fromEntries(
|
|
Object.entries(all).filter(([name]) => !HIDDEN_GROUPS.includes(name))
|
|
);
|
|
});
|
|
|
|
const currentGroupSettings = computed(() => {
|
|
if (!groupedSettings.value || !activeGroup.value) return [];
|
|
return groupedSettings.value[activeGroup.value] || [];
|
|
});
|
|
|
|
const getGroupIcon = (group) => {
|
|
const icons = {
|
|
general: 'fas fa-globe',
|
|
branding: 'fas fa-paint-brush',
|
|
advanced: 'fas fa-sliders-h',
|
|
security: 'fas fa-shield-alt',
|
|
content: 'fas fa-bible',
|
|
};
|
|
return icons[group] || 'fas fa-cog';
|
|
};
|
|
|
|
const handleLogoUpload = async (event, key) => {
|
|
const file = event.target.files[0];
|
|
if (!file) return;
|
|
|
|
// Create preview
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
logoPreviews[key] = e.target.result;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
|
|
try {
|
|
const result = await uploadLogo(file);
|
|
if (result.success) {
|
|
formData[key] = result.hashkey;
|
|
showToast('Logo updated successfully!');
|
|
}
|
|
} catch (err) {
|
|
// Error handled by composable
|
|
}
|
|
};
|
|
|
|
const saveAll = async () => {
|
|
try {
|
|
const result = await updateSettings(formData);
|
|
if (result.success) {
|
|
showToast('All settings saved successfully!');
|
|
await fetchAdminSettings(); // Refresh to get current state
|
|
uiStore.refreshSettings(); // Sync with global UI store
|
|
}
|
|
} catch (err) {
|
|
// Error handled by composable
|
|
}
|
|
};
|
|
|
|
const showToast = (msg) => {
|
|
successMsg.value = msg;
|
|
setTimeout(() => successMsg.value = '', 3000);
|
|
};
|
|
|
|
const goBack = () => {
|
|
window.history.back();
|
|
};
|
|
|
|
onMounted(async () => {
|
|
await Promise.all([fetchAdminSettings(), fetchOrganizations()]);
|
|
|
|
// Initialize form data
|
|
if (groupedSettings.value) {
|
|
Object.keys(groupedSettings.value).forEach(group => {
|
|
groupedSettings.value[group].forEach(setting => {
|
|
// Handle boolean and JSON parsing
|
|
if (setting.type === 'boolean') {
|
|
formData[setting.key] = setting.value === 'true' || setting.value === true || setting.value === '1' || setting.value === 1;
|
|
} else if (setting.type === 'json') {
|
|
try {
|
|
formData[setting.key] = typeof setting.value === 'string' ? JSON.parse(setting.value) : (setting.value || []);
|
|
} catch (e) {
|
|
formData[setting.key] = [];
|
|
}
|
|
} else if (setting.type === 'organization') {
|
|
formData[setting.key] = setting.value ? setting.value : null;
|
|
} else {
|
|
formData[setting.key] = setting.value;
|
|
}
|
|
|
|
// Add value_url helper for images
|
|
if (setting.type === 'image' && setting.value) {
|
|
setting.value_url = `/RequestData/File/${setting.value}`;
|
|
}
|
|
|
|
// Parse options for select type
|
|
if (setting.type === 'select' && setting.options) {
|
|
try {
|
|
setting.options_parsed = typeof setting.options === 'string' ? JSON.parse(setting.options) : setting.options;
|
|
} catch (e) {
|
|
setting.options_parsed = [];
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Set default active group
|
|
if (Object.keys(groupedSettings.value).length > 0 && !activeGroup.value) {
|
|
activeGroup.value = Object.keys(groupedSettings.value)[0];
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.text-primary-gradient {
|
|
background: linear-gradient(135deg, #0d6efd 0%, #00d2ff 100%);
|
|
-webkit-background-clip: text;
|
|
background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
}
|
|
|
|
.btn-primary-gradient {
|
|
background: linear-gradient(135deg, #0d6efd 0%, #0db9fd 100%);
|
|
border: none;
|
|
color: white;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.btn-primary-gradient:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 10px 20px rgba(13, 110, 253, 0.3);
|
|
}
|
|
|
|
.ls-tight { letter-spacing: -0.025em; }
|
|
.ls-wide { letter-spacing: 0.1em; }
|
|
.fw-black { font-weight: 900; }
|
|
|
|
.h-48px { height: 48px; }
|
|
|
|
|
|
.animate-fade-in {
|
|
animation: fadeIn 0.8s ease-out;
|
|
}
|
|
|
|
.animate-slide-up {
|
|
animation: slideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; }
|
|
to { opacity: 1; }
|
|
}
|
|
|
|
@keyframes slideUp {
|
|
from { opacity: 0; transform: translateY(20px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
@keyframes slideDown {
|
|
from { opacity: 0; transform: translateY(-20px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
.animate-slide-down {
|
|
animation: slideDown 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
|
}
|
|
|
|
.pulse-ring {
|
|
width: 80px;
|
|
height: 80px;
|
|
background: rgba(13, 110, 253, 0.1);
|
|
border-radius: 50%;
|
|
animation: pulse 2s infinite;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0% { transform: translate(-50%, -50%) scale(1); opacity: 0.5; }
|
|
100% { transform: translate(-50%, -50%) scale(1.5); opacity: 0; }
|
|
}
|
|
|
|
.hover-bg-light:hover {
|
|
background-color: #f8f9fa;
|
|
}
|
|
|
|
.transition-all {
|
|
transition: all 0.2s ease-in-out;
|
|
}
|
|
|
|
.custom-switch .form-check-input {
|
|
width: 3rem;
|
|
height: 1.5rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.custom-switch .form-check-input:checked {
|
|
background-color: #0d6efd;
|
|
border-color: #0d6efd;
|
|
}
|
|
|
|
.shadow-primary-lg {
|
|
box-shadow: 0 15px 30px rgba(13, 110, 253, 0.2);
|
|
}
|
|
|
|
.animate-spin-slow {
|
|
animation: spin 8s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
from { transform: rotate(0deg); }
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.logo-preview {
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.logo-preview:hover {
|
|
transform: scale(1.05);
|
|
}
|
|
|
|
.bg-soft-primary {
|
|
background-color: rgba(13, 110, 253, 0.1);
|
|
}
|
|
|
|
.hover-text-danger:hover {
|
|
color: #dc3545 !important;
|
|
}
|
|
|
|
.cursor-pointer {
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Fixed header that stays at the top */
|
|
.sticky-top-header {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
z-index: 1030;
|
|
}
|
|
</style>
|