Add settings page and store with UI customization options
- Implement SettingsView with appearance, data, notifications, and advanced tabs - Add settings store (Pinia) for theme, navigation, notifications, and app config - Integrate settings store into HomeView and BottomNav for theme and navigation mode - Add room management modal and store methods for adding/removing rooms - Update SensorManagementView with room management button and modal - Support exporting/importing settings and resetting to defaults - Enable dark mode via Tailwind config
This commit is contained in:
@@ -1,10 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fixed bottom-0 left-0 right-0 h-16 group md:h-16">
|
<div
|
||||||
<!-- Invisible hover trigger area for desktop -->
|
v-if="settingsStore.settings.ui.navigationMode !== 'hidden'"
|
||||||
<div class="absolute inset-0 hidden md:block"></div>
|
class="fixed bottom-0 left-0 right-0 h-16 group md:h-16"
|
||||||
|
>
|
||||||
|
<!-- Invisible hover trigger area for desktop (only for hover mode) -->
|
||||||
|
<div
|
||||||
|
v-if="settingsStore.settings.ui.navigationMode === 'hover'"
|
||||||
|
class="absolute inset-0 hidden md:block"
|
||||||
|
></div>
|
||||||
<!-- Navigation bar -->
|
<!-- Navigation bar -->
|
||||||
<nav
|
<nav
|
||||||
class="absolute bottom-0 left-0 right-0 transform-none md:transform md:translate-y-full md:group-hover:translate-y-0 md:transition-transform md:duration-300 md:ease-in-out bg-white md:bg-transparent border-t md:border-t-0 border-gray-200 md:shadow-none shadow-lg"
|
class="absolute bottom-0 left-0 right-0 bg-white md:bg-transparent border-t md:border-t-0 border-gray-200 md:shadow-none shadow-lg"
|
||||||
|
:class="getNavigationClasses()"
|
||||||
>
|
>
|
||||||
<div class="flex justify-center md:pb-4 pb-2">
|
<div class="flex justify-center md:pb-4 pb-2">
|
||||||
<ul
|
<ul
|
||||||
@@ -67,9 +74,12 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<router-link
|
||||||
class="flex flex-col items-center text-gray-400 cursor-not-allowed"
|
to="/settings"
|
||||||
aria-disabled="true"
|
class="flex flex-col items-center font-medium"
|
||||||
|
:class="
|
||||||
|
$route.name === 'settings' ? 'text-green-700' : 'text-gray-600 hover:text-green-700'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<svg class="w-6 h-6 mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
@@ -86,7 +96,7 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-xs">Settings</span>
|
<span class="text-xs">Settings</span>
|
||||||
</a>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,4 +104,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts"></script>
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
|
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
|
// Compute navigation classes based on settings
|
||||||
|
const getNavigationClasses = () => {
|
||||||
|
const mode = settingsStore.settings.ui.navigationMode
|
||||||
|
|
||||||
|
if (mode === 'always') {
|
||||||
|
// Always visible - no transform on desktop
|
||||||
|
return 'transform-none md:transform-none'
|
||||||
|
} else if (mode === 'hover') {
|
||||||
|
// Hover mode - original behavior
|
||||||
|
return 'transform-none md:transform md:translate-y-full md:group-hover:translate-y-0 md:transition-transform md:duration-300 md:ease-in-out'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default fallback
|
||||||
|
return 'transform-none md:transform md:translate-y-full md:group-hover:translate-y-0 md:transition-transform md:duration-300 md:ease-in-out'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize settings store
|
||||||
|
onMounted(() => {
|
||||||
|
if (!settingsStore.lastSaved) {
|
||||||
|
settingsStore.initialize()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|||||||
299
src/components/modals/RoomManagementModal.vue
Normal file
299
src/components/modals/RoomManagementModal.vue
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||||
|
<div class="bg-white rounded-xl max-w-2xl w-full max-h-[90vh] overflow-hidden">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="p-6 border-b border-gray-200">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900">Room Management</h2>
|
||||||
|
<button
|
||||||
|
@click="$emit('close')"
|
||||||
|
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600 mt-2">Create new rooms and manage existing ones</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="p-6 max-h-[calc(90vh-140px)] overflow-y-auto">
|
||||||
|
<!-- Add New Room Section -->
|
||||||
|
<div class="bg-blue-50 rounded-lg p-4 mb-6">
|
||||||
|
<h3 class="font-medium text-gray-900 mb-3">Add New Room</h3>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div class="flex-1">
|
||||||
|
<input
|
||||||
|
v-model="newRoomName"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter room name (e.g., Meeting Room 3, Lab A, etc.)"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
@keyup.enter="addNewRoom"
|
||||||
|
:class="{ 'border-red-300 focus:ring-red-500 focus:border-red-500': errorMessage }"
|
||||||
|
/>
|
||||||
|
<p v-if="errorMessage" class="text-red-600 text-sm mt-1">{{ errorMessage }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="addNewRoom"
|
||||||
|
:disabled="!newRoomName.trim() || isAdding"
|
||||||
|
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<span v-if="isAdding">Adding...</span>
|
||||||
|
<span v-else>Add Room</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Existing Rooms Section -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="font-medium text-gray-900">Existing Rooms</h3>
|
||||||
|
<span class="text-sm text-gray-500">{{ roomsWithStats.length }} rooms total</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-3">
|
||||||
|
<div
|
||||||
|
v-for="room in roomsWithStats"
|
||||||
|
:key="room.name"
|
||||||
|
class="bg-gray-50 rounded-lg p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<h4 class="font-medium text-gray-900">{{ room.name }}</h4>
|
||||||
|
<span class="text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded-full font-medium">
|
||||||
|
{{ room.sensorCount }} sensors
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-600">Types:</span>
|
||||||
|
<div class="flex flex-wrap gap-1 mt-1">
|
||||||
|
<span
|
||||||
|
v-for="type in room.sensorTypes"
|
||||||
|
:key="type"
|
||||||
|
class="text-xs px-2 py-0.5 bg-gray-200 text-gray-700 rounded"
|
||||||
|
>
|
||||||
|
{{ type }}
|
||||||
|
</span>
|
||||||
|
<span v-if="room.sensorTypes.length === 0" class="text-xs text-gray-500">None</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-600">Energy:</span>
|
||||||
|
<div class="font-medium" :class="room.hasMetrics ? 'text-gray-900' : 'text-gray-400'">
|
||||||
|
{{ room.hasMetrics ? room.energyConsumption.toFixed(2) + ' kWh' : 'No data' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-600">CO2:</span>
|
||||||
|
<div class="font-medium" :class="getCO2Color(room.co2Level)">
|
||||||
|
{{ room.hasMetrics ? Math.round(room.co2Level) + ' ppm' : 'No data' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-600">Last Update:</span>
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
{{ room.lastUpdated ? formatTime(room.lastUpdated) : 'Never' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ml-4">
|
||||||
|
<button
|
||||||
|
@click="confirmDeleteRoom(room.name)"
|
||||||
|
:disabled="isDeleting === room.name"
|
||||||
|
class="px-3 py-1.5 text-red-600 hover:bg-red-50 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
:class="{ 'opacity-50 cursor-not-allowed': isDeleting === room.name }"
|
||||||
|
>
|
||||||
|
<span v-if="isDeleting === room.name">Deleting...</span>
|
||||||
|
<span v-else>Delete</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-if="roomsWithStats.length === 0" class="text-center py-8">
|
||||||
|
<div class="text-gray-400 text-4xl mb-2">🏢</div>
|
||||||
|
<p class="text-gray-600">No rooms created yet</p>
|
||||||
|
<p class="text-gray-500 text-sm">Add your first room above to get started</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="p-6 border-t border-gray-200 bg-gray-50">
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
@click="$emit('close')"
|
||||||
|
class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<div v-if="roomToDelete" class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-60">
|
||||||
|
<div class="bg-white rounded-xl max-w-md w-full p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">Delete Room</h3>
|
||||||
|
<p class="text-gray-600 mb-4">
|
||||||
|
Are you sure you want to delete <strong>"{{ roomToDelete }}"</strong>?
|
||||||
|
{{ getRoomStats(roomToDelete).sensorCount > 0
|
||||||
|
? `This will unassign ${getRoomStats(roomToDelete).sensorCount} sensor(s).`
|
||||||
|
: 'This action cannot be undone.' }}
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
@click="roomToDelete = null"
|
||||||
|
class="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="deleteRoom"
|
||||||
|
class="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Delete Room
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useEnergyStore } from '@/stores/energy'
|
||||||
|
|
||||||
|
const energyStore = useEnergyStore()
|
||||||
|
|
||||||
|
// Emit events
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Component state
|
||||||
|
const newRoomName = ref('')
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const isAdding = ref(false)
|
||||||
|
const isDeleting = ref<string | null>(null)
|
||||||
|
const roomToDelete = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Computed properties
|
||||||
|
const roomsWithStats = computed(() => {
|
||||||
|
return energyStore.getAllRoomsWithStats()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const addNewRoom = async () => {
|
||||||
|
if (!newRoomName.value.trim()) {
|
||||||
|
errorMessage.value = 'Room name is required'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newRoomName.value.trim().length < 2) {
|
||||||
|
errorMessage.value = 'Room name must be at least 2 characters'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newRoomName.value.trim().length > 50) {
|
||||||
|
errorMessage.value = 'Room name must be less than 50 characters'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdding.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = energyStore.addRoom(newRoomName.value.trim())
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
newRoomName.value = ''
|
||||||
|
// Show success feedback
|
||||||
|
console.log('Room added successfully')
|
||||||
|
} else {
|
||||||
|
errorMessage.value = 'Room name already exists'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = 'Failed to add room'
|
||||||
|
console.error('Error adding room:', error)
|
||||||
|
} finally {
|
||||||
|
isAdding.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDeleteRoom = (roomName: string) => {
|
||||||
|
roomToDelete.value = roomName
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteRoom = async () => {
|
||||||
|
if (!roomToDelete.value) return
|
||||||
|
|
||||||
|
isDeleting.value = roomToDelete.value
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = energyStore.removeRoom(roomToDelete.value)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
console.log(`Room "${roomToDelete.value}" deleted successfully`)
|
||||||
|
} else {
|
||||||
|
console.error('Failed to delete room')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting room:', error)
|
||||||
|
} finally {
|
||||||
|
isDeleting.value = null
|
||||||
|
roomToDelete.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRoomStats = (roomName: string) => {
|
||||||
|
return energyStore.getRoomStats(roomName)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCO2Color = (co2Level: number) => {
|
||||||
|
if (co2Level === 0) return 'text-gray-400'
|
||||||
|
if (co2Level < 400) return 'text-green-600'
|
||||||
|
if (co2Level < 1000) return 'text-yellow-600'
|
||||||
|
if (co2Level < 5000) return 'text-orange-600'
|
||||||
|
return 'text-red-600'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (timestamp: number) => {
|
||||||
|
const date = new Date(timestamp * 1000)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
const diffSecs = Math.floor(diffMs / 1000)
|
||||||
|
|
||||||
|
if (diffSecs < 60) {
|
||||||
|
return `${diffSecs}s ago`
|
||||||
|
} else if (diffSecs < 3600) {
|
||||||
|
return `${Math.floor(diffSecs / 60)}m ago`
|
||||||
|
} else if (diffSecs < 86400) {
|
||||||
|
return `${Math.floor(diffSecs / 3600)}h ago`
|
||||||
|
} else {
|
||||||
|
return date.toLocaleDateString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear error when user starts typing
|
||||||
|
const clearError = () => {
|
||||||
|
if (errorMessage.value) {
|
||||||
|
errorMessage.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for changes in newRoomName to clear errors
|
||||||
|
import { watch } from 'vue'
|
||||||
|
watch(newRoomName, clearError)
|
||||||
|
</script>
|
||||||
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||||||
import HomeView from '../views/HomeView.vue'
|
import HomeView from '../views/HomeView.vue'
|
||||||
import SensorManagementView from '../views/SensorManagementView.vue'
|
import SensorManagementView from '../views/SensorManagementView.vue'
|
||||||
import AIOptimizationView from '../views/AIOptimizationView.vue'
|
import AIOptimizationView from '../views/AIOptimizationView.vue'
|
||||||
|
import SettingsView from '../views/SettingsView.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
@@ -21,6 +22,11 @@ const router = createRouter({
|
|||||||
name: 'ai-optimization',
|
name: 'ai-optimization',
|
||||||
component: AIOptimizationView,
|
component: AIOptimizationView,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
name: 'settings',
|
||||||
|
component: SettingsView,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -425,6 +425,68 @@ export const useEnergyStore = defineStore('energy', () => {
|
|||||||
return Array.from(sensorDevices.values()).filter(sensor => sensor.type === type)
|
return Array.from(sensorDevices.values()).filter(sensor => sensor.type === type)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Room management functions
|
||||||
|
function addRoom(roomName: string): boolean {
|
||||||
|
if (!roomName.trim()) return false
|
||||||
|
|
||||||
|
// Check if room already exists
|
||||||
|
if (availableRooms.value.includes(roomName.trim())) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add room to available rooms list
|
||||||
|
availableRooms.value.push(roomName.trim())
|
||||||
|
availableRooms.value.sort() // Keep rooms sorted alphabetically
|
||||||
|
|
||||||
|
console.log(`Added new room: ${roomName}`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRoom(roomName: string): boolean {
|
||||||
|
const index = availableRooms.value.indexOf(roomName)
|
||||||
|
if (index === -1) return false
|
||||||
|
|
||||||
|
// Check if any sensors are assigned to this room
|
||||||
|
const sensorsInRoom = Array.from(sensorDevices.values()).filter(sensor => sensor.room === roomName)
|
||||||
|
if (sensorsInRoom.length > 0) {
|
||||||
|
// Reassign sensors to 'Unassigned'
|
||||||
|
sensorsInRoom.forEach(sensor => {
|
||||||
|
sensor.room = ''
|
||||||
|
sensorDevices.set(sensor.id, { ...sensor })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove room data
|
||||||
|
roomsData.delete(roomName)
|
||||||
|
|
||||||
|
// Remove from available rooms
|
||||||
|
availableRooms.value.splice(index, 1)
|
||||||
|
|
||||||
|
console.log(`Removed room: ${roomName}`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoomStats(roomName: string) {
|
||||||
|
const sensorsInRoom = getSensorsByRoom(roomName)
|
||||||
|
const roomMetrics = roomsData.get(roomName)
|
||||||
|
|
||||||
|
return {
|
||||||
|
sensorCount: sensorsInRoom.length,
|
||||||
|
sensorTypes: [...new Set(sensorsInRoom.map(s => s.type))],
|
||||||
|
hasMetrics: !!roomMetrics,
|
||||||
|
energyConsumption: roomMetrics?.energy.current || 0,
|
||||||
|
co2Level: roomMetrics?.co2.current || 0,
|
||||||
|
lastUpdated: roomMetrics?.lastUpdated || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllRoomsWithStats() {
|
||||||
|
return availableRooms.value.map(room => ({
|
||||||
|
name: room,
|
||||||
|
...getRoomStats(room)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize mock sensors on store creation
|
// Initialize mock sensors on store creation
|
||||||
initializeMockSensors()
|
initializeMockSensors()
|
||||||
|
|
||||||
@@ -443,6 +505,10 @@ export const useEnergyStore = defineStore('energy', () => {
|
|||||||
updateSensorRoom,
|
updateSensorRoom,
|
||||||
executeSensorAction,
|
executeSensorAction,
|
||||||
getSensorsByRoom,
|
getSensorsByRoom,
|
||||||
getSensorsByType
|
getSensorsByType,
|
||||||
|
addRoom,
|
||||||
|
removeRoom,
|
||||||
|
getRoomStats,
|
||||||
|
getAllRoomsWithStats
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
282
src/stores/settings.ts
Normal file
282
src/stores/settings.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, reactive, watch } from 'vue'
|
||||||
|
|
||||||
|
export type NavigationMode = 'hover' | 'always' | 'hidden'
|
||||||
|
export type Theme = 'light' | 'dark' | 'system'
|
||||||
|
export type Language = 'en' | 'es' | 'fr' | 'de'
|
||||||
|
|
||||||
|
interface NotificationSettings {
|
||||||
|
enabled: boolean
|
||||||
|
sound: boolean
|
||||||
|
desktop: boolean
|
||||||
|
email: boolean
|
||||||
|
criticalOnly: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UISettings {
|
||||||
|
navigationMode: NavigationMode
|
||||||
|
compactMode: boolean
|
||||||
|
showAnimations: boolean
|
||||||
|
autoRefresh: boolean
|
||||||
|
refreshInterval: number // seconds
|
||||||
|
dateFormat: 'relative' | 'absolute'
|
||||||
|
timeFormat: '12h' | '24h'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppSettings {
|
||||||
|
theme: Theme
|
||||||
|
language: Language
|
||||||
|
ui: UISettings
|
||||||
|
notifications: NotificationSettings
|
||||||
|
autoConnect: boolean
|
||||||
|
websocketUrl: string
|
||||||
|
developerMode: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: AppSettings = {
|
||||||
|
theme: 'system',
|
||||||
|
language: 'en',
|
||||||
|
ui: {
|
||||||
|
navigationMode: 'hover',
|
||||||
|
compactMode: false,
|
||||||
|
showAnimations: true,
|
||||||
|
autoRefresh: true,
|
||||||
|
refreshInterval: 5,
|
||||||
|
dateFormat: 'relative',
|
||||||
|
timeFormat: '12h'
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
enabled: true,
|
||||||
|
sound: true,
|
||||||
|
desktop: false,
|
||||||
|
email: false,
|
||||||
|
criticalOnly: false
|
||||||
|
},
|
||||||
|
autoConnect: true,
|
||||||
|
websocketUrl: 'ws://192.168.1.73:8000/ws',
|
||||||
|
developerMode: false
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSettingsStore = defineStore('settings', () => {
|
||||||
|
// State
|
||||||
|
const settings = reactive<AppSettings>({ ...DEFAULT_SETTINGS })
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const lastSaved = ref<Date | null>(null)
|
||||||
|
|
||||||
|
// Local storage key
|
||||||
|
const STORAGE_KEY = 'dashboard-settings'
|
||||||
|
|
||||||
|
// Load settings from localStorage
|
||||||
|
function loadSettings() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored)
|
||||||
|
// Merge with defaults to handle new settings
|
||||||
|
Object.assign(settings, { ...DEFAULT_SETTINGS, ...parsed })
|
||||||
|
console.log('Settings loaded from localStorage')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load settings:', error)
|
||||||
|
resetToDefaults()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save settings to localStorage
|
||||||
|
function saveSettings() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
|
||||||
|
lastSaved.value = new Date()
|
||||||
|
console.log('Settings saved to localStorage')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save settings:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset to default settings
|
||||||
|
function resetToDefaults() {
|
||||||
|
Object.assign(settings, DEFAULT_SETTINGS)
|
||||||
|
saveSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update specific setting
|
||||||
|
function updateSetting(path: string, value: any) {
|
||||||
|
const keys = path.split('.')
|
||||||
|
let current: any = settings
|
||||||
|
|
||||||
|
for (let i = 0; i < keys.length - 1; i++) {
|
||||||
|
current = current[keys[i]]
|
||||||
|
}
|
||||||
|
|
||||||
|
current[keys[keys.length - 1]] = value
|
||||||
|
saveSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get setting value by path
|
||||||
|
function getSetting(path: string): any {
|
||||||
|
const keys = path.split('.')
|
||||||
|
let current: any = settings
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
current = current[key]
|
||||||
|
if (current === undefined) break
|
||||||
|
}
|
||||||
|
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export settings
|
||||||
|
function exportSettings(): string {
|
||||||
|
return JSON.stringify(settings, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import settings
|
||||||
|
function importSettings(settingsJson: string): boolean {
|
||||||
|
try {
|
||||||
|
const imported = JSON.parse(settingsJson)
|
||||||
|
// Validate structure
|
||||||
|
if (typeof imported === 'object' && imported !== null) {
|
||||||
|
Object.assign(settings, { ...DEFAULT_SETTINGS, ...imported })
|
||||||
|
saveSettings()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to import settings:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme helpers
|
||||||
|
function applyTheme() {
|
||||||
|
const root = document.documentElement
|
||||||
|
|
||||||
|
if (settings.theme === 'dark') {
|
||||||
|
root.classList.add('dark')
|
||||||
|
} else if (settings.theme === 'light') {
|
||||||
|
root.classList.remove('dark')
|
||||||
|
} else {
|
||||||
|
// System theme
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
root.classList.toggle('dark', prefersDark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket URL validation
|
||||||
|
function isValidWebSocketUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
return parsed.protocol === 'ws:' || parsed.protocol === 'wss:'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification permission handling
|
||||||
|
async function requestNotificationPermission(): Promise<boolean> {
|
||||||
|
if (!('Notification' in window)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Notification.permission === 'granted') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Notification.permission === 'denied') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await Notification.requestPermission()
|
||||||
|
return permission === 'granted'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize store
|
||||||
|
function initialize() {
|
||||||
|
loadSettings()
|
||||||
|
applyTheme()
|
||||||
|
|
||||||
|
// Watch for theme changes
|
||||||
|
watch(() => settings.theme, applyTheme, { immediate: true })
|
||||||
|
|
||||||
|
// Watch for system theme changes
|
||||||
|
if (settings.theme === 'system') {
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
mediaQuery.addEventListener('change', applyTheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-save on changes (debounced)
|
||||||
|
let saveTimeout: number | undefined
|
||||||
|
watch(settings, () => {
|
||||||
|
if (saveTimeout) clearTimeout(saveTimeout)
|
||||||
|
saveTimeout = window.setTimeout(saveSettings, 500)
|
||||||
|
}, { deep: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get available languages
|
||||||
|
function getAvailableLanguages() {
|
||||||
|
return [
|
||||||
|
{ code: 'en', name: 'English', nativeName: 'English' },
|
||||||
|
{ code: 'es', name: 'Spanish', nativeName: 'Español' },
|
||||||
|
{ code: 'fr', name: 'French', nativeName: 'Français' },
|
||||||
|
{ code: 'de', name: 'German', nativeName: 'Deutsch' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get theme options
|
||||||
|
function getThemeOptions() {
|
||||||
|
return [
|
||||||
|
{ value: 'system', label: 'System Default', icon: '🔄' },
|
||||||
|
{ value: 'light', label: 'Light Mode', icon: '☀️' },
|
||||||
|
{ value: 'dark', label: 'Dark Mode', icon: '🌙' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get navigation mode options
|
||||||
|
function getNavigationModeOptions() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
value: 'hover',
|
||||||
|
label: 'Show on Hover',
|
||||||
|
description: 'Navigation appears when hovering near bottom (desktop only)',
|
||||||
|
icon: '👆'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'always',
|
||||||
|
label: 'Always Visible',
|
||||||
|
description: 'Navigation is permanently visible',
|
||||||
|
icon: '👁️'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'hidden',
|
||||||
|
label: 'Hidden',
|
||||||
|
description: 'Navigation is completely hidden',
|
||||||
|
icon: '🫥'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
settings,
|
||||||
|
isLoading,
|
||||||
|
lastSaved,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
loadSettings,
|
||||||
|
saveSettings,
|
||||||
|
resetToDefaults,
|
||||||
|
updateSetting,
|
||||||
|
getSetting,
|
||||||
|
exportSettings,
|
||||||
|
importSettings,
|
||||||
|
applyTheme,
|
||||||
|
isValidWebSocketUrl,
|
||||||
|
requestNotificationPermission,
|
||||||
|
initialize,
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
getAvailableLanguages,
|
||||||
|
getThemeOptions,
|
||||||
|
getNavigationModeOptions
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -66,9 +66,11 @@ import SensorConsumptionTable from '@/components/cards/SensorConsumptionTable.vu
|
|||||||
import RoomMetricsCard from '@/components/cards/RoomMetricsCard.vue'
|
import RoomMetricsCard from '@/components/cards/RoomMetricsCard.vue'
|
||||||
import AirQualityCard from '@/components/cards/AirQualityCard.vue'
|
import AirQualityCard from '@/components/cards/AirQualityCard.vue'
|
||||||
import { useEnergyStore } from '@/stores/energy'
|
import { useEnergyStore } from '@/stores/energy'
|
||||||
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
import { computed, onMounted, onUnmounted } from 'vue'
|
import { computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
const energyStore = useEnergyStore()
|
const energyStore = useEnergyStore()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
const currentEnergyValue = computed(() => {
|
const currentEnergyValue = computed(() => {
|
||||||
return energyStore.latestMessage?.value?.toFixed(2) || '0.00'
|
return energyStore.latestMessage?.value?.toFixed(2) || '0.00'
|
||||||
@@ -82,7 +84,13 @@ const averageEnergyUsage = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
energyStore.connect('ws://192.168.1.73:8000/ws')
|
// Initialize settings
|
||||||
|
settingsStore.initialize()
|
||||||
|
|
||||||
|
// Auto-connect based on settings
|
||||||
|
if (settingsStore.settings.autoConnect) {
|
||||||
|
energyStore.connect(settingsStore.settings.websocketUrl)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|||||||
@@ -23,12 +23,24 @@
|
|||||||
<div class="flex flex-col lg:flex-row gap-4">
|
<div class="flex flex-col lg:flex-row gap-4">
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="flex flex-col sm:flex-row gap-4 flex-1">
|
<div class="flex flex-col sm:flex-row gap-4 flex-1">
|
||||||
<select v-model="selectedRoom" class="px-4 py-2 border border-gray-200 rounded-lg bg-white">
|
<div class="flex gap-2">
|
||||||
<option value="">All Rooms</option>
|
<select v-model="selectedRoom" class="px-4 py-2 border border-gray-200 rounded-lg bg-white flex-1">
|
||||||
<option v-for="room in energyStore.availableRooms" :key="room" :value="room">
|
<option value="">All Rooms</option>
|
||||||
{{ room }}
|
<option v-for="room in energyStore.availableRooms" :key="room" :value="room">
|
||||||
</option>
|
{{ room }}
|
||||||
</select>
|
</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
@click="showRoomManagementModal = true"
|
||||||
|
class="px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-1"
|
||||||
|
title="Manage Rooms"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
|
</svg>
|
||||||
|
<span class="hidden sm:inline">Rooms</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<select v-model="selectedType" class="px-4 py-2 border border-gray-200 rounded-lg bg-white">
|
<select v-model="selectedType" class="px-4 py-2 border border-gray-200 rounded-lg bg-white">
|
||||||
<option value="">All Types</option>
|
<option value="">All Types</option>
|
||||||
@@ -187,6 +199,12 @@
|
|||||||
@execute="handleActionExecute"
|
@execute="handleActionExecute"
|
||||||
@close="closeActionModal"
|
@close="closeActionModal"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Room Management Modal -->
|
||||||
|
<RoomManagementModal
|
||||||
|
v-if="showRoomManagementModal"
|
||||||
|
@close="showRoomManagementModal = false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -194,6 +212,7 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useEnergyStore } from '@/stores/energy'
|
import { useEnergyStore } from '@/stores/energy'
|
||||||
import ActionModal from '@/components/modals/ActionModal.vue'
|
import ActionModal from '@/components/modals/ActionModal.vue'
|
||||||
|
import RoomManagementModal from '@/components/modals/RoomManagementModal.vue'
|
||||||
import SimpleSensorCard from '@/components/cards/SimpleSensorCard.vue'
|
import SimpleSensorCard from '@/components/cards/SimpleSensorCard.vue'
|
||||||
import DetailedSensorCard from '@/components/cards/DetailedSensorCard.vue'
|
import DetailedSensorCard from '@/components/cards/DetailedSensorCard.vue'
|
||||||
|
|
||||||
@@ -213,6 +232,9 @@ const selectedSensor = ref<any>(null)
|
|||||||
const selectedAction = ref<any>(null)
|
const selectedAction = ref<any>(null)
|
||||||
const isExecutingAction = ref(false)
|
const isExecutingAction = ref(false)
|
||||||
|
|
||||||
|
// Room management modal
|
||||||
|
const showRoomManagementModal = ref(false)
|
||||||
|
|
||||||
const sensorList = computed(() => {
|
const sensorList = computed(() => {
|
||||||
return Array.from(energyStore.sensorDevices.values()).sort((a, b) => a.name.localeCompare(b.name))
|
return Array.from(energyStore.sensorDevices.values()).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
})
|
})
|
||||||
|
|||||||
511
src/views/SettingsView.vue
Normal file
511
src/views/SettingsView.vue
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Settings</h1>
|
||||||
|
<p class="text-gray-600">Customize your dashboard experience and application preferences</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 sm:mt-0 flex items-center gap-3">
|
||||||
|
<span v-if="settingsStore.lastSaved" class="text-xs text-gray-500">
|
||||||
|
Last saved: {{ formatTime(settingsStore.lastSaved) }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
@click="showResetDialog = true"
|
||||||
|
class="px-3 py-1.5 text-red-600 hover:bg-red-50 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Reset All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<!-- Settings Navigation -->
|
||||||
|
<div class="lg:col-span-1">
|
||||||
|
<nav class="bg-white rounded-xl shadow-sm border border-gray-100 p-4 sticky top-4">
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li v-for="section in settingSections" :key="section.id">
|
||||||
|
<button
|
||||||
|
@click="activeSection = section.id"
|
||||||
|
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-colors"
|
||||||
|
:class="activeSection === section.id
|
||||||
|
? 'bg-blue-100 text-blue-700'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100'"
|
||||||
|
>
|
||||||
|
<span class="text-lg">{{ section.icon }}</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{{ section.name }}</div>
|
||||||
|
<div class="text-xs opacity-75">{{ section.description }}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Content -->
|
||||||
|
<div class="lg:col-span-2 space-y-6">
|
||||||
|
<!-- Appearance Settings -->
|
||||||
|
<div v-if="activeSection === 'appearance'" class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||||
|
<div class="p-6 border-b border-gray-100">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">Appearance</h3>
|
||||||
|
<p class="text-gray-600 text-sm mt-1">Customize the look and feel of your dashboard</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 space-y-6">
|
||||||
|
<!-- Theme Selection -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-3">Theme</label>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<div
|
||||||
|
v-for="theme in settingsStore.getThemeOptions()"
|
||||||
|
:key="theme.value"
|
||||||
|
@click="settingsStore.updateSetting('theme', theme.value)"
|
||||||
|
class="relative p-4 border-2 rounded-lg cursor-pointer transition-all hover:border-blue-300"
|
||||||
|
:class="settingsStore.settings.theme === theme.value
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: 'border-gray-200'"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-lg">{{ theme.icon }}</span>
|
||||||
|
<span class="font-medium text-gray-900">{{ theme.label }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="settingsStore.settings.theme === theme.value" class="text-blue-600">
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation Mode -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-3">Bottom Navigation</label>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="mode in settingsStore.getNavigationModeOptions()"
|
||||||
|
:key="mode.value"
|
||||||
|
@click="settingsStore.updateSetting('ui.navigationMode', mode.value)"
|
||||||
|
class="relative p-4 border-2 rounded-lg cursor-pointer transition-all hover:border-blue-300"
|
||||||
|
:class="settingsStore.settings.ui.navigationMode === mode.value
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: 'border-gray-200'"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="text-lg mt-0.5">{{ mode.icon }}</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-900">{{ mode.label }}</div>
|
||||||
|
<div class="text-sm text-gray-600 mt-1">{{ mode.description }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="settingsStore.settings.ui.navigationMode === mode.value" class="text-blue-600 mt-1">
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- UI Options -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div class="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-900">Compact Mode</div>
|
||||||
|
<div class="text-sm text-gray-600">Reduce spacing and padding</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="settingsStore.updateSetting('ui.compactMode', !settingsStore.settings.ui.compactMode)"
|
||||||
|
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
|
:class="settingsStore.settings.ui.compactMode ? 'bg-blue-600' : 'bg-gray-200'"
|
||||||
|
>
|
||||||
|
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||||
|
:class="settingsStore.settings.ui.compactMode ? 'translate-x-6' : 'translate-x-1'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-900">Animations</div>
|
||||||
|
<div class="text-sm text-gray-600">Enable smooth transitions</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="settingsStore.updateSetting('ui.showAnimations', !settingsStore.settings.ui.showAnimations)"
|
||||||
|
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
|
:class="settingsStore.settings.ui.showAnimations ? 'bg-blue-600' : 'bg-gray-200'"
|
||||||
|
>
|
||||||
|
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||||
|
:class="settingsStore.settings.ui.showAnimations ? 'translate-x-6' : 'translate-x-1'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data & Sync Settings -->
|
||||||
|
<div v-if="activeSection === 'data'" class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||||
|
<div class="p-6 border-b border-gray-100">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">Data & Synchronization</h3>
|
||||||
|
<p class="text-gray-600 text-sm mt-1">Configure data refresh and connection settings</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 space-y-6">
|
||||||
|
<!-- Auto Refresh -->
|
||||||
|
<div class="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-900">Auto Refresh</div>
|
||||||
|
<div class="text-sm text-gray-600">Automatically refresh data periodically</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="settingsStore.updateSetting('ui.autoRefresh', !settingsStore.settings.ui.autoRefresh)"
|
||||||
|
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
|
:class="settingsStore.settings.ui.autoRefresh ? 'bg-blue-600' : 'bg-gray-200'"
|
||||||
|
>
|
||||||
|
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||||
|
:class="settingsStore.settings.ui.autoRefresh ? 'translate-x-6' : 'translate-x-1'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Refresh Interval -->
|
||||||
|
<div v-if="settingsStore.settings.ui.autoRefresh">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-3">Refresh Interval</label>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="60"
|
||||||
|
:value="settingsStore.settings.ui.refreshInterval"
|
||||||
|
@input="settingsStore.updateSetting('ui.refreshInterval', parseInt(($event.target as HTMLInputElement).value))"
|
||||||
|
class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
|
||||||
|
/>
|
||||||
|
<div class="bg-gray-100 px-3 py-1 rounded text-sm font-medium min-w-[80px] text-center">
|
||||||
|
{{ settingsStore.settings.ui.refreshInterval }}s
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WebSocket URL -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">WebSocket URL</label>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<input
|
||||||
|
v-model="websocketUrlInput"
|
||||||
|
type="text"
|
||||||
|
placeholder="ws://localhost:8000/ws"
|
||||||
|
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
:class="{ 'border-red-300 focus:ring-red-500 focus:border-red-500': websocketUrlError }"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="updateWebSocketUrl"
|
||||||
|
:disabled="!websocketUrlInput || websocketUrlInput === settingsStore.settings.websocketUrl"
|
||||||
|
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="websocketUrlError" class="text-red-600 text-sm mt-1">{{ websocketUrlError }}</p>
|
||||||
|
<p v-else class="text-gray-500 text-sm mt-1">Current: {{ settingsStore.settings.websocketUrl }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto Connect -->
|
||||||
|
<div class="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-900">Auto Connect</div>
|
||||||
|
<div class="text-sm text-gray-600">Automatically connect to WebSocket on app start</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="settingsStore.updateSetting('autoConnect', !settingsStore.settings.autoConnect)"
|
||||||
|
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
|
:class="settingsStore.settings.autoConnect ? 'bg-blue-600' : 'bg-gray-200'"
|
||||||
|
>
|
||||||
|
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||||
|
:class="settingsStore.settings.autoConnect ? 'translate-x-6' : 'translate-x-1'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notifications Settings -->
|
||||||
|
<div v-if="activeSection === 'notifications'" class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||||
|
<div class="p-6 border-b border-gray-100">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">Notifications</h3>
|
||||||
|
<p class="text-gray-600 text-sm mt-1">Configure how you receive alerts and notifications</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 space-y-6">
|
||||||
|
<!-- Enable Notifications -->
|
||||||
|
<div class="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-900">Enable Notifications</div>
|
||||||
|
<div class="text-sm text-gray-600">Receive system alerts and updates</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="settingsStore.updateSetting('notifications.enabled', !settingsStore.settings.notifications.enabled)"
|
||||||
|
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
|
:class="settingsStore.settings.notifications.enabled ? 'bg-blue-600' : 'bg-gray-200'"
|
||||||
|
>
|
||||||
|
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||||
|
:class="settingsStore.settings.notifications.enabled ? 'translate-x-6' : 'translate-x-1'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="settingsStore.settings.notifications.enabled" class="space-y-4">
|
||||||
|
<!-- Notification Types -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div class="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-900">Sound Alerts</div>
|
||||||
|
<div class="text-sm text-gray-600">Play sound for notifications</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="settingsStore.updateSetting('notifications.sound', !settingsStore.settings.notifications.sound)"
|
||||||
|
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
|
:class="settingsStore.settings.notifications.sound ? 'bg-blue-600' : 'bg-gray-200'"
|
||||||
|
>
|
||||||
|
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||||
|
:class="settingsStore.settings.notifications.sound ? 'translate-x-6' : 'translate-x-1'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-900">Desktop Notifications</div>
|
||||||
|
<div class="text-sm text-gray-600">Show browser notifications</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="toggleDesktopNotifications"
|
||||||
|
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
|
:class="settingsStore.settings.notifications.desktop ? 'bg-blue-600' : 'bg-gray-200'"
|
||||||
|
>
|
||||||
|
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||||
|
:class="settingsStore.settings.notifications.desktop ? 'translate-x-6' : 'translate-x-1'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Critical Only -->
|
||||||
|
<div class="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-900">Critical Alerts Only</div>
|
||||||
|
<div class="text-sm text-gray-600">Only show high-priority notifications</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="settingsStore.updateSetting('notifications.criticalOnly', !settingsStore.settings.notifications.criticalOnly)"
|
||||||
|
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
|
:class="settingsStore.settings.notifications.criticalOnly ? 'bg-blue-600' : 'bg-gray-200'"
|
||||||
|
>
|
||||||
|
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||||
|
:class="settingsStore.settings.notifications.criticalOnly ? 'translate-x-6' : 'translate-x-1'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Advanced Settings -->
|
||||||
|
<div v-if="activeSection === 'advanced'" class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||||
|
<div class="p-6 border-b border-gray-100">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">Advanced</h3>
|
||||||
|
<p class="text-gray-600 text-sm mt-1">Developer options and advanced configuration</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 space-y-6">
|
||||||
|
<!-- Developer Mode -->
|
||||||
|
<div class="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-900">Developer Mode</div>
|
||||||
|
<div class="text-sm text-gray-600">Enable debug logs and development features</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="settingsStore.updateSetting('developerMode', !settingsStore.settings.developerMode)"
|
||||||
|
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
|
:class="settingsStore.settings.developerMode ? 'bg-blue-600' : 'bg-gray-200'"
|
||||||
|
>
|
||||||
|
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||||
|
:class="settingsStore.settings.developerMode ? 'translate-x-6' : 'translate-x-1'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Export/Import Settings -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Export Settings</label>
|
||||||
|
<button
|
||||||
|
@click="exportSettings"
|
||||||
|
class="w-full px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Export Configuration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Import Settings</label>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<textarea
|
||||||
|
v-model="importSettingsJson"
|
||||||
|
placeholder="Paste exported settings JSON here..."
|
||||||
|
rows="3"
|
||||||
|
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="importSettings"
|
||||||
|
:disabled="!importSettingsJson.trim()"
|
||||||
|
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="importError" class="text-red-600 text-sm mt-1">{{ importError }}</p>
|
||||||
|
<p v-if="importSuccess" class="text-green-600 text-sm mt-1">Settings imported successfully!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reset Confirmation Dialog -->
|
||||||
|
<div v-if="showResetDialog" class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||||
|
<div class="bg-white rounded-xl max-w-md w-full p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">Reset All Settings</h3>
|
||||||
|
<p class="text-gray-600 mb-4">
|
||||||
|
Are you sure you want to reset all settings to their default values? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
@click="showResetDialog = false"
|
||||||
|
class="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="resetAllSettings"
|
||||||
|
class="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Reset Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
|
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
|
// Component state
|
||||||
|
const activeSection = ref('appearance')
|
||||||
|
const showResetDialog = ref(false)
|
||||||
|
const websocketUrlInput = ref('')
|
||||||
|
const websocketUrlError = ref('')
|
||||||
|
const importSettingsJson = ref('')
|
||||||
|
const importError = ref('')
|
||||||
|
const importSuccess = ref(false)
|
||||||
|
|
||||||
|
// Settings sections
|
||||||
|
const settingSections = [
|
||||||
|
{
|
||||||
|
id: 'appearance',
|
||||||
|
name: 'Appearance',
|
||||||
|
description: 'Theme & UI',
|
||||||
|
icon: '🎨'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'data',
|
||||||
|
name: 'Data & Sync',
|
||||||
|
description: 'Connection & refresh',
|
||||||
|
icon: '🔄'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'notifications',
|
||||||
|
name: 'Notifications',
|
||||||
|
description: 'Alerts & sounds',
|
||||||
|
icon: '🔔'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'advanced',
|
||||||
|
name: 'Advanced',
|
||||||
|
description: 'Developer options',
|
||||||
|
icon: '⚙️'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const formatTime = (date: Date) => {
|
||||||
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateWebSocketUrl = () => {
|
||||||
|
if (!settingsStore.isValidWebSocketUrl(websocketUrlInput.value)) {
|
||||||
|
websocketUrlError.value = 'Please enter a valid WebSocket URL (ws:// or wss://)'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsStore.updateSetting('websocketUrl', websocketUrlInput.value)
|
||||||
|
websocketUrlError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleDesktopNotifications = async () => {
|
||||||
|
if (!settingsStore.settings.notifications.desktop) {
|
||||||
|
const permission = await settingsStore.requestNotificationPermission()
|
||||||
|
if (permission) {
|
||||||
|
settingsStore.updateSetting('notifications.desktop', true)
|
||||||
|
} else {
|
||||||
|
alert('Notification permission is required to enable desktop notifications.')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
settingsStore.updateSetting('notifications.desktop', false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportSettings = () => {
|
||||||
|
const settingsJson = settingsStore.exportSettings()
|
||||||
|
const blob = new Blob([settingsJson], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `dashboard-settings-${new Date().toISOString().split('T')[0]}.json`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const importSettings = () => {
|
||||||
|
importError.value = ''
|
||||||
|
importSuccess.value = false
|
||||||
|
|
||||||
|
const success = settingsStore.importSettings(importSettingsJson.value)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
importSuccess.value = true
|
||||||
|
importSettingsJson.value = ''
|
||||||
|
setTimeout(() => {
|
||||||
|
importSuccess.value = false
|
||||||
|
}, 3000)
|
||||||
|
} else {
|
||||||
|
importError.value = 'Invalid settings format. Please check the JSON structure.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetAllSettings = () => {
|
||||||
|
settingsStore.resetToDefaults()
|
||||||
|
showResetDialog.value = false
|
||||||
|
websocketUrlInput.value = settingsStore.settings.websocketUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
onMounted(() => {
|
||||||
|
settingsStore.initialize()
|
||||||
|
websocketUrlInput.value = settingsStore.settings.websocketUrl
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -4,6 +4,7 @@ export default {
|
|||||||
"./index.html",
|
"./index.html",
|
||||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||||
],
|
],
|
||||||
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user