Add "Mein Zimmer" room-control page (Home Assistant, proxied through the backend)
Implements the room-control page from the design mockup: a scenes row above cards for shutters, color lamps, and brightness-only lamps, all driven by a new `general.ha` config section (server URL, token, ordered device/scene lists). The backend proxies every Home Assistant call server-side (GET/POST /api/ha/...) rather than the browser calling Home Assistant directly, so the long-lived token never leaves the LAN device and Home Assistant's own CORS settings don't need to know about musicmouse at all. Card kind (shutter/color/brightness-only) is inferred at runtime from what Home Assistant reports about each entity, not configured explicitly. Also stops tracking python-backend/config.yml, which had drifted into the repo despite its own header saying it shouldn't be - it now carries real credentials locally and needs to stay untracked. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,21 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
mascot?: string;
|
||||
/** Shown next to the title when the mouse is reachable but the firmware is not. */
|
||||
status?: ReactNode;
|
||||
/** A pill linking to the other page - there's no router, so it's a click handler
|
||||
* rather than an `href`. */
|
||||
link?: { label: string; onClick: () => void };
|
||||
}
|
||||
|
||||
export function AppHeader({ status }: Props) {
|
||||
export function AppHeader({
|
||||
title = "Musik Delphin",
|
||||
mascot = "/dolphin-mascot.png",
|
||||
status,
|
||||
link,
|
||||
}: Props) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -16,13 +26,30 @@ export function AppHeader({ status }: Props) {
|
||||
// The status bar is translucent in standalone mode, so make room for it.
|
||||
padding: "calc(18px + env(safe-area-inset-top)) 32px 6px",
|
||||
flex: "none",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/dolphin-mascot.png"
|
||||
alt=""
|
||||
style={{ width: 64, height: 64, objectFit: "contain" }}
|
||||
/>
|
||||
{link && (
|
||||
<button
|
||||
onClick={link.onClick}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 24,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: "oklch(97% 0.01 230 / .95)",
|
||||
color: "oklch(30% 0.03 230)",
|
||||
fontWeight: 800,
|
||||
fontSize: 14,
|
||||
padding: "9px 16px",
|
||||
borderRadius: 999,
|
||||
boxShadow: "0 6px 18px oklch(15% 0.04 230 / .4)",
|
||||
}}
|
||||
>
|
||||
{link.label}
|
||||
</button>
|
||||
)}
|
||||
<img src={mascot} alt="" style={{ width: 64, height: 64, objectFit: "contain" }} />
|
||||
<div
|
||||
style={{
|
||||
fontSize: 34,
|
||||
@@ -31,7 +58,7 @@ export function AppHeader({ status }: Props) {
|
||||
textShadow: "0 3px 14px oklch(15% 0.05 210 / .5)",
|
||||
}}
|
||||
>
|
||||
Musik Delphin
|
||||
{title}
|
||||
</div>
|
||||
{status}
|
||||
</div>
|
||||
|
||||
199
web/src/components/LightCard.tsx
Normal file
199
web/src/components/LightCard.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
/** A `light.*` device card - color-capable and brightness-only lights share this one
|
||||
* component, branching on capability rather than being two components, since HA
|
||||
* reports the difference on the entity itself (`supported_color_modes`). */
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { HaDevice, HaEntityState } from "../api/types";
|
||||
import { oklchToRgb } from "../lib/oklch";
|
||||
|
||||
const SWATCHES: { name: string; oklch: string }[] = [
|
||||
{ name: "Warmweiß", oklch: "oklch(90% 0.06 85)" },
|
||||
{ name: "Sonnengelb", oklch: "oklch(85% 0.16 95)" },
|
||||
{ name: "Korallenrot", oklch: "oklch(65% 0.20 25)" },
|
||||
{ name: "Delfinblau", oklch: "oklch(70% 0.15 235)" },
|
||||
{ name: "Riffgrün", oklch: "oklch(75% 0.16 155)" },
|
||||
{ name: "Quallenlila", oklch: "oklch(65% 0.18 310)" },
|
||||
];
|
||||
|
||||
/** `["brightness"]`/`["onoff"]` lights get the brightness row only (a `brightness_pct`
|
||||
* sent to a plain on/off bulb is harmlessly ignored by Home Assistant). */
|
||||
const COLOR_MODES = new Set(["hs", "rgb", "rgbw", "rgbww", "xy"]);
|
||||
|
||||
interface Props {
|
||||
device: HaDevice;
|
||||
state: HaEntityState | undefined;
|
||||
callService: (domain: string, service: string, body: Record<string, unknown>) => Promise<void>;
|
||||
optimistic: (entityId: string, patch: Partial<HaEntityState>) => void;
|
||||
onManualChange: () => void;
|
||||
}
|
||||
|
||||
function sameRgb(a: [number, number, number], b: [number, number, number]): boolean {
|
||||
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
|
||||
}
|
||||
|
||||
export function LightCard({ device, state, callService, optimistic, onManualChange }: Props) {
|
||||
const swatchRgb = useMemo(
|
||||
() => SWATCHES.map((swatch) => ({ ...swatch, rgb: oklchToRgb(swatch.oklch) })),
|
||||
[],
|
||||
);
|
||||
|
||||
const on = state?.state === "on";
|
||||
const modes = (state?.attributes.supported_color_modes as string[] | undefined) ?? [];
|
||||
const isColor = modes.some((mode) => COLOR_MODES.has(mode));
|
||||
const brightness = (state?.attributes.brightness as number | undefined) ?? 0; // 0..255
|
||||
const level = Math.min(5, Math.max(0, Math.round((brightness / 255) * 5)));
|
||||
const rgb = (state?.attributes.rgb_color as [number, number, number] | undefined) ?? [
|
||||
255, 214, 140,
|
||||
];
|
||||
const tint = `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
|
||||
|
||||
const turnOn = (body: Record<string, unknown> = {}) => {
|
||||
onManualChange();
|
||||
optimistic(device.entity_id, {
|
||||
state: "on",
|
||||
attributes: { ...state?.attributes, ...body },
|
||||
});
|
||||
void callService("light", "turn_on", { entity_id: device.entity_id, ...body });
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
onManualChange();
|
||||
if (on) {
|
||||
optimistic(device.entity_id, { state: "off" });
|
||||
void callService("light", "turn_off", { entity_id: device.entity_id });
|
||||
} else {
|
||||
turnOn();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="room-card"
|
||||
style={{
|
||||
boxShadow: on
|
||||
? `0 8px 24px var(--room-shadow), 0 0 0 3px color-mix(in srgb, ${tint} 55%, transparent)`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 16,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flex: "none",
|
||||
background: on ? tint : "oklch(88% 0.02 300)",
|
||||
color: on ? "oklch(28% 0.05 300)" : "oklch(55% 0.02 300)",
|
||||
boxShadow: on
|
||||
? `0 0 22px color-mix(in srgb, ${tint} 70%, transparent)`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{isColor ? <PendantIcon /> : <CeilingIcon />}
|
||||
</div>
|
||||
<div style={{ flex: 1, fontSize: 19, fontWeight: 900, color: "var(--room-ink)" }}>
|
||||
{device.name ?? device.entity_id}
|
||||
</div>
|
||||
<ToggleSwitch on={on} onClick={toggle} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 6, marginTop: 16, opacity: on ? 1 : 0.55 }}>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => turnOn({ brightness_pct: n * 20 })}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 46,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
borderRadius: 12,
|
||||
background: on && level >= n ? tint : "oklch(88% 0.02 300)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isColor && (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 10, marginTop: 14 }}>
|
||||
{swatchRgb.map((swatch) => {
|
||||
const selected = on && sameRgb(rgb, swatch.rgb);
|
||||
return (
|
||||
<button
|
||||
key={swatch.name}
|
||||
title={swatch.name}
|
||||
onClick={() => turnOn({ rgb_color: swatch.rgb })}
|
||||
style={{
|
||||
width: 46,
|
||||
height: 46,
|
||||
borderRadius: 999,
|
||||
cursor: "pointer",
|
||||
background: swatch.oklch,
|
||||
border: selected
|
||||
? "4px solid oklch(28% 0.05 300)"
|
||||
: "4px solid oklch(96% 0.012 300 / .8)",
|
||||
boxShadow: "0 3px 8px oklch(20% 0.03 300 / .35)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleSwitch({ on, onClick }: { on: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
aria-pressed={on}
|
||||
style={{
|
||||
width: 74,
|
||||
height: 42,
|
||||
padding: 4,
|
||||
border: "none",
|
||||
borderRadius: 999,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
justifyContent: on ? "flex-end" : "flex-start",
|
||||
background: on ? "var(--room-accent)" : "oklch(84% 0.01 300)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: "50%",
|
||||
background: "#fff",
|
||||
boxShadow: "0 2px 6px oklch(20% 0.03 300 / .4)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PendantIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
|
||||
<circle cx="24" cy="24" r="17" fill="none" stroke="currentColor" strokeWidth="3" />
|
||||
<circle cx="24" cy="24" r="9" fill="currentColor" opacity=".85" />
|
||||
<circle cx="24" cy="24" r="22" fill="none" stroke="currentColor" strokeWidth="1.5" opacity=".4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CeilingIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
|
||||
<path d="M24 4v7" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||
<path d="M9 30 L24 11 L39 30 Z" fill="none" stroke="currentColor" strokeWidth="3" strokeLinejoin="round" />
|
||||
<path d="M16 37h16" stroke="currentColor" strokeWidth="3" strokeLinecap="round" opacity=".55" />
|
||||
<path d="M20 43h8" stroke="currentColor" strokeWidth="3" strokeLinecap="round" opacity=".3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
91
web/src/components/RoomView.tsx
Normal file
91
web/src/components/RoomView.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
/** The room-control page ("Mein Zimmer"). Owns its Home Assistant polling directly -
|
||||
* unlike BrowseView/PlayView, its data source has nothing to do with the musicmouse
|
||||
* player state that App.tsx otherwise orchestrates. Ported from
|
||||
* `claude-design/Mein Zimmer.dc.html`. */
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import type { HaConfig } from "../api/types";
|
||||
import { useHomeAssistant } from "../hooks/useHomeAssistant";
|
||||
import { AppHeader } from "./AppHeader";
|
||||
import { Bubbles } from "./Bubbles";
|
||||
import { LightCard } from "./LightCard";
|
||||
import { SceneRow } from "./SceneRow";
|
||||
import { ShutterCard } from "./ShutterCard";
|
||||
|
||||
export function RoomView({ config, onBrowse }: { config: HaConfig; onBrowse: () => void }) {
|
||||
const ha = useHomeAssistant(config);
|
||||
// Purely local: Home Assistant scenes have no "currently active" state of their own.
|
||||
// Cleared by any manual device change, set by activating a scene.
|
||||
const [activeScene, setActiveScene] = useState<string | null>(null);
|
||||
|
||||
const onManualChange = () => setActiveScene(null);
|
||||
|
||||
const onActivateScene = (entityId: string) => {
|
||||
setActiveScene(entityId);
|
||||
void ha.callService("scene", "turn_on", { entity_id: entityId });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stage room-stage" style={{ display: "flex", flexDirection: "column" }}>
|
||||
<Bubbles />
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<AppHeader
|
||||
title="Mein Zimmer"
|
||||
mascot="/dolphin-remote.png"
|
||||
link={{ label: "♪ Musik", onClick: onBrowse }}
|
||||
/>
|
||||
<div style={{ flex: 1, overflow: "auto", minHeight: 0, padding: "14px 32px 64px" }}>
|
||||
<div style={{ maxWidth: 1180, margin: "0 auto" }}>
|
||||
{config.scenes.length > 0 && (
|
||||
<SceneRow
|
||||
scenes={config.scenes}
|
||||
activeScene={activeScene}
|
||||
onActivate={onActivateScene}
|
||||
/>
|
||||
)}
|
||||
<div className="room-grid" style={{ marginTop: 30 }}>
|
||||
{config.devices.map((device) => {
|
||||
const state = ha.states[device.entity_id];
|
||||
if (device.entity_id.startsWith("cover.")) {
|
||||
return (
|
||||
<ShutterCard
|
||||
key={device.entity_id}
|
||||
device={device}
|
||||
state={state}
|
||||
callService={ha.callService}
|
||||
optimistic={ha.optimistic}
|
||||
onManualChange={onManualChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (device.entity_id.startsWith("light.")) {
|
||||
return (
|
||||
<LightCard
|
||||
key={device.entity_id}
|
||||
device={device}
|
||||
state={state}
|
||||
callService={ha.callService}
|
||||
optimistic={ha.optimistic}
|
||||
onManualChange={onManualChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Not a domain this page knows how to draw - skip rather than crash.
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
web/src/components/SceneRow.tsx
Normal file
40
web/src/components/SceneRow.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { HaDevice } from "../api/types";
|
||||
|
||||
export function SceneRow({
|
||||
scenes,
|
||||
activeScene,
|
||||
onActivate,
|
||||
}: {
|
||||
scenes: HaDevice[];
|
||||
activeScene: string | null;
|
||||
onActivate: (entityId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 19,
|
||||
fontWeight: 900,
|
||||
color: "var(--room-paper)",
|
||||
textAlign: "center",
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
Szenen
|
||||
</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", justifyContent: "center", gap: 12 }}>
|
||||
{scenes.map((scene) => (
|
||||
<button
|
||||
key={scene.entity_id}
|
||||
className="room-pill"
|
||||
data-active={scene.entity_id === activeScene}
|
||||
onClick={() => onActivate(scene.entity_id)}
|
||||
>
|
||||
<span style={{ fontSize: 22, marginRight: 8 }}>✨</span>
|
||||
{scene.name ?? scene.entity_id}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
web/src/components/ShutterCard.tsx
Normal file
201
web/src/components/ShutterCard.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
/** A `cover.*` device card ("Rollo"). Real covers report their own movement and
|
||||
* position, so this just reflects HA's state - no client-side movement animation like
|
||||
* the design mockup used (it had no real backend to poll). */
|
||||
|
||||
import type { HaDevice, HaEntityState } from "../api/types";
|
||||
import {
|
||||
SHUTTER_PRESETS,
|
||||
closedLabelFor,
|
||||
closedPercentFromPosition,
|
||||
positionFromClosedPercent,
|
||||
} from "../lib/shutter";
|
||||
|
||||
//: Home Assistant's `CoverEntityFeature.SET_POSITION` bit.
|
||||
const SUPPORT_SET_POSITION = 4;
|
||||
|
||||
interface Props {
|
||||
device: HaDevice;
|
||||
state: HaEntityState | undefined;
|
||||
callService: (domain: string, service: string, body: Record<string, unknown>) => Promise<void>;
|
||||
optimistic: (entityId: string, patch: Partial<HaEntityState>) => void;
|
||||
onManualChange: () => void;
|
||||
}
|
||||
|
||||
export function ShutterCard({ device, state, callService, optimistic, onManualChange }: Props) {
|
||||
const position = state?.attributes.current_position as number | undefined;
|
||||
const supportedFeatures = (state?.attributes.supported_features as number | undefined) ?? 0;
|
||||
const supportsPosition = position != null || (supportedFeatures & SUPPORT_SET_POSITION) !== 0;
|
||||
const closedPercent = closedPercentFromPosition(position ?? 0);
|
||||
|
||||
const moving = state?.state === "opening" ? "up" : state?.state === "closing" ? "down" : null;
|
||||
const statusLabel =
|
||||
moving === "down" ? "Fährt runter …" : moving === "up" ? "Fährt hoch …" : closedLabelFor(closedPercent);
|
||||
|
||||
const setPosition = (target: number) => {
|
||||
onManualChange();
|
||||
optimistic(device.entity_id, {
|
||||
attributes: { ...state?.attributes, current_position: target },
|
||||
});
|
||||
void callService("cover", "set_cover_position", {
|
||||
entity_id: device.entity_id,
|
||||
position: target,
|
||||
});
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
onManualChange();
|
||||
void callService("cover", "open_cover", { entity_id: device.entity_id });
|
||||
};
|
||||
const close = () => {
|
||||
onManualChange();
|
||||
void callService("cover", "close_cover", { entity_id: device.entity_id });
|
||||
};
|
||||
const stop = () => {
|
||||
onManualChange();
|
||||
void callService("cover", "stop_cover", { entity_id: device.entity_id });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="room-card">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 16,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flex: "none",
|
||||
background: "oklch(88% 0.03 300)",
|
||||
color: "oklch(35% 0.05 300)",
|
||||
}}
|
||||
>
|
||||
<BlindsIcon />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 19, fontWeight: 900, color: "var(--room-ink)" }}>
|
||||
{device.name ?? "Rollo"}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: "oklch(45% 0.03 300 / .8)" }}>
|
||||
{statusLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 18, marginTop: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 104,
|
||||
height: 140,
|
||||
flex: "none",
|
||||
borderRadius: 12,
|
||||
border: "3px solid oklch(35% 0.04 300)",
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
background: "linear-gradient(180deg, oklch(82% 0.08 220), oklch(70% 0.09 200))",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: `${closedPercent}%`,
|
||||
background:
|
||||
"repeating-linear-gradient(180deg, oklch(70% 0.03 300) 0px, oklch(70% 0.03 300) 9px, oklch(58% 0.03 300) 9px, oklch(58% 0.03 300) 12px)",
|
||||
boxShadow: "0 4px 10px oklch(20% 0.03 300 / .4)",
|
||||
transition: "height .2s linear",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{supportsPosition && (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
{SHUTTER_PRESETS.map((preset) => {
|
||||
const active = closedLabelFor(closedPercent) === preset.label;
|
||||
return (
|
||||
<button
|
||||
key={preset.label}
|
||||
onClick={() => setPosition(positionFromClosedPercent(preset.closedPercent))}
|
||||
style={{
|
||||
height: 48,
|
||||
border: "none",
|
||||
borderRadius: 14,
|
||||
cursor: "pointer",
|
||||
fontWeight: 800,
|
||||
fontSize: 14,
|
||||
background: active ? "var(--room-accent)" : "oklch(89% 0.02 300)",
|
||||
color: active ? "#fff" : "oklch(28% 0.04 300)",
|
||||
}}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 8 }}>
|
||||
<button
|
||||
onClick={open}
|
||||
style={{
|
||||
height: 52,
|
||||
border: "none",
|
||||
borderRadius: 14,
|
||||
cursor: "pointer",
|
||||
fontWeight: 900,
|
||||
fontSize: 20,
|
||||
background: moving === "up" ? "var(--room-accent)" : "oklch(89% 0.02 300)",
|
||||
color: moving === "up" ? "#fff" : "oklch(28% 0.04 300)",
|
||||
}}
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
onClick={stop}
|
||||
style={{
|
||||
height: 52,
|
||||
border: "none",
|
||||
borderRadius: 14,
|
||||
cursor: "pointer",
|
||||
fontWeight: 900,
|
||||
fontSize: 18,
|
||||
background: "oklch(65% 0.18 25)",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
■
|
||||
</button>
|
||||
<button
|
||||
onClick={close}
|
||||
style={{
|
||||
height: 52,
|
||||
border: "none",
|
||||
borderRadius: 14,
|
||||
cursor: "pointer",
|
||||
fontWeight: 900,
|
||||
fontSize: 20,
|
||||
background: moving === "down" ? "var(--room-accent)" : "oklch(89% 0.02 300)",
|
||||
color: moving === "down" ? "#fff" : "oklch(28% 0.04 300)",
|
||||
}}
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlindsIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 48 48" width="34" height="34" aria-hidden="true">
|
||||
<rect x="7" y="7" width="34" height="34" rx="4" fill="none" stroke="currentColor" strokeWidth="3" />
|
||||
<path d="M7 16h34M7 24h34M7 32h34" stroke="currentColor" strokeWidth="2.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user