initial: bootstrap from BukidBountyApp base
This commit is contained in:
545
resources/js/Pages/SystemSettings.vue
Normal file
545
resources/js/Pages/SystemSettings.vue
Normal file
@@ -0,0 +1,545 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user