Gisle Aune
2 years ago
14 changed files with 4049 additions and 107 deletions
-
3543frontend/package-lock.json
-
2frontend/package.json
-
23frontend/src/lib/client/lucifer.ts
-
23frontend/src/lib/components/ColorPalette.svelte
-
194frontend/src/lib/components/Lamp.svelte
-
75frontend/src/lib/components/Toolbar.svelte
-
54frontend/src/lib/contexts/SelectContext.svelte
-
57frontend/src/lib/contexts/StateContext.svelte
-
12frontend/src/lib/models/color.ts
-
59frontend/src/lib/models/device.ts
-
60frontend/src/lib/models/palette.ts
-
5frontend/src/lib/models/uistate.ts
-
10frontend/src/routes/+layout.svelte
-
19frontend/src/routes/+page.svelte
3543
frontend/package-lock.json
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,23 @@ |
|||
import type UIState from "$lib/models/uistate"; |
|||
|
|||
export default async function fetchLucifer<T>(path: string, init?: RequestInit): Promise<T> { |
|||
const url = import.meta.env.VITE_LUCIFER4_BACKEND_URL + "/" + path; |
|||
console.log(url); |
|||
|
|||
const res = await fetch(url, init); |
|||
if (res.status !== 200) { |
|||
if (res.headers.get("Content-Type")?.includes("application/json")) { |
|||
throw await res.json(); |
|||
} else { |
|||
throw await res.text(); |
|||
} |
|||
} |
|||
|
|||
const json = await res.json(); |
|||
|
|||
return json as T; |
|||
} |
|||
|
|||
export async function fetchUIState(): Promise<UIState> { |
|||
return fetchLucifer("state"); |
|||
} |
@ -0,0 +1,23 @@ |
|||
<script lang="ts"> |
|||
import { rgbString } from "$lib/models/color"; |
|||
import type { Palette } from "$lib/models/palette"; |
|||
|
|||
export let palette: Palette |
|||
</script> |
|||
|
|||
<div class="palette"> |
|||
{#each palette as p (p.value)} |
|||
<div title={p.label} class="color" style="background-color: {rgbString(p.rgb)}"></div> |
|||
{/each} |
|||
</div> |
|||
|
|||
<style lang="sass"> |
|||
div.palette |
|||
display: flex |
|||
flex-direction: row |
|||
|
|||
div.color |
|||
margin: 0.05em |
|||
width: 1ch |
|||
height: 0.5em |
|||
</style> |
@ -1,76 +1,162 @@ |
|||
<script lang="ts"> |
|||
import type { ColorRGB } from "../models/color"; |
|||
import { getSelectedContext } from "$lib/contexts/SelectContext.svelte"; |
|||
import type Device from "$lib/models/device"; |
|||
import { SupportFlags } from "$lib/models/device"; |
|||
import { rgb, type ColorRGB } from "../models/color"; |
|||
|
|||
export let selected: boolean = false; |
|||
export let title: string; |
|||
export let color: ColorRGB; |
|||
export let intensity: number; |
|||
export let device: Device; |
|||
|
|||
const {selectedMap, toggleSelection} = getSelectedContext(); |
|||
|
|||
function onSelect() { |
|||
toggleSelection(device.id); |
|||
} |
|||
|
|||
// Process device |
|||
let deviceTitle: string; |
|||
let iconColor: ColorRGB | null; |
|||
let barColor: ColorRGB | null; |
|||
let barPercentage: number | null; |
|||
let roundboiText: string | null; |
|||
$: { |
|||
// TODO: Fix device.name on the backend |
|||
const nameAlias = device.aliases.find(a => a.startsWith("lucifer:name:")); |
|||
if (nameAlias != null) { |
|||
deviceTitle = nameAlias.slice("lucifer:name:".length); |
|||
} else { |
|||
deviceTitle = ""; |
|||
} |
|||
|
|||
barPercentage = null; |
|||
barColor = null; |
|||
iconColor = null; |
|||
|
|||
if (device.hwState) { |
|||
const hws = device.hwState; |
|||
const sflags = hws.supportFlags; |
|||
|
|||
if (deviceTitle == "") { |
|||
deviceTitle = device.hwState.internalName; |
|||
} |
|||
|
|||
if (sflags & SupportFlags.Color) { |
|||
iconColor = device.activeColorRgb; |
|||
} else { |
|||
iconColor = null; |
|||
} |
|||
|
|||
if (sflags & SupportFlags.SensorPresence) { |
|||
barColor = rgb(0.5,0.5,0.5); |
|||
barPercentage = Math.max(0, (300 - (device.sensors.lastMotion||300))/300) * 100; |
|||
} |
|||
|
|||
if (sflags & SupportFlags.Temperature) { |
|||
barColor = rgb(1.000,0.2,0.2); |
|||
barPercentage = Math.min(1, Math.max(0, (device.desiredState.temperature||0) - 5) / 35) * 100; |
|||
} |
|||
|
|||
if (sflags & SupportFlags.Intensity) { |
|||
if (sflags & SupportFlags.Color) { |
|||
barColor = device.activeColorRgb; |
|||
} else { |
|||
barColor = rgb(1.000,0.671,0.355); |
|||
} |
|||
|
|||
barPercentage = device.desiredState.intensity; |
|||
} |
|||
|
|||
if (sflags & SupportFlags.SensorTemperature && !!device.sensors.temperature) { |
|||
roundboiText = `${device.sensors.temperature.toFixed(0)}°` |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Make title visible |
|||
let displayTitle: string; |
|||
$: { |
|||
if (title.length > 12) { |
|||
let last = title.split(" ").pop() || title; |
|||
if (deviceTitle.length > 12) { |
|||
let last = deviceTitle.split(" ").pop() || deviceTitle; |
|||
if (last.length > 4) { |
|||
last = last.slice(-3); |
|||
} |
|||
|
|||
displayTitle = `${(title.split(" ").shift()||"").slice(0, 10 - last.length)}... ${last}` |
|||
displayTitle = `${(deviceTitle.split(" ").shift()||"").slice(0, 10 - last.length)}... ${last}` |
|||
} else { |
|||
displayTitle = title; |
|||
displayTitle = deviceTitle; |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<div class="lamp" class:selected> |
|||
<!-- svelte-ignore a11y-click-events-have-key-events --> |
|||
<div class="lamp" class:selected={$selectedMap[device.id]} on:click={onSelect}> |
|||
<div class="row"> |
|||
<div style="background-color: rgb({color.red*255},{color.green*255},{color.blue*255})" class="roundboi"></div> |
|||
{#if iconColor != null} |
|||
<div style="background-color: rgb({iconColor.red*255},{iconColor.green*255},{iconColor.blue*255})" class="roundboi"></div> |
|||
{:else} |
|||
<div style="background-color: #223)" class="roundboi"> |
|||
{#if !!roundboiText} |
|||
<div class="roundboi-text">{roundboiText}</div> |
|||
{/if} |
|||
</div> |
|||
{/if} |
|||
|
|||
<div class="title">{displayTitle}</div> |
|||
</div> |
|||
<div class="flatboi"> |
|||
<div style="width: {intensity*100}%; background-color: rgb({color.red*255},{color.green*255},{color.blue*255})" class="flatboi2"></div> |
|||
{#if barColor != null && barPercentage != null} |
|||
<div style="width: {barPercentage*100}%; background-color: rgb({barColor.red*255},{barColor.green*255},{barColor.blue*255})" class="flatboi2"></div> |
|||
{/if} |
|||
</div> |
|||
</div> |
|||
|
|||
<style> |
|||
div.lamp { |
|||
width: 15ch; |
|||
height: 2em; |
|||
margin: 0.25em; |
|||
background: #18181c; |
|||
color: #84888f; |
|||
border-radius: 0.25em; |
|||
border-top-right-radius: 1em; |
|||
overflow: hidden; |
|||
} |
|||
div.lamp.selected { |
|||
background: #282833; |
|||
color: #cde; |
|||
} |
|||
div.lamp > div.row { |
|||
display: flex; |
|||
} |
|||
div.lamp > div.row > div.roundboi { |
|||
width: 1.25em; |
|||
height: 1.25em; |
|||
margin-left: 0.25em; |
|||
margin-top: 0.30em; |
|||
border-radius: 2em; |
|||
box-sizing: border-box; |
|||
border: 0.5px solid #000; |
|||
} |
|||
div.lamp > div.row > div.title { |
|||
font-size: 0.9em; |
|||
text-align: left; |
|||
margin-top: 0.4em; |
|||
margin-left: 0.75ch; |
|||
margin-right: 1ch; |
|||
height: 1.6em; |
|||
} |
|||
div.lamp > div.flatboi { |
|||
height: 0.2em; |
|||
background-color: #040408; |
|||
} |
|||
div.lamp > div.flatboi > div.flatboi2 { |
|||
height: 0.2em; |
|||
} |
|||
<style lang="sass"> |
|||
div.lamp |
|||
cursor: pointer |
|||
width: 19ch |
|||
height: 2em |
|||
margin: 0.25em |
|||
background: #18181c |
|||
color: #84888f |
|||
border-radius: 0.25em |
|||
border-top-right-radius: 1em |
|||
overflow: hidden |
|||
box-shadow: 1px 1px 1px #000 |
|||
|
|||
@media screen and (max-width: 700px) |
|||
width: 100% |
|||
|
|||
&.selected |
|||
background: #282833 |
|||
color: #cde |
|||
|
|||
> div.row |
|||
display: flex |
|||
|
|||
> div.roundboi |
|||
width: 1.25em |
|||
height: 1.25em |
|||
margin-left: 0.25em |
|||
margin-top: 0.30em |
|||
border-radius: 2em |
|||
box-sizing: border-box |
|||
border: 0.5px solid #000 |
|||
|
|||
> div.roundboi-text |
|||
color: #abc |
|||
margin-top: 0.2em |
|||
font-size: 0.8em |
|||
|
|||
> div.title |
|||
font-size: 0.9em |
|||
text-align: left |
|||
margin-top: 0.4em |
|||
margin-left: 0.75ch |
|||
margin-right: 1ch |
|||
height: 1.6em |
|||
|
|||
> div.flatboi |
|||
height: 0.2em |
|||
|
|||
> div.flatboi2 |
|||
height: 0.2em |
|||
</style> |
@ -0,0 +1,75 @@ |
|||
<script lang="ts"> |
|||
import { getSelectedContext } from "$lib/contexts/SelectContext.svelte"; |
|||
import { getStateContext } from "$lib/contexts/StateContext.svelte"; |
|||
import type Device from "$lib/models/device"; |
|||
import { KELVIN_PALETTE, RGB_PALETTE } from "$lib/models/palette"; |
|||
import ColorPalette from "./ColorPalette.svelte"; |
|||
|
|||
const {deviceList} = getStateContext(); |
|||
const {selectedMap} = getSelectedContext(); |
|||
|
|||
let selected: Device[]; |
|||
let unselected: Device[]; |
|||
$: selected = $deviceList.filter(d => !!$selectedMap[d.id]); |
|||
$: unselected = $deviceList.filter(d => !$selectedMap[d.id]); |
|||
|
|||
let assignment: string; |
|||
$: { |
|||
assignment = ""; |
|||
if (selected.length > 0) { |
|||
for (const alias of selected[0].aliases) { |
|||
if (!selected.find(d => !d.aliases.includes(alias)) && !unselected.find(d => d.aliases.includes(alias))) { |
|||
assignment = alias; |
|||
break; |
|||
} |
|||
} |
|||
|
|||
if (assignment === "") { |
|||
if (selected.length == 1) { |
|||
assignment = selected[0].id; |
|||
} else { |
|||
let prefix = selected[0].id; |
|||
for (const device of selected) { |
|||
for (let i = 0; i < prefix.length; i++) { |
|||
if (device.id.charAt(i) !== prefix.charAt(i)) { |
|||
prefix = prefix.slice(0, i); |
|||
break; |
|||
} |
|||
} |
|||
} |
|||
|
|||
if (prefix.length > 0) { |
|||
assignment = `${prefix}{${selected.map(d => d.id.slice(prefix.length)).join("|")}}` |
|||
} |
|||
} |
|||
|
|||
if (assignment === "") { |
|||
assignment = `{${selected.map(d => d.id).join("|")}}` |
|||
} |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<div class="toolbar"> |
|||
<ColorPalette palette={RGB_PALETTE} /> |
|||
</div> |
|||
|
|||
<style lang="sass"> |
|||
div.toolbar |
|||
position: fixed |
|||
bottom: 0 |
|||
|
|||
width: 100% |
|||
height: 2em |
|||
padding: 0.35em 1ch |
|||
box-sizing: border-box |
|||
font-size: 1.5em |
|||
background: #18181c |
|||
color: #abc |
|||
box-shadow: 1px 1px 1px #000 |
|||
|
|||
display: flex |
|||
flex-direction: row |
|||
flex-wrap: wrap |
|||
</style> |
@ -0,0 +1,54 @@ |
|||
<script lang="ts" context="module"> |
|||
import { getContext, setContext } from "svelte"; |
|||
import { writable, type Readable } from "svelte/store"; |
|||
import { getStateContext } from "./StateContext.svelte"; |
|||
|
|||
const ctxKey = {ctx: "SelectContext"}; |
|||
|
|||
export interface SelectContextData { |
|||
toggleSelection(id: string): void |
|||
selectedList: Readable<string[]> |
|||
selectedMap: Readable<{[id:string]: boolean}> |
|||
} |
|||
|
|||
export function getSelectedContext(): SelectContextData { |
|||
return getContext(ctxKey) as SelectContextData; |
|||
} |
|||
</script> |
|||
|
|||
<script lang="ts"> |
|||
const selectedList = writable<string[]>([]); |
|||
const selectedMap = writable<{[id:string]: boolean}>({}); |
|||
|
|||
const {state} = getStateContext(); |
|||
|
|||
function toggleSelection(id: string) { |
|||
selectedMap.update(m => ({...m, [id]: !m[id]})); |
|||
} |
|||
|
|||
// Effect: remove non-existent devices |
|||
$: { |
|||
const newMap = {...$selectedMap}; |
|||
for (const id in newMap) { |
|||
if (!Object.hasOwn(newMap, id)) { |
|||
continue; |
|||
} |
|||
if (!$state?.devices[id]) { |
|||
delete newMap[id]; |
|||
} |
|||
} |
|||
|
|||
$selectedMap = newMap; |
|||
} |
|||
|
|||
// Effect: sync list |
|||
$: $selectedList = Object.keys($selectedMap).filter(id => !!$selectedMap[id]); |
|||
|
|||
setContext(ctxKey, { |
|||
selectedList: {subscribe: selectedList.subscribe}, |
|||
selectedMap: {subscribe: selectedMap.subscribe}, |
|||
toggleSelection, |
|||
}); |
|||
</script> |
|||
|
|||
<slot></slot> |
@ -0,0 +1,57 @@ |
|||
<script lang="ts" context="module"> |
|||
import { fetchUIState } from "$lib/client/lucifer"; |
|||
import type Device from "$lib/models/device"; |
|||
import type UIState from "$lib/models/uistate"; |
|||
import { getContext, onMount, setContext } from "svelte"; |
|||
import { derived, writable, type Readable } from "svelte/store"; |
|||
|
|||
const ctxKey = {ctx: "StateContext"}; |
|||
|
|||
export interface StateContextData { |
|||
reload(): Promise<void> |
|||
state: Readable<UIState | null> |
|||
error: Readable<string | null> |
|||
deviceList: Readable<Device[]> |
|||
} |
|||
|
|||
export function getStateContext(): StateContextData { |
|||
return getContext(ctxKey) as StateContextData; |
|||
} |
|||
</script> |
|||
|
|||
<script lang="ts"> |
|||
const state = writable<UIState | null>(null); |
|||
const error = writable<string | null>(null); |
|||
|
|||
const deviceList = derived(state, state => { |
|||
if (state == null) { |
|||
return []; |
|||
} |
|||
|
|||
return Object.keys(state.devices) |
|||
.map(k => state.devices[k]) |
|||
.sort((a,b) => a.id.localeCompare(b.id)); |
|||
}) |
|||
|
|||
async function reload() { |
|||
error.set(null); |
|||
|
|||
try { |
|||
const newState = await fetchUIState(); |
|||
state.set(newState); |
|||
} catch(err) { |
|||
error.set(err?.toString ? err.toString() : String(err)) |
|||
} |
|||
} |
|||
|
|||
onMount(() => { reload(); }); |
|||
|
|||
setContext<StateContextData>(ctxKey, { |
|||
reload, |
|||
error: { subscribe: error.subscribe }, |
|||
state: { subscribe: state.subscribe }, |
|||
deviceList, |
|||
}); |
|||
</script> |
|||
|
|||
<slot></slot> |
@ -0,0 +1,59 @@ |
|||
import type { ColorFlags, ColorRGB } from "./color" |
|||
|
|||
export default interface Device { |
|||
id: string |
|||
name: string |
|||
hwMetadata: HardwareMetadata | null |
|||
hwState: HardwareState | null |
|||
activeColorRgb: ColorRGB | null |
|||
desiredState: State, |
|||
aliases: string[], |
|||
assignment: string, |
|||
sensors: Sensors |
|||
} |
|||
|
|||
export interface Sensors { |
|||
lastMotion?: number |
|||
temperature?: number |
|||
} |
|||
|
|||
export interface HardwareState { |
|||
id: string |
|||
internalName: string |
|||
supportFlags: SupportFlags |
|||
colorFlags: ColorFlags |
|||
buttons: string[] | null |
|||
state: State |
|||
batteryPercentage: number |
|||
unreachable: boolean |
|||
} |
|||
|
|||
export interface HardwareMetadata { |
|||
firmwareVersion?: string |
|||
} |
|||
|
|||
export interface State { |
|||
power: boolean | null |
|||
temperature: number | null |
|||
intensity: number | null |
|||
color: string | null |
|||
} |
|||
|
|||
export enum SupportFlags { |
|||
Power = 1 << 0, |
|||
Intensity = 1 << 1, |
|||
Color = 1 << 2, |
|||
Temperature = 1 << 3, |
|||
SensorButtons = 1 << 4, |
|||
SensorTemperature = 1 << 5, |
|||
SensorLightLevel = 1 << 6, |
|||
SensorPresence = 1 << 7, |
|||
} |
|||
|
|||
|
|||
export const BLANK_STATE: State = (Object.seal||(v=>v))({ |
|||
power: null, |
|||
temperature: null, |
|||
intensity: null, |
|||
color: null, |
|||
}); |
@ -0,0 +1,60 @@ |
|||
import { rgb, type ColorRGB } from "./color" |
|||
|
|||
export type Palette = { value: string, label: string, rgb: ColorRGB }[]; |
|||
|
|||
export const RGB_PALETTE: Palette = [ |
|||
{value: "hs:235.00,0.550", label: "Sky Blue", rgb: rgb(0.450,0.496,1.000) }, |
|||
{value: "hs:235.00,0.330", label: "Sky Blue 2", rgb: rgb(0.670,0.698,1.000) }, |
|||
{value: "xy:0.2721,0.1908", label: "Lilac", rgb: rgb(0.792,0.500,1.000) }, |
|||
{value: "hs:220,0.1", label: "Bluish White", rgb: rgb(0.900,0.933,1.000)} |
|||
] |
|||
|
|||
export const KELVIN_PALETTE: Palette = [ |
|||
{value: "k:1500", label: "1500 K", rgb: rgb(1.000,0.427,0.000)}, |
|||
{value: "k:1625", label: "1625 K", rgb: rgb(1.000,0.469,0.000)}, |
|||
{value: "k:1750", label: "1750 K", rgb: rgb(1.000,0.484,0.000)}, |
|||
{value: "k:1875", label: "1875 K", rgb: rgb(1.000,0.499,0.000)}, |
|||
{value: "k:2000", label: "2000 K", rgb: rgb(1.000,0.541,0.071)}, |
|||
{value: "k:2125", label: "2125 K", rgb: rgb(1.000,0.571,0.162)}, |
|||
{value: "k:2250", label: "2250 K", rgb: rgb(1.000,0.586,0.193)}, |
|||
{value: "k:2375", label: "2375 K", rgb: rgb(1.000,0.601,0.221)}, |
|||
{value: "k:2500", label: "2500 K", rgb: rgb(1.000,0.631,0.282)}, |
|||
{value: "k:2625", label: "2625 K", rgb: rgb(1.000,0.659,0.333)}, |
|||
{value: "k:2750", label: "2750 K", rgb: rgb(1.000,0.671,0.355)}, |
|||
{value: "k:2875", label: "2875 K", rgb: rgb(1.000,0.682,0.376)}, |
|||
{value: "k:3000", label: "3000 K", rgb: rgb(1.000,0.706,0.420)}, |
|||
{value: "k:3125", label: "3125 K", rgb: rgb(1.000,0.730,0.465)}, |
|||
{value: "k:3250", label: "3250 K", rgb: rgb(1.000,0.739,0.482)}, |
|||
{value: "k:3375", label: "3375 K", rgb: rgb(1.000,0.748,0.500)}, |
|||
{value: "k:3500", label: "3500 K", rgb: rgb(1.000,0.769,0.537)}, |
|||
{value: "k:3625", label: "3625 K", rgb: rgb(1.000,0.786,0.575)}, |
|||
{value: "k:3750", label: "3750 K", rgb: rgb(1.000,0.794,0.590)}, |
|||
{value: "k:3875", label: "3875 K", rgb: rgb(1.000,0.802,0.606)}, |
|||
{value: "k:4000", label: "4000 K", rgb: rgb(1.000,0.820,0.639)}, |
|||
{value: "k:4125", label: "4125 K", rgb: rgb(1.000,0.833,0.673)}, |
|||
{value: "k:4250", label: "4250 K", rgb: rgb(1.000,0.839,0.686)}, |
|||
{value: "k:4375", label: "4375 K", rgb: rgb(1.000,0.845,0.699)}, |
|||
{value: "k:4500", label: "4500 K", rgb: rgb(1.000,0.859,0.729)}, |
|||
{value: "k:4625", label: "4625 K", rgb: rgb(1.000,0.873,0.757)}, |
|||
{value: "k:4750", label: "4750 K", rgb: rgb(1.000,0.879,0.768)}, |
|||
{value: "k:4875", label: "4875 K", rgb: rgb(1.000,0.884,0.780)}, |
|||
{value: "k:5000", label: "5000 K", rgb: rgb(1.000,0.894,0.808)}, |
|||
{value: "k:5125", label: "5125 K", rgb: rgb(1.000,0.908,0.832)}, |
|||
{value: "k:5250", label: "5250 K", rgb: rgb(1.000,0.912,0.843)}, |
|||
{value: "k:5375", label: "5375 K", rgb: rgb(1.000,0.916,0.854)}, |
|||
{value: "k:5500", label: "5500 K", rgb: rgb(1.000,0.925,0.878)}, |
|||
{value: "k:5625", label: "5625 K", rgb: rgb(1.000,0.936,0.899)}, |
|||
{value: "k:5750", label: "5750 K", rgb: rgb(1.000,0.939,0.908)}, |
|||
{value: "k:5875", label: "5875 K", rgb: rgb(1.000,0.943,0.917)}, |
|||
{value: "k:6000", label: "6000 K", rgb: rgb(1.000,0.953,0.937)}, |
|||
{value: "k:6125", label: "6125 K", rgb: rgb(1.000,0.960,0.958)}, |
|||
{value: "k:6250", label: "6250 K", rgb: rgb(1.000,0.963,0.965)}, |
|||
{value: "k:6375", label: "6375 K", rgb: rgb(1.000,0.967,0.973)}, |
|||
{value: "k:6500", label: "6500 K", rgb: rgb(1.000,0.976,0.992)}, |
|||
{value: "k:7000", label: "7000 K", rgb: rgb(0.961,0.953,1.000)}, |
|||
{value: "k:8000", label: "8000 K", rgb: rgb(0.890,0.914,1.000)}, |
|||
{value: "k:9000", label: "9000 K", rgb: rgb(0.839,0.882,1.000)}, |
|||
{value: "k:10000", label: "10000 K", rgb: rgb(0.812,0.855,1.000)}, |
|||
{value: "k:11000", label: "11000 K", rgb: rgb(0.784,0.835,1.000)}, |
|||
{value: "k:12000", label: "12000 K", rgb: rgb(0.765,0.820,1.000)}, |
|||
]; |
@ -0,0 +1,5 @@ |
|||
import type Device from "./device"; |
|||
|
|||
export default interface UIState { |
|||
devices: {[id: string]: Device} |
|||
} |
@ -0,0 +1,10 @@ |
|||
<script lang="ts"> |
|||
import SelectContext from "$lib/contexts/SelectContext.svelte"; |
|||
import StateContext from "$lib/contexts/StateContext.svelte"; |
|||
</script> |
|||
|
|||
<StateContext> |
|||
<SelectContext> |
|||
<slot></slot> |
|||
</SelectContext> |
|||
</StateContext> |
Write
Preview
Loading…
Cancel
Save
Reference in new issue