mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0bb6f4fc7 | |||
| 89190b6c2d |
@@ -9,6 +9,12 @@ 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;
|
||||
@@ -32,6 +38,28 @@ export class StateCard extends LitElement {
|
||||
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;
|
||||
@@ -108,7 +136,15 @@ export class StateCard extends LitElement {
|
||||
const badge = this.badgeClass(state);
|
||||
|
||||
return html`
|
||||
<div class="card" part="card">
|
||||
<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>`
|
||||
@@ -123,6 +159,21 @@ export class StateCard extends LitElement {
|
||||
</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 {
|
||||
|
||||
@@ -102,6 +102,8 @@ export class Dashboard extends LitElement {
|
||||
@state() private loading = true;
|
||||
@state() private modalOpen = false;
|
||||
@state() private submitToast: string | null = null;
|
||||
@state() private editingState: StateView | null = null; // null = create mode
|
||||
@state() private deletingState: StateView | null = null; // null = no confirm
|
||||
|
||||
@query('hc-entity-form') private _form?: EntityForm;
|
||||
|
||||
@@ -135,8 +137,42 @@ export class Dashboard extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _openCreate() {
|
||||
this.editingState = null;
|
||||
this.modalOpen = true;
|
||||
}
|
||||
|
||||
private _openEdit(e: CustomEvent<{ state: StateView }>) {
|
||||
this.editingState = e.detail.state;
|
||||
this.modalOpen = true;
|
||||
}
|
||||
|
||||
private _openDeleteConfirm(e: CustomEvent<{ state: StateView }>) {
|
||||
this.deletingState = e.detail.state;
|
||||
}
|
||||
|
||||
private async _confirmDelete() {
|
||||
const target = this.deletingState;
|
||||
if (!target) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/states/${encodeURIComponent(target.entity_id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${resolveToken()}` },
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
|
||||
this.deletingState = null;
|
||||
this.submitToast = `Deleted ${target.entity_id}`;
|
||||
window.setTimeout(() => (this.submitToast = null), 3000);
|
||||
await this.refresh();
|
||||
} catch (err) {
|
||||
this.error = err instanceof Error ? err.message : String(err);
|
||||
this.deletingState = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async _onSubmit(e: CustomEvent<{ entity_id: string; state: string; attributes: Record<string, unknown> }>) {
|
||||
const { entity_id, state, attributes } = e.detail;
|
||||
const wasEditing = this.editingState !== null;
|
||||
try {
|
||||
const resp = await fetch(`/api/states/${encodeURIComponent(entity_id)}`, {
|
||||
method: 'POST',
|
||||
@@ -148,11 +184,11 @@ export class Dashboard extends LitElement {
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
|
||||
this.modalOpen = false;
|
||||
this.submitToast = `Created ${entity_id} = ${state}`;
|
||||
this.editingState = null;
|
||||
this.submitToast = `${wasEditing ? 'Updated' : 'Created'} ${entity_id} = ${state}`;
|
||||
window.setTimeout(() => (this.submitToast = null), 3000);
|
||||
await this.refresh();
|
||||
} catch (err) {
|
||||
// Form-level error stays in the form; surface at top too for visibility.
|
||||
this.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
@@ -173,7 +209,7 @@ export class Dashboard extends LitElement {
|
||||
${this.submitToast ? html`<div class="toast">${this.submitToast}</div>` : ''}
|
||||
<div class="toolbar">
|
||||
<span class="grow"></span>
|
||||
<button class="add" @click=${() => (this.modalOpen = true)}>+ Add entity</button>
|
||||
<button class="add" @click=${this._openCreate}>+ Add entity</button>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span><strong>${loc}</strong></span>
|
||||
@@ -187,19 +223,44 @@ export class Dashboard extends LitElement {
|
||||
or boot <code>homecore-server</code> without
|
||||
<code>--no-seed-entities</code>.
|
||||
</div>`
|
||||
: html`<div class="grid">
|
||||
: html`<div class="grid"
|
||||
@hc-state-card-click=${(e: Event) => this._openEdit(e as CustomEvent)}
|
||||
@hc-state-card-delete=${(e: Event) => this._openDeleteConfirm(e as CustomEvent)}>
|
||||
${this.states.map(
|
||||
(s) => html`<hc-state-card .state=${s}></hc-state-card>`
|
||||
)}
|
||||
</div>`}
|
||||
|
||||
<hc-modal .open=${this.modalOpen} heading="Add entity"
|
||||
@hc-modal-close=${() => (this.modalOpen = false)}>
|
||||
<hc-modal .open=${this.deletingState !== null}
|
||||
heading="Delete entity"
|
||||
@hc-modal-close=${() => (this.deletingState = null)}>
|
||||
<p style="margin:0 0 12px 0; line-height:1.5;">
|
||||
Permanently remove
|
||||
<code style="background:hsl(220 25% 14%); padding:2px 6px; border-radius:4px;">${this.deletingState?.entity_id ?? ''}</code>
|
||||
from the state machine?
|
||||
<br>
|
||||
<span style="color:var(--hc-text-muted,#7b899d); font-size:12px;">
|
||||
This is immediate. To restore, re-create the entity via "+ Add entity".
|
||||
</span>
|
||||
</p>
|
||||
<button slot="footer" class="btn" @click=${() => (this.deletingState = null)}>Cancel</button>
|
||||
<button slot="footer" class="btn"
|
||||
style="background:hsl(0 50% 25%); border-color:hsl(0 50% 35%); color:hsl(0 60% 88%);"
|
||||
@click=${this._confirmDelete}>Delete</button>
|
||||
</hc-modal>
|
||||
|
||||
<hc-modal .open=${this.modalOpen}
|
||||
heading=${this.editingState ? `Edit ${this.editingState.entity_id}` : 'Add entity'}
|
||||
@hc-modal-close=${() => { this.modalOpen = false; this.editingState = null; }}>
|
||||
<hc-entity-form
|
||||
.entityId=${this.editingState?.entity_id ?? ''}
|
||||
.state=${this.editingState?.state ?? ''}
|
||||
.entityAttrs=${this.editingState?.attributes ?? {}}
|
||||
.editing=${this.editingState !== null}
|
||||
@hc-entity-submit=${(e: Event) => this._onSubmit(e as CustomEvent)}
|
||||
@hc-entity-cancel=${() => (this.modalOpen = false)}></hc-entity-form>
|
||||
@hc-entity-cancel=${() => { this.modalOpen = false; this.editingState = null; }}></hc-entity-form>
|
||||
<button slot="footer" class="btn" @click=${() => this._form?.requestCancel()}>Cancel</button>
|
||||
<button slot="footer" class="btn primary" @click=${() => this._form?.requestSubmit()}>Create</button>
|
||||
<button slot="footer" class="btn primary" @click=${() => this._form?.requestSubmit()}>${this.editingState ? 'Save' : 'Create'}</button>
|
||||
</hc-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,12 @@ pub fn router(state: SharedState) -> Router {
|
||||
.route("/api/", get(rest::api_root))
|
||||
.route("/api/config", get(rest::get_config))
|
||||
.route("/api/states", get(rest::get_states))
|
||||
.route("/api/states/:entity_id", get(rest::get_state).post(rest::set_state))
|
||||
.route(
|
||||
"/api/states/:entity_id",
|
||||
get(rest::get_state)
|
||||
.post(rest::set_state)
|
||||
.delete(rest::delete_state),
|
||||
)
|
||||
.route("/api/services", get(rest::get_services))
|
||||
.route("/api/services/:domain/:service", post(rest::call_service))
|
||||
.route("/api/websocket", get(ws::websocket_handler))
|
||||
|
||||
@@ -92,6 +92,21 @@ pub struct SetStateRequest {
|
||||
pub attributes: serde_json::Value,
|
||||
}
|
||||
|
||||
/// DELETE /api/states/:entity_id — remove an entity from the state
|
||||
/// machine. Idempotent: returns 204 whether or not the entity existed,
|
||||
/// matching HA's removal semantics. 4xx only for malformed entity_id or
|
||||
/// auth failure.
|
||||
pub async fn delete_state(
|
||||
headers: HeaderMap,
|
||||
State(s): State<SharedState>,
|
||||
Path(entity_id): Path<String>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
|
||||
let id = EntityId::parse(entity_id).map_err(|e| ApiError::BadRequest(e.to_string()))?;
|
||||
s.homecore().states().remove(&id);
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn set_state(
|
||||
headers: HeaderMap,
|
||||
State(s): State<SharedState>,
|
||||
|
||||
Reference in New Issue
Block a user