Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
314 changes: 167 additions & 147 deletions bun.lock

Large diffs are not rendered by default.

162 changes: 99 additions & 63 deletions src/lib/components/ButtonEditModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
import IconIcon from "@iconify-svelte/material-symbols/image-rounded";
import ColorIcon from "@iconify-svelte/material-symbols/colorize-rounded";
import ScriptIcon from "@iconify-svelte/material-symbols/code-rounded";
import ClearColorIcon from "@iconify-svelte/material-symbols/format-color-reset-rounded";

import ScriptButton from "./ScriptButton.svelte";
import type { buttonTable } from "$lib/server/db/schema";
Expand All @@ -27,7 +26,10 @@
const uid = $props.id();

let activeButton = $state<Button | undefined>(undefined);
let openedAt = 0;

export function edit(button: Button) {
openedAt = Date.now();
activeButton = $state.snapshot(button);
editButton.fields.set({
id: button.id,
Expand All @@ -47,6 +49,30 @@
}

let scripts = getScriptsContext();

const colors = [
["#000000", "Black"],
["#777777", "Grey"],
["#ffffff", "White"],
[],
["#ff6467", "Red"],
["#ff8904", "Orange"],
["#fcc800", "Yellow"],
["#9ae600", "Lime"],
["#05df72", "Green"],
["#00d5be", "Teal"],
["#00bcff", "Sky"],
["#cc99ff", "Laserviolet"],
["#e83d84", "Rosa"],
[],
["#cc99ff", "Laserviolet"],
["#44687d", "Silicone blue"],
["#acff5b", "ITK Green"],
["#800000", "QMISK Ockraröd"],
["#004791", "KTH-Blue"],
["#45b8da", "THS-Blue"],
["#a2ee8d", "TBas Green"],
] as const;
</script>

{#if activeButton !== undefined}
Expand All @@ -55,7 +81,15 @@
element.showModal();
return () => element.close();
}}
closedby="closerequest"
closedby="any"
oncancel={(event) => {
// Prevent instant closing from clicking the backdrop after being just opened
// Cuases issues with mobile long press opening
const timeSinceOpen = Date.now() - openedAt;
if (timeSinceOpen < 300) {
event.preventDefault();
}
}}
onclose={() => {
activeButton = undefined;
}}
Expand Down Expand Up @@ -184,72 +218,65 @@
<label for="{uid}-color" class="flex items-center gap-1 font-semibold">
<ColorIcon class="size-[1lh]" />
<span class="grow">Color</span>
{#if activeButton.color}
<button
type="button"
class="bg-transparent p-1 text-foreground opacity-50 transition-opacity hover:opacity-100 focus:ring focus:outline-none"
onclick={() => {
if (activeButton) activeButton.color = null;
}}
>
<ClearColorIcon class="size-[1lh]" />
</button>
{/if}
</label>
{#each editButton.fields.color.issues() as issue, i (i)}
<span class="text-sm text-red-400">{issue.message}</span>
{/each}
<div
class="relative h-10 w-full border ring-brand focus-within:ring"
style:background={activeButton.color ?? "var(--color-secondary)"}
>
<input
type="color"
value={activeButton.color || "#808080"}
oninput={(event) => {
if (activeButton) activeButton.color = event.currentTarget.value;
<div class="flex flex-wrap">
<button
type="button"
title="Default"
class={[
"m-0.5 size-6 cursor-pointer rounded border-2 bg-secondary",
activeButton.color === null ? "border-brand" : "",
]}
onclick={() => {
if (activeButton) activeButton.color = null;
}}
id="{uid}-color"
class="absolute inset-0 size-full cursor-pointer opacity-0"
list="{uid}-color-suggestions"
/>
<!-- Similar hack to be able to submit null colors -->
<input
{...editButton.fields.color.as("hidden", "text")}
value={activeButton.color || ""}
/>
</div>
<datalist id="{uid}-color-suggestions">
<!-- Our beloved -->
<option value="#cc99ff">Laserviolet</option>
<!-- Greyscale -->
<option value="#000000">Black</option>
<option value="#777777">Grey</option>
<option value="#ffffff">White</option>
<!-- Primitives, mostly stripped-down tailwind -->
<option value="#ff6467">Red</option>
<option value="#ff8904">Orange</option>
<option value="#fcc800">Yellow</option>
<option value="#9ae600">Lime</option>
<option value="#05df72">Green</option>
<option value="#00d5be">Teal</option>
<option value="#00bcff">Sky</option>
<!-- omg again!?!? -->
<option value="#cc99ff">Laserviolet</option>
<!-- teehee -->
<option value="#e83d84">Rosa</option>
<!-- More specific -->
<!-- Also used by TMEIT... I think???? -->
<option value="#44687d">Silicone blue</option>
<!-- "Fun" fact: The lighter ITK shade is not well-defined! -->
<option value="#acff5b">ITK Green</option>
<option value="#800000">QMISK Ockraröd</option>
>
</button>

<option value="#004791">KTH-Blue</option>
<option value="#45b8da">THS-Blue</option>
<!-- In case I graduate before we share the new locale -->
<option value="#a2ee8d">TBas Green</option>
</datalist>
<div
class={[
"rainbow-gradient relative m-0.5 size-6 rounded border-2",
activeButton?.color &&
!colors.some(([color]) => color === activeButton?.color)
? "border-brand"
: "",
]}
>
<input
id="{uid}-color"
{...editButton.fields.color.as("color")}
class="absolute inset-0 size-full cursor-pointer opacity-0"
title="Custom"
value={activeButton.color ?? "#000000"}
oninput={(event) => {
if (activeButton) activeButton.color = event.currentTarget.value;
}}
/>
</div>

{#each colors as [color, name], i (i)}
{#if color}
<button
type="button"
title={name}
class={[
"m-0.5 size-6 cursor-pointer rounded border-2",
activeButton.color === color ? "border-brand" : "",
]}
style:background-color={color}
onclick={() => {
if (activeButton) activeButton.color = color;
}}
>
</button>
{:else}
<div class="w-full shrink"></div>
{/if}
{/each}
</div>
</div>

<!-- Script field -->
Expand Down Expand Up @@ -285,8 +312,10 @@
/>
</div>

<!-- Action buttons -->
<div class="flex justify-end gap-2">
<button
type="button"
class="bg-red-400 px-2 py-1 text-black"
onclick={() => {
if (activeButton) {
Expand All @@ -300,15 +329,22 @@
Delete
</button>
<button
type="button"
class="bg-gray-400 px-2 py-1 text-black"
onclick={() => {
activeButton = undefined;
}}
>
Cancel
</button>
<button class="bg-green-400 px-2 py-1 text-black"> Save </button>
<button type="submit" class="bg-green-400 px-2 py-1 text-black"> Save </button>
</div>
</form>
</dialog>
{/if}

<style>
.rainbow-gradient {
background: conic-gradient(in oklch longer hue, oklch(0.75 0.12 0), oklch(0.75 0.12 0));
}
</style>
6 changes: 4 additions & 2 deletions src/lib/components/ScriptButton.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { onLongPress } from "$lib/attachments/longpress";
import type { buttonTable } from "$lib/server/db/schema";
import { runScript } from "$lib/lmixer.remote";
import { getScriptsContext } from "$lib/context";
import { confirmScriptExecution, getScriptsContext } from "$lib/context";

type Button = typeof buttonTable.$inferSelect;

Expand Down Expand Up @@ -34,7 +34,9 @@
<button
class="size-full cursor-pointer truncate overflow-hidden px-4 text-xl font-semibold"
onclick={() => {
runScript(btn.script);
if (confirmScriptExecution(btn.label)) {
runScript(btn.script);
}
}}
// Allow editing via long press on touch devices
{@attach onLongPress(500, () => onEdit?.(btn))}
Expand Down
46 changes: 20 additions & 26 deletions src/lib/components/ScriptTree.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@
import FolderOpenIcon from "@iconify-svelte/material-symbols/folder-open-rounded";
import AddIcon from "@iconify-svelte/material-symbols/add-2-rounded";

import { SvelteSet } from "svelte/reactivity";
import { SvelteMap } from "svelte/reactivity";
import ScriptTree from "./ScriptTree.svelte";
import { runScript } from "$lib/lmixer.remote";
import { createButton } from "$lib/db.remote";
import { getCollectionContext } from "$lib/context";
import { confirmScriptExecution, getCollectionContext } from "$lib/context";

function constructTree(paths: string[]): TreeNode[] {
const tree: TreeNode[] = [];
Expand Down Expand Up @@ -73,10 +73,12 @@
let {
isRoot = true,
pathPrefix = "",
expandAll = false,
...props
}: {
isRoot?: boolean;
pathPrefix?: string;
expandAll?: boolean;
} & ({ scriptPaths: string[] } | { scriptTree: TreeNode[] }) = $props();

let processedTree: TreeNode[] = $derived(
Expand All @@ -90,47 +92,37 @@
}),
),
);

let expandedNodes = new SvelteSet();
// If we only have a single node, make it expanded by default
$effect(() => {
if (processedTree.length === 1 && processedTree[0].type === "directory") {
expandedNodes.add(processedTree[0].name);
}
});

let nodeExpansions = new SvelteMap<string, boolean>();
let collection = getCollectionContext();
</script>

<!-- TODO: Turn into an accessible tree view. Requires some effort for to-spec keyboard navigation though -->
<ul class="flex flex-col" role={isRoot ? "tree" : "group"}>
{#each processedTree as node (node.name)}
{@const shouldExpand = nodeExpansions.get(node.name) ?? expandAll}
{#if node.type === "directory"}
<li class="flex flex-col" data-expanded={expandedNodes.has(node.name)}>
<li class="flex flex-col" data-expanded={shouldExpand}>
<button
class={[
"flex size-full items-center gap-1 p-0.5",
"cursor-pointer hover:bg-secondary focus:bg-secondary",
"hover:bg-secondary focus:bg-secondary",
"border-secondary pointer-coarse:border-b pointer-coarse:py-2",
!expandAll && "cursor-pointer",
]}
onclick={() => {
if (expandedNodes.has(node.name)) {
expandedNodes.delete(node.name);
} else {
expandedNodes.add(node.name);
}
nodeExpansions.set(node.name, !shouldExpand);
}}
>
{#if expandedNodes.has(node.name)}
<FolderOpenIcon class="size-[1lh] opacity-50" />
{#if shouldExpand}
<FolderOpenIcon class="size-[1lh] shrink-0 opacity-50" />
{:else}
<FolderClosedIcon class="size-[1lh] opacity-50" />
<FolderClosedIcon class="size-[1lh] shrink-0 opacity-50" />
{/if}
{node.name}/
<span class="truncate">{node.name}/</span>
</button>
{#if expandedNodes.has(node.name)}
{#if shouldExpand}
<div class="border-l pl-[0.8lh]">
<ScriptTree scriptTree={node.children} isRoot={false} />
<ScriptTree scriptTree={node.children} isRoot={false} {expandAll} />
</div>
{/if}
</li>
Expand All @@ -139,12 +131,14 @@
<button
data-path={node.path}
class={[
"flex grow flex-row items-center gap-1 p-0.5",
"flex grow flex-row items-center gap-1 truncate p-0.5",
"cursor-grab hover:bg-secondary focus:bg-secondary",
"pointer-coarse:py-2",
]}
onclick={() => {
runScript(node.path);
if (confirmScriptExecution(node.name)) {
runScript(node.path);
}
}}
draggable="true"
ondragstart={(event) => {
Expand Down
12 changes: 12 additions & 0 deletions src/lib/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,15 @@ export const [getCollectionContext, setCollectionContext] = createContext<{
export const [getScriptsContext, setScriptsContext] = createContext<{
paths: string[] | undefined;
}>();

export const confirmScriptExecution = (scriptName: string) => {
const key = "confirmScriptExecution";
if (sessionStorage.getItem(key) === "true") {
return true;
}
const confirmed = confirm(`Run ${scriptName}?`);
if (confirmed) {
sessionStorage.setItem(key, "true");
}
return confirmed;
};
6 changes: 5 additions & 1 deletion src/lib/lmixer.remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,9 @@ export const runScript = command(vb.string(), async (scriptPath) => {
return;
}
console.log("Running script:", scriptPath);
mqttClient.publish("light_mixer/code/startScript", scriptPath);
if (import.meta.env.DEV) {
console.warn("Running in development mode. Will not execute script.");
} else {
// mqttClient.publish("light_mixer/code/startScript", scriptPath);
}
});
Loading