Browse Source

Version 0.2.0

* Hide nanoleaf:IP devices in UI
* Color clicking behavior is more clear
* small icons for mega-groups
* common tags/roles for groups
* fix default names in UI state
* hexagon corner
* secondary lamp icon
* fix selection bugs in device form
* select all/none hotkey
* quick assign modal
beelzebub 0.2.0
Gisle Aune 1 year ago
parent
commit
5fee980f79
  1. 3
      frontend/build/.gitignore
  2. 1
      frontend/build/_app/immutable/assets/2.8d52c342.css
  3. 1
      frontend/build/_app/immutable/assets/_page.b4e1e124.css
  4. 1
      frontend/build/_app/immutable/chunks/SelectContext.07ce997f.js
  5. 1
      frontend/build/_app/immutable/chunks/index.f09c780f.js
  6. 1
      frontend/build/_app/immutable/chunks/index.faf1c53d.js
  7. 1
      frontend/build/_app/immutable/chunks/singletons.6d605789.js
  8. 1
      frontend/build/_app/immutable/entry/app.16612d08.js
  9. 3
      frontend/build/_app/immutable/entry/start.08a3b3b3.js
  10. 1
      frontend/build/_app/immutable/nodes/0.202e929e.js
  11. 1
      frontend/build/_app/immutable/nodes/1.e0de7c8c.js
  12. 198
      frontend/build/_app/immutable/nodes/2.2ea37eba.js
  13. 1
      frontend/build/_app/version.json
  14. BIN
      frontend/build/color-hsk.png
  15. BIN
      frontend/build/color-xy.png
  16. BIN
      frontend/build/favicon.png
  17. 68
      frontend/build/index.html
  18. BIN
      frontend/icons.blend
  19. 29
      frontend/src/lib/components/ColorPicker.svelte
  20. 4
      frontend/src/lib/components/DeviceIcon.svelte
  21. 2
      frontend/src/lib/components/Icon.svelte
  22. 5
      frontend/src/lib/components/Lamp.svelte
  23. 67
      frontend/src/lib/components/MetaLamp.svelte
  24. 18
      frontend/src/lib/components/bforms/BFormColorOption.svelte
  25. 315
      frontend/src/lib/components/exetrnal/Range.svelte
  26. 13
      frontend/src/lib/components/icons/generic_lamp2.svg
  27. 10
      frontend/src/lib/components/icons/shape_hexagon_corner.svg
  28. 1
      frontend/src/lib/contexts/ModalContext.svelte
  29. 7
      frontend/src/lib/contexts/SelectContext.svelte
  30. 10
      frontend/src/lib/contexts/StateContext.svelte
  31. 11
      frontend/src/lib/modals/DeviceModal.svelte
  32. 94
      frontend/src/lib/modals/QuickAssignModal.svelte
  33. 1
      frontend/src/lib/modals/ScriptModal.svelte
  34. 19
      frontend/src/routes/+page.svelte
  35. BIN
      lucifer4-server
  36. 4
      services/nanoleaf/client.go
  37. 2
      services/nanoleaf/data.go
  38. 17
      services/uistate/data.go
  39. 3
      services/uistate/patch.go

3
frontend/build/.gitignore

@ -1,3 +0,0 @@
/_app
*.png
*.html

1
frontend/build/_app/immutable/assets/2.8d52c342.css
File diff suppressed because it is too large
View File

1
frontend/build/_app/immutable/assets/_page.b4e1e124.css
File diff suppressed because it is too large
View File

1
frontend/build/_app/immutable/chunks/SelectContext.07ce997f.js
File diff suppressed because it is too large
View File

1
frontend/build/_app/immutable/chunks/index.f09c780f.js
File diff suppressed because it is too large
View File

1
frontend/build/_app/immutable/chunks/index.faf1c53d.js

@ -0,0 +1 @@
import{H as f,s as y,a8 as m,Z as q,a9 as w}from"./index.f09c780f.js";const o=[];function z(n,i){return{subscribe:A(n,i).subscribe}}function A(n,i=f){let u;const t=new Set;function a(e){if(y(n,e)&&(n=e,u)){const r=!o.length;for(const s of t)s[1](),o.push(s,n);if(r){for(let s=0;s<o.length;s+=2)o[s][0](o[s+1]);o.length=0}}}function l(e){a(e(n))}function b(e,r=f){const s=[e,r];return t.add(s),t.size===1&&(u=i(a)||f),e(n),()=>{t.delete(s),t.size===0&&(u(),u=null)}}return{set:a,update:l,subscribe:b}}function H(n,i,u){const t=!Array.isArray(n),a=t?[n]:n,l=i.length<2;return z(u,b=>{let e=!1;const r=[];let s=0,d=f;const g=()=>{if(s)return;d();const c=i(t?r[0]:r,b);l?b(c):d=w(c)?c:f},_=a.map((c,p)=>m(c,h=>{r[p]=h,s&=~(1<<p),e&&g()},()=>{s|=1<<p}));return e=!0,g(),function(){q(_),d()}})}export{H as d,A as w};

1
frontend/build/_app/immutable/chunks/singletons.6d605789.js

@ -0,0 +1 @@
import{w as u}from"./index.faf1c53d.js";var _;const v=((_=globalThis.__sveltekit_1k9l1nc)==null?void 0:_.base)??"";var g;const k=((g=globalThis.__sveltekit_1k9l1nc)==null?void 0:g.assets)??v,m="1697835102898",R="sveltekit:snapshot",T="sveltekit:scroll",y="sveltekit:index",f={tap:1,hover:2,viewport:3,eager:4,off:-1};function I(e){let t=e.baseURI;if(!t){const n=e.getElementsByTagName("base");t=n.length?n[0].href:e.URL}return t}function S(){return{x:pageXOffset,y:pageYOffset}}function c(e,t){return e.getAttribute(`data-sveltekit-${t}`)}const d={...f,"":f.hover};function h(e){let t=e.assignedSlot??e.parentNode;return(t==null?void 0:t.nodeType)===11&&(t=t.host),t}function x(e,t){for(;e&&e!==t;){if(e.nodeName.toUpperCase()==="A"&&e.hasAttribute("href"))return e;e=h(e)}}function O(e,t){let n;try{n=new URL(e instanceof SVGAElement?e.href.baseVal:e.href,document.baseURI)}catch{}const o=e instanceof SVGAElement?e.target.baseVal:e.target,l=!n||!!o||E(n,t)||(e.getAttribute("rel")||"").split(/\s+/).includes("external"),r=(n==null?void 0:n.origin)===location.origin&&e.hasAttribute("download");return{url:n,external:l,target:o,download:r}}function U(e){let t=null,n=null,o=null,l=null,r=null,a=null,s=e;for(;s&&s!==document.documentElement;)o===null&&(o=c(s,"preload-code")),l===null&&(l=c(s,"preload-data")),t===null&&(t=c(s,"keepfocus")),n===null&&(n=c(s,"noscroll")),r===null&&(r=c(s,"reload")),a===null&&(a=c(s,"replacestate")),s=h(s);function i(b){switch(b){case"":case"true":return!0;case"off":case"false":return!1;default:return null}}return{preload_code:d[o??"off"],preload_data:d[l??"off"],keep_focus:i(t),noscroll:i(n),reload:i(r),replace_state:i(a)}}function p(e){const t=u(e);let n=!0;function o(){n=!0,t.update(a=>a)}function l(a){n=!1,t.set(a)}function r(a){let s;return t.subscribe(i=>{(s===void 0||n&&i!==s)&&a(s=i)})}return{notify:o,set:l,subscribe:r}}function w(){const{set:e,subscribe:t}=u(!1);let n;async function o(){clearTimeout(n);try{const l=await fetch(`${k}/_app/version.json`,{headers:{pragma:"no-cache","cache-control":"no-cache"}});if(!l.ok)return!1;const a=(await l.json()).version!==m;return a&&(e(!0),clearTimeout(n)),a}catch{return!1}}return{subscribe:t,check:o}}function E(e,t){return e.origin!==location.origin||!e.pathname.startsWith(t)}function L(e){e.client}const N={url:p({}),page:p({}),navigating:u(null),updated:w()};export{y as I,f as P,T as S,R as a,O as b,U as c,N as d,v as e,x as f,I as g,L as h,E as i,S as s};

1
frontend/build/_app/immutable/entry/app.16612d08.js
File diff suppressed because it is too large
View File

3
frontend/build/_app/immutable/entry/start.08a3b3b3.js
File diff suppressed because it is too large
View File

1
frontend/build/_app/immutable/nodes/0.202e929e.js

@ -0,0 +1 @@
import{S as i,i as _,s as m,y as c,z as $,A as f,g as r,d as l,B as u,C as p,D as g,E as d,F as S}from"../chunks/index.f09c780f.js";import{S as x,a as h,M as C}from"../chunks/SelectContext.07ce997f.js";const b=!0,q=Object.freeze(Object.defineProperty({__proto__:null,prerender:b},Symbol.toStringTag,{value:"Module"}));function v(a){let t;const o=a[0].default,e=p(o,a,a[1],null);return{c(){e&&e.c()},l(n){e&&e.l(n)},m(n,s){e&&e.m(n,s),t=!0},p(n,s){e&&e.p&&(!t||s&2)&&g(e,o,n,n[1],t?S(o,n[1],s,null):d(n[1]),null)},i(n){t||(r(e,n),t=!0)},o(n){l(e,n),t=!1},d(n){e&&e.d(n)}}}function w(a){let t,o;return t=new C({props:{$$slots:{default:[v]},$$scope:{ctx:a}}}),{c(){c(t.$$.fragment)},l(e){$(t.$$.fragment,e)},m(e,n){f(t,e,n),o=!0},p(e,n){const s={};n&2&&(s.$$scope={dirty:n,ctx:e}),t.$set(s)},i(e){o||(r(t.$$.fragment,e),o=!0)},o(e){l(t.$$.fragment,e),o=!1},d(e){u(t,e)}}}function y(a){let t,o;return t=new h({props:{$$slots:{default:[w]},$$scope:{ctx:a}}}),{c(){c(t.$$.fragment)},l(e){$(t.$$.fragment,e)},m(e,n){f(t,e,n),o=!0},p(e,n){const s={};n&2&&(s.$$scope={dirty:n,ctx:e}),t.$set(s)},i(e){o||(r(t.$$.fragment,e),o=!0)},o(e){l(t.$$.fragment,e),o=!1},d(e){u(t,e)}}}function M(a){let t,o;return t=new x({props:{$$slots:{default:[y]},$$scope:{ctx:a}}}),{c(){c(t.$$.fragment)},l(e){$(t.$$.fragment,e)},m(e,n){f(t,e,n),o=!0},p(e,[n]){const s={};n&2&&(s.$$scope={dirty:n,ctx:e}),t.$set(s)},i(e){o||(r(t.$$.fragment,e),o=!0)},o(e){l(t.$$.fragment,e),o=!1},d(e){u(t,e)}}}function j(a,t,o){let{$$slots:e={},$$scope:n}=t;return a.$$set=s=>{"$$scope"in s&&o(1,n=s.$$scope)},[e,n]}class A extends i{constructor(t){super(),_(this,t,j,M,m,{})}}export{A as component,q as universal};

1
frontend/build/_app/immutable/nodes/1.e0de7c8c.js

@ -0,0 +1 @@
import{S,i as q,s as x,k as _,q as f,a as H,l as d,m as g,r as h,h as u,c as k,b as m,G as v,u as $,H as E,I as y}from"../chunks/index.f09c780f.js";import{d as C}from"../chunks/singletons.6d605789.js";const G=()=>{const s=C;return{page:{subscribe:s.page.subscribe},navigating:{subscribe:s.navigating.subscribe},updated:s.updated}},I={subscribe(s){return G().page.subscribe(s)}};function P(s){var b;let t,r=s[0].status+"",o,n,i,c=((b=s[0].error)==null?void 0:b.message)+"",l;return{c(){t=_("h1"),o=f(r),n=H(),i=_("p"),l=f(c)},l(e){t=d(e,"H1",{});var a=g(t);o=h(a,r),a.forEach(u),n=k(e),i=d(e,"P",{});var p=g(i);l=h(p,c),p.forEach(u)},m(e,a){m(e,t,a),v(t,o),m(e,n,a),m(e,i,a),v(i,l)},p(e,[a]){var p;a&1&&r!==(r=e[0].status+"")&&$(o,r),a&1&&c!==(c=((p=e[0].error)==null?void 0:p.message)+"")&&$(l,c)},i:E,o:E,d(e){e&&u(t),e&&u(n),e&&u(i)}}}function j(s,t,r){let o;return y(s,I,n=>r(0,o=n)),[o]}let A=class extends S{constructor(t){super(),q(this,t,j,P,x,{})}};export{A as component};

198
frontend/build/_app/immutable/nodes/2.2ea37eba.js
File diff suppressed because it is too large
View File

1
frontend/build/_app/version.json

@ -0,0 +1 @@
{"version":"1697835102898"}

BIN
frontend/build/color-hsk.png

After

Width: 1000  |  Height: 1000  |  Size: 173 KiB

BIN
frontend/build/color-xy.png

After

Width: 500  |  Height: 500  |  Size: 45 KiB

BIN
frontend/build/favicon.png

After

Width: 128  |  Height: 128  |  Size: 1.5 KiB

68
frontend/build/index.html

@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="./favicon.png" />
<meta name="viewport" content="width=device-width" />
<meta name="darkreader" content="we've already gone dark" />
<style>
body, html {
background: #111114;
color: #ccc;
margin: 0;
padding: 0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
</style>
<link href="./_app/immutable/assets/2.8d52c342.css" rel="stylesheet">
<link rel="modulepreload" href="./_app/immutable/entry/start.08a3b3b3.js">
<link rel="modulepreload" href="./_app/immutable/chunks/index.f09c780f.js">
<link rel="modulepreload" href="./_app/immutable/chunks/singletons.6d605789.js">
<link rel="modulepreload" href="./_app/immutable/chunks/index.faf1c53d.js">
<link rel="modulepreload" href="./_app/immutable/entry/app.16612d08.js">
<link rel="modulepreload" href="./_app/immutable/nodes/0.202e929e.js">
<link rel="modulepreload" href="./_app/immutable/chunks/SelectContext.07ce997f.js">
<link rel="modulepreload" href="./_app/immutable/nodes/2.2ea37eba.js">
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">
<div class="page svelte-170wh9b"></div>
<form novalidate></form>
<form novalidate></form>
<form novalidate></form>
<script>
{
__sveltekit_1k9l1nc = {
base: new URL(".", location).pathname.slice(0, -1),
env: {}
};
const element = document.currentScript.parentElement;
const data = [null,null];
Promise.all([
import("./_app/immutable/entry/start.08a3b3b3.js"),
import("./_app/immutable/entry/app.16612d08.js")
]).then(([kit, app]) => {
kit.start(app, element, {
node_ids: [0, 2],
data,
form: null,
error: null
});
});
}
</script>
</div>
</body>
</html>

BIN
frontend/icons.blend

29
frontend/src/lib/components/ColorPicker.svelte

@ -1,14 +1,19 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
export const selectedColorPicker = writable<any>(null);</script>
export const selectedColorPicker = writable<any>(null);
</script>
<script lang="ts"> <script lang="ts">
import type { Color } from "$lib/models/color"; import type { Color } from "$lib/models/color";
import { rgb2hsv } from "$lib/utils/color"; import { rgb2hsv } from "$lib/utils/color";
import { tick } from "svelte";
import { createEventDispatcher, tick } from "svelte";
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import Icon from "./Icon.svelte";
const dispatch = createEventDispatcher();
export let color: Color; export let color: Color;
export let id: any = null; export let id: any = null;
export let deletable: boolean = false;
let xy: boolean; let xy: boolean;
let x: number; let x: number;
@ -62,8 +67,8 @@
} }
</script> </script>
{#if $selectedColorPicker == id}
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
{#if $selectedColorPicker == id}
<div class="color-picker" on:click={onClickColor}> <div class="color-picker" on:click={onClickColor}>
{#if xy} {#if xy}
<img draggable="false" alt="color wheel" src="/color-xy.png" /> <img draggable="false" alt="color wheel" src="/color-xy.png" />
@ -77,11 +82,29 @@
</div> </div>
{/if} {/if}
</div> </div>
<div class="color-picker-buttons">
{#if deletable} <Icon block name="trash" on:click={() => dispatch("delete")} /> {/if}
</div>
{/if} {/if}
<style lang="sass"> <style lang="sass">
@import "$lib/css/colors.sass" @import "$lib/css/colors.sass"
div.color-picker-buttons
position: relative
color: $color-main7
top: -12.3ch
height: 1em
width: 10ch
display: flex
flex-direction: row-reverse
:global(div.icon-wrapper)
padding: 0.05em
&:hover
color: $color-main8
div.color-picker div.color-picker
position: relative position: relative
width: 10ch width: 10ch

4
frontend/src/lib/components/DeviceIcon.svelte

@ -1,5 +1,6 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
import generic_lamp from "./icons/generic_lamp.svg?raw"; import generic_lamp from "./icons/generic_lamp.svg?raw";
import generic_lamp2 from "./icons/generic_lamp2.svg?raw";
import generic_boob from "./icons/generic_boob.svg?raw"; import generic_boob from "./icons/generic_boob.svg?raw";
import generic_ball from "./icons/generic_ball.svg?raw"; import generic_ball from "./icons/generic_ball.svg?raw";
import generic_strip from "./icons/generic_strip.svg?raw"; import generic_strip from "./icons/generic_strip.svg?raw";
@ -15,12 +16,14 @@
import hue_go from "./icons/hue_go.svg?raw"; import hue_go from "./icons/hue_go.svg?raw";
import shape_square from "./icons/shape_square.svg?raw"; import shape_square from "./icons/shape_square.svg?raw";
import shape_hexagon from "./icons/shape_hexagon.svg?raw"; import shape_hexagon from "./icons/shape_hexagon.svg?raw";
import shape_hexagon_corner from "./icons/shape_hexagon_corner.svg?raw";
import shape_triangle from "./icons/shape_triangle.svg?raw"; import shape_triangle from "./icons/shape_triangle.svg?raw";
import hue_ensis_up from "./icons/hue_ensis_up.svg?raw"; import hue_ensis_up from "./icons/hue_ensis_up.svg?raw";
import hue_ensis_down from "./icons/hue_ensis_down.svg?raw"; import hue_ensis_down from "./icons/hue_ensis_down.svg?raw";
export const deviceIconMap = Object.seal({ export const deviceIconMap = Object.seal({
generic_lamp, generic_lamp,
generic_lamp2,
generic_boob, generic_boob,
generic_ball, generic_ball,
generic_strip, generic_strip,
@ -35,6 +38,7 @@
hue_playbar, hue_playbar,
shape_square, shape_square,
shape_hexagon, shape_hexagon,
shape_hexagon_corner,
shape_triangle, shape_triangle,
hue_go, hue_go,
hue_ensis_up, hue_ensis_up,

2
frontend/src/lib/components/Icon.svelte

@ -8,7 +8,7 @@
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
{#if block} {#if block}
<div on:click class:marginAutio>
<div class="icon-wrapper" on:click class:marginAutio>
<Icon class="icon" icon={icons[name] || icons.question} /> <Icon class="icon" icon={icons[name] || icons.question} />
</div> </div>
{:else} {:else}

5
frontend/src/lib/components/Lamp.svelte

@ -29,6 +29,7 @@
let roles: string[]; let roles: string[];
let tags: string[]; let tags: string[];
let hasRoleOrTag: boolean; let hasRoleOrTag: boolean;
$: { $: {
// TODO: Fix device.name on the backend // TODO: Fix device.name on the backend
const nameAlias = device.aliases?.find(a => a.startsWith("lucifer:name:")); const nameAlias = device.aliases?.find(a => a.startsWith("lucifer:name:"));
@ -109,7 +110,7 @@
</script> </script>
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="lamp" class:compact class:selected={$selectedMap[device.id]} on:click={onSelect}>
<div title={deviceTitle} class="lamp" class:compact class:selected={$selectedMap[device.id]} on:click={onSelect}>
<div class="row"> <div class="row">
<div class="row-icon"> <div class="row-icon">
{#if iconColor != null} {#if iconColor != null}
@ -123,7 +124,7 @@
{/if} {/if}
</div> </div>
<div class="title"> <div class="title">
<div class="name" class:hasRoleOrTag>{displayTitle}</div>
<div class="name" class:hasRoleOrTag>{deviceTitle}</div>
{#if hasRoleOrTag} {#if hasRoleOrTag}
<div class="tag-list"> <div class="tag-list">
{#each roles as role} {#each roles as role}

67
frontend/src/lib/components/MetaLamp.svelte

@ -6,9 +6,8 @@
<script lang="ts"> <script lang="ts">
import { getSelectedContext } from "$lib/contexts/SelectContext.svelte"; import { getSelectedContext } from "$lib/contexts/SelectContext.svelte";
import type Device from "$lib/models/device"; import type Device from "$lib/models/device";
import { SupportFlags } from "$lib/models/device";
import { rgb, type ColorRGB } from "../models/color"; import { rgb, type ColorRGB } from "../models/color";
import DeviceIcon from "./DeviceIcon.svelte";
import Icon from "./Icon.svelte";
import Lamp from "./Lamp.svelte"; import Lamp from "./Lamp.svelte";
export let name: string export let name: string
@ -23,6 +22,11 @@
} }
$: selected = !devices.find(d => !$selectedMap[d.id]) $: selected = !devices.find(d => !$selectedMap[d.id])
$: tiny = devices.length > 30;
$: allAliases = devices.flatMap(d => d.aliases).sort().filter((e, i, a) => a[i-1] !== e);
$: allTags = allAliases.filter(a => a.startsWith("lucifer:tag:")).map(a => a.split(":")[2]);
$: allRoles = allAliases.filter(a => a.startsWith("lucifer:role:")).map(a => a.split(":")[2]);
$: uncommon = allAliases.filter(a => devices.find(d => !d.aliases.includes(a)))
</script> </script>
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
@ -30,20 +34,36 @@
<div class="row"> <div class="row">
<div class="title" on:click={onSelect}>{name}</div> <div class="title" on:click={onSelect}>{name}</div>
</div> </div>
<div class="row">
<div class="row" class:tiny>
{#each devices as device (device.id)} {#each devices as device (device.id)}
<Lamp compact device={device} /> <Lamp compact device={device} />
{/each} {/each}
<!-- Skittent datasnok -->
{#each Array.from({length: 64}) as _}
<div style="width: 3.6ch"></div> <div style="width: 3.6ch"></div>
<div style="width: 3.6ch"></div>
<div style="width: 3.6ch"></div>
<div style="width: 3.6ch"></div>
<div style="width: 3.6ch"></div>
<div style="width: 3.6ch"></div>
<div style="width: 3.6ch"></div>
<div style="width: 3.6ch"></div>
<div style="width: 3.6ch"></div>
<div style="width: 3.6ch"></div>
{/each}
</div>
<div class="row">
{#if allTags.length > 0 || allRoles.length > 0}
<div class="tag-list">
{#each allRoles as role}
<div class="tag" class:uncommon={uncommon.includes(`lucifer:role:${role}`)}>
<Icon block name="masks_theater" />
<div class="tag-name">
{role}
</div>
</div>
{/each}
{#each allTags as tag}
<div class="tag" class:uncommon={uncommon.includes(`lucifer:tag:${tag}`)}>
<Icon block name="tag" />
<div class="tag-name">
{tag}
</div>
</div>
{/each}
</div>
{/if}
</div> </div>
</div> </div>
@ -93,6 +113,28 @@
align-content: center align-content: center
justify-content: space-between justify-content: space-between
&.tiny
margin-top: 0.2em
font-size: 0.4em
> div.tag-list
display: flex
flex-direction: row
font-size: 0.5em
margin-top: 0.5em
> div.tag
display: flex
flex-direction: row
padding: 0 0.5ch
padding-right: 1ch
&.uncommon
opacity: 0.333
div.tag-name
padding-left: 0.5ch
> div.title > div.title
font-size: 0.75em font-size: 0.75em
text-align: left text-align: left
@ -101,4 +143,5 @@
height: 1.6em height: 1.6em
user-select: none user-select: none
margin-left: 0.4ch margin-left: 0.4ch
width: 100%
</style> </style>

18
frontend/src/lib/components/bforms/BFormColorOption.svelte

@ -19,7 +19,10 @@
$: submit(color); $: submit(color);
function toggle(event: MouseEvent & { currentTarget: EventTarget & HTMLDivElement; }) { function toggle(event: MouseEvent & { currentTarget: EventTarget & HTMLDivElement; }) {
if (event.shiftKey) {
if (color === null) {
color = { k: 2750 }
$selectedColorPicker = colorPickerId;
} else {
if (color !== null) { if (color !== null) {
if ($selectedColorPicker !== colorPickerId) { if ($selectedColorPicker !== colorPickerId) {
$selectedColorPicker = colorPickerId; $selectedColorPicker = colorPickerId;
@ -30,17 +33,10 @@
return return
} }
}
if (color === null || (color.hs == null && color.k == null)) {
color = { k: 2750 }
$selectedColorPicker = colorPickerId;
} else {
function clearColor() {
color = null; color = null;
if ($selectedColorPicker === colorPickerId) {
$selectedColorPicker = null;
}
}
} }
function submit(color: Color | null) { function submit(color: Color | null) {
@ -80,7 +76,7 @@
<BFormOption on:click={toggle} icon="palette" state={(color !== null) || null} color={colorStr}> <BFormOption on:click={toggle} icon="palette" state={(color !== null) || null} color={colorStr}>
{#if color != null} {#if color != null}
<div class="picker-wrapper"> <div class="picker-wrapper">
<ColorPicker bind:color={color} id={colorPickerId} />
<ColorPicker on:delete={clearColor} deletable bind:color={color} id={colorPickerId} />
</div> </div>
{/if} {/if}
{#if color?.xy != null} {#if color?.xy != null}

315
frontend/src/lib/components/exetrnal/Range.svelte

@ -0,0 +1,315 @@
<script lang="js">
import { createEventDispatcher } from "svelte";
import { fly, fade } from "svelte/transition";
// Props
export let min = 0;
export let max = 100;
export let initialValue = 0;
export let id = null;
export let value =
typeof initialValue === "string" ? parseInt(initialValue) : initialValue;
// Node Bindings
let container = null;
let thumb = null;
let progressBar = null;
let element = null;
// Internal State
let elementX = null;
let currentThumb = null;
let holding = false;
let thumbHover = false;
let keydownAcceleration = 0;
let accelerationTimer = null;
// Dispatch 'change' events
const dispatch = createEventDispatcher();
// Mouse shield used onMouseDown to prevent any mouse events penetrating other elements,
// ie. hover events on other elements while dragging. Especially for Safari
const mouseEventShield = document.createElement("div");
mouseEventShield.setAttribute("class", "mouse-over-shield");
mouseEventShield.addEventListener("mouseover", (e) => {
e.preventDefault();
e.stopPropagation();
});
function resizeWindow() {
elementX = element.getBoundingClientRect().left;
}
// Allows both bind:value and on:change for parent value retrieval
function setValue(val) {
value = val;
dispatch("change", { value });
}
function onTrackEvent(e) {
// Update value immediately before beginning drag
updateValueOnEvent(e);
onDragStart(e);
}
function onHover(e) {
thumbHover = thumbHover ? false : true;
}
function onDragStart(e) {
// If mouse event add a pointer events shield
if (e.type === "mousedown") document.body.append(mouseEventShield);
currentThumb = thumb;
}
function onDragEnd(e) {
// If using mouse - remove pointer event shield
if (e.type === "mouseup") {
if (document.body.contains(mouseEventShield))
document.body.removeChild(mouseEventShield);
// Needed to check whether thumb and mouse overlap after shield removed
if (isMouseInElement(e, thumb)) thumbHover = true;
}
currentThumb = null;
}
// Check if mouse event cords overlay with an element's area
function isMouseInElement(event, element) {
let rect = element.getBoundingClientRect();
let { clientX: x, clientY: y } = event;
if (x < rect.left || x >= rect.right) return false;
if (y < rect.top || y >= rect.bottom) return false;
return true;
}
// Accessible keypress handling
function onKeyPress(e) {
// Max out at +/- 10 to value per event (50 events / 5)
// 100 below is to increase the amount of events required to reach max velocity
if (keydownAcceleration < 50) keydownAcceleration++;
let throttled = Math.ceil(keydownAcceleration / 5);
if (e.key === "ArrowUp" || e.key === "ArrowRight") {
if (value + throttled > max || value >= max) {
setValue(max);
} else {
setValue(value + throttled);
}
}
if (e.key === "ArrowDown" || e.key === "ArrowLeft") {
if (value - throttled < min || value <= min) {
setValue(min);
} else {
setValue(value - throttled);
}
}
// Reset acceleration after 100ms of no events
clearTimeout(accelerationTimer);
accelerationTimer = setTimeout(() => (keydownAcceleration = 1), 100);
}
function calculateNewValue(clientX) {
// Find distance between cursor and element's left cord (20px / 2 = 10px) - Center of thumb
let delta = clientX - (elementX + 10);
// Use width of the container minus (5px * 2 sides) offset for percent calc
let percent = (delta * 100) / (container.clientWidth - 10);
// Limit percent 0 -> 100
percent = percent < 0 ? 0 : percent > 100 ? 100 : percent;
// Limit value min -> max
setValue(parseInt((percent * (max - min)) / 100) + min);
}
// Handles both dragging of touch/mouse as well as simple one-off click/touches
function updateValueOnEvent(e) {
// touchstart && mousedown are one-off updates, otherwise expect a currentPointer node
if (!currentThumb && e.type !== "touchstart" && e.type !== "mousedown")
return false;
if (e.stopPropagation) e.stopPropagation();
if (e.preventDefault) e.preventDefault();
// Get client's x cord either touch or mouse
const clientX =
e.type === "touchmove" || e.type === "touchstart"
? e.touches[0].clientX
: e.clientX;
calculateNewValue(clientX);
}
// React to left position of element relative to window
$: if (element) elementX = element.getBoundingClientRect().left;
// Set a class based on if dragging
$: holding = Boolean(currentThumb);
// Update progressbar and thumb styles to represent value
$: if (progressBar && thumb) {
// Limit value min -> max
value = value > min ? value : min;
value = value < max ? value : max;
let percent = ((value - min) * 100) / (max - min);
let offsetLeft = (container.clientWidth - 10) * (percent / 100) + 5;
// Update thumb position + active range track width
thumb.style.left = `${offsetLeft}px`;
progressBar.style.width = `${offsetLeft}px`;
}
</script>
<svelte:window
on:touchmove|nonpassive={updateValueOnEvent}
on:touchcancel={onDragEnd}
on:touchend={onDragEnd}
on:mousemove={updateValueOnEvent}
on:mouseup={onDragEnd}
on:resize={resizeWindow}
/>
<div class="range">
<div
class="range__wrapper"
tabindex="0"
on:keydown={onKeyPress}
bind:this={element}
role="slider"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
{id}
on:mousedown={onTrackEvent}
on:touchstart={onTrackEvent}
>
<div class="range__track" bind:this={container}>
<div class="range__track--highlighted" bind:this={progressBar} />
<div
class="range__thumb"
class:range__thumb--holding={holding}
bind:this={thumb}
on:touchstart={onDragStart}
on:mousedown={onDragStart}
on:mouseover={() => (thumbHover = true)}
on:mouseout={() => (thumbHover = false)}
>
{#if holding || thumbHover}
<div
class="range__tooltip"
in:fly={{ y: 7, duration: 200 }}
out:fade={{ duration: 100 }}
>
{value}
</div>
{/if}
</div>
</div>
</div>
</div>
<svelte:head>
<style>
.mouse-over-shield {
position: fixed;
top: 0px;
left: 0px;
height: 100%;
width: 100%;
background-color: rgba(255, 0, 0, 0);
z-index: 10000;
cursor: grabbing;
}
</style>
</svelte:head>
<style>
.range {
position: relative;
flex: 1;
}
.range__wrapper {
min-width: 100%;
position: relative;
padding: 0.5rem;
box-sizing: border-box;
outline: none;
}
.range__wrapper:focus-visible > .range__track {
box-shadow: 0 0 0 2px white, 0 0 0 3px var(--track-focus, #6185ff);
}
.range__track {
height: 6px;
background-color: var(--track-bgcolor, #d0d0d0);
border-radius: 999px;
}
.range__track--highlighted {
background-color: var(--track-highlight-bgcolor, #6185ff);
background: var(
--track-highlight-bg,
linear-gradient(90deg, #6185ff, #9c65ff)
);
width: 0;
height: 6px;
position: absolute;
border-radius: 999px;
}
.range__thumb {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
width: 20px;
height: 20px;
background-color: var(--thumb-bgcolor, white);
cursor: pointer;
border-radius: 999px;
margin-top: -8px;
transition: box-shadow 100ms;
user-select: none;
box-shadow: var(
--thumb-boxshadow,
0 1px 1px 0 rgba(0, 0, 0, 0.14),
0 0px 2px 1px rgba(0, 0, 0, 0.2)
);
}
.range__thumb--holding {
box-shadow: 0 1px 1px 0 rgba(0, 0, 0, 0.14),
0 1px 2px 1px rgba(0, 0, 0, 0.2),
0 0 0 6px var(--thumb-holding-outline, rgba(113, 119, 250, 0.3));
}
.range__tooltip {
pointer-events: none;
position: absolute;
top: -33px;
color: var(--tooltip-text, white);
width: 38px;
padding: 4px 0;
border-radius: 4px;
text-align: center;
background-color: var(--tooltip-bgcolor, #6185ff);
background: var(--tooltip-bg, linear-gradient(45deg, #6185ff, #9c65ff));
}
.range__tooltip::after {
content: "";
display: block;
position: absolute;
height: 7px;
width: 7px;
background-color: var(--tooltip-bgcolor, #6185ff);
bottom: -3px;
left: calc(50% - 3px);
clip-path: polygon(0% 0%, 100% 100%, 0% 100%);
transform: rotate(-45deg);
border-radius: 0 0 0 3px;
}
</style>

13
frontend/src/lib/components/icons/generic_lamp2.svg

@ -0,0 +1,13 @@
<?xml version='1.0' encoding='ascii'?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="320" height="320">
<g id="View Layer_LineSet" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" inkscape:groupmode="lineset" inkscape:label="View Layer_LineSet">
<g xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" inkscape:groupmode="layer" id="strokes" inkscape:label="strokes">
<path style="fill:#0000ff;fill-opacity:1" fill="none" stroke-width="3.0" stroke-linecap="butt" stroke-opacity="1.0" stroke="rgb(0, 0, 0)" stroke-linejoin="miter" d=" M 200.384, 268.295 198.051, 264.451 194.264, 260.908 189.167, 257.803 182.957, 255.255 175.872, 253.361 168.184, 252.195 160.188, 251.801 152.193, 252.195 144.505, 253.361 137.419, 255.255 131.209, 257.803 126.112, 260.908 122.325, 264.451 119.993, 268.295 119.205, 272.293 119.205, 278.205 119.993, 282.203 122.325, 286.047 126.112, 289.589 131.209, 292.695 137.419, 295.243 144.505, 297.137 152.193, 298.303 160.188, 298.696 168.184, 298.303 175.872, 297.137 182.957, 295.243 189.167, 292.695 194.264, 289.589 198.051, 286.047 200.384, 282.203 201.171, 278.205 " />
<path style="fill:#0000ff;fill-opacity:1" fill="none" stroke-width="3.0" stroke-linecap="butt" stroke-opacity="1.0" stroke="rgb(0, 0, 0)" stroke-linejoin="miter" d=" M 119.205, 272.293 119.993, 276.291 122.325, 280.135 126.112, 283.677 131.209, 286.783 137.419, 289.331 144.505, 291.224 152.193, 292.391 160.188, 292.784 168.184, 292.391 175.872, 291.224 182.957, 289.331 189.167, 286.783 194.264, 283.677 198.051, 280.135 200.384, 276.291 200.589, 275.249 201.171, 272.293 200.384, 268.295 " />
<path style="fill:#0000ff;fill-opacity:1" fill="none" stroke-width="3.0" stroke-linecap="butt" stroke-opacity="1.0" stroke="rgb(0, 0, 0)" stroke-linejoin="miter" d=" M 201.171, 278.205 201.171, 272.293 " />
<path style="fill:#0000ff;fill-opacity:1" fill="none" stroke-width="3.0" stroke-linecap="butt" stroke-opacity="1.0" stroke="rgb(0, 0, 0)" stroke-linejoin="miter" d=" M 153.947, 179.170 153.947, 189.170 153.947, 199.170 153.947, 209.170 153.947, 219.170 153.947, 229.170 153.947, 239.170 153.947, 249.170 153.947, 252.109 153.947, 262.109 153.947, 270.606 154.309, 271.203 154.897, 271.753 155.689, 272.235 156.653, 272.631 157.753, 272.925 158.947, 273.106 160.188, 273.167 161.430, 273.106 162.623, 272.925 163.723, 272.631 164.688, 272.235 165.479, 271.753 166.067, 271.203 166.429, 270.606 166.429, 260.606 166.429, 252.109 166.429, 242.109 166.429, 232.109 166.429, 222.109 166.429, 212.109 166.429, 202.109 166.429, 192.109 166.429, 182.109 166.429, 179.170 " />
<path style="fill:#ff0000;fill-opacity:0.85" fill="none" stroke-width="3.0" stroke-linecap="butt" stroke-opacity="1.0" stroke="rgb(0, 0, 0)" stroke-linejoin="miter" d=" M 88.941, 155.674 96.067, 162.340 104.607, 167.543 105.658, 168.183 114.909, 171.979 117.344, 172.979 127.005, 175.560 130.677, 176.542 140.563, 178.041 145.143, 178.736 153.947, 179.170 160.188, 179.477 166.429, 179.170 175.233, 178.736 185.120, 177.236 189.700, 176.542 199.361, 173.960 203.032, 172.979 212.284, 169.182 214.718, 168.183 223.258, 162.980 224.309, 162.340 231.435, 155.674 235.824, 148.441 237.306, 140.918 233.154, 131.821 229.002, 122.723 224.850, 113.626 220.698, 104.529 216.546, 95.431 212.394, 86.334 208.242, 77.237 204.090, 68.139 199.938, 59.042 195.786, 49.945 191.635, 40.847 189.441, 36.041 188.879, 38.895 187.214, 41.638 184.511, 44.167 180.873, 46.384 176.440, 48.203 171.383, 49.554 165.895, 50.387 160.188, 50.668 154.481, 50.387 148.994, 49.554 143.936, 48.203 139.503, 46.384 135.865, 44.167 133.162, 41.638 131.497, 38.895 130.935, 36.041 126.783, 45.139 122.631, 54.236 118.479, 63.333 114.328, 72.431 110.176, 81.528 106.024, 90.625 101.872, 99.723 97.720, 108.820 93.568, 117.917 89.416, 127.015 85.264, 136.112 83.071, 140.918 84.552, 148.441 88.941, 155.674 " />
<path style="fill:#ff0000;fill-opacity:1" fill="none" stroke-width="3.0" stroke-linecap="butt" stroke-opacity="1.0" stroke="rgb(0, 0, 0)" stroke-linejoin="miter" d=" M 160.188, 21.446 154.493, 21.727 149.018, 22.557 143.971, 23.906 139.548, 25.721 135.917, 27.933 133.220, 30.456 131.559, 33.194 130.998, 36.041 131.559, 38.889 133.220, 41.626 135.917, 44.150 139.548, 46.361 143.971, 48.177 149.018, 49.525 154.493, 50.356 160.188, 50.636 165.883, 50.356 171.359, 49.525 176.405, 48.177 180.829, 46.361 184.459, 44.150 187.156, 41.626 188.817, 38.889 189.378, 36.041 188.817, 33.194 187.156, 30.456 184.459, 27.933 180.829, 25.721 176.405, 23.906 171.359, 22.557 165.883, 21.727 160.188, 21.446 " />
</g>
</g>
</svg>

10
frontend/src/lib/components/icons/shape_hexagon_corner.svg

@ -0,0 +1,10 @@
<?xml version='1.0' encoding='ascii'?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="320" height="320">
<g id="View Layer_LineSet" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" inkscape:groupmode="lineset" inkscape:label="View Layer_LineSet">
<g xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" inkscape:groupmode="layer" id="strokes" inkscape:label="strokes">
<path style="fill:#ff0000;fill-opacity:0.333" fill="none" stroke-width="3.0" stroke-linecap="butt" stroke-opacity="1.0" stroke="rgb(0, 0, 0)" stroke-linejoin="miter" d=" M 201.919, 108.039 198.923, 99.664 195.119, 91.623 193.302, 88.591 184.641, 93.591 175.981, 98.591 170.168, 101.947 171.536, 104.229 174.217, 109.898 176.329, 115.802 177.853, 121.885 178.773, 128.087 179.081, 134.351 178.773, 140.614 177.853, 146.817 176.329, 152.899 174.217, 158.803 171.536, 164.472 168.312, 169.851 164.577, 174.887 160.365, 179.534 155.719, 183.745 150.682, 187.480 145.304, 190.704 139.635, 193.385 133.731, 195.498 127.648, 197.021 121.445, 197.941 115.182, 198.249 114.613, 198.221 114.613, 208.221 114.613, 218.221 114.613, 224.962 115.182, 224.990 124.067, 224.554 132.865, 223.249 141.494, 221.087 149.869, 218.091 157.910, 214.288 165.539, 209.715 172.684, 204.416 179.274, 198.443 185.248, 191.852 190.546, 184.707 195.119, 177.078 198.923, 169.037 201.919, 160.662 204.080, 152.034 205.386, 143.235 205.822, 134.351 205.386, 125.466 204.080, 116.668 201.919, 108.039 " />
<path style="fill:#ff0000;fill-opacity:0.666" fill="none" stroke-width="3.0" stroke-linecap="butt" stroke-opacity="1.0" stroke="rgb(0, 0, 0)" stroke-linejoin="miter" d=" M 139.635, 193.385 145.304, 190.704 150.682, 187.480 155.719, 183.745 160.365, 179.534 164.577, 174.887 168.312, 169.851 171.536, 164.472 174.217, 158.803 176.329, 152.899 177.853, 146.817 178.773, 140.614 179.081, 134.351 178.773, 128.087 177.853, 121.885 176.329, 115.802 174.217, 109.898 171.536, 104.229 170.168, 101.947 161.508, 106.947 152.847, 111.947 149.399, 113.938 150.363, 115.546 152.037, 119.085 153.356, 122.771 154.307, 126.568 154.881, 130.441 155.073, 134.351 154.881, 138.261 154.307, 142.133 153.356, 145.930 152.037, 149.616 150.363, 153.155 148.350, 156.513 146.018, 159.657 143.389, 162.558 140.489, 165.187 137.345, 167.519 133.987, 169.531 130.448, 171.205 126.762, 172.524 122.965, 173.475 119.092, 174.049 115.182, 174.242 114.613, 174.214 114.613, 184.214 114.613, 194.214 114.613, 198.221 115.182, 198.249 121.445, 197.941 127.648, 197.021 133.731, 195.498 139.635, 193.385 " />
<path style="fill:#ff0000;fill-opacity:1.0" fill="none" stroke-width="3.0" stroke-linecap="butt" stroke-opacity="1.0" stroke="rgb(0, 0, 0)" stroke-linejoin="miter" d=" M 114.879, 134.338 114.765, 134.591 114.681, 134.848 114.630, 135.099 114.613, 135.337 114.613, 145.337 114.613, 155.337 114.613, 165.337 114.613, 174.214 115.182, 174.242 119.092, 174.049 122.965, 173.475 126.762, 172.524 130.448, 171.205 133.987, 169.531 137.345, 167.519 140.489, 165.187 143.389, 162.558 146.018, 159.657 148.350, 156.513 150.363, 153.155 152.037, 149.616 153.356, 145.930 154.307, 142.133 154.881, 138.261 155.073, 134.351 154.881, 130.441 154.307, 126.568 153.356, 122.771 152.037, 119.085 150.363, 115.546 149.399, 113.938 140.739, 118.938 132.078, 123.938 123.418, 128.938 115.752, 133.364 115.554, 133.498 115.362, 133.668 115.182, 133.869 115.020, 134.095 114.879, 134.338 " />
</g>
</g>
</svg>

1
frontend/src/lib/contexts/ModalContext.svelte

@ -12,6 +12,7 @@
| { kind: "device.edit", op: DeviceEditOp } | { kind: "device.edit", op: DeviceEditOp }
| { kind: "script.edit" } | { kind: "script.edit" }
| { kind: "trigger.edit" } | { kind: "trigger.edit" }
| { kind: "device.quickassign" }
export interface ModalContextState { export interface ModalContextState {
modal: Writable<ModalSelection> modal: Writable<ModalSelection>

7
frontend/src/lib/contexts/SelectContext.svelte

@ -87,11 +87,16 @@
let shortestIdPrefix = firstDevice.id; let shortestIdPrefix = firstDevice.id;
let shortestNamePrefix = firstDevice.name; let shortestNamePrefix = firstDevice.name;
let nonames = false;
for (const device of $deviceList) { for (const device of $deviceList) {
if (!$selectedMap[device.id]) { if (!$selectedMap[device.id]) {
continue continue
} }
if (device.name === "") {
nonames = true;
}
let longestIdMatch = 0; let longestIdMatch = 0;
for (let i = 1; i <= shortestIdPrefix.length; ++i) { for (let i = 1; i <= shortestIdPrefix.length; ++i) {
if (device.id.startsWith(shortestIdPrefix.slice(0, i))) { if (device.id.startsWith(shortestIdPrefix.slice(0, i))) {
@ -111,8 +116,10 @@
} }
nextMasks.push(`${shortestIdPrefix}{${$selectedList.map(s => s.substring(shortestIdPrefix.length)).sort().join(",")}}`) nextMasks.push(`${shortestIdPrefix}{${$selectedList.map(s => s.substring(shortestIdPrefix.length)).sort().join(",")}}`)
if (!nonames) {
nextMasks.push(`lucifer:name:${shortestNamePrefix}{${$selectedList.map(id => $state.devices[id].name).map(s => s.substring(shortestNamePrefix.length)).sort().join(",")}}`) nextMasks.push(`lucifer:name:${shortestNamePrefix}{${$selectedList.map(id => $state.devices[id].name).map(s => s.substring(shortestNamePrefix.length)).sort().join(",")}}`)
} }
}
$selectedMasks = nextMasks.map(m => m.endsWith("{}") ? m.slice(0, -2) : m); $selectedMasks = nextMasks.map(m => m.endsWith("{}") ? m.slice(0, -2) : m);
} }

10
frontend/src/lib/contexts/StateContext.svelte

@ -132,11 +132,19 @@
state.update(state => { state.update(state => {
if (patch.full != null) { if (patch.full != null) {
state = patch.full; state = patch.full;
for (const deviceId in state.devices) {
if (deviceId.startsWith("nanoleaf:") && deviceId.split(":").length === 2) {
delete state.devices[deviceId];
}
}
} }
for (const deviceId in patch.devices) { for (const deviceId in patch.devices) {
if (!patch.devices.hasOwnProperty(deviceId)) { if (!patch.devices.hasOwnProperty(deviceId)) {
continue
continue;;
}
if (deviceId.startsWith("nanoleaf:") && deviceId.split(":").length === 2) {
continue;
} }
state.devices = {...state.devices} state.devices = {...state.devices}

11
frontend/src/lib/modals/DeviceModal.svelte

@ -156,7 +156,7 @@
shouldWait = match.startsWith("lucifer:room:"); shouldWait = match.startsWith("lucifer:room:");
newRoom = newRoom || customRoom; newRoom = newRoom || customRoom;
} }
if (enableGroup) {
if (enableGroup && (newGroup || customGroup)) {
await runCommand({addAlias: { match, alias: `lucifer:group:${newGroup || customGroup}` }}); await runCommand({addAlias: { match, alias: `lucifer:group:${newGroup || customGroup}` }});
enableGroup = false; enableGroup = false;
shouldWait = match.startsWith("lucifer:group:"); shouldWait = match.startsWith("lucifer:group:");
@ -209,14 +209,16 @@
.filter(k => k.startsWith("lucifer:room:")) .filter(k => k.startsWith("lucifer:room:"))
.sort() .sort()
.filter((v, i, a) => v !== a[i-1]) .filter((v, i, a) => v !== a[i-1])
.map(r => r.slice("lucifer:room:".length));
.map(r => r.slice("lucifer:room:".length))
.filter(r => r !== "");
let groupOptions: string[] = []; let groupOptions: string[] = [];
$: groupOptions = $deviceList.flatMap(d => d.aliases) $: groupOptions = $deviceList.flatMap(d => d.aliases)
.filter(k => k.startsWith("lucifer:group:")) .filter(k => k.startsWith("lucifer:group:"))
.sort() .sort()
.filter((v, i, a) => v !== a[i-1]) .filter((v, i, a) => v !== a[i-1])
.map(r => r.slice("lucifer:group:".length));
.map(g => g.slice("lucifer:group:".length))
.filter(g => g !== "");
$: { $: {
if ($modal.kind === "device.edit") { if ($modal.kind === "device.edit") {
@ -229,6 +231,9 @@
$: if (!$selectedMasks.includes(match)) { $: if (!$selectedMasks.includes(match)) {
match = $selectedMasks[0]; match = $selectedMasks[0];
} }
$: newTags = newTags.filter(nt => nt);
$: newRoles = newRoles.filter(nr => nr);
</script> </script>
<form novalidate on:submit|preventDefault={onSubmit}> <form novalidate on:submit|preventDefault={onSubmit}>

94
frontend/src/lib/modals/QuickAssignModal.svelte

@ -0,0 +1,94 @@
<script lang="ts">
import { runCommand } from "$lib/client/lucifer";
import ColorPicker from "$lib/components/ColorPicker.svelte";
import Modal from "$lib/components/Modal.svelte";
import ModalBody from "$lib/components/ModalBody.svelte";
import Range from "$lib/components/exetrnal/Range.svelte";
import { getModalContext } from "$lib/contexts/ModalContext.svelte";
import { getSelectedContext } from "$lib/contexts/SelectContext.svelte";
import { getStateContext } from "$lib/contexts/StateContext.svelte";
import { toEffectRaw } from "$lib/models/assignment";
import { parseColor, type Color, stringifyColor } from "$lib/models/color";
const { modal } = getModalContext();
const { selectedMasks, selectedMap } = getSelectedContext();
const { assignmentList } = getStateContext();
let show: boolean = false;
let color: Color = { k: 2750 };
let intensity: number = 50;
let match: string = "";
let disabled: boolean = false;
async function submitForm() {
disabled = true;
try {
await runCommand({assign: { match, effect: { solid: {
interleave: 0,
animationMs: 0,
states: [ { color: stringifyColor(color)!, intensity: (intensity / 100), power: intensity > 0, temperature: null } ]
} } }});
} catch (err) {}
disabled = false;
}
function initForm() {
show = true;
let mostPopularEffect = 0;
for (const assignment of $assignmentList) {
const selectedCount = assignment.deviceIds?.filter(id => $selectedMap[id]).length || 0;
if (selectedCount > mostPopularEffect) {
color = parseColor(toEffectRaw(assignment.effect).states.find(s => s.color)?.color || "k:2750") || { k: 2750 };
intensity = (toEffectRaw(assignment.effect).states.find(s => s.intensity)?.intensity || 0.5) * 100;
mostPopularEffect = selectedCount;
}
}
}
$: if ($modal.kind === "device.quickassign") {
initForm()
} else {
show = false;
}
$: if (!$selectedMasks.includes(match)) {
match = $selectedMasks[0];
}
</script>
<form novalidate on:submit|preventDefault={submitForm}>
<Modal
closable {show} {disabled}
titleText="Quick Assign"
submitText="Apply"
>
<ModalBody>
<label for="mask">Selection</label>
<select bind:value={match}>
{#each $selectedMasks as option (option)}
<option value={option}>{option}</option>
{/each}
</select>
<div class="color-name">{stringifyColor(color)}</div>
<div class="color-wrapper">
<ColorPicker bind:color={color} />
</div>
<Range bind:value={intensity} />
</ModalBody>
</Modal>
</form>
<style lang="sass">
div.color-wrapper
padding-top: 0.2em
margin-bottom: -0.5em
font-size: 3.6em
overflow: hidden
div.color-name
font-size: 1.5em
text-align: center
</style>

1
frontend/src/lib/modals/ScriptModal.svelte

@ -73,6 +73,7 @@
$: scriptNames = Object.keys($state?.scripts||{}).sort(); $: scriptNames = Object.keys($state?.scripts||{}).sort();
$: loadScript(selectedScript); $: loadScript(selectedScript);
$: if (!$selectedMasks.includes(runMatch)) { runMatch = ""; }
</script> </script>
<form novalidate on:submit|preventDefault={onSubmit}> <form novalidate on:submit|preventDefault={onSubmit}>

19
frontend/src/routes/+page.svelte

@ -6,11 +6,12 @@
import { getSelectedContext } from "$lib/contexts/SelectContext.svelte"; import { getSelectedContext } from "$lib/contexts/SelectContext.svelte";
import { getStateContext } from "$lib/contexts/StateContext.svelte"; import { getStateContext } from "$lib/contexts/StateContext.svelte";
import DeviceModal from "$lib/modals/DeviceModal.svelte"; import DeviceModal from "$lib/modals/DeviceModal.svelte";
import QuickAssignModal from "$lib/modals/QuickAssignModal.svelte";
import ScriptModal from "$lib/modals/ScriptModal.svelte"; import ScriptModal from "$lib/modals/ScriptModal.svelte";
import TriggerModal from "$lib/modals/TriggerModal.svelte"; import TriggerModal from "$lib/modals/TriggerModal.svelte";
const {selectedList} = getSelectedContext();
const {roomList} = getStateContext();
const {selectedList, toggleMultiSelection} = getSelectedContext();
const {roomList, deviceList} = getStateContext();
const {modal} = getModalContext(); const {modal} = getModalContext();
function handleKeyPress(e: KeyboardEvent) { function handleKeyPress(e: KeyboardEvent) {
@ -30,6 +31,19 @@
modal.set({kind: "trigger.edit"}); modal.set({kind: "trigger.edit"});
e.preventDefault(); e.preventDefault();
break; break;
case 'c':
if ($selectedList.length) {
modal.set({kind: "device.quickassign"});
e.preventDefault();
}
break;
case 'a':
if ($selectedList.length > 0) {
toggleMultiSelection($selectedList);
} else {
toggleMultiSelection($deviceList.map(d => d.id));
}
break;
} }
} }
} }
@ -61,6 +75,7 @@
<DeviceModal /> <DeviceModal />
<ScriptModal /> <ScriptModal />
<TriggerModal /> <TriggerModal />
<QuickAssignModal />
<style> <style>
div.page { div.page {

BIN
lucifer4-server

4
services/nanoleaf/client.go

@ -46,7 +46,7 @@ func (b *bridge) HardwareEvents() []lucifer3.Event {
shapeType, shapeTypeOK := shapeTypeMap[panel.ShapeType] shapeType, shapeTypeOK := shapeTypeMap[panel.ShapeType]
if !shapeTypeOK { if !shapeTypeOK {
shapeType = "Unknown"
shapeType = fmt.Sprint("Unknown:", panel.ShapeType)
} }
shapeIcon, shapeIconOK := shapeIconMap[panel.ShapeType] shapeIcon, shapeIconOK := shapeIconMap[panel.ShapeType]
@ -65,7 +65,7 @@ func (b *bridge) HardwareEvents() []lucifer3.Event {
results = append(results, events.HardwareState{ results = append(results, events.HardwareState{
ID: panel.FullID, ID: panel.FullID,
InternalName: fmt.Sprintf("%s %d (%s)", shapeType, i+1, strings.SplitN(panel.FullID, ":", 3)[2]),
InternalName: fmt.Sprintf("%s %03d", shapeType, i+1),
SupportFlags: device.SFlagPower | device.SFlagIntensity | device.SFlagColor, SupportFlags: device.SFlagPower | device.SFlagIntensity | device.SFlagColor,
ColorFlags: device.CFlagRGB, ColorFlags: device.CFlagRGB,
Buttons: []string{"Touch"}, Buttons: []string{"Touch"},

2
services/nanoleaf/data.go

@ -138,6 +138,7 @@ var shapeTypeMap = map[int]string{
8: "Triangle", 8: "Triangle",
9: "Mini Triangle", 9: "Mini Triangle",
12: "Shapes Controller", 12: "Shapes Controller",
15: "Element Corner",
} }
var shapeIconMap = map[int]string{ var shapeIconMap = map[int]string{
@ -150,6 +151,7 @@ var shapeIconMap = map[int]string{
8: "shape_triangle", 8: "shape_triangle",
9: "shape_triangle", 9: "shape_triangle",
12: "shape_hexagon", 12: "shape_hexagon",
15: "shape_hexagon_corner",
} }
var shapeWidthMap = map[int]int{ var shapeWidthMap = map[int]int{

17
services/uistate/data.go

@ -24,7 +24,6 @@ func (d *Data) WithPatch(patches ...Patch) Data {
if patch.Device != nil { if patch.Device != nil {
pd := d.ensureDevice(patch.Device.ID) pd := d.ensureDevice(patch.Device.ID)
gentools.ApplyUpdate(&pd.Name, patch.Device.Name)
gentools.ApplyUpdatePtr(&pd.HWState, patch.Device.HWState) gentools.ApplyUpdatePtr(&pd.HWState, patch.Device.HWState)
gentools.ApplyUpdatePtr(&pd.HWMetadata, patch.Device.HWMetadata) gentools.ApplyUpdatePtr(&pd.HWMetadata, patch.Device.HWMetadata)
gentools.ApplyUpdatePtr(&pd.DesiredState, patch.Device.DesiredState) gentools.ApplyUpdatePtr(&pd.DesiredState, patch.Device.DesiredState)
@ -66,6 +65,22 @@ func (d *Data) WithPatch(patches ...Patch) Data {
pd.Icon = patch.Device.HWMetadata.Icon pd.Icon = patch.Device.HWMetadata.Icon
} }
} }
if patch.Device.HWState != nil {
hasName := false
for _, alias := range pd.Aliases {
if strings.HasPrefix(alias, "lucifer:name:") {
hasName = true
break
}
}
if !hasName {
pd.Aliases = append(pd.Aliases, "lucifer:name:"+patch.Device.HWState.InternalName)
}
if pd.Name == "" {
pd.Name = patch.Device.HWState.InternalName
}
}
if patch.Device.ClearAssignment { if patch.Device.ClearAssignment {
pd.Assignment = nil pd.Assignment = nil
} }

3
services/uistate/patch.go

@ -24,8 +24,6 @@ func (e Patch) VerboseKey() string {
func (e Patch) EventDescription() string { func (e Patch) EventDescription() string {
if e.Device != nil { if e.Device != nil {
switch { switch {
case e.Device.Name != nil:
return fmt.Sprintf("uistate.Patch(device=%s, name=%s)", e.Device.ID, *e.Device.Name)
case e.Device.DesiredState != nil: case e.Device.DesiredState != nil:
return fmt.Sprintf("uistate.Patch(device=%s, desiredState=%s)", e.Device.ID, e.Device.DesiredState.String()) return fmt.Sprintf("uistate.Patch(device=%s, desiredState=%s)", e.Device.ID, e.Device.DesiredState.String())
case e.Device.HWState != nil: case e.Device.HWState != nil:
@ -56,7 +54,6 @@ func (e Patch) EventDescription() string {
type DevicePatch struct { type DevicePatch struct {
ID string `json:"id,omitempty"` ID string `json:"id,omitempty"`
Name *string `json:"name,omitempty"`
HWMetadata *events.HardwareMetadata `json:"hwMetadata,omitempty"` HWMetadata *events.HardwareMetadata `json:"hwMetadata,omitempty"`
HWState *events.HardwareState `json:"hwState,omitempty"` HWState *events.HardwareState `json:"hwState,omitempty"`
DesiredState *device.State `json:"desiredState,omitempty"` DesiredState *device.State `json:"desiredState,omitempty"`

Loading…
Cancel
Save