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>
|
||||
<div class="fixed bottom-0 left-0 right-0 h-16 group md:h-16">
|
||||
<!-- Invisible hover trigger area for desktop -->
|
||||
<div class="absolute inset-0 hidden md:block"></div>
|
||||
<div
|
||||
v-if="settingsStore.settings.ui.navigationMode !== 'hidden'"
|
||||
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 -->
|
||||
<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">
|
||||
<ul
|
||||
@@ -67,9 +74,12 @@
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
class="flex flex-col items-center text-gray-400 cursor-not-allowed"
|
||||
aria-disabled="true"
|
||||
<router-link
|
||||
to="/settings"
|
||||
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">
|
||||
<path
|
||||
@@ -86,7 +96,7 @@
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-xs">Settings</span>
|
||||
</a>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -94,4 +104,32 @@
|
||||
</div>
|
||||
</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>
|
||||
Reference in New Issue
Block a user