Files
smart-home-tablet/components/DeviceCard.tsx
2026-07-22 23:27:20 +03:00

203 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect } from 'react'
import { Fan, Lightbulb, Tv, Snowflake, Power } from 'lucide-react'
interface DeviceCardProps {
id: string
name: string
icon: string
entityId?: string
domain?: string
initialState: boolean
isMock?: boolean
extraInfo?: string
}
type DeviceKind = 'air_purifier' | 'light' | 'tv' | 'ac' | 'default'
function getDeviceKind(id: string): DeviceKind {
if (id.includes('air_purifier')) return 'air_purifier'
if (id.includes('light')) return 'light'
if (id.includes('tv')) return 'tv'
if (id.includes('ac')) return 'ac'
return 'default'
}
// Каждый тип устройства — свой data-token (а не хардкод hex)
const DEVICE_ACCENT: Record<DeviceKind, string> = {
air_purifier: 'var(--data-violet)',
light: 'var(--data-warm)',
tv: 'var(--data-info)',
ac: 'var(--data-cool)',
default: 'var(--accent)',
}
function getDeviceIcon(kind: DeviceKind, isOn: boolean) {
const color = isOn ? DEVICE_ACCENT[kind] : 'var(--text-tertiary)'
const size = 26
const strokeWidth = 1.9
switch (kind) {
case 'air_purifier': return <Fan size={size} color={color} strokeWidth={strokeWidth} />
case 'light': return <Lightbulb size={size} color={color} strokeWidth={strokeWidth} />
case 'tv': return <Tv size={size} color={color} strokeWidth={strokeWidth} />
case 'ac': return <Snowflake size={size} color={color} strokeWidth={strokeWidth} />
default: return <Power size={size} color={color} strokeWidth={strokeWidth} />
}
}
export default function DeviceCard({
id, name, icon, entityId, domain, initialState, isMock = false, extraInfo,
}: DeviceCardProps) {
const kind = getDeviceKind(id)
const accent = DEVICE_ACCENT[kind]
const [isOn, setIsOn] = useState(initialState)
const [synced, setSynced] = useState(false)
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!synced && !isMock) {
setIsOn(initialState)
setSynced(true)
}
}, [initialState, isMock, synced])
const toggle = async () => {
const next = !isOn
setIsOn(next)
if (!isMock && entityId && domain) {
setLoading(true)
try {
await fetch('/api/ha', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
domain,
service: next ? 'turn_on' : 'turn_off',
entity_id: entityId,
}),
})
} catch {
setIsOn(!next)
} finally {
setLoading(false)
}
}
}
const activeBg = `color-mix(in srgb, ${accent} 14%, var(--surface-1))`
const activeBorder = `color-mix(in srgb, ${accent} 35%, var(--border-subtle))`
const activeGlow = `color-mix(in srgb, ${accent} 22%, transparent)`
return (
<div className="card-interactive" style={{
background: isOn
? activeBg
: 'linear-gradient(155deg, color-mix(in srgb, var(--surface-3) 92%, transparent), color-mix(in srgb, var(--surface-1) 88%, transparent))',
border: `1px solid ${isOn ? activeBorder : 'rgba(160,168,255,0.14)'}`,
borderRadius: 22,
padding: '20px 18px 18px',
minHeight: 160,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
transition: 'all 0.35s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: isOn
? `0 12px 36px -12px ${activeGlow}, inset 0 1px 0 rgba(255,255,255,0.08)`
: 'var(--shadow-lg), inset 0 1px 0 rgba(255,255,255,0.08)',
WebkitBackdropFilter: 'blur(16px)',
backdropFilter: 'blur(16px)',
position: 'relative',
overflow: 'hidden',
opacity: isMock ? 0.88 : 1,
}}>
{/* Subtle accent glow when on */}
{isOn && (
<div style={{
position: 'absolute',
top: -30,
right: -30,
width: 120,
height: 120,
borderRadius: '50%',
background: `radial-gradient(circle, color-mix(in srgb, ${accent} 25%, transparent) 0%, transparent 70%)`,
pointerEvents: 'none',
}} />
)}
{/* Top: icon + toggle */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', position: 'relative', zIndex: 1 }}>
<div
className={isOn ? (kind === 'air_purifier' ? 'fan-spinning' : kind === 'light' ? 'light-on-pulse' : 'device-active-breathe') : ''}
style={{
width: 52, height: 52, borderRadius: 16,
background: isOn
? `color-mix(in srgb, ${accent} 20%, var(--surface-2))`
: 'var(--surface-2)',
border: `1px solid ${isOn ? `color-mix(in srgb, ${accent} 35%, transparent)` : 'var(--border-subtle)'}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'all 0.3s ease',
}}>
{getDeviceIcon(kind, isOn)}
</div>
{/* Toggle switch — 60x36, touch-friendly */}
<button
onClick={toggle}
disabled={loading || isMock}
aria-label={isOn ? `Выключить ${name}` : `Включить ${name}`}
style={{
width: 60, height: 36, borderRadius: 18,
background: isOn
? `linear-gradient(90deg, ${accent}, color-mix(in srgb, ${accent} 70%, black))`
: 'var(--off-color)',
position: 'relative', border: 'none', cursor: isMock ? 'not-allowed' : 'pointer',
flexShrink: 0,
transition: 'all 0.3s ease',
opacity: loading ? 0.6 : 1,
boxShadow: isOn ? `0 0 18px -2px ${activeGlow}` : 'none',
}}
>
<span style={{
position: 'absolute', top: 4,
left: isOn ? 28 : 4,
width: 28, height: 28, borderRadius: '50%',
background: '#fff',
boxShadow: '0 2px 6px rgba(0,0,0,0.25)',
transition: 'left 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
display: 'block',
}} />
</button>
</div>
{/* Bottom: name + status */}
<div style={{ position: 'relative', zIndex: 1 }}>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.3 }}>
{name}
</div>
<div style={{
fontSize: 12,
color: isOn ? accent : 'var(--text-secondary)',
marginTop: 4,
fontWeight: 600,
}}>
{isOn ? 'Включён' : 'Выключен'}
{extraInfo && isOn ? ` · ${extraInfo}` : ''}
</div>
{isMock && (
<div style={{
fontSize: 10,
color: 'var(--text-tertiary)',
marginTop: 3,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.08em',
}}>demo</div>
)}
</div>
</div>
)
}