2025-10-30 00:13:08 +00:00
|
|
|
import { type JrpcRequest, type JrpcResponse, newJrpcRequestId } from "@eva/jrpc"
|
2025-10-28 01:11:57 +00:00
|
|
|
import { ZIGBEE_DEVICE, type ZigbeeDeviceName } from "@eva/zigbee"
|
2025-10-24 19:36:05 +00:00
|
|
|
import { useQuery } from "@tanstack/react-query"
|
2025-10-28 01:11:57 +00:00
|
|
|
import { useDrag } from "@use-gesture/react"
|
2025-10-25 01:09:53 +00:00
|
|
|
import Chart from "chart.js/auto"
|
2025-10-28 01:11:57 +00:00
|
|
|
import { atom, useAtomValue, useSetAtom, useStore } from "jotai"
|
2025-10-26 16:49:35 +00:00
|
|
|
import { useEffect, useLayoutEffect, useRef, useState } from "react"
|
2025-10-25 01:09:53 +00:00
|
|
|
import { beszelSystemsQuery } from "./beszel"
|
2025-10-24 19:36:05 +00:00
|
|
|
import cn from "./components/lib/cn"
|
2025-10-26 16:49:35 +00:00
|
|
|
import { StatusSeverity, TubeLine, formatLineName, tflDisruptionsQuery } from "./tfl"
|
2025-10-24 19:36:05 +00:00
|
|
|
import {
|
|
|
|
|
DEFAULT_LATITUDE,
|
|
|
|
|
DEFAULT_LONGITUDE,
|
|
|
|
|
currentWeatherQuery,
|
|
|
|
|
dailyForecastQuery,
|
|
|
|
|
getWeatherIcon,
|
|
|
|
|
weatherDescriptionQuery,
|
|
|
|
|
} from "./weather"
|
|
|
|
|
|
2025-10-30 01:33:02 +00:00
|
|
|
const LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT = 44
|
|
|
|
|
|
|
|
|
|
// Store brightness as step (0-43) to match the 44 bars exactly
|
|
|
|
|
// Step 0 = OFF, Steps 1-43 map to bars 42-0
|
|
|
|
|
const brightnessStepAtoms = atom({
|
2025-10-28 01:11:57 +00:00
|
|
|
[ZIGBEE_DEVICE.deskLamp]: atom(0),
|
|
|
|
|
[ZIGBEE_DEVICE.livingRoomFloorLamp]: atom(0),
|
|
|
|
|
})
|
|
|
|
|
|
2025-10-30 01:33:02 +00:00
|
|
|
const intermediateBrightnessStepAtoms = atom({
|
2025-10-28 01:11:57 +00:00
|
|
|
[ZIGBEE_DEVICE.deskLamp]: atom(-1),
|
|
|
|
|
[ZIGBEE_DEVICE.livingRoomFloorLamp]: atom(-1),
|
|
|
|
|
})
|
|
|
|
|
|
2025-10-30 01:33:02 +00:00
|
|
|
// Convert brightness (0-254) to step (0-43)
|
|
|
|
|
// Step 0 = brightness 0, steps 1-43 map to brightness 1-254
|
|
|
|
|
function brightnessToStep(brightness: number): number {
|
|
|
|
|
if (brightness === 0) return 0
|
|
|
|
|
// Map brightness 1-254 to steps 1-43
|
|
|
|
|
return Math.max(1, Math.round((brightness / 254) * (LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT - 1)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert step (0-43) to brightness (0-254)
|
|
|
|
|
// Step 0 = brightness 0, steps 1-43 map to brightness 1-254
|
|
|
|
|
function stepToBrightness(step: number): number {
|
|
|
|
|
if (step === 0) return 0
|
|
|
|
|
// Map steps 1-43 to brightness 1-254
|
|
|
|
|
return Math.max(1, Math.round((step / (LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT - 1)) * 254))
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-30 00:33:07 +00:00
|
|
|
const DEVICE_FRIENDLY_NAMES = {
|
|
|
|
|
[ZIGBEE_DEVICE.deskLamp]: "Desk Lamp",
|
|
|
|
|
[ZIGBEE_DEVICE.livingRoomFloorLamp]: "Floor Lamp",
|
|
|
|
|
} as const
|
|
|
|
|
|
2025-10-24 19:36:05 +00:00
|
|
|
function App() {
|
2025-10-29 23:10:25 +00:00
|
|
|
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
|
|
|
|
const wsHost = import.meta.env.VITE_API_HOST || window.location.host
|
|
|
|
|
const websocket = useRef(new WebSocket(`${wsProtocol}//${wsHost}/api/zigbee`))
|
2025-10-28 01:11:57 +00:00
|
|
|
|
|
|
|
|
const store = useStore()
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2025-10-29 23:10:25 +00:00
|
|
|
const ws = websocket.current
|
|
|
|
|
|
|
|
|
|
ws.onopen = () => {
|
|
|
|
|
console.log("WebSocket connected")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ws.onerror = (error) => {
|
|
|
|
|
console.error("WebSocket error:", error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ws.onclose = () => {
|
|
|
|
|
console.log("WebSocket disconnected")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ws.onmessage = (event) => {
|
2025-10-28 01:11:57 +00:00
|
|
|
const data = JSON.parse(event.data) as JrpcRequest | JrpcResponse
|
|
|
|
|
if ("method" in data) {
|
|
|
|
|
switch (data.method) {
|
|
|
|
|
case "showDeviceState": {
|
|
|
|
|
const { deviceName, state } = data.params
|
2025-10-30 01:33:02 +00:00
|
|
|
const brightnessStepAtom = store.get(brightnessStepAtoms)[deviceName]
|
|
|
|
|
store.set(brightnessStepAtom, brightnessToStep(state.brightness))
|
2025-10-28 01:11:57 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-29 23:10:25 +00:00
|
|
|
|
2025-10-28 01:11:57 +00:00
|
|
|
return () => {
|
2025-10-29 23:10:25 +00:00
|
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
|
|
|
ws.close()
|
2025-10-28 01:11:57 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [store])
|
|
|
|
|
|
2025-10-30 01:33:02 +00:00
|
|
|
function setBrightnessStep(deviceName: ZigbeeDeviceName, step: number) {
|
2025-10-29 23:10:25 +00:00
|
|
|
const ws = websocket.current
|
|
|
|
|
|
|
|
|
|
if (ws.readyState !== WebSocket.OPEN) {
|
|
|
|
|
console.warn("WebSocket is not open. Current state:", ws.readyState)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-30 01:33:02 +00:00
|
|
|
const brightness = stepToBrightness(step)
|
|
|
|
|
|
2025-10-30 00:13:08 +00:00
|
|
|
const req: JrpcRequest<"setDeviceState"> = {
|
|
|
|
|
id: newJrpcRequestId(),
|
2025-10-28 01:11:57 +00:00
|
|
|
jsonrpc: "2.0",
|
|
|
|
|
method: "setDeviceState",
|
|
|
|
|
params: {
|
|
|
|
|
deviceName,
|
2025-10-30 01:33:02 +00:00
|
|
|
state: step === 0 ? { state: "OFF", brightness: 0 } : { state: "ON", brightness },
|
2025-10-28 01:11:57 +00:00
|
|
|
},
|
|
|
|
|
}
|
2025-10-29 23:10:25 +00:00
|
|
|
|
2025-10-30 00:13:08 +00:00
|
|
|
ws.send(JSON.stringify(req))
|
2025-10-28 01:11:57 +00:00
|
|
|
}
|
|
|
|
|
|
2025-10-24 19:36:05 +00:00
|
|
|
return (
|
2025-10-28 01:11:57 +00:00
|
|
|
<div className="h-screen bg-neutral-300 dark:bg-neutral-800 p-2 select-none">
|
2025-10-26 16:49:35 +00:00
|
|
|
<div className="w-full h-full grid grid-cols-4 grid-rows-5 gap-2 bg-neutral-300 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
|
|
|
|
|
<DateTimeTile />
|
|
|
|
|
<WeatherTile />
|
2025-10-28 01:11:57 +00:00
|
|
|
|
|
|
|
|
<TFLTile className="row-start-1 row-span-1" />
|
|
|
|
|
|
2025-10-26 16:49:35 +00:00
|
|
|
<SystemTile className="row-start-2 row-span-1" systemName="helian" displayName="Helian" />
|
|
|
|
|
<SystemTile className="row-start-2 row-span-1" systemName="akira" displayName="Akira" />
|
2025-10-28 01:11:57 +00:00
|
|
|
|
|
|
|
|
<LightControlTile
|
|
|
|
|
className="row-start-3 col-start-3 col-span-1"
|
|
|
|
|
deviceName={ZIGBEE_DEVICE.livingRoomFloorLamp}
|
2025-10-30 01:33:02 +00:00
|
|
|
onRequestBrightnessStepChange={(step) => {
|
|
|
|
|
setBrightnessStep(ZIGBEE_DEVICE.livingRoomFloorLamp, step)
|
2025-10-28 01:11:57 +00:00
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<LightControlTile
|
|
|
|
|
className="row-start-3 col-start-4 col-span-1"
|
|
|
|
|
deviceName={ZIGBEE_DEVICE.deskLamp}
|
2025-10-30 01:33:02 +00:00
|
|
|
onRequestBrightnessStepChange={(step) => {
|
|
|
|
|
setBrightnessStep(ZIGBEE_DEVICE.deskLamp, step)
|
2025-10-28 01:11:57 +00:00
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<Tile className="row-start-4 col-span-2 row-span-3" />
|
2025-10-26 16:49:35 +00:00
|
|
|
</div>
|
2025-10-24 19:36:05 +00:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-26 16:49:35 +00:00
|
|
|
function Tile({ children, className }: { children?: React.ReactNode; className?: string }) {
|
2025-10-24 19:36:05 +00:00
|
|
|
return (
|
2025-10-26 16:49:35 +00:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"relative rounded-xl bg-neutral-200 dark:bg-neutral-900 flex flex-col justify-end items-start",
|
|
|
|
|
className,
|
2025-10-24 23:03:55 +00:00
|
|
|
)}
|
2025-10-26 16:49:35 +00:00
|
|
|
>
|
2025-10-24 19:36:05 +00:00
|
|
|
{children}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function DateTimeTile() {
|
|
|
|
|
const [time, setTime] = useState(new Date())
|
|
|
|
|
|
|
|
|
|
const formattedDate = time.toLocaleDateString("en-US", {
|
|
|
|
|
weekday: "short",
|
|
|
|
|
month: "short",
|
|
|
|
|
day: "numeric",
|
|
|
|
|
})
|
|
|
|
|
const formattedTime = time.toLocaleTimeString("en-US", {
|
|
|
|
|
hour12: false,
|
|
|
|
|
hour: "2-digit",
|
|
|
|
|
minute: "2-digit",
|
|
|
|
|
second: "2-digit",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const interval = setInterval(() => {
|
|
|
|
|
setTime(new Date())
|
|
|
|
|
}, 1000)
|
|
|
|
|
return () => clearInterval(interval)
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Tile className="col-start-1 row-start-1 col-span-2 row-span-3 p-6">
|
2025-10-26 16:49:35 +00:00
|
|
|
<p className="text-4xl mb-2 font-mono uppercase tracking-tigher">{formattedDate}</p>
|
|
|
|
|
<p className="text-8xl font-extralight tracking-tight">{formattedTime}</p>
|
2025-10-24 19:36:05 +00:00
|
|
|
</Tile>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function WeatherTile() {
|
|
|
|
|
const {
|
|
|
|
|
data: currentWeatherData,
|
|
|
|
|
isLoading: isLoadingCurrentWeather,
|
|
|
|
|
error: errorCurrentWeather,
|
|
|
|
|
} = useQuery({
|
|
|
|
|
...currentWeatherQuery(DEFAULT_LATITUDE, DEFAULT_LONGITUDE),
|
|
|
|
|
refetchInterval: 5 * 60 * 1000, // 5 minutes
|
|
|
|
|
refetchIntervalInBackground: true,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
data: dailyForecastData,
|
|
|
|
|
isLoading: isLoadingDailyForecast,
|
|
|
|
|
error: errorDailyForecast,
|
|
|
|
|
} = useQuery({
|
|
|
|
|
...dailyForecastQuery(DEFAULT_LATITUDE, DEFAULT_LONGITUDE),
|
|
|
|
|
refetchInterval: 5 * 60 * 1000, // 5 minutes
|
|
|
|
|
refetchIntervalInBackground: true,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
data: weatherDescriptionData,
|
|
|
|
|
isLoading: isLoadingWeatherDescription,
|
|
|
|
|
error: errorWeatherDescription,
|
|
|
|
|
} = useQuery({
|
|
|
|
|
...weatherDescriptionQuery(DEFAULT_LATITUDE, DEFAULT_LONGITUDE),
|
|
|
|
|
refetchInterval: 60 * 60 * 1000, // 1 hour
|
|
|
|
|
refetchIntervalInBackground: true,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const isLoading = isLoadingCurrentWeather || isLoadingDailyForecast
|
|
|
|
|
const error = errorCurrentWeather || errorDailyForecast
|
|
|
|
|
|
|
|
|
|
if (isLoading) {
|
|
|
|
|
return (
|
|
|
|
|
<Tile className="col-start-1 h-full row-start-4 col-span-2 row-span-2 flex flex-row justify-center items-center p-8">
|
|
|
|
|
<p className="text-2xl font-light animate-pulse">Loading weather</p>
|
|
|
|
|
</Tile>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (error || !currentWeatherData?.currentWeather) {
|
|
|
|
|
return (
|
|
|
|
|
<Tile className="col-start-1 h-full row-start-4 col-span-2 row-span-2 flex flex-col justify-center items-center p-8">
|
|
|
|
|
<p className="text-2xl text-red-400 font-light">Error loading weather</p>
|
|
|
|
|
<p className=" text-neutral-400">{error?.message ?? "Unknown error"}</p>
|
|
|
|
|
</Tile>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const currentWeather = currentWeatherData.currentWeather
|
|
|
|
|
const temperature = Math.round(currentWeather.temperature)
|
|
|
|
|
const lowTemp = Math.round(dailyForecastData?.forecastDaily?.days[0].temperatureMin ?? 0)
|
|
|
|
|
const highTemp = Math.round(dailyForecastData?.forecastDaily?.days[0].temperatureMax ?? 0)
|
|
|
|
|
const percentage = lowTemp && highTemp ? (temperature - lowTemp) / (highTemp - lowTemp) : 0
|
|
|
|
|
const highlightIndexStart = Math.floor((1 - percentage) * 23)
|
|
|
|
|
const WeatherIcon = getWeatherIcon(currentWeather.conditionCode)
|
|
|
|
|
|
|
|
|
|
let weatherDescriptionContent: string
|
|
|
|
|
if (isLoadingWeatherDescription) {
|
|
|
|
|
weatherDescriptionContent = "Loading weather description"
|
|
|
|
|
} else if (errorWeatherDescription) {
|
|
|
|
|
weatherDescriptionContent = `Error: ${errorWeatherDescription.message}`
|
|
|
|
|
} else if (!weatherDescriptionData?.description) {
|
|
|
|
|
weatherDescriptionContent = "No weather description available"
|
|
|
|
|
} else {
|
|
|
|
|
weatherDescriptionContent = weatherDescriptionData.description
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Tile className="col-start-1 h-full row-start-4 col-span-2 row-span-2 flex flex-row justify-start items-center p-8">
|
|
|
|
|
<div className="flex flex-row h-full items-center space-x-2 flex-[2]">
|
|
|
|
|
<div className="flex flex-col justify-between items-end h-full">
|
|
|
|
|
<p className={cn("leading-none text-sm text-neutral-400", temperature === highTemp && "invisible")}>
|
|
|
|
|
H:{highTemp}°
|
|
|
|
|
</p>
|
|
|
|
|
<p className={cn("leading-none text-sm text-neutral-400", temperature === lowTemp && "invisible")}>
|
|
|
|
|
L:{lowTemp}°
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-col space-y-2 flex-[1]">
|
|
|
|
|
{Array.from({ length: 24 }).map((_, index) => {
|
|
|
|
|
if (index === highlightIndexStart) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="relative w-fit">
|
|
|
|
|
<div
|
|
|
|
|
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
|
|
|
|
|
key={index}
|
2025-10-26 16:49:35 +00:00
|
|
|
className={cn("w-10 bg-teal-500 dark:bg-teal-400 h-[2px]")}
|
2025-10-24 19:36:05 +00:00
|
|
|
/>
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
2025-10-26 16:49:35 +00:00
|
|
|
"absolute flex flex-row items-center space-x-1 top-0 right-0 bg-teal-500 dark:bg-teal-400 text-neutral-200 dark:text-neutral-900 px-2 py-1 text-4xl font-bold rounded-r translate-x-[calc(100%-1px)]",
|
2025-10-24 19:36:05 +00:00
|
|
|
percentage < 0.3
|
2025-10-26 16:49:35 +00:00
|
|
|
? "-translate-y-[calc(100%-2px)] rounded-tl"
|
|
|
|
|
: "rounded-bl",
|
2025-10-24 19:36:05 +00:00
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<p className="leading-none translate-y-px">{temperature}°</p>
|
2025-10-26 15:50:03 +00:00
|
|
|
<WeatherIcon className="size-8" strokeWidth={3} />
|
2025-10-24 19:36:05 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
|
|
|
|
|
key={index}
|
|
|
|
|
className={cn(
|
|
|
|
|
"w-4",
|
|
|
|
|
index >= highlightIndexStart
|
2025-10-26 16:49:35 +00:00
|
|
|
? "bg-teal-500 dark:bg-teal-400 w-8 h-[2px]"
|
2025-10-24 19:36:05 +00:00
|
|
|
: "bg-neutral-400 w-4 h-[1px]",
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
2025-10-26 15:36:22 +00:00
|
|
|
<div className="flex flex-col justify-start h-full space-y-2 flex-[2]">
|
2025-10-24 19:36:05 +00:00
|
|
|
<p
|
|
|
|
|
className={cn("text-3xl leading-none tracking-tight font-light", {
|
|
|
|
|
"text-red-400": errorWeatherDescription,
|
|
|
|
|
"animate-pulse": isLoadingWeatherDescription,
|
|
|
|
|
})}
|
|
|
|
|
>
|
|
|
|
|
{weatherDescriptionContent}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Tile>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-26 16:49:35 +00:00
|
|
|
function TFLTile({ className }: { className?: string }) {
|
|
|
|
|
const linesIDontCareAbout = [
|
|
|
|
|
TubeLine.WaterlooCity,
|
|
|
|
|
TubeLine.Windrush,
|
|
|
|
|
TubeLine.Lioness,
|
|
|
|
|
TubeLine.Lioness,
|
|
|
|
|
TubeLine.Tram,
|
|
|
|
|
TubeLine.Mildmay,
|
|
|
|
|
]
|
|
|
|
|
|
2025-10-24 23:03:55 +00:00
|
|
|
const {
|
|
|
|
|
data: tflData,
|
|
|
|
|
isLoading: isLoadingTFL,
|
|
|
|
|
error: errorTFL,
|
|
|
|
|
} = useQuery({
|
|
|
|
|
...tflDisruptionsQuery(),
|
|
|
|
|
select: (data) => {
|
|
|
|
|
data.disruptions.sort((a, b) => {
|
|
|
|
|
if (a.lineName.match(/northern/i)) return -1
|
2025-10-26 15:46:07 +00:00
|
|
|
if (b.lineName.match(/northern/i)) return 1
|
2025-10-24 23:03:55 +00:00
|
|
|
return a.statusSeverity - b.statusSeverity
|
|
|
|
|
})
|
2025-10-26 16:49:35 +00:00
|
|
|
data.disruptions = data.disruptions.filter((disruption) => !linesIDontCareAbout.includes(disruption.lineId))
|
2025-10-24 23:03:55 +00:00
|
|
|
return data
|
|
|
|
|
},
|
|
|
|
|
refetchInterval: 5 * 60 * 1000, // 5 minutes
|
|
|
|
|
refetchIntervalInBackground: true,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (isLoadingTFL) {
|
|
|
|
|
return (
|
2025-10-26 16:49:35 +00:00
|
|
|
<Tile
|
|
|
|
|
className={cn("h-full col-span-2 row-span-1 flex flex-row justify-start items-center p-8", className)}
|
|
|
|
|
>
|
2025-10-24 23:10:46 +00:00
|
|
|
<p className="text-2xl font-light animate-pulse">Loading tube status</p>
|
|
|
|
|
</Tile>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (errorTFL) {
|
|
|
|
|
return (
|
2025-10-26 16:49:35 +00:00
|
|
|
<Tile
|
|
|
|
|
className={cn("h-full col-span-2 row-span-1 flex flex-row justify-start items-center p-8", className)}
|
|
|
|
|
>
|
2025-10-24 23:10:46 +00:00
|
|
|
<p className="text-2xl font-light text-red-400">Error loading from TfL</p>
|
|
|
|
|
<p className="text-neutral-400">{errorTFL?.message}</p>
|
|
|
|
|
</Tile>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!tflData) {
|
|
|
|
|
return (
|
2025-10-26 16:49:35 +00:00
|
|
|
<Tile
|
|
|
|
|
className={cn("h-full col-span-2 row-span-1 flex flex-row justify-start items-center p-8", className)}
|
|
|
|
|
>
|
2025-10-24 23:10:46 +00:00
|
|
|
<p className="text-2xl font-light">No TfL data available</p>
|
2025-10-24 23:03:55 +00:00
|
|
|
</Tile>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Tile
|
2025-10-26 16:49:35 +00:00
|
|
|
className={cn(
|
|
|
|
|
"gap-x-1 pt-1 h-full col-span-2 row-span-1 grid grid-cols-[min-content_1fr] auto-rows-min overflow-y-auto",
|
|
|
|
|
className,
|
|
|
|
|
)}
|
2025-10-24 23:03:55 +00:00
|
|
|
>
|
2025-10-24 23:10:46 +00:00
|
|
|
{tflData.goodService.includes("Northern") && (
|
2025-10-26 16:49:35 +00:00
|
|
|
<TFLDistruptionItem
|
|
|
|
|
lineId={TubeLine.Northern}
|
|
|
|
|
reason="Good service"
|
|
|
|
|
severity={StatusSeverity.GoodService}
|
|
|
|
|
/>
|
2025-10-24 23:03:55 +00:00
|
|
|
)}
|
2025-10-26 16:49:35 +00:00
|
|
|
{tflData.disruptions.map((disruption) => (
|
|
|
|
|
<TFLDistruptionItem
|
|
|
|
|
key={disruption.lineId}
|
|
|
|
|
lineId={disruption.lineId}
|
|
|
|
|
reason={disruption.reason ?? "Unknown reason"}
|
|
|
|
|
severity={disruption.statusSeverity}
|
|
|
|
|
/>
|
2025-10-24 23:03:55 +00:00
|
|
|
))}
|
|
|
|
|
</Tile>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-26 16:49:35 +00:00
|
|
|
function TFLDistruptionItem({ lineId, reason, severity }: { lineId: TubeLine; reason: string; severity: number }) {
|
2025-10-25 01:09:32 +00:00
|
|
|
const lineName = formatLineName(lineId)
|
2025-10-26 15:46:07 +00:00
|
|
|
|
|
|
|
|
let lineStyleClass: string
|
|
|
|
|
switch (lineId) {
|
|
|
|
|
case "bakerloo":
|
|
|
|
|
lineStyleClass = "bg-amber-700"
|
|
|
|
|
break
|
|
|
|
|
case "central":
|
|
|
|
|
lineStyleClass = "bg-red-600"
|
|
|
|
|
break
|
|
|
|
|
case "circle":
|
|
|
|
|
lineStyleClass = "bg-yellow-400 text-neutral-900"
|
|
|
|
|
break
|
|
|
|
|
case "district":
|
|
|
|
|
lineStyleClass = "bg-green-600"
|
|
|
|
|
break
|
|
|
|
|
case "hammersmith-city":
|
|
|
|
|
lineStyleClass = "bg-pink-400"
|
|
|
|
|
break
|
|
|
|
|
case "jubilee":
|
|
|
|
|
lineStyleClass = "bg-slate-500"
|
|
|
|
|
break
|
|
|
|
|
case "metropolitan":
|
|
|
|
|
lineStyleClass = "bg-purple-800"
|
|
|
|
|
break
|
|
|
|
|
case "northern":
|
|
|
|
|
lineStyleClass = "bg-black"
|
|
|
|
|
break
|
|
|
|
|
case "piccadilly":
|
|
|
|
|
lineStyleClass = "bg-blue-900"
|
|
|
|
|
break
|
|
|
|
|
case "victoria":
|
|
|
|
|
lineStyleClass = "bg-sky-500"
|
|
|
|
|
break
|
|
|
|
|
case "waterloo-city":
|
|
|
|
|
lineStyleClass = "bg-teal-500"
|
|
|
|
|
break
|
|
|
|
|
case "london-overground":
|
|
|
|
|
lineStyleClass = "bg-orange-500"
|
|
|
|
|
break
|
|
|
|
|
case "dlr":
|
|
|
|
|
lineStyleClass = "bg-teal-600"
|
|
|
|
|
break
|
|
|
|
|
case "elizabeth":
|
|
|
|
|
lineStyleClass = "bg-purple-600"
|
|
|
|
|
break
|
|
|
|
|
case "tram":
|
|
|
|
|
lineStyleClass = "bg-green-500"
|
|
|
|
|
break
|
|
|
|
|
default:
|
|
|
|
|
lineStyleClass = "bg-gray-500"
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let statusBorderClass: string
|
|
|
|
|
switch (severity) {
|
|
|
|
|
case StatusSeverity.GoodService:
|
|
|
|
|
statusBorderClass = "border-green-500"
|
|
|
|
|
break
|
|
|
|
|
case StatusSeverity.MinorDelays:
|
|
|
|
|
statusBorderClass = "border-yellow-500"
|
|
|
|
|
break
|
|
|
|
|
case StatusSeverity.Suspended:
|
|
|
|
|
statusBorderClass = "border-red-600"
|
|
|
|
|
break
|
|
|
|
|
case StatusSeverity.PartSuspended:
|
|
|
|
|
statusBorderClass = "border-red-500"
|
|
|
|
|
break
|
|
|
|
|
case StatusSeverity.PlannedClosure:
|
|
|
|
|
statusBorderClass = "border-orange-600"
|
|
|
|
|
break
|
|
|
|
|
case StatusSeverity.PartClosure:
|
|
|
|
|
statusBorderClass = "border-yellow-500"
|
|
|
|
|
break
|
|
|
|
|
case StatusSeverity.SevereDelays:
|
|
|
|
|
statusBorderClass = "border-red-500"
|
|
|
|
|
break
|
|
|
|
|
case StatusSeverity.ReducedService:
|
|
|
|
|
statusBorderClass = "border-orange-500"
|
|
|
|
|
break
|
|
|
|
|
case StatusSeverity.BusService:
|
|
|
|
|
statusBorderClass = "border-blue-500"
|
|
|
|
|
break
|
|
|
|
|
case StatusSeverity.Information:
|
|
|
|
|
statusBorderClass = "border-blue-400"
|
|
|
|
|
break
|
|
|
|
|
case StatusSeverity.ServiceClosed:
|
|
|
|
|
statusBorderClass = "border-red-700"
|
|
|
|
|
break
|
|
|
|
|
default:
|
|
|
|
|
statusBorderClass = "border-gray-400"
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-24 23:03:55 +00:00
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<div className="h-full flex items-center justify-center px-2 py-0.5">
|
2025-10-26 16:49:35 +00:00
|
|
|
<p
|
|
|
|
|
className={cn(
|
|
|
|
|
"text-neutral-200 text-xl uppercase font-bold w-full text-center px-1 rounded-lg",
|
|
|
|
|
lineStyleClass,
|
|
|
|
|
)}
|
|
|
|
|
>
|
2025-10-24 23:03:55 +00:00
|
|
|
{lineName}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<p
|
|
|
|
|
className={cn(
|
2025-10-26 16:49:35 +00:00
|
|
|
"text-xl text-wrap leading-tight self-center pr-2 py-1.5 font-light border-r-4",
|
2025-10-26 15:48:47 +00:00
|
|
|
statusBorderClass,
|
2025-10-24 23:03:55 +00:00
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{reason}
|
|
|
|
|
</p>
|
|
|
|
|
</>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-25 01:09:53 +00:00
|
|
|
function SystemTile({
|
|
|
|
|
className,
|
|
|
|
|
systemName,
|
|
|
|
|
displayName,
|
|
|
|
|
}: { className?: string; systemName: string; displayName: string }) {
|
|
|
|
|
const { data } = useQuery({
|
|
|
|
|
...beszelSystemsQuery(),
|
|
|
|
|
refetchInterval: 1000,
|
|
|
|
|
refetchIntervalInBackground: true,
|
|
|
|
|
})
|
|
|
|
|
const chartRef = useRef<Chart | null>(null)
|
|
|
|
|
|
|
|
|
|
const beszelSystemsData = data?.systems.find((system) => system.name === systemName)
|
|
|
|
|
|
|
|
|
|
const onCanvasRef = (elem: HTMLCanvasElement | null) => {
|
|
|
|
|
if (!elem || chartRef.current) return
|
|
|
|
|
|
|
|
|
|
const fillGradient = elem?.getContext("2d")?.createLinearGradient(0, 0, 0, elem.height)
|
|
|
|
|
fillGradient?.addColorStop(0, "#2dd4bf")
|
|
|
|
|
fillGradient?.addColorStop(0.5, "rgba(45, 212, 191, 0)")
|
|
|
|
|
fillGradient?.addColorStop(1, "rgba(45, 212, 191, 0)")
|
|
|
|
|
chartRef.current = new Chart(elem, {
|
|
|
|
|
type: "line",
|
|
|
|
|
data: {
|
|
|
|
|
labels: Array.from({ length: 20 }, (_, index) => index),
|
|
|
|
|
datasets: [
|
|
|
|
|
{
|
2025-10-30 00:13:08 +00:00
|
|
|
data: Array.from({ length: 20 }, (_, __) => null),
|
2025-10-25 01:09:53 +00:00
|
|
|
fill: true,
|
|
|
|
|
backgroundColor: fillGradient,
|
|
|
|
|
borderColor: "#2dd4bf",
|
|
|
|
|
tension: 0.1,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
options: {
|
|
|
|
|
responsive: true,
|
|
|
|
|
scales: {
|
|
|
|
|
x: { display: false },
|
|
|
|
|
y: { display: false, min: 0, max: 100 },
|
|
|
|
|
},
|
|
|
|
|
maintainAspectRatio: false,
|
|
|
|
|
elements: {
|
|
|
|
|
point: { radius: 0 },
|
|
|
|
|
line: {
|
|
|
|
|
backgroundColor: "rgba(255, 255, 255, 0.5)",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
plugins: {
|
|
|
|
|
legend: {
|
|
|
|
|
display: false,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
useLayoutEffect(() => {
|
|
|
|
|
const cpu = beszelSystemsData?.info.cpu
|
|
|
|
|
if (!chartRef.current || cpu === undefined) return
|
|
|
|
|
|
|
|
|
|
const dataset = chartRef.current.data.datasets[0]
|
|
|
|
|
|
|
|
|
|
const nextData = Array.from({ length: 20 }, (_, i) => {
|
|
|
|
|
if (i === 19) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
return dataset.data[i + 1]
|
|
|
|
|
})
|
|
|
|
|
nextData[19] = cpu
|
|
|
|
|
|
|
|
|
|
dataset.data = nextData
|
|
|
|
|
chartRef.current.update()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!beszelSystemsData) {
|
|
|
|
|
return (
|
2025-10-26 16:49:35 +00:00
|
|
|
<Tile className={cn("h-full flex flex-row justify-start items-center p-8", className)}>
|
2025-10-25 01:09:53 +00:00
|
|
|
<p className="text-2xl font-light">No system status available</p>
|
|
|
|
|
</Tile>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2025-10-26 16:49:35 +00:00
|
|
|
<Tile className={cn("h-full flex flex-col justify-start items-start", className)}>
|
2025-10-25 01:09:53 +00:00
|
|
|
<div className="grid grid-cols-6 px-4 pt-3 w-full">
|
|
|
|
|
<div className="col-span-3 flex flex-row items-center space-x-2">
|
|
|
|
|
<p className="text-2xl">{displayName}</p>
|
|
|
|
|
<div className="size-2 border border-green-300 bg-green-500 rounded-full animate-pulse" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-col font-mono">
|
|
|
|
|
<p className="text-neutral-400 text-right leading-none">CPU</p>
|
|
|
|
|
<p className="text-right">{beszelSystemsData.info.cpu.toFixed(0).padStart(3, "0")}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-col font-mono">
|
|
|
|
|
<p className="text-neutral-400 text-right leading-none">RAM</p>
|
|
|
|
|
<p className="text-right">{beszelSystemsData.info.ram.toFixed(0).padStart(3, "0")}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-col font-mono">
|
|
|
|
|
<p className="text-neutral-400 text-right leading-none">DSK</p>
|
|
|
|
|
<p className="text-right">{beszelSystemsData.info.disk.toFixed(0).padStart(3, "0")}</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="w-full flex-1 min-w-0 basis-0 relative mb-2">
|
|
|
|
|
<canvas ref={onCanvasRef} className="min-h-0 absolute top-0 left-0 w-full h-full" />
|
|
|
|
|
</div>
|
|
|
|
|
</Tile>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-28 01:11:57 +00:00
|
|
|
function LightControlTile({
|
|
|
|
|
deviceName,
|
|
|
|
|
className,
|
2025-10-30 01:33:02 +00:00
|
|
|
onRequestBrightnessStepChange,
|
|
|
|
|
}: { deviceName: ZigbeeDeviceName; className?: string; onRequestBrightnessStepChange: (step: number) => void }) {
|
|
|
|
|
const currentBrightnessStep = useAtomValue(useAtomValue(brightnessStepAtoms)[deviceName])
|
|
|
|
|
// Map step to bar index for thumb position
|
|
|
|
|
// Step 0 = OFF (no thumb shown, set to invalid index)
|
|
|
|
|
// Step 1-43 map to bars 42-0
|
|
|
|
|
const initialHighlightIndexStart =
|
|
|
|
|
currentBrightnessStep === 0
|
|
|
|
|
? LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT - 1 // No thumb (index out of range, but no bars highlighted)
|
|
|
|
|
: LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT - 1 - currentBrightnessStep
|
2025-10-28 01:11:57 +00:00
|
|
|
const touchContainerRef = useRef<HTMLDivElement | null>(null)
|
2025-10-30 01:33:02 +00:00
|
|
|
const barRefs = useRef<(HTMLDivElement | null)[]>(
|
|
|
|
|
Array.from({ length: LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT }, () => null),
|
|
|
|
|
)
|
|
|
|
|
const setIntermediateBrightnessStep = useSetAtom(useAtomValue(intermediateBrightnessStepAtoms)[deviceName])
|
2025-10-28 01:11:57 +00:00
|
|
|
const store = useStore()
|
|
|
|
|
|
2025-10-30 01:33:02 +00:00
|
|
|
useEffect(() => {
|
|
|
|
|
const brightnessStepAtom = store.get(brightnessStepAtoms)[deviceName]
|
|
|
|
|
if (store.get(brightnessStepAtom) === currentBrightnessStep) {
|
|
|
|
|
setIntermediateBrightnessStep(-1)
|
|
|
|
|
}
|
|
|
|
|
}, [currentBrightnessStep, deviceName, setIntermediateBrightnessStep, store])
|
|
|
|
|
|
2025-10-28 01:11:57 +00:00
|
|
|
const bind = useDrag(({ xy: [x], first, last }) => {
|
|
|
|
|
if (!touchContainerRef.current) return
|
|
|
|
|
|
|
|
|
|
if (!first) {
|
|
|
|
|
touchContainerRef.current.dataset.active = "true"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (last) {
|
|
|
|
|
delete touchContainerRef.current.dataset.active
|
2025-10-30 01:33:02 +00:00
|
|
|
let thumbIndex = -1
|
|
|
|
|
for (let i = 0; i < LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT; i++) {
|
2025-10-28 01:11:57 +00:00
|
|
|
const bar = barRefs.current[i]
|
|
|
|
|
if (!bar) continue
|
|
|
|
|
|
2025-10-30 01:33:02 +00:00
|
|
|
const barRect = bar.getBoundingClientRect()
|
|
|
|
|
|
|
|
|
|
if (x >= barRect.left - 2 && x < barRect.right + 2 && thumbIndex === -1) {
|
|
|
|
|
thumbIndex = i
|
2025-10-28 01:11:57 +00:00
|
|
|
bar.dataset.thumb = "true"
|
2025-10-29 23:09:51 +00:00
|
|
|
} else {
|
2025-10-30 01:33:02 +00:00
|
|
|
delete bar.dataset.thumb
|
2025-10-28 01:11:57 +00:00
|
|
|
}
|
|
|
|
|
|
2025-10-30 01:33:02 +00:00
|
|
|
delete bar.dataset.touched
|
2025-10-28 01:11:57 +00:00
|
|
|
delete bar.dataset.touchProximity
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-30 01:33:02 +00:00
|
|
|
if (thumbIndex !== -1) {
|
|
|
|
|
// Map bar index to step: bar 42 -> step 1, bar 0 -> step 43
|
|
|
|
|
const step = LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT - 1 - thumbIndex
|
|
|
|
|
onRequestBrightnessStepChange(step)
|
|
|
|
|
} else {
|
|
|
|
|
const firstElement = barRefs.current[barRefs.current.length - 1]
|
|
|
|
|
const lastElement = barRefs.current[0]
|
|
|
|
|
if (lastElement && x > lastElement.getBoundingClientRect().right) {
|
|
|
|
|
lastElement.dataset.thumb = "true"
|
|
|
|
|
setIntermediateBrightnessStep(LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT - 1)
|
|
|
|
|
if (last) {
|
|
|
|
|
onRequestBrightnessStepChange(LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT - 1)
|
|
|
|
|
}
|
|
|
|
|
} else if (firstElement && x < firstElement.getBoundingClientRect().left) {
|
|
|
|
|
firstElement.dataset.thumb = "true"
|
|
|
|
|
setIntermediateBrightnessStep(0)
|
|
|
|
|
if (last) {
|
|
|
|
|
onRequestBrightnessStepChange(0)
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-28 01:11:57 +00:00
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
let touchedIndex = -1
|
2025-10-30 01:33:02 +00:00
|
|
|
for (let i = 0; i < LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT; i++) {
|
2025-10-28 01:11:57 +00:00
|
|
|
const bar = barRefs.current[i]
|
|
|
|
|
if (!bar) continue
|
|
|
|
|
|
|
|
|
|
const barRect = bar.getBoundingClientRect()
|
|
|
|
|
|
|
|
|
|
delete bar.dataset.thumb
|
|
|
|
|
|
2025-10-30 01:33:02 +00:00
|
|
|
if (x >= barRect.left - 2 && x < barRect.right + 2 && touchedIndex === -1) {
|
2025-10-28 01:11:57 +00:00
|
|
|
touchedIndex = i
|
|
|
|
|
|
|
|
|
|
bar.dataset.touched = "true"
|
2025-10-29 23:09:51 +00:00
|
|
|
bar.dataset.highlighted = "false"
|
2025-10-28 01:11:57 +00:00
|
|
|
delete bar.dataset.touchProximity
|
|
|
|
|
|
2025-10-30 01:33:02 +00:00
|
|
|
const step = LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT - i - 1
|
|
|
|
|
setIntermediateBrightnessStep(step)
|
2025-10-28 01:11:57 +00:00
|
|
|
|
|
|
|
|
if (barRefs.current[i - 1]) {
|
|
|
|
|
barRefs.current[i - 1]!.dataset.touchProximity = "close"
|
|
|
|
|
}
|
|
|
|
|
if (barRefs.current[i - 2]) {
|
|
|
|
|
barRefs.current[i - 2]!.dataset.touchProximity = "medium"
|
|
|
|
|
}
|
|
|
|
|
if (barRefs.current[i - 3]) {
|
|
|
|
|
barRefs.current[i - 3]!.dataset.touchProximity = "far"
|
|
|
|
|
}
|
|
|
|
|
} else if (barRect.left < x) {
|
2025-10-29 23:09:51 +00:00
|
|
|
if (bar.dataset.touched === "true") {
|
|
|
|
|
bar.dataset.prevTouched = "true"
|
|
|
|
|
} else {
|
|
|
|
|
delete bar.dataset.prevTouched
|
|
|
|
|
}
|
2025-10-28 01:11:57 +00:00
|
|
|
bar.dataset.touched = "false"
|
|
|
|
|
bar.dataset.highlighted = "true"
|
|
|
|
|
if (touchedIndex >= 0) {
|
|
|
|
|
const diff = i - touchedIndex
|
|
|
|
|
if (diff === 1) {
|
|
|
|
|
bar.dataset.touchProximity = "close"
|
|
|
|
|
} else if (diff === 2) {
|
|
|
|
|
bar.dataset.touchProximity = "medium"
|
|
|
|
|
} else if (diff === 3) {
|
|
|
|
|
bar.dataset.touchProximity = "far"
|
|
|
|
|
} else {
|
|
|
|
|
delete bar.dataset.touchProximity
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
delete bar.dataset.touchProximity
|
|
|
|
|
}
|
|
|
|
|
} else if (barRect.right > x) {
|
|
|
|
|
bar.dataset.highlighted = "false"
|
|
|
|
|
bar.dataset.touched = "false"
|
|
|
|
|
if (touchedIndex >= 0) {
|
|
|
|
|
const diff = i - touchedIndex
|
|
|
|
|
if (diff === 1) {
|
|
|
|
|
bar.dataset.touchProximity = "close"
|
|
|
|
|
} else if (diff === 2) {
|
|
|
|
|
bar.dataset.touchProximity = "medium"
|
|
|
|
|
} else if (diff === 3) {
|
|
|
|
|
bar.dataset.touchProximity = "far"
|
|
|
|
|
} else {
|
|
|
|
|
delete bar.dataset.touchProximity
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
delete bar.dataset.touchProximity
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
bar.dataset.touched = "false"
|
|
|
|
|
bar.dataset.highlighted = "false"
|
|
|
|
|
delete bar.dataset.touchProximity
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (touchedIndex === -1) {
|
|
|
|
|
const firstElement = barRefs.current[barRefs.current.length - 1]
|
|
|
|
|
const lastElement = barRefs.current[0]
|
|
|
|
|
if (lastElement && x > lastElement.getBoundingClientRect().right) {
|
|
|
|
|
lastElement.dataset.thumb = "true"
|
2025-10-30 01:33:02 +00:00
|
|
|
setIntermediateBrightnessStep(LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT - 1)
|
2025-10-28 01:11:57 +00:00
|
|
|
} else if (firstElement && x < firstElement.getBoundingClientRect().left) {
|
2025-10-30 01:33:02 +00:00
|
|
|
firstElement.dataset.thumb = "true"
|
|
|
|
|
setIntermediateBrightnessStep(0)
|
2025-10-28 01:11:57 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Tile className={cn("h-full flex flex-col justify-start items-start", className)}>
|
|
|
|
|
<div
|
|
|
|
|
{...bind()}
|
|
|
|
|
ref={touchContainerRef}
|
|
|
|
|
className="group flex-1 flex flex-row-reverse justify-center items-center touch-none gap-x-1 w-full translate-y-6"
|
|
|
|
|
>
|
2025-10-30 01:33:02 +00:00
|
|
|
{Array.from({ length: LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT }).map((_, index) => {
|
|
|
|
|
const highlighted = index > initialHighlightIndexStart
|
2025-10-28 01:11:57 +00:00
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
data-highlighted={highlighted}
|
|
|
|
|
data-thumb={index === initialHighlightIndexStart}
|
2025-10-30 01:33:02 +00:00
|
|
|
data-prev-touched={false}
|
2025-10-28 01:11:57 +00:00
|
|
|
data-touched={false}
|
|
|
|
|
ref={(ref) => {
|
|
|
|
|
barRefs.current[index] = ref
|
|
|
|
|
}}
|
|
|
|
|
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
|
|
|
|
|
key={index}
|
2025-10-29 23:09:51 +00:00
|
|
|
className="transition-all transition-75 w-[2px] h-[2px] bg-neutral-400 rounded-full data-[highlighted=true]:h-2 data-[touch-proximity=close]:h-6 data-[touch-proximity=medium]:h-4 data-[touch-proximity=far]:h-2 data-[highlighted=true]:bg-teal-500 data-[touched=true]:h-8 data-[touched=true]:w-1 data-[touched=true]:bg-teal-500 data-[touched=true]:transition-none data-[prev-touched=true]:transition-none data-[thumb=true]:h-8 data-[thumb=true]:bg-teal-500"
|
2025-10-28 01:11:57 +00:00
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="px-4 pb-2 w-full flex flex-row items-center justify-center space-x-2">
|
2025-10-30 00:33:07 +00:00
|
|
|
<p className="tracking-tigher uppercase">{DEVICE_FRIENDLY_NAMES[deviceName]}</p>
|
2025-10-28 01:11:57 +00:00
|
|
|
<BrightnessLevelLabel deviceName={deviceName} />
|
|
|
|
|
</div>
|
|
|
|
|
</Tile>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function BrightnessLevelLabel({ deviceName }: { deviceName: ZigbeeDeviceName }) {
|
2025-10-30 01:33:02 +00:00
|
|
|
const currentBrightnessStep = useAtomValue(useAtomValue(brightnessStepAtoms)[deviceName])
|
|
|
|
|
const intermediateBrightnessStep = useAtomValue(useAtomValue(intermediateBrightnessStepAtoms)[deviceName])
|
2025-10-28 01:11:57 +00:00
|
|
|
|
2025-10-30 01:33:02 +00:00
|
|
|
const step = intermediateBrightnessStep === -1 ? currentBrightnessStep : intermediateBrightnessStep
|
2025-10-28 01:11:57 +00:00
|
|
|
|
|
|
|
|
let label: string
|
2025-10-30 01:33:02 +00:00
|
|
|
if (step === 0) {
|
2025-10-28 01:11:57 +00:00
|
|
|
label = "OFF"
|
|
|
|
|
} else {
|
2025-10-30 01:33:02 +00:00
|
|
|
// Convert step to percentage: step 1 = ~2%, step 43 = 100%
|
|
|
|
|
const brightnessPercentage = Math.round((step / (LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT - 1)) * 100)
|
|
|
|
|
label = `${brightnessPercentage}%`
|
2025-10-28 01:11:57 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<p
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex-1 text-right font-bold font-mono tracking-tigher",
|
2025-10-30 01:33:02 +00:00
|
|
|
step === 0 ? "text-neutral-400" : "text-teal-400",
|
2025-10-28 01:11:57 +00:00
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{label}
|
|
|
|
|
</p>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-24 19:36:05 +00:00
|
|
|
export default App
|