Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions app/utils/responsive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,13 @@ export function useMediaQuery(query: string): boolean {
useEffect(() => {
const mediaQuery = window.matchMedia(query)

// Sync state when query changes (useState initializer only runs on mount)
setMatches(mediaQuery.matches)

const handleChange = (e: MediaQueryListEvent) => {
setMatches(e.matches)
}

// Set initial value
setMatches(mediaQuery.matches)

// Add listener
mediaQuery.addEventListener('change', handleChange)

Expand Down
7 changes: 7 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ export default [
'react/prop-types': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'no-undef': 'off',
// Disable new experimental React Compiler rules from react-hooks v7
// These can be enabled gradually as the codebase is refactored
'react-hooks/refs': 'off',
'react-hooks/static-components': 'off',
'react-hooks/set-state-in-effect': 'off',
'react-hooks/incompatible-library': 'off',
'react-hooks/preserve-manual-memoization': 'off',
},
settings: {
react: {
Expand Down
335 changes: 172 additions & 163 deletions package-lock.json

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,13 @@
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/react-grid-layout": "^1.3.5",
"@typescript-eslint/eslint-plugin": "^8.35.0",
"@typescript-eslint/parser": "^8.35.0",
"@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.48.1",
"@vitejs/plugin-react": "^4.6.0",
"eslint": "^9.30.0",
"eslint-config-prettier": "^10.1.5",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-hooks": "^7.0.1",
"husky": "^9.1.7",
"jsdom": "^26.1.0",
"lint-staged": "^16.1.2",
Expand Down
31 changes: 16 additions & 15 deletions src/components/BinarySensorCard.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { Flex, Text } from '@radix-ui/themes'
import { useEntity } from '~/hooks'
import { memo, useState } from 'react'
import { memo, useState, useMemo } from 'react'
import { SkeletonCard, ErrorDisplay } from './ui'
import { GridCardWithComponents as GridCard } from './GridCard'
import { useDashboardStore, dashboardStore, dashboardActions } from '~/store'
import { CardConfig } from './CardConfig'
import type { GridItem } from '~/store/types'
import { getTablerIcon } from '~/utils/icons'
import { getIcon } from '~/utils/iconList'
import { IconCircle, IconCircleCheck } from '@tabler/icons-react'

interface BinarySensorCardProps {
entityId: string
Expand Down Expand Up @@ -57,6 +58,20 @@ function BinarySensorCardComponent({

// Get config from item
const config = (item?.config as { onIcon?: string; offIcon?: string }) || {}
const deviceClass = entity?.attributes?.device_class as string | undefined

// Memoize icon computation based on primitive values - must be before early returns
const IconComponent = useMemo(() => {
const isOn = entity?.state === 'on'
const defaults = getDefaultIcons(deviceClass)
const onIconName = config.onIcon || defaults.onIcon
const offIconName = config.offIcon || defaults.offIcon
const iconName = isOn ? onIconName : offIconName
return getTablerIcon(iconName) || getIcon(iconName) || (isOn ? IconCircleCheck : IconCircle)
}, [entity?.state, config.onIcon, config.offIcon, deviceClass])

// Compute isOn for use in rendering (after useMemo to follow rules of hooks)
const isOn = entity?.state === 'on'

// Show skeleton while loading initial data
if (isEntityLoading || (!entity && isConnected)) {
Expand All @@ -76,22 +91,8 @@ function BinarySensorCardComponent({
}

const friendlyName = entity.attributes.friendly_name || entity.entity_id
const isOn = entity.state === 'on'
const isUnavailable = entity.state === 'unavailable'

// Get device class and default icons
const deviceClass = entity.attributes.device_class as string | undefined
const defaults = getDefaultIcons(deviceClass)

// Get configured icons or use defaults
const onIconName = config.onIcon || defaults.onIcon
const offIconName = config.offIcon || defaults.offIcon

// Get the icon component
const iconName = isOn ? onIconName : offIconName
const IconComponent =
getTablerIcon(iconName) || (isOn ? getIcon('CircleCheck') : getIcon('Circle')) || (() => null)

// Get icon size based on card size
const iconSize = size === 'large' ? 24 : size === 'medium' ? 20 : 16

Expand Down
10 changes: 5 additions & 5 deletions src/components/CameraCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Flex, Text, Button, Spinner, Card, Badge, Separator, Grid } from '@radix-ui/themes'
import { Flex, Text, Button, Spinner, Card } from '@radix-ui/themes'
import {
VideoIcon,
ReloadIcon,
Expand Down Expand Up @@ -40,18 +40,18 @@
// Stats display component
function CameraStats({
size,
hasFrameWarning,
_hasFrameWarning,
isStreaming,

Check warning on line 44 in src/components/CameraCard.tsx

View workflow job for this annotation

GitHub Actions / Lint

'isStreaming' is defined but never used. Allowed unused args must match /^_/u
videoElement,
peerConnection,
}: {
size: 'small' | 'medium' | 'large'
hasFrameWarning: boolean
_hasFrameWarning: boolean
isStreaming: boolean
videoElement: HTMLVideoElement | null
peerConnection: RTCPeerConnection | null
}) {
const scaleFactor = size === 'small' ? 0.64 : size === 'large' ? 0.96 : 0.8

Check warning on line 54 in src/components/CameraCard.tsx

View workflow job for this annotation

GitHub Actions / Lint

'scaleFactor' is assigned a value but never used
const [stats, setStats] = useState({
timestamp: new Date().toLocaleTimeString(),
fps: 0,
Expand Down Expand Up @@ -691,7 +691,7 @@
{showStats && supportsStream && !streamError && (
<CameraStats
size={size}
hasFrameWarning={hasFrameWarning}
_hasFrameWarning={hasFrameWarning}
isStreaming={isStreaming}
videoElement={videoElementRef.current}
peerConnection={peerConnection}
Expand Down Expand Up @@ -757,7 +757,7 @@
{showStats && (
<CameraStats
size="large"
hasFrameWarning={hasFrameWarning}
_hasFrameWarning={hasFrameWarning}
isStreaming={isStreaming}
videoElement={videoElementRef.current}
peerConnection={peerConnection}
Expand Down
3 changes: 2 additions & 1 deletion src/components/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,9 @@ export function Dashboard() {
)}
</Box>

{/* Screen Config Dialog */}
{/* Screen Config Dialog - key forces remount when screen changes to reset form state */}
<ScreenConfigDialog
key={editScreen?.id ?? 'new'}
open={addViewOpen}
onOpenChange={(open) => {
setAddViewOpen(open)
Expand Down
44 changes: 24 additions & 20 deletions src/components/InputTextCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { memo, useCallback, useState, useEffect } from 'react'
import React, { memo, useCallback, useState } from 'react'
import { Box, Flex, IconButton, Text, TextField } from '@radix-ui/themes'
import { Archive, Check, Edit2, Type, X } from 'lucide-react'
import { useEntity } from '../hooks/useEntity'
Expand Down Expand Up @@ -33,21 +33,25 @@ export const InputTextCard = memo(function InputTextCard({
const { entity, isConnected, isLoading: isEntityLoading } = useEntity(entityId)
const { setValue, loading, error } = useServiceCall()

const [localValue, setLocalValue] = useState<string>('')
const [isEditing, setIsEditing] = useState(false)
// Local value for editing - initialized when entering edit mode
const [localValue, setLocalValue] = useState<string>('')

// Update local value when entity changes
useEffect(() => {
if (entity && !isEditing) {
// Computed display value - entity state when not editing, local value when editing
const displayValue = isEditing ? localValue : (entity?.state ?? '')

const enterEditMode = useCallback(() => {
if (entity) {
setLocalValue(entity.state)
setIsEditing(true)
}
}, [entity, isEditing])
}, [entity])

const handleClick = useCallback(() => {
if (!isEditing) {
setIsEditing(true)
enterEditMode()
}
}, [isEditing])
}, [isEditing, enterEditMode])

const handleSubmit = useCallback(
(e: React.FormEvent) => {
Expand All @@ -58,21 +62,23 @@ export const InputTextCard = memo(function InputTextCard({

// Validate length constraints
if (attributes.min && localValue.length < attributes.min) {
setLocalValue(entity.state)
// Invalid - exit edit mode, displayValue reverts to entity.state
setIsEditing(false)
return
}

if (attributes.max && localValue.length > attributes.max) {
setLocalValue(localValue.substring(0, attributes.max))
// Truncate value
const truncated = localValue.substring(0, attributes.max)
setLocalValue(truncated)
return
}

// Validate pattern if provided
if (attributes.pattern) {
const regex = new RegExp(attributes.pattern)
if (!regex.test(localValue)) {
setLocalValue(entity.state)
// Invalid - exit edit mode, displayValue reverts to entity.state
setIsEditing(false)
return
}
Expand All @@ -85,11 +91,9 @@ export const InputTextCard = memo(function InputTextCard({
)

const handleCancel = useCallback(() => {
if (entity) {
setLocalValue(entity.state)
setIsEditing(false)
}
}, [entity])
// Just exit editing mode - displayValue will show entity.state again
setIsEditing(false)
}, [])

const handleFieldClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
Expand Down Expand Up @@ -145,8 +149,8 @@ export const InputTextCard = memo(function InputTextCard({
const isStale = attributes._stale === true
const isPassword = attributes.mode === 'password'

// Display value (mask if password and not editing)
const displayValue = isPassword && !isEditing ? '••••••••' : entity.state
// For display: mask if password and not editing, otherwise show displayValue (computed at top)
const shownValue = isPassword && !isEditing ? '••••••••' : displayValue

return (
<GridCard
Expand Down Expand Up @@ -215,15 +219,15 @@ export const InputTextCard = memo(function InputTextCard({
size={size === 'small' ? '1' : size === 'large' ? '3' : '2'}
style={{ fontFamily: isPassword ? 'monospace' : undefined }}
>
{displayValue || '(empty)'}
{shownValue || '(empty)'}
</Text>
</Box>
<IconButton
size={cardSize.buttonSize as '1' | '2' | '3'}
variant="ghost"
onClick={(e) => {
e.stopPropagation()
setIsEditing(true)
enterEditMode()
}}
>
<Edit2 size={16} />
Expand Down
34 changes: 19 additions & 15 deletions src/components/KeepAlive.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useRef, useEffect, ReactNode } from 'react'
import { useState, useEffect, ReactNode } from 'react'
import { createPortal } from 'react-dom'

interface KeepAliveProps {
Expand All @@ -11,36 +11,40 @@ interface KeepAliveProps {
const portalCache = new Map<string, HTMLDivElement>()

export function KeepAlive({ children, cacheKey, containerRef }: KeepAliveProps) {
const portalElementRef = useRef<HTMLDivElement | null>(null)
// Use state instead of ref to track portal element - this triggers re-render when set
const [portalElement, setPortalElement] = useState<HTMLDivElement | null>(() => {
// Initialize from cache synchronously during initial render
return portalCache.get(cacheKey) ?? null
})

useEffect(() => {
// Get or create portal element for this cache key
let portalElement = portalCache.get(cacheKey)
let element = portalCache.get(cacheKey)

if (!portalElement) {
portalElement = document.createElement('div')
portalElement.style.width = '100%'
portalElement.style.height = '100%'
portalCache.set(cacheKey, portalElement)
if (!element) {
element = document.createElement('div')
element.style.width = '100%'
element.style.height = '100%'
portalCache.set(cacheKey, element)
}

portalElementRef.current = portalElement
setPortalElement(element)

// Append portal element to container
if (containerRef.current && portalElement) {
containerRef.current.appendChild(portalElement)
if (containerRef.current && element) {
containerRef.current.appendChild(element)
}

return () => {
// Remove portal element from current container but don't destroy it
if (portalElement && portalElement.parentNode) {
portalElement.parentNode.removeChild(portalElement)
if (element && element.parentNode) {
element.parentNode.removeChild(element)
}
}
}, [cacheKey, containerRef])

// Render children into the cached portal element
if (!portalElementRef.current) return null
if (!portalElement) return null

return createPortal(children, portalElementRef.current)
return createPortal(children, portalElement)
}
23 changes: 13 additions & 10 deletions src/components/LightCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,22 +81,25 @@ function LightCardComponent({
)

const lightAttributes = entity?.attributes as LightAttributes | undefined
// Check if light supports brightness control
const supportedColorModes = lightAttributes?.supported_color_modes
const supportedFeatures = lightAttributes?.supported_features ?? 0
const supportsBrightness = useMemo(() => {
// Modern Home Assistant uses supported_color_modes
if (lightAttributes?.supported_color_modes) {
if (supportedColorModes) {
return (
lightAttributes.supported_color_modes.includes('brightness') ||
lightAttributes.supported_color_modes.includes('color_temp') ||
lightAttributes.supported_color_modes.includes('hs') ||
lightAttributes.supported_color_modes.includes('xy') ||
lightAttributes.supported_color_modes.includes('rgb') ||
lightAttributes.supported_color_modes.includes('rgbw') ||
lightAttributes.supported_color_modes.includes('rgbww')
supportedColorModes.includes('brightness') ||
supportedColorModes.includes('color_temp') ||
supportedColorModes.includes('hs') ||
supportedColorModes.includes('xy') ||
supportedColorModes.includes('rgb') ||
supportedColorModes.includes('rgbw') ||
supportedColorModes.includes('rgbww')
)
}
// Fallback to old supported_features check
return (lightAttributes?.supported_features ?? 0) & SUPPORT_BRIGHTNESS
}, [lightAttributes?.supported_features, lightAttributes?.supported_color_modes])
return supportedFeatures & SUPPORT_BRIGHTNESS
}, [supportedColorModes, supportedFeatures])

// These will be used for color picker implementation
// const supportsColor = useMemo(() => {
Expand Down
Loading
Loading