mirror of
https://github.com/ruvnet/RuView
synced 2026-07-17 16:33:18 +00:00
c0bb6f4fc7
CRUD increment 3/6. Full delete path lands end-to-end.
Backend (homecore-api):
rest.rs +18 LOC — new `delete_state` handler. Idempotent (matches HA's
removal semantics): returns 204 No Content whether the entity existed
or not. 4xx only for malformed entity_id or auth failure.
app.rs +6 LOC — adds `.delete(rest::delete_state)` to the
/api/states/:entity_id route alongside existing GET + POST.
Backend curl smoke:
POST /api/states/sensor.test_delete 201
DELETE /api/states/sensor.test_delete 204
GET /api/states/sensor.test_delete 404
Frontend:
components/StateCard.ts +25 LOC — small `×` delete button in the
card's top-right corner. opacity 0 by default, fades in on hover
or keyboard focus. dispatches `hc-state-card-delete` (NOT
`hc-state-card-click`) with stopPropagation so the card's own
click-to-edit handler doesn't also fire.
pages/Dashboard.ts +45 LOC — deletingState (StateView | null), a
confirm modal that names the entity_id in the body, Cancel /
Delete buttons in the footer (Delete styled in muted red),
`_confirmDelete()` dispatches DELETE with bearer, toast on
success, grid refresh.
Browser-verified end-to-end on real homecore-server :8123:
- Hover card → × button visible
- Click × → DELETE confirm modal (NOT edit modal — stopPropagation works)
- Modal names entity_id in code block
- Cancel: entity preserved, modal closes
- Delete: backend GET-after-DELETE returns 404, grid card vanishes,
toast "Deleted sensor.delete_target"
- 0 unexpected console errors (1 expected 404 from verification fetch)
Co-Authored-By: claude-flow <ruv@ruv.net>
184 lines
5.8 KiB
TypeScript
184 lines
5.8 KiB
TypeScript
/**
|
||
* `<hc-state-card>` — renders one HOMECORE entity state in the cognitum-v0 card style.
|
||
* Uses Lit 3 (LitElement + html/css template tags).
|
||
*/
|
||
|
||
import { LitElement, html, css, nothing } from 'lit';
|
||
import { customElement, property } from 'lit/decorators.js';
|
||
import type { StateView } from '../api/types.js';
|
||
|
||
@customElement('hc-state-card')
|
||
export class StateCard extends LitElement {
|
||
// `delegatesFocus` lets Tab key traversal from the light DOM reach the
|
||
// role="button" element inside this card's shadow root. Without it the
|
||
// user can only activate the card via mouse click or by JS-focusing the
|
||
// inner div; with it, the natural tab sequence flows through every card.
|
||
static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true };
|
||
|
||
@property({ type: Object }) state!: StateView;
|
||
/** Optional: icon SVG string (use `iconSvg()` from lucide.ts) */
|
||
@property({ type: String }) iconSvg?: string;
|
||
|
||
static styles = css`
|
||
:host {
|
||
display: block;
|
||
}
|
||
|
||
.card {
|
||
background: var(--hc-gradient-card, linear-gradient(180deg, #181c24 0%, #111318 100%));
|
||
border: 1px solid hsl(220 15% 18% / 0.5);
|
||
border-radius: var(--hc-radius, 0.75rem);
|
||
box-shadow: var(--hc-shadow-card, 0 8px 32px -8px hsl(220 25% 2% / 0.8));
|
||
padding: 1.25rem;
|
||
transition: transform 200ms, border-color 200ms;
|
||
}
|
||
|
||
.card:hover {
|
||
transform: translateY(-2px);
|
||
border-color: hsl(185 80% 50% / 0.4);
|
||
}
|
||
|
||
.card { cursor: pointer; position: relative; }
|
||
.card:focus-visible { outline: 2px solid var(--hc-primary, #19d4e5); outline-offset: 2px; }
|
||
button.delete {
|
||
position: absolute;
|
||
top: 0.5rem; right: 0.5rem;
|
||
width: 24px; height: 24px;
|
||
border: none;
|
||
border-radius: 4px;
|
||
background: transparent;
|
||
color: var(--hc-text-muted, #7b899d);
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
line-height: 1;
|
||
padding: 0;
|
||
opacity: 0;
|
||
transition: opacity 150ms, background 150ms, color 150ms;
|
||
}
|
||
.card:hover button.delete,
|
||
.card:focus-within button.delete { opacity: 1; }
|
||
button.delete:hover { background: hsl(0 50% 30%); color: hsl(0 80% 88%); }
|
||
button.delete:focus-visible { opacity: 1; outline: 2px solid hsl(0 60% 55%); }
|
||
|
||
.header {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 0.75rem;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
|
||
.icon-wrap {
|
||
flex-shrink: 0;
|
||
width: 38px;
|
||
height: 38px;
|
||
border-radius: var(--hc-radius-sm, 0.4rem);
|
||
background: hsl(220 20% 14%);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--hc-primary, #19d4e5);
|
||
}
|
||
|
||
.meta { flex: 1; min-width: 0; }
|
||
|
||
.entity-id {
|
||
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
|
||
font-size: 0.6875rem;
|
||
font-weight: 600;
|
||
color: var(--hc-text-muted, #7b899d);
|
||
text-overflow: ellipsis;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
|
||
.state-value {
|
||
font-family: var(--hc-font-display, 'Outfit', system-ui, sans-serif);
|
||
font-size: 1.125rem;
|
||
font-weight: 600;
|
||
color: var(--hc-text, #e6eaee);
|
||
letter-spacing: -0.02em;
|
||
margin-top: 0.2rem;
|
||
}
|
||
|
||
.badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 0.15rem 0.5rem;
|
||
border-radius: 9999px;
|
||
border: 1px solid var(--hc-border, #272b34);
|
||
font-family: var(--hc-font-mono, monospace);
|
||
font-size: 0.6875rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.badge.on { color: #26d867; border-color: hsl(142 70% 50% / 0.4); }
|
||
.badge.off { color: #d22c2c; border-color: hsl(0 65% 50% / 0.4); }
|
||
|
||
.timestamp {
|
||
font-family: var(--hc-font-mono, monospace);
|
||
font-size: 0.625rem;
|
||
color: var(--hc-text-muted, #7b899d);
|
||
margin-top: 0.75rem;
|
||
}
|
||
`;
|
||
|
||
private badgeClass(state: string): string {
|
||
const s = state.toLowerCase();
|
||
if (s === 'on' || s === 'open' || s === 'home' || s === 'running') return 'on';
|
||
if (s === 'off' || s === 'closed' || s === 'away' || s === 'unavailable') return 'off';
|
||
return '';
|
||
}
|
||
|
||
render() {
|
||
if (!this.state) return nothing;
|
||
const { entity_id, state, last_updated } = this.state;
|
||
const badge = this.badgeClass(state);
|
||
|
||
return html`
|
||
<div class="card" part="card" role="button" tabindex="0"
|
||
@click=${this._onClick}
|
||
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this._onClick(); } }}
|
||
aria-label="Edit ${entity_id}">
|
||
<button class="delete" type="button"
|
||
@click=${this._onDelete}
|
||
@keydown=${(e: KeyboardEvent) => { e.stopPropagation(); }}
|
||
aria-label="Delete ${entity_id}"
|
||
title="Delete ${entity_id}">×</button>
|
||
<div class="header">
|
||
${this.iconSvg
|
||
? html`<div class="icon-wrap" .innerHTML=${this.iconSvg}></div>`
|
||
: nothing}
|
||
<div class="meta">
|
||
<div class="entity-id" title=${entity_id}>${entity_id}</div>
|
||
<div class="state-value">${state}</div>
|
||
</div>
|
||
<span class="badge ${badge}">${state}</span>
|
||
</div>
|
||
<div class="timestamp">updated ${new Date(last_updated).toLocaleTimeString()}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
private _onClick() {
|
||
this.dispatchEvent(new CustomEvent('hc-state-card-click', {
|
||
detail: { state: this.state }, bubbles: true, composed: true,
|
||
}));
|
||
}
|
||
|
||
private _onDelete(e: Event) {
|
||
// Stop propagation so the parent card's click handler (which would
|
||
// open the edit modal) doesn't also fire.
|
||
e.stopPropagation();
|
||
this.dispatchEvent(new CustomEvent('hc-state-card-delete', {
|
||
detail: { state: this.state }, bubbles: true, composed: true,
|
||
}));
|
||
}
|
||
}
|
||
|
||
declare global {
|
||
interface HTMLElementTagNameMap {
|
||
'hc-state-card': StateCard;
|
||
}
|
||
}
|