Skip to content

Commit 9f52313

Browse files
committed
fix(dashboard): restore card surfaces and panel urls
- restore original domain surfaces for untinted lighting, switch, rss, and calendar cards - keep accent color controls responsive without overriding card backgrounds - add panel-safe Home Assistant URL handling and RSS proxy support
1 parent 73cad77 commit 9f52313

24 files changed

Lines changed: 452 additions & 51 deletions

custom_components/navet/__init__.py

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,182 @@
22

33
from __future__ import annotations
44

5+
import ipaddress
56
from pathlib import Path
7+
from urllib.parse import urlsplit
68

79
from homeassistant.components import panel_custom
810
from homeassistant.components.frontend import async_remove_panel
9-
from homeassistant.components.http import StaticPathConfig
11+
from homeassistant.components.http import HomeAssistantView, StaticPathConfig
1012
from homeassistant.config_entries import ConfigEntry
1113
from homeassistant.core import HomeAssistant
14+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
15+
16+
from aiohttp import web
1217

1318
from .const import (
1419
DOMAIN,
1520
FRONTEND_MODULE_URL,
21+
HA_PROXY_PATH,
1622
PANEL_COMPONENT_NAME,
1723
PANEL_FRONTEND_PATH,
1824
PANEL_ICON,
1925
PANEL_TITLE,
26+
RSS_PROXY_PATH,
2027
STATIC_PATH,
2128
)
2229

30+
MAX_FEED_BYTES = 1024 * 1024
31+
RSS_ACCEPT_HEADER = "application/rss+xml, application/atom+xml, application/xml, text/xml;q=0.9"
32+
RSS_USER_AGENT = "Navet RSS Reader/1.0"
33+
XML_CONTENT_TYPES = (
34+
"application/rss+xml",
35+
"application/atom+xml",
36+
"application/xml",
37+
"text/xml",
38+
)
39+
PRIVATE_HOSTNAMES = {"localhost", "localhost.", "0.0.0.0"}
40+
41+
42+
def _json_error(status: int, message: str) -> web.Response:
43+
"""Return a no-store JSON error response."""
44+
return web.json_response(
45+
{"error": message},
46+
status=status,
47+
headers={"Cache-Control": "no-store"},
48+
)
49+
50+
51+
def _is_private_ip_address(hostname: str) -> bool:
52+
try:
53+
address = ipaddress.ip_address(hostname)
54+
except ValueError:
55+
return False
56+
57+
return (
58+
address.is_loopback
59+
or address.is_private
60+
or address.is_link_local
61+
or address.is_unspecified
62+
)
63+
64+
65+
def _is_blocked_hostname(hostname: str) -> bool:
66+
normalized_hostname = hostname.lower()
67+
return (
68+
normalized_hostname in PRIVATE_HOSTNAMES
69+
or normalized_hostname.endswith(".local")
70+
or _is_private_ip_address(normalized_hostname)
71+
)
72+
73+
74+
def _is_xml_content_type(content_type: str | None) -> bool:
75+
if not content_type:
76+
return False
77+
78+
media_type = content_type.split(";", 1)[0].strip().lower()
79+
return media_type in XML_CONTENT_TYPES or media_type.endswith("+xml")
80+
81+
82+
class NavetRSSProxyView(HomeAssistantView):
83+
"""Same-origin RSS fetch proxy for the native panel."""
84+
85+
url = RSS_PROXY_PATH
86+
name = "api:navet:rss_proxy"
87+
requires_auth = True
88+
89+
def __init__(self, hass: HomeAssistant) -> None:
90+
"""Initialize the RSS proxy view."""
91+
self._hass = hass
92+
93+
async def get(self, request: web.Request) -> web.Response:
94+
"""Fetch an HTTPS RSS/Atom feed and return it to the panel."""
95+
target_url = request.query.get("url", "").strip()
96+
97+
if not target_url:
98+
return _json_error(400, "Missing url query parameter")
99+
100+
parsed_url = urlsplit(target_url)
101+
if parsed_url.scheme != "https":
102+
return _json_error(400, "Only HTTPS feeds are allowed")
103+
104+
if not parsed_url.hostname:
105+
return _json_error(400, "Invalid feed URL")
106+
107+
if _is_blocked_hostname(parsed_url.hostname):
108+
return _json_error(400, "Private feed hosts are not allowed")
109+
110+
session = async_get_clientsession(self._hass)
111+
try:
112+
async with session.get(
113+
target_url,
114+
headers={
115+
"Accept": RSS_ACCEPT_HEADER,
116+
"User-Agent": RSS_USER_AGENT,
117+
},
118+
) as response:
119+
if not 200 <= response.status < 300:
120+
return _json_error(
121+
502,
122+
f"Upstream feed request failed with status {response.status}",
123+
)
124+
125+
content_type = response.headers.get("Content-Type")
126+
if not _is_xml_content_type(content_type):
127+
return _json_error(
128+
502,
129+
"Upstream feed returned an unsupported content type",
130+
)
131+
132+
try:
133+
content_length = int(response.headers.get("Content-Length") or "0")
134+
except ValueError:
135+
content_length = 0
136+
if content_length > MAX_FEED_BYTES:
137+
return _json_error(502, "Upstream feed is too large")
138+
139+
body = bytearray()
140+
async for chunk in response.content.iter_chunked(64 * 1024):
141+
body.extend(chunk)
142+
if len(body) > MAX_FEED_BYTES:
143+
return _json_error(502, "Upstream feed is too large")
144+
145+
if len(body) > MAX_FEED_BYTES:
146+
return _json_error(502, "Upstream feed is too large")
147+
148+
return web.Response(
149+
body=bytes(body),
150+
headers={
151+
"Cache-Control": "no-store",
152+
"Content-Type": content_type,
153+
"Referrer-Policy": "strict-origin-when-cross-origin",
154+
"X-Content-Type-Options": "nosniff",
155+
},
156+
)
157+
except Exception:
158+
return _json_error(502, "Unable to load feed")
159+
160+
161+
class NavetHomeAssistantProxyCompatibilityView(HomeAssistantView):
162+
"""Redirect legacy panel resource proxy URLs back to Home Assistant."""
163+
164+
url = f"{HA_PROXY_PATH}/{{requested_path:.*}}"
165+
name = "api:navet:ha_proxy_compat"
166+
requires_auth = True
167+
168+
async def get(self, request: web.Request) -> web.Response:
169+
"""Redirect proxied HA resource requests to their native same-origin path."""
170+
requested_path = request.match_info.get("requested_path", "")
171+
172+
if ".." in requested_path.split("/"):
173+
return _json_error(400, "Invalid Home Assistant resource path")
174+
175+
target = f"/{requested_path}"
176+
if request.query_string:
177+
target = f"{target}?{request.query_string}"
178+
179+
raise web.HTTPFound(target)
180+
23181

24182
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
25183
"""Set up Navet from a config entry."""
@@ -40,6 +198,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
40198
)
41199
domain_data["static_path_registered"] = True
42200

201+
if not domain_data.get("proxy_views_registered"):
202+
hass.http.register_view(NavetRSSProxyView(hass))
203+
hass.http.register_view(NavetHomeAssistantProxyCompatibilityView())
204+
domain_data["proxy_views_registered"] = True
205+
43206
await panel_custom.async_register_panel(
44207
hass,
45208
frontend_url_path=PANEL_FRONTEND_PATH,

custom_components/navet/const.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
PANEL_TITLE = "Navet"
1111

1212
STATIC_PATH = "/api/navet/static"
13+
RSS_PROXY_PATH = "/__navet_rss_proxy__"
14+
HA_PROXY_PATH = "/__navet_ha_proxy__"
1315
FRONTEND_DIR = Path(__file__).parent / "frontend"
1416
PANEL_ENTRYPOINT = FRONTEND_DIR / "navet-panel.js"
1517

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { fireEvent } from '@testing-library/react';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { renderWithProviders } from '@/test/render';
4+
import { ColorInputSwatch } from '../color-input-swatch';
5+
6+
describe('ColorInputSwatch', () => {
7+
beforeEach(() => {
8+
vi.useFakeTimers();
9+
});
10+
11+
afterEach(() => {
12+
vi.useRealTimers();
13+
});
14+
15+
it('debounces color changes and only emits the latest value', () => {
16+
const onChange = vi.fn();
17+
18+
const { container } = renderWithProviders(
19+
<ColorInputSwatch
20+
value="#f97316"
21+
ariaLabel="Custom color"
22+
changeDebounceMs={220}
23+
onChange={onChange}
24+
/>
25+
);
26+
27+
const input = container.querySelector('input[type="color"]') as HTMLInputElement;
28+
fireEvent.change(input, { target: { value: '#ff0000' } });
29+
fireEvent.change(input, { target: { value: '#00ff00' } });
30+
31+
expect(onChange).not.toHaveBeenCalled();
32+
33+
vi.advanceTimersByTime(219);
34+
expect(onChange).not.toHaveBeenCalled();
35+
36+
vi.advanceTimersByTime(1);
37+
expect(onChange).toHaveBeenCalledTimes(1);
38+
expect(onChange).toHaveBeenCalledWith('#00ff00');
39+
});
40+
41+
it('flushes a pending color change on blur', () => {
42+
const onChange = vi.fn();
43+
44+
const { container } = renderWithProviders(
45+
<ColorInputSwatch
46+
value="#f97316"
47+
ariaLabel="Custom color"
48+
changeDebounceMs={220}
49+
onChange={onChange}
50+
/>
51+
);
52+
53+
const input = container.querySelector('input[type="color"]') as HTMLInputElement;
54+
fireEvent.change(input, { target: { value: '#ff0000' } });
55+
fireEvent.blur(input);
56+
57+
expect(onChange).toHaveBeenCalledTimes(1);
58+
expect(onChange).toHaveBeenCalledWith('#ff0000');
59+
});
60+
});

src/app/components/primitives/color-input-swatch.tsx

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Check } from 'lucide-react';
2-
import { memo } from 'react';
2+
import { memo, useCallback, useEffect, useRef, useState } from 'react';
33
import { useTheme } from '@/app/hooks';
44

55
type ColorInputSwatchSize = 'small' | 'medium' | 'large';
@@ -15,6 +15,7 @@ export interface ColorInputSwatchProps {
1515
mode?: ColorInputSwatchMode;
1616
ringColor?: string;
1717
className?: string;
18+
changeDebounceMs?: number;
1819
onChange?: (value: string) => void;
1920
onClick?: React.MouseEventHandler<HTMLButtonElement | HTMLLabelElement>;
2021
}
@@ -62,12 +63,57 @@ export const ColorInputSwatch = memo(function ColorInputSwatch({
6263
mode = 'picker',
6364
ringColor,
6465
className = '',
66+
changeDebounceMs = 0,
6567
onChange,
6668
onClick,
6769
}: ColorInputSwatchProps) {
6870
const { theme } = useTheme();
6971
const classes = SIZE_CLASSES[size];
70-
const safeValue = isValidHexColor(value) ? value : '#f97316';
72+
const [previewValue, setPreviewValue] = useState(value);
73+
const changeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
74+
const latestChangeRef = useRef<string | null>(null);
75+
const safeValue = isValidHexColor(previewValue) ? previewValue : '#f97316';
76+
77+
const clearPendingChange = useCallback(() => {
78+
if (changeTimeoutRef.current) {
79+
clearTimeout(changeTimeoutRef.current);
80+
changeTimeoutRef.current = null;
81+
}
82+
}, []);
83+
84+
const flushPendingChange = useCallback(() => {
85+
const nextValue = latestChangeRef.current;
86+
if (!nextValue) {
87+
return;
88+
}
89+
90+
clearPendingChange();
91+
latestChangeRef.current = null;
92+
onChange?.(nextValue);
93+
}, [clearPendingChange, onChange]);
94+
95+
const handleInputChange = useCallback(
96+
(nextValue: string) => {
97+
setPreviewValue(nextValue);
98+
99+
if (!changeDebounceMs) {
100+
onChange?.(nextValue);
101+
return;
102+
}
103+
104+
latestChangeRef.current = nextValue;
105+
clearPendingChange();
106+
changeTimeoutRef.current = setTimeout(flushPendingChange, changeDebounceMs);
107+
},
108+
[changeDebounceMs, clearPendingChange, flushPendingChange, onChange]
109+
);
110+
111+
useEffect(() => {
112+
setPreviewValue(value);
113+
}, [value]);
114+
115+
useEffect(() => () => clearPendingChange(), [clearPendingChange]);
116+
71117
const hasFullColorSurface = !disabled && (mode === 'picker' || selected);
72118
const background =
73119
mode === 'swatch'
@@ -151,7 +197,8 @@ export const ColorInputSwatch = memo(function ColorInputSwatch({
151197
value={safeValue}
152198
aria-label={ariaLabel}
153199
disabled={disabled}
154-
onChange={(event) => onChange?.(event.target.value)}
200+
onChange={(event) => handleInputChange(event.target.value)}
201+
onBlur={flushPendingChange}
155202
className="absolute inset-0 cursor-pointer opacity-0"
156203
/>
157204
{selected ? (

src/app/components/shared/device-editor/brightness-slider.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ interface BrightnessSliderProps {
1717
disabled?: boolean;
1818
showLabel?: boolean;
1919
size?: CardSize;
20+
activeColor?: string | null;
2021
}
2122

2223
export const BrightnessSlider = memo(function BrightnessSlider({
@@ -27,6 +28,7 @@ export const BrightnessSlider = memo(function BrightnessSlider({
2728
disabled = false,
2829
showLabel = true,
2930
size = 'medium',
31+
activeColor: activeColorOverride,
3032
}: BrightnessSliderProps) {
3133
const { theme, accentColor } = useTheme();
3234
const { t } = useI18n();
@@ -37,12 +39,13 @@ export const BrightnessSlider = memo(function BrightnessSlider({
3739
theme,
3840
tone: isOn ? 'primary' : 'neutral',
3941
accentColor,
42+
baseColor: isOn ? (activeColorOverride ?? accentColor) : undefined,
4043
});
4144
const heightClass = isExtraSmall ? 'h-4' : isCompact ? 'h-5' : 'h-6';
4245
const trackHeightClass = 'h-[3px]';
4346
const thumbSizeClass = 'w-4 h-4';
4447
const trackBg = theme === 'light' ? 'bg-gray-200' : 'bg-white/10';
45-
const activeColor = accentColor;
48+
const activeColor = activeColorOverride ?? accentColor;
4649
const rangeBg = isOn
4750
? theme === 'glass'
4851
? `linear-gradient(to right, rgba(255,255,255,0.42), ${activeColor}cc)`

0 commit comments

Comments
 (0)