Gisle Aune
1 year ago
48 changed files with 1684 additions and 91 deletions
-
43effects/animation.go
-
4effects/gradient.go
-
5effects/serializable.go
-
11effects/vrange.go
-
101frontend/package-lock.json
-
5frontend/package.json
-
14frontend/src/lib/client/lucifer.ts
-
294frontend/src/lib/components/AssignmentState.svelte
-
31frontend/src/lib/components/Button.svelte
-
117frontend/src/lib/components/Checkbox.svelte
-
22frontend/src/lib/components/DeviceIcon.svelte
-
43frontend/src/lib/components/DeviceIconSelector.svelte
-
18frontend/src/lib/components/HSplit.svelte
-
18frontend/src/lib/components/HSplitPart.svelte
-
97frontend/src/lib/components/Icon.svelte
-
45frontend/src/lib/components/Lamp.svelte
-
32frontend/src/lib/components/Modal.svelte
-
15frontend/src/lib/components/ModalBody.svelte
-
59frontend/src/lib/components/ModalSection.svelte
-
92frontend/src/lib/components/TagInput.svelte
-
0frontend/src/lib/components/icons/shape_hexagon.svg
-
0frontend/src/lib/components/icons/shape_square.svg
-
0frontend/src/lib/components/icons/shape_triangle.svg
-
3frontend/src/lib/contexts/ModalContext.svelte
-
8frontend/src/lib/contexts/SelectContext.svelte
-
40frontend/src/lib/contexts/StateContext.svelte
-
4frontend/src/lib/css/colors.sass
-
345frontend/src/lib/modals/DeviceModal.svelte
-
54frontend/src/lib/models/assignment.ts
-
17frontend/src/lib/models/color.ts
-
17frontend/src/lib/models/command.ts
-
10frontend/src/lib/models/device.ts
-
2frontend/src/lib/models/uistate.ts
-
2frontend/src/routes/+layout.svelte
-
1frontend/src/routes/+layout.ts
-
62frontend/src/routes/+page.svelte
-
2frontend/svelte.config.js
-
21services/effectenforcer/service.go
-
19services/httpapiv1/service.go
-
42services/hue/bridge.go
-
9services/mysqldb/migrations/20230909214407_script_trigger_column_name.sql
-
1services/mysqldb/mysqlgen/models.go
-
9services/mysqldb/mysqlgen/script.sql.go
-
4services/mysqldb/queries/script.sql
-
4services/mysqldb/service.go
-
18services/nanoleaf/data.go
-
1services/script/trigger.go
-
10services/uistate/data.go
@ -0,0 +1,43 @@ |
|||
package effects |
|||
|
|||
import ( |
|||
"fmt" |
|||
"git.aiterp.net/lucifer3/server/device" |
|||
"time" |
|||
) |
|||
|
|||
type Solid struct { |
|||
States []device.State `json:"states,omitempty"` |
|||
AnimationMS int64 `json:"animationMs,omitempty"` |
|||
Interleave int `json:"interleave,omitempty"` |
|||
} |
|||
|
|||
func (e Solid) State(_, length, round int) device.State { |
|||
if len(e.States) == 0 { |
|||
return device.State{} |
|||
} |
|||
if len(e.States) == 1 { |
|||
return e.States[0] |
|||
} |
|||
|
|||
interleave := e.Interleave + 1 |
|||
if interleave < 1 { |
|||
interleave = 1 |
|||
} |
|||
|
|||
if interleave > 1 { |
|||
calcIndex := round % (len(e.States) * interleave) |
|||
return gradientState(append(e.States, e.States[0]), e.Interleave != 0, calcIndex, len(e.States)*interleave+1) |
|||
} else { |
|||
calcIndex := round % len(e.States) |
|||
return gradientState(e.States, e.Interleave != 0, calcIndex, len(e.States)*interleave) |
|||
} |
|||
} |
|||
|
|||
func (e Solid) Frequency() time.Duration { |
|||
return time.Duration(e.AnimationMS) * time.Millisecond |
|||
} |
|||
|
|||
func (e Solid) EffectDescription() string { |
|||
return fmt.Sprintf("Solid(states:%s, anim:%dms, interleave:%d)", statesDescription(e.States), e.AnimationMS, e.Interleave) |
|||
} |
@ -0,0 +1,294 @@ |
|||
<script lang="ts" context="module"> |
|||
const cache: Record<string, ColorRGB> = {}; |
|||
|
|||
function getColor(color: string) { |
|||
if (cache.hasOwnProperty(color)) { |
|||
return Promise.resolve(cache[color]); |
|||
} |
|||
|
|||
return fetchColor(color).then(r => { |
|||
cache[color] = r.rgb || {red: 255, green: 255, blue: 255}; |
|||
return cache[color]; |
|||
}).catch(err => { |
|||
delete cache[color]; |
|||
throw err; |
|||
}); |
|||
} |
|||
</script> |
|||
|
|||
<script lang="ts"> |
|||
import type { State } from "$lib/models/device"; |
|||
import Icon from "./Icon.svelte"; |
|||
import type { ColorRGB } from '$lib/models/color'; |
|||
import { fetchColor } from '$lib/client/lucifer'; |
|||
import { createEventDispatcher } from "svelte"; |
|||
|
|||
export let value: State; |
|||
export let deletable: boolean = false; |
|||
|
|||
const dispatch = createEventDispatcher() |
|||
|
|||
function togglePower() { |
|||
switch (value.power) { |
|||
case null: value.power = true; break; |
|||
case true: value.power = false; break; |
|||
case false: value.power = null; break; |
|||
} |
|||
} |
|||
|
|||
function toggleIntensity() { |
|||
if (value.intensity === null) { |
|||
value.intensity = 0.5; |
|||
} else { |
|||
value.intensity = null; |
|||
} |
|||
} |
|||
|
|||
function toggleTemperature() { |
|||
if (value.temperature === null) { |
|||
value.temperature = 20; |
|||
} else { |
|||
value.temperature = null; |
|||
} |
|||
} |
|||
|
|||
function toggleColor() { |
|||
switch (value.color?.split(":")[0]) { |
|||
case undefined: value.color = "k:2750"; break; |
|||
case "k": value.color = "hs:180,0.5"; break; |
|||
case "hs": value.color = "rgb:1.000,0.800,0.066"; break; |
|||
case "rgb": value.color = "xy:0.2000,0.2000"; break; |
|||
case "xy": value.color = null; break; |
|||
} |
|||
} |
|||
|
|||
function computeColorInputs(color: string | null) { |
|||
const kind = color?.split(":")[0]; |
|||
const values = color?.split(":")[1]?.split(",").map(v => parseFloat(v)) || [0,0]; |
|||
|
|||
switch (kind) { |
|||
case undefined: colorKind = "null"; break; |
|||
case "k": colorKind = "k"; colorX = values[0]; colorY = values[1]||0; break; |
|||
case "hs": colorKind = "hs"; colorX = values[0]; colorY = values[1]||0; break; |
|||
case "xy": colorKind = "xy"; colorX = values[0]; colorY = values[1]||0; break; |
|||
case "rgb": colorKind = "rgb"; colorX = values[0]; colorY = values[1]||0, colorZ = values[2]||0; break; |
|||
} |
|||
|
|||
computedColor = color; |
|||
} |
|||
|
|||
function updateColor(kind: string, x: number, y: number, z: number) { |
|||
x = x || 0; |
|||
y = y || 0; |
|||
z = z || 0; |
|||
|
|||
switch (kind) { |
|||
case "k": value.color = `k:${x.toFixed(0)}`; break; |
|||
case "xy": value.color = `xy:${x.toFixed(4)},${y.toFixed(4)}`; break; |
|||
case "hs": value.color = `hs:${x.toFixed(0)},${y.toFixed(3)}`; break; |
|||
case "rgb": value.color = `rgb:${x.toFixed(3)},${y.toFixed(3)},${z.toFixed(3)}`; break; |
|||
} |
|||
|
|||
computedColor = value.color; |
|||
} |
|||
|
|||
let intensityColor = "none"; |
|||
$: intensityColor = value.intensity !== null ? "off" : "none"; |
|||
|
|||
let temperatureColor = "none"; |
|||
$: temperatureColor = value.temperature !== null ? "off" : "none"; |
|||
|
|||
let powerColor = ""; |
|||
$: if (value.power != null) { |
|||
powerColor = value.power ? "on" : "off" |
|||
} else { |
|||
powerColor = "none" |
|||
} |
|||
|
|||
let computedColor: string | null = ""; |
|||
let colorButton = "none"; |
|||
let colorKind = "null"; |
|||
let colorX = 0; |
|||
let colorY = 0; |
|||
let colorZ = 0; |
|||
|
|||
$: if (value.color !== computedColor) { |
|||
computeColorInputs(value.color); |
|||
} |
|||
|
|||
$: updateColor(colorKind, colorX, colorY, colorZ); |
|||
|
|||
let timeout: NodeJS.Timeout | null = null; |
|||
let rgb = ""; |
|||
$: if (value.color !== null) { |
|||
let before = value.color; |
|||
|
|||
if (timeout !== null) { |
|||
clearTimeout(timeout); |
|||
} |
|||
|
|||
timeout = setTimeout(() => { |
|||
if (value.color !== null) { |
|||
getColor(value.color).then(v => { |
|||
if (value.color === before) { |
|||
rgb = `rgb(${v.red*255}, ${v.green*255}, ${v.blue*255})`; |
|||
} |
|||
}); |
|||
} |
|||
}, 50) |
|||
} else { |
|||
rgb = "hsl(240, 8%, 21%)"; |
|||
} |
|||
</script> |
|||
|
|||
<!-- svelte-ignore a11y-click-events-have-key-events --> |
|||
<div class="assignment-state"> |
|||
<div class="option {powerColor}"><Icon on:click={togglePower} block name="power" /></div> |
|||
<div class="option {intensityColor}"> |
|||
<Icon on:click={toggleIntensity} block name="cirlce_notch" /> |
|||
{#if value.intensity != null} |
|||
<input class="custom" type="number" min={0} max={1} step={0.01} bind:value={value.intensity} /> |
|||
{/if} |
|||
</div> |
|||
<div class="option {temperatureColor}"> |
|||
<Icon on:click={toggleTemperature} block name="temperature_half" /> |
|||
{#if value.temperature != null} |
|||
<input class="custom" type="number" min={10} max={40} step={0.5} bind:value={value.temperature} /> |
|||
{/if} |
|||
</div> |
|||
<div class="option {colorButton}" style="--color: {rgb}"> |
|||
<Icon on:click={toggleColor} block name="palette" /> |
|||
{#if colorKind !== "null"} |
|||
{#if colorKind === "k"} |
|||
<div class="color-input"> |
|||
<label for="color_x">Kelvin</label> |
|||
<input class="custom" name="color_x" type="number" min={1000} max={12000} step={10} bind:value={colorX} /> |
|||
</div> |
|||
{/if} |
|||
{#if colorKind === "hs"} |
|||
<div class="color-input"> |
|||
<label for="color_x">Hue</label> |
|||
<input class="custom" name="color_x" type="number" bind:value={colorX} /> |
|||
</div> |
|||
<div class="color-input"> |
|||
<label for="color_y">Sat</label> |
|||
<input class="custom" name="color_y" type="number" min={0} max={1} step={0.01} bind:value={colorY} /> |
|||
</div> |
|||
{/if} |
|||
{#if colorKind === "xy"} |
|||
<div class="color-input"> |
|||
<label for="color_x">X</label> |
|||
<input class="custom" name="color_x" type="number" min={0} max={1} step={0.0001} bind:value={colorX} /> |
|||
</div> |
|||
<div class="color-input"> |
|||
<label for="color_y">Y</label> |
|||
<input class="custom" name="color_y" type="number" min={0} max={1} step={0.0001} bind:value={colorY} /> |
|||
</div> |
|||
{/if} |
|||
{#if colorKind === "rgb"} |
|||
<div class="color-input"> |
|||
<label class="short" for="color_x">Red</label> |
|||
<input class="custom short" name="color_x" type="number" min={0} max={1} step={0.001} bind:value={colorX} /> |
|||
</div> |
|||
<div class="color-input"> |
|||
<label class="short" for="color_y">Green</label> |
|||
<input class="custom short" name="color_y" type="number" min={0} max={1} step={0.001} bind:value={colorY} /> |
|||
</div> |
|||
<div class="color-input"> |
|||
<label class="short" for="color_z">Blue</label> |
|||
<input class="custom short" name="color_z" type="number" min={0} max={1} step={0.001} bind:value={colorZ} /> |
|||
</div> |
|||
{/if} |
|||
{/if} |
|||
</div> |
|||
{#if deletable} |
|||
<div class="option red"> |
|||
<Icon on:click={() => dispatch("delete")} block name="trash" /> |
|||
</div> |
|||
{/if} |
|||
</div> |
|||
|
|||
<style lang="sass"> |
|||
@import "$lib/css/colors.sass" |
|||
|
|||
div.assignment-state |
|||
display: flex |
|||
user-select: none |
|||
font-size: 1rem |
|||
width: 100% |
|||
flex-wrap: wrap |
|||
|
|||
@media screen and (max-width: 749px) |
|||
font-size: 0.66rem |
|||
|
|||
> div.option |
|||
box-shadow: 1px 1px 1px #000 |
|||
display: flex |
|||
margin: 0.25em 0.25ch |
|||
cursor: pointer |
|||
|
|||
:global(.icon) |
|||
padding: 0.1em 0.5ch |
|||
padding-top: 0.35em |
|||
color: var(--color) |
|||
|
|||
input, :global(div.rangeSlider) |
|||
width: 4rem |
|||
font-size: 0.9rem |
|||
padding-left: 0 |
|||
background: $color-main1 |
|||
outline: none |
|||
border: none |
|||
text-align: center |
|||
font-size: 1rem |
|||
color: $color-main9 |
|||
&::-webkit-inner-spin-button |
|||
-webkit-appearance: none |
|||
margin: 0 |
|||
|
|||
moz-appearance: textfield |
|||
|
|||
&:focus |
|||
color: $color-main13 |
|||
|
|||
> div.color-input |
|||
display: flex |
|||
flex-direction: column |
|||
|
|||
> label |
|||
font-size: 0.5em |
|||
color: $color-main5 |
|||
text-align: center |
|||
margin: 0 |
|||
line-height: 1em |
|||
margin-top: 0.15rem !important |
|||
padding-top: 0 |
|||
margin-bottom: -0.2em |
|||
width: 2.5rem |
|||
|
|||
&.short |
|||
width: 2.2rem |
|||
|
|||
|
|||
> input |
|||
width: 2.5rem |
|||
font-size: 0.8em |
|||
background: none |
|||
|
|||
&.short |
|||
width: 2.2rem |
|||
|
|||
|
|||
&.none |
|||
background: $color-main1 |
|||
color: $color-main2 |
|||
&.off |
|||
background-color: $color-main2 |
|||
color: $color-main6 |
|||
&.on |
|||
background-color: $color-main2 |
|||
color: #00FF00 |
|||
&.red |
|||
background-color: $color-main2-red |
|||
color: $color-main6-redder |
|||
</style> |
@ -0,0 +1,31 @@ |
|||
<script lang="ts"> |
|||
export let red: boolean = false; |
|||
export let icon: boolean = false; |
|||
</script> |
|||
|
|||
<!-- svelte-ignore a11y-click-events-have-key-events --> |
|||
<div class:red class:icon class="button" on:click><slot></slot></div> |
|||
|
|||
<style lang="sass"> |
|||
@import "$lib/css/colors.sass" |
|||
|
|||
div.button |
|||
box-shadow: 1px 1px 1px #000 |
|||
margin: 0.25em 0.25ch |
|||
cursor: pointer |
|||
user-select: none |
|||
font-size: 1rem |
|||
background-color: $color-main2 |
|||
color: $color-main6 |
|||
|
|||
> :global(.icon) |
|||
padding: 0.1em 0.5ch |
|||
padding-top: 0.35em |
|||
|
|||
&.icon |
|||
width: 2.8ch |
|||
|
|||
&.red |
|||
background-color: $color-main2-red |
|||
color: $color-main6-redder |
|||
</style> |
@ -0,0 +1,117 @@ |
|||
<script lang="ts"> |
|||
import { createEventDispatcher } from "svelte"; |
|||
|
|||
import Icon, { type IconName } from "./Icon.svelte"; |
|||
|
|||
export let tabIndex: number | undefined | null = void(0); |
|||
export let checked = false; |
|||
export let centered = false; |
|||
export let disabled = false; |
|||
export let noLabel = false; |
|||
export let noBorder = false; |
|||
export let inline = false; |
|||
export let icon: IconName = "check"; |
|||
export let label = "(Missing label property)"; |
|||
|
|||
const dispatch = createEventDispatcher(); |
|||
|
|||
function handleClick() { |
|||
if (!disabled) { |
|||
checked = !checked; |
|||
dispatch('check', { checked }); |
|||
} |
|||
} |
|||
|
|||
function handlePress(e: KeyboardEvent) { |
|||
if (!disabled && ["enter", " ", " ", "c"].includes(e.key?.toLowerCase())) { |
|||
checked = !checked; |
|||
dispatch('check', { checked }); |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<div |
|||
class="checkbox" |
|||
class:centered |
|||
class:noLabel |
|||
class:inline |
|||
role="checkbox" |
|||
aria-checked={checked} |
|||
aria-disabled={disabled} |
|||
tabindex={tabIndex} |
|||
on:keypress={handlePress} |
|||
on:click={handleClick} |
|||
> |
|||
<div class="box" class:noBorder class:disabled class:checked class:unchecked={!checked}> |
|||
<Icon name={icon} /> |
|||
</div> |
|||
{#if !noLabel} |
|||
<div class="label">{label}</div> |
|||
{/if} |
|||
</div> |
|||
|
|||
<style lang="sass"> |
|||
@import "$lib/css/colors" |
|||
|
|||
div.checkbox |
|||
display: flex |
|||
flex-direction: row |
|||
flex-shrink: 0 |
|||
margin-top: 0.5em |
|||
margin-bottom: 1em |
|||
-webkit-user-select: none |
|||
-moz-user-select: none |
|||
user-select: none |
|||
transition: 250ms |
|||
padding: 0.1em |
|||
font-size: 0.9em |
|||
|
|||
&.noLabel |
|||
display: block |
|||
margin-top: 0.35em |
|||
margin-bottom: 0 |
|||
font-size: 0.75em |
|||
|
|||
div.checkbox.centered |
|||
margin: auto |
|||
|
|||
:global(div.checkbox + div.checkbox) |
|||
margin-top: -0.75em |
|||
|
|||
div.checkbox.inline |
|||
margin-top: 0.25rem |
|||
margin-bottom: 0.25rem |
|||
margin-right: 1rch |
|||
|
|||
div.box |
|||
cursor: pointer |
|||
border: 0.5px solid |
|||
padding: 0.05em 0.3ch |
|||
padding-top: 0.2em |
|||
line-height: 1 |
|||
background-color: $color-mainhalf |
|||
color: $color-mainhalf |
|||
border-color: $color-main5 |
|||
font-size: 0.9em |
|||
&.noBorder |
|||
color: $color-main2 |
|||
&.checked |
|||
color: $color-main9 |
|||
background-color: $color-main4 |
|||
&.disabled |
|||
color: $color-main1 |
|||
&.checked |
|||
color: $color-main5 |
|||
background-color: $color-main1 |
|||
&.noBorder |
|||
border: none |
|||
background: none |
|||
|
|||
div.label |
|||
margin: auto 0 |
|||
margin-left: 1ch |
|||
|
|||
-webkit-user-select: none |
|||
-moz-user-select: none |
|||
user-select: none |
|||
</style> |
@ -0,0 +1,43 @@ |
|||
<script lang="ts"> |
|||
import { rgb, type ColorRGB } from "$lib/models/color"; |
|||
import DeviceIcon, { deviceIconList, type DeviceIconName } from "./DeviceIcon.svelte"; |
|||
import { DARK_COLOR, DARK_COLOR_SELECTED } from "./Lamp.svelte"; |
|||
|
|||
export let value: DeviceIconName; |
|||
</script> |
|||
|
|||
<div class="device-icon-selector"> |
|||
{#each deviceIconList as deviceIconName (deviceIconName)} |
|||
<!-- svelte-ignore a11y-click-events-have-key-events --> |
|||
<div class="item" class:selected={value === deviceIconName} on:click={() => { value = deviceIconName }}> |
|||
<DeviceIcon |
|||
darkColor={ value === deviceIconName ? DARK_COLOR_SELECTED : DARK_COLOR } |
|||
brightColor={ value === deviceIconName ? rgb(0.517, 0.537, 1.000) : DARK_COLOR } |
|||
name={deviceIconName} |
|||
/> |
|||
</div> |
|||
{/each} |
|||
</div> |
|||
|
|||
<style lang="sass"> |
|||
@import "$lib/css/colors.sass" |
|||
|
|||
div.device-icon-selector |
|||
display: flex |
|||
flex-direction: row |
|||
flex-wrap: wrap |
|||
|
|||
> div.item |
|||
font-size: 2em |
|||
padding: 0.3rem |
|||
padding-bottom: 0 |
|||
padding-top: 0.5rem |
|||
box-sizing: border-box |
|||
border: 2px solid $color-mainhalf |
|||
|
|||
&.selected |
|||
border: 2px solid $color-main1 |
|||
|
|||
&:hover |
|||
background-color: $color-mainquarter |
|||
</style> |
@ -0,0 +1,18 @@ |
|||
<script lang="ts"> |
|||
export let reverse: boolean = false |
|||
</script> |
|||
|
|||
<div class="hsplit" class:reverse><slot></slot></div> |
|||
|
|||
<style lang="sass"> |
|||
div.hsplit |
|||
display: flex |
|||
flex-direction: row |
|||
flex-basis: 10 |
|||
|
|||
&.reverse |
|||
flex-direction: row-reverse |
|||
|
|||
@media screen and (max-width: 500px) |
|||
display: block |
|||
</style> |
@ -0,0 +1,18 @@ |
|||
<script lang="ts"> |
|||
export let weight = 1; |
|||
export let left = false; |
|||
export let right = false; |
|||
</script> |
|||
|
|||
<div class="hsplit-part" class:right class:left style="flex: {weight}"><slot></slot></div> |
|||
|
|||
<style lang="sass"> |
|||
div.hsplit-part |
|||
box-sizing: border-box |
|||
padding: 0 0.5ch |
|||
|
|||
&.left |
|||
padding-left: 0 |
|||
&.right |
|||
padding-right: 0 |
|||
</style> |
@ -0,0 +1,97 @@ |
|||
<script lang="ts"> |
|||
import Icon from "fa-svelte"; |
|||
|
|||
export let name: IconName = "question"; |
|||
export let block: boolean = false; |
|||
export let marginAutio: boolean = false; |
|||
</script> |
|||
|
|||
<!-- svelte-ignore a11y-click-events-have-key-events --> |
|||
{#if block} |
|||
<div on:click class:marginAutio> |
|||
<Icon class="icon" icon={icons[name] || icons.question} /> |
|||
</div> |
|||
{:else} |
|||
<Icon on:click class="icon" icon={icons[name] || icons.question} /> |
|||
{/if} |
|||
|
|||
<style> |
|||
div.marginAutio { |
|||
margin: auto; |
|||
} |
|||
</style> |
|||
|
|||
<script lang="ts" context="module"> |
|||
import { faQuestion } from "@fortawesome/free-solid-svg-icons/faQuestion"; |
|||
import { faPlus } from "@fortawesome/free-solid-svg-icons/faPlus"; |
|||
import { faPen } from "@fortawesome/free-solid-svg-icons/faPen"; |
|||
import { faArchive } from "@fortawesome/free-solid-svg-icons/faArchive"; |
|||
import { faCheck } from "@fortawesome/free-solid-svg-icons/faCheck"; |
|||
import { faCog } from "@fortawesome/free-solid-svg-icons/faCog"; |
|||
import { faLink } from "@fortawesome/free-solid-svg-icons/faLink"; |
|||
import { faStar } from "@fortawesome/free-solid-svg-icons/faStar"; |
|||
import { faTimes } from "@fortawesome/free-solid-svg-icons/faTimes"; |
|||
import { faSpinner } from "@fortawesome/free-solid-svg-icons/faSpinner"; |
|||
import { faHourglass } from "@fortawesome/free-solid-svg-icons/faHourglass"; |
|||
import { faCalendar } from "@fortawesome/free-solid-svg-icons/faCalendar"; |
|||
import { faExpand } from "@fortawesome/free-solid-svg-icons/faExpand"; |
|||
import { faSearch } from "@fortawesome/free-solid-svg-icons/faSearch"; |
|||
import { faClock } from "@fortawesome/free-solid-svg-icons/faClock"; |
|||
import { faThumbtack } from "@fortawesome/free-solid-svg-icons/faThumbtack"; |
|||
import { faHistory } from "@fortawesome/free-solid-svg-icons/faHistory"; |
|||
import { faLightbulb } from "@fortawesome/free-solid-svg-icons/faLightbulb"; |
|||
import { faChevronRight } from "@fortawesome/free-solid-svg-icons/faChevronRight"; |
|||
import { faChevronDown } from "@fortawesome/free-solid-svg-icons/faChevronDown"; |
|||
import { faTrash } from "@fortawesome/free-solid-svg-icons/faTrash"; |
|||
import { faCheckToSlot } from "@fortawesome/free-solid-svg-icons/faCheckToSlot"; |
|||
import { faEye } from "@fortawesome/free-solid-svg-icons/faEye"; |
|||
import { faList } from "@fortawesome/free-solid-svg-icons/faList"; |
|||
import { faPowerOff } from "@fortawesome/free-solid-svg-icons/faPowerOff"; |
|||
import { faCircleNotch } from "@fortawesome/free-solid-svg-icons/faCircleNotch"; |
|||
import { faTemperatureHalf } from "@fortawesome/free-solid-svg-icons/faTemperatureHalf"; |
|||
import { faPalette } from "@fortawesome/free-solid-svg-icons/faPalette"; |
|||
import { faSwatchbook } from "@fortawesome/free-solid-svg-icons/faSwatchbook"; |
|||
import { faCircleDot } from "@fortawesome/free-solid-svg-icons/faCircleDot"; |
|||
import { faMasksTheater } from "@fortawesome/free-solid-svg-icons/faMasksTheater"; |
|||
import { faTag } from "@fortawesome/free-solid-svg-icons/faTag"; |
|||
|
|||
const icons = { |
|||
"clock": faClock, |
|||
"thumbtack": faThumbtack, |
|||
"history": faHistory, |
|||
"question": faQuestion, |
|||
"plus": faPlus, |
|||
"pen": faPen, |
|||
"archive": faArchive, |
|||
"check": faCheck, |
|||
"cog": faCog, |
|||
"link": faLink, |
|||
"star": faStar, |
|||
"times": faTimes, |
|||
"lightbulb": faLightbulb, |
|||
"spinner": faSpinner, |
|||
"hourglass": faHourglass, |
|||
"calendar": faCalendar, |
|||
"expand": faExpand, |
|||
"search": faSearch, |
|||
"chevron_right": faChevronRight, |
|||
"chevron_down": faChevronDown, |
|||
"trash": faTrash, |
|||
"check_slot": faCheckToSlot, |
|||
"eye": faEye, |
|||
"list": faList, |
|||
"power": faPowerOff, |
|||
"cirlce_notch": faCircleNotch, |
|||
"temperature_half": faTemperatureHalf, |
|||
"palette": faPalette, |
|||
"swatch_book": faSwatchbook, |
|||
"circle_dot": faCircleDot, |
|||
"masks_theater": faMasksTheater, |
|||
"tag": faTag, |
|||
}; |
|||
|
|||
export type IconName = keyof typeof icons; |
|||
|
|||
export const iconNames = Object.keys(icons).sort() as IconName[]; |
|||
export const DEFAULT_ICON: IconName = "question"; |
|||
</script> |
@ -0,0 +1,15 @@ |
|||
<div class="modal-body"><slot></slot></div> |
|||
|
|||
<style lang="sass"> |
|||
div.modal-body |
|||
padding: 1em |
|||
padding-left: 0 |
|||
flex: 1 |
|||
|
|||
@media screen and (max-width: 749px) |
|||
padding-left: 1em |
|||
padding: 0 1em |
|||
|
|||
&:first-of-type |
|||
padding-left: 1em |
|||
</style> |
@ -0,0 +1,59 @@ |
|||
<script lang="ts"> |
|||
import Icon from "./Icon.svelte"; |
|||
|
|||
export let expanded: boolean; |
|||
export let title: string; |
|||
|
|||
function toggleExpand() { |
|||
expanded = !expanded; |
|||
} |
|||
</script> |
|||
|
|||
<div class="modal-section" class:expanded> |
|||
<!-- svelte-ignore a11y-click-events-have-key-events --> |
|||
<div class="modal-section-header" on:click={toggleExpand}> |
|||
<div class="title">{title}</div> |
|||
<Icon block name="{expanded ? "chevron_down" : "chevron_right"}" /> |
|||
</div> |
|||
<div class="modal-section-body"> |
|||
<slot></slot> |
|||
</div> |
|||
</div> |
|||
|
|||
<style lang="sass"> |
|||
@import "$lib/css/colors.sass" |
|||
|
|||
div.modal-section |
|||
margin-left: -1.8ch |
|||
margin-right: -1.8ch |
|||
margin-bottom: 0.5em |
|||
|
|||
> div.modal-section-body |
|||
display: none |
|||
padding: 0.25em 2.3ch |
|||
|
|||
> div.modal-section-header |
|||
display: flex |
|||
flex-direction: row |
|||
user-select: none |
|||
padding: 0.5ch 1.6ch |
|||
padding-right: 1.8ch |
|||
color: $color-main4 |
|||
|
|||
> div.title |
|||
margin-left: 0.5ch |
|||
font-size: 0.9em |
|||
color: $color-main5 |
|||
margin-right: auto |
|||
|
|||
&.expanded |
|||
background-color: $color-mainhalf |
|||
|
|||
> div.modal-section-body |
|||
display: block |
|||
|
|||
> div.modal-section-header |
|||
color: $color-main5 |
|||
> div.title |
|||
color: $color-main9 |
|||
</style> |
@ -0,0 +1,92 @@ |
|||
<script lang="ts"> |
|||
import Icon from "./Icon.svelte"; |
|||
|
|||
export let value: string[]; |
|||
export let exclaimMode = false; |
|||
|
|||
let nextTag = ""; |
|||
|
|||
function onKey(ev: KeyboardEvent) { |
|||
if ((ev.metaKey||ev.ctrlKey) && nextTag === "" && ev.key === "Backspace") { |
|||
value = value.slice(0, -1); |
|||
} |
|||
|
|||
if ((ev.metaKey||ev.ctrlKey) && nextTag !== "" && ev.key === "Enter") { |
|||
value = [...value, nextTag]; |
|||
nextTag = ""; |
|||
ev.preventDefault(); |
|||
} |
|||
} |
|||
|
|||
$: while (nextTag.includes(",")) { |
|||
const newTags = nextTag.split(",").map(t => t.trim()); |
|||
value = [...value, ...newTags.slice(0, -1)]; |
|||
nextTag = newTags[newTags.length - 1]; |
|||
} |
|||
|
|||
$: value = (value||[]).filter((e, i) => !value.slice(0, i).includes(e)); |
|||
|
|||
$: if (exclaimMode) { |
|||
value = (value||[]).filter((e, i) => ( |
|||
e.startsWith("!") |
|||
? !value.slice(i+1).includes(e.slice(1)) |
|||
: !value.slice(i+1).includes("!"+e) |
|||
)); |
|||
} |
|||
</script> |
|||
|
|||
<div class="tag-input"> |
|||
{#each value as tag (tag)} |
|||
<div class="tag"> |
|||
<span>{tag}</span> |
|||
<span class="comma">,</span> |
|||
<!-- svelte-ignore a11y-click-events-have-key-events --> |
|||
<span class="x" on:click={() => value = value.filter(v => v !== tag)}> |
|||
<Icon name="times" /> |
|||
</span> |
|||
</div> |
|||
{/each} |
|||
<input placeholder="(type , or press Ctrl+Enter to add)" on:keyup={onKey} bind:value={nextTag} /> |
|||
</div> |
|||
|
|||
<style lang="sass"> |
|||
@import "$lib/css/colors.sass" |
|||
|
|||
div.tag-input |
|||
width: calc(100% - 2ch) |
|||
margin-bottom: 1em |
|||
margin-top: 0.20em |
|||
min-height: 2em |
|||
|
|||
background: $color-mainhalf |
|||
color: $color-main8 |
|||
border: none |
|||
outline: none |
|||
resize: vertical |
|||
padding: 0.5em 1ch |
|||
|
|||
display: flex |
|||
flex-direction: row |
|||
flex-wrap: wrap |
|||
|
|||
> div.tag |
|||
margin: 0.25em 0.5ch |
|||
background-color: $color-main1 |
|||
padding: 0.25em 1ch |
|||
border-radius: 0.25em |
|||
|
|||
span.x |
|||
font-size: 0.75em |
|||
line-height: 1em |
|||
user-select: none |
|||
cursor: pointer |
|||
|
|||
&:hover |
|||
color: $color-main12 |
|||
|
|||
span.comma |
|||
font-size: 0 |
|||
|
|||
> input |
|||
margin-bottom: 0.125em |
|||
</style> |
@ -0,0 +1,345 @@ |
|||
<script lang="ts"> |
|||
import { runCommand } from "$lib/client/lucifer"; |
|||
import AssignmentState from "$lib/components/AssignmentState.svelte"; |
|||
import Button from "$lib/components/Button.svelte"; |
|||
import Checkbox from "$lib/components/Checkbox.svelte"; |
|||
import type { DeviceIconName } from "$lib/components/DeviceIcon.svelte"; |
|||
import DeviceIconSelector from "$lib/components/DeviceIconSelector.svelte"; |
|||
import HSplit from "$lib/components/HSplit.svelte"; |
|||
import HSplitPart from "$lib/components/HSplitPart.svelte"; |
|||
import Icon from "$lib/components/Icon.svelte"; |
|||
import Modal from "$lib/components/Modal.svelte"; |
|||
import ModalBody from "$lib/components/ModalBody.svelte"; |
|||
import ModalSection from "$lib/components/ModalSection.svelte"; |
|||
import TagInput from "$lib/components/TagInput.svelte"; |
|||
import { getModalContext } from "$lib/contexts/ModalContext.svelte"; |
|||
import { getSelectedContext } from "$lib/contexts/SelectContext.svelte"; |
|||
import { getStateContext } from "$lib/contexts/StateContext.svelte"; |
|||
import { toEffectRaw, type EffectRaw, fromEffectRaw } from "$lib/models/assignment"; |
|||
import type { DeviceEditOp } from "$lib/models/device"; |
|||
import { iconName } from "@fortawesome/free-solid-svg-icons/faQuestion"; |
|||
|
|||
const { modal } = getModalContext(); |
|||
const { selectedMasks, selectedMap, selectedList } = getSelectedContext(); |
|||
const { deviceList, assignmentList } = getStateContext(); |
|||
|
|||
let show: boolean = false; |
|||
let match: string = ""; |
|||
let disabled: boolean = false; |
|||
|
|||
let enableRename: boolean = false; |
|||
let newName: string = ""; |
|||
|
|||
let enableRoom: boolean = false; |
|||
let newRoom: string = ""; |
|||
let customRoom: string = ""; |
|||
|
|||
let enableGroup: boolean = false; |
|||
let newGroup: string = ""; |
|||
let customGroup: string = ""; |
|||
|
|||
let enableAssign: boolean = false; |
|||
let newEffect: EffectRaw = toEffectRaw(undefined); |
|||
|
|||
let enableIcon: boolean = false; |
|||
let newIcon: DeviceIconName = "generic_ball"; |
|||
|
|||
let enableTag: boolean = false; |
|||
let newTags: string[] = []; |
|||
let oldTags: string[] = []; |
|||
|
|||
let enableRole: boolean = false; |
|||
let newRoles: string[] = []; |
|||
let oldRoles: string[] = []; |
|||
|
|||
function setupModal(op: DeviceEditOp) { |
|||
show = true; |
|||
|
|||
enableRename = (op === "rename"); |
|||
enableAssign = (op === "assign"); |
|||
enableIcon = (op === "change_icon"); |
|||
enableRoom = (op === "move_room"); |
|||
enableGroup = (op === "move_group"); |
|||
enableTag = (op === "change_tags"); |
|||
enableRole = (op === "change_roles"); |
|||
|
|||
const firstDevice = $deviceList.find(d => $selectedMap[d.id]); |
|||
|
|||
newName = firstDevice?.name || ""; |
|||
newIcon = firstDevice?.icon || "generic_ball"; |
|||
newEffect = toEffectRaw(undefined); |
|||
newRoom = ""; |
|||
newGroup = ""; |
|||
|
|||
reloadTagsAndRoles(); |
|||
|
|||
for (const device of $deviceList) { |
|||
if (!$selectedMap[device.id]) { |
|||
continue; |
|||
} |
|||
|
|||
if (newRoom == "") { |
|||
const roomAlias = device.aliases.find(a => a.startsWith("lucifer:room:"))?.slice("lucifer:room:".length); |
|||
if (roomAlias != null) { |
|||
newRoom = roomAlias; |
|||
} |
|||
} |
|||
|
|||
if (newGroup == "") { |
|||
const groupAlias = device.aliases.find(a => a.startsWith("lucifer:group:"))?.slice("lucifer:group:".length); |
|||
if (groupAlias != null) { |
|||
newGroup = groupAlias; |
|||
} |
|||
} |
|||
} |
|||
|
|||
let mostPopularEffect = 0; |
|||
for (const assignment of $assignmentList) { |
|||
const selectedCount = assignment.deviceIds?.filter(id => $selectedMap[id]).length || 0; |
|||
if (selectedCount > mostPopularEffect) { |
|||
newEffect = toEffectRaw(assignment.effect); |
|||
mostPopularEffect = selectedCount; |
|||
} |
|||
} |
|||
} |
|||
|
|||
function closeModal() { |
|||
show = false; |
|||
match = ""; |
|||
} |
|||
|
|||
function addEffectState() { |
|||
if (newEffect.states.length > 0) { |
|||
newEffect.states = [...newEffect.states, {...newEffect.states[newEffect.states.length - 1]}]; |
|||
} else { |
|||
newEffect.states = [...newEffect.states, {color: null, intensity: null, power: null, temperature: null}] |
|||
} |
|||
} |
|||
|
|||
function removeEffectState(i: number) { |
|||
newEffect.states = [...newEffect.states.slice(0, i), ...newEffect.states.slice(i+1)]; |
|||
} |
|||
|
|||
function reloadTagsAndRoles() { |
|||
const firstDevice = $deviceList.find(d => $selectedMap[d.id]); |
|||
|
|||
newTags = firstDevice?.aliases |
|||
.filter(a => a.startsWith("lucifer:tag:")) |
|||
.filter(a => !$deviceList.filter(d => $selectedMap[d.id]).find(d => !d.aliases.includes(a))) |
|||
.map(a => a.slice("lucifer:tag:".length)) || []; |
|||
newRoles = firstDevice?.aliases |
|||
.filter(a => a.startsWith("lucifer:role:")) |
|||
.filter(a => !$deviceList.filter(d => $selectedMap[d.id]).find(d => !d.aliases.includes(a))) |
|||
.map(a => a.slice("lucifer:role:".length)) || []; |
|||
|
|||
oldTags = [...newTags]; |
|||
oldRoles = [...newRoles]; |
|||
} |
|||
|
|||
async function onSubmit() { |
|||
disabled = true; |
|||
let shouldWait = false; |
|||
|
|||
console.log("SOOBMIEET") |
|||
|
|||
try { |
|||
if (enableRename && newName !== "") { |
|||
await runCommand({addAlias: { match, alias: `lucifer:name:${newName}` }}); |
|||
enableRename = false; |
|||
shouldWait = match.startsWith("lucifer:name:"); |
|||
} |
|||
if (enableAssign) { |
|||
await runCommand({assign: { match, effect: fromEffectRaw(newEffect) }}); |
|||
} |
|||
if (enableRoom) { |
|||
await runCommand({addAlias: { match, alias: `lucifer:room:${newRoom || customRoom}` }}); |
|||
enableRoom = false; |
|||
shouldWait = match.startsWith("lucifer:room:"); |
|||
newRoom = newRoom || customRoom; |
|||
} |
|||
if (enableGroup) { |
|||
await runCommand({addAlias: { match, alias: `lucifer:group:${newGroup || customGroup}` }}); |
|||
enableGroup = false; |
|||
shouldWait = match.startsWith("lucifer:group:"); |
|||
newGroup = newGroup || customGroup; |
|||
} |
|||
if (enableIcon) { |
|||
await runCommand({addAlias: { match, alias: `lucifer:icon:${newIcon}` }}); |
|||
enableIcon = false; |
|||
shouldWait = match.startsWith("lucifer:icon:"); |
|||
} |
|||
if (enableTag) { |
|||
const removeTags = oldTags.filter(ot => !newTags.includes(ot)); |
|||
for (const removeTag of removeTags) { |
|||
await runCommand({removeAlias: { match, alias: `lucifer:tag:${removeTag}` }}); |
|||
} |
|||
|
|||
const addTags = newTags.filter(nt => !oldTags.includes(nt)); |
|||
for (const addTag of addTags) { |
|||
await runCommand({addAlias: { match, alias: `lucifer:tag:${addTag}` }}); |
|||
} |
|||
|
|||
shouldWait = removeTags.length > 0 || addTags.length > 0; |
|||
} |
|||
if (enableRole) { |
|||
const removeRoles = oldRoles.filter(or => !newRoles.includes(or)); |
|||
for (const removeRole of removeRoles) { |
|||
await runCommand({removeAlias: { match, alias: `lucifer:role:${removeRole}` }}); |
|||
} |
|||
|
|||
const addRoles = newRoles.filter(nr => !oldRoles.includes(nr)); |
|||
for (const addRole of addRoles) { |
|||
await runCommand({addAlias: { match, alias: `lucifer:role:${addRole}` }}); |
|||
} |
|||
|
|||
shouldWait = removeRoles.length > 0 || addRoles.length > 0; |
|||
} |
|||
|
|||
if (shouldWait) { |
|||
await new Promise(resolve => setTimeout(resolve, 1000)) |
|||
} |
|||
} catch (err) {} |
|||
|
|||
reloadTagsAndRoles(); |
|||
|
|||
disabled = false; |
|||
} |
|||
|
|||
let roomOptions: string[] = []; |
|||
$: roomOptions = $deviceList.flatMap(d => d.aliases) |
|||
.filter(k => k.startsWith("lucifer:room:")) |
|||
.sort() |
|||
.filter((v, i, a) => v !== a[i-1]) |
|||
.map(r => r.slice("lucifer:room:".length)); |
|||
|
|||
let groupOptions: string[] = []; |
|||
$: groupOptions = $deviceList.flatMap(d => d.aliases) |
|||
.filter(k => k.startsWith("lucifer:group:")) |
|||
.sort() |
|||
.filter((v, i, a) => v !== a[i-1]) |
|||
.map(r => r.slice("lucifer:group:".length)); |
|||
|
|||
$: { |
|||
if ($modal.kind === "device.edit") { |
|||
setupModal($modal.op); |
|||
} else { |
|||
closeModal(); |
|||
} |
|||
} |
|||
|
|||
$: if (!$selectedMasks.includes(match)) { |
|||
match = $selectedMasks[0]; |
|||
} |
|||
</script> |
|||
|
|||
<form novalidate on:submit|preventDefault={onSubmit}> |
|||
<Modal wide disabled={disabled} closable show={show} titleText="Device Editor" submitText="Save Changes"> |
|||
<ModalBody> |
|||
<label for="mask">Selection</label> |
|||
<select bind:value={match}> |
|||
{#each $selectedMasks as option (option)} |
|||
<option value={option}>{option}</option> |
|||
{/each} |
|||
</select> |
|||
<ModalSection bind:expanded={enableRename} title="Rename"> |
|||
<label for="name">New Name</label> |
|||
<input type="text" name="name" bind:value={newName} /> |
|||
</ModalSection> |
|||
<ModalSection bind:expanded={enableRoom} title="Change Room"> |
|||
<label for="newRoom">Select Room</label> |
|||
<select bind:value={newRoom}> |
|||
{#each roomOptions as roomOption} |
|||
<option value={roomOption}>{roomOption}</option> |
|||
{/each} |
|||
<option value="">Create Room</option> |
|||
</select> |
|||
{#if newRoom == ""} |
|||
<label for="customRoom">New Room</label> |
|||
<input type="text" name="customRoom" bind:value={customRoom} /> |
|||
{/if} |
|||
</ModalSection> |
|||
<ModalSection bind:expanded={enableGroup} title="Change Group"> |
|||
<label for="newGroup">Select Group</label> |
|||
<select bind:value={newGroup}> |
|||
{#each groupOptions as groupOption} |
|||
<option value={groupOption}>{groupOption}</option> |
|||
{/each} |
|||
<option value="">Create Group</option> |
|||
</select> |
|||
{#if newGroup == ""} |
|||
<label for="customGroup">New Group</label> |
|||
<input type="text" name="customGroup" bind:value={customGroup} /> |
|||
{/if} |
|||
</ModalSection> |
|||
<ModalSection bind:expanded={enableIcon} title="Change Icon"> |
|||
<label for="icon">New Icon</label> |
|||
<DeviceIconSelector bind:value={newIcon} /> |
|||
</ModalSection> |
|||
<ModalSection bind:expanded={enableTag} title="Change Tags"> |
|||
<label for="icon">Tags</label> |
|||
<TagInput bind:value={newTags} /> |
|||
</ModalSection> |
|||
<ModalSection bind:expanded={enableRole} title="Change Roles"> |
|||
<label for="icon">Roles</label> |
|||
<TagInput bind:value={newRoles} /> |
|||
</ModalSection> |
|||
<ModalSection bind:expanded={enableAssign} title="Assign"> |
|||
<HSplit reverse> |
|||
<HSplitPart> |
|||
<label for="states">Effect</label> |
|||
<select bind:value={newEffect.kind}> |
|||
<option value="gradient">Gradient</option> |
|||
<option value="pattern">Pattern</option> |
|||
<option value="random">Random</option> |
|||
<option value="solid">Solid</option> |
|||
<option value="vrange">Variable Range</option> |
|||
</select> |
|||
{#if newEffect.kind !== "manual" && newEffect.kind !== "vrange"} |
|||
<label for="animationMs">Interval (ms)</label> |
|||
<input type="number" name="animationMs" min=0 max=10000 step=100 bind:value={newEffect.animationMs} /> |
|||
{/if} |
|||
{#if newEffect.kind === "solid"} |
|||
<label for="interleave">Interleave</label> |
|||
<input type="number" name="interleave" min=0 step=1 bind:value={newEffect.interleave} /> |
|||
{/if} |
|||
{#if newEffect.kind === "vrange"} |
|||
<label for="states">Variable</label> |
|||
<select bind:value={newEffect.variable}> |
|||
<option value="motion.min">Motion Min (Seconds)</option> |
|||
<option value="motion.avg">Motion Avg (Seconds)</option> |
|||
<option value="motion.max">Motion Max (Seconds)</option> |
|||
<option value="temperature.min">Temperature Min (Celcius)</option> |
|||
<option value="temperature.avg">Temperature Avg (Celcius)</option> |
|||
<option value="temperature.max">Temperature Max (Celcius)</option> |
|||
</select> |
|||
<HSplit> |
|||
<HSplitPart left> |
|||
<label for="min">Min</label> |
|||
<input type="number" name="min" min=0 step=1 bind:value={newEffect.min} /> |
|||
</HSplitPart> |
|||
<HSplitPart right> |
|||
<label for="max">Max</label> |
|||
<input type="number" name="max" min=0 step=1 bind:value={newEffect.max} /> |
|||
</HSplitPart> |
|||
</HSplit> |
|||
{/if} |
|||
{#if ["gradient", "random", "vrange"].includes(newEffect.kind)} |
|||
<label for="states">Options</label> |
|||
<Checkbox bind:checked={newEffect.interpolate} label="Interpolate" /> |
|||
{#if (newEffect.kind === "gradient")} |
|||
<Checkbox bind:checked={newEffect.reverse} label="Reverse" /> |
|||
{/if} |
|||
{/if} |
|||
</HSplitPart> |
|||
<HSplitPart weight={1.0}> |
|||
<label for="states">States</label> |
|||
{#each newEffect.states as state, i } |
|||
<AssignmentState deletable bind:value={state} on:delete={() => removeEffectState(i)} /> |
|||
{/each} |
|||
<Button on:click={addEffectState} icon><Icon name="plus" /></Button> |
|||
</HSplitPart> |
|||
</HSplit> |
|||
</ModalSection> |
|||
</ModalBody> |
|||
</Modal> |
|||
</form> |
@ -0,0 +1,17 @@ |
|||
import type { AssignmentInput } from "./assignment" |
|||
|
|||
export default interface CommandInput { |
|||
addAlias?: AddAliasCommand |
|||
removeAlias?: RemoveAliasComamnd |
|||
assign?: AssignmentInput |
|||
} |
|||
|
|||
export interface AddAliasCommand { |
|||
match: string |
|||
alias: string |
|||
} |
|||
|
|||
export interface RemoveAliasComamnd { |
|||
match: string |
|||
alias: string |
|||
} |
@ -0,0 +1 @@ |
|||
export const prerender = true; |
@ -0,0 +1,9 @@ |
|||
-- +goose Up |
|||
-- +goose StatementBegin |
|||
ALTER TABLE script_trigger ADD COLUMN name VARCHAR(255) NOT NULL DEFAULT ''; |
|||
-- +goose StatementEnd |
|||
|
|||
-- +goose Down |
|||
-- +goose StatementBegin |
|||
ALTER TABLE script_trigger DROP COLUMN IF EXISTS name; |
|||
-- +goose StatementEnd |
Write
Preview
Loading…
Cancel
Save
Reference in new issue