initial Commit

This commit is contained in:
2026-05-10 12:46:33 +02:00
commit 108f08645c
36 changed files with 8688 additions and 0 deletions

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Custom Streamdeck</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1901
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
frontend/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "custom-streamdeck-ui",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc -b && vite build",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@tailwindcss/vite": "^4.1.17",
"lucide-react": "^0.555.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"tailwindcss": "^4.1.17"
},
"devDependencies": {
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"typescript": "^5.9.3",
"vite": "^8.0.11"
}
}

970
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,970 @@
import {
closestCenter,
DndContext,
type DragEndEvent,
PointerSensor,
useDraggable,
useDroppable,
useSensor,
useSensors
} from "@dnd-kit/core";
import {
AppWindow,
Boxes,
Cable,
Keyboard,
Layers3,
Loader2,
MoreVertical,
MousePointerClick,
Pencil,
Play,
Plus,
Power,
RefreshCw,
RotateCw,
Route,
Save,
ShieldCheck,
Trash2,
Unplug
} from "lucide-react";
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { api } from "./api";
import type { ActionType, AppEntry, ButtonConfig, DeckState, Folder, PluginAction, PluginField, PluginInfo } from "./types";
const emptyState: DeckState = {
settings: { serial_port: null, click_check: false, active_profile_id: "", active_folder_id: "" },
profiles: [],
folders: [],
buttons: [],
apps: [],
plugins: [],
device: { connected_port: null }
};
const actionLabels: Record<ActionType, string> = {
noop: "No Action",
key_combo: "Key Press",
chain: "Action Chain",
app_launch: "App Launch",
folder: "Folder",
folder_rotation: "Folder Rotation",
plugin: "Plugin"
};
const actionIcons: Record<ActionType, ReactNode> = {
noop: <Power size={16} />,
key_combo: <Keyboard size={16} />,
chain: <Route size={16} />,
app_launch: <AppWindow size={16} />,
folder: <Layers3 size={16} />,
folder_rotation: <RotateCw size={16} />,
plugin: <Boxes size={16} />
};
export function App() {
const [state, setState] = useState<DeckState>(emptyState);
const [selectedButtonId, setSelectedButtonId] = useState<string>("");
const [pressedButtons, setPressedButtons] = useState<Record<number, boolean>>({});
const [status, setStatus] = useState("Loading");
const [wsOpen, setWsOpen] = useState(false);
const [createMenuOpen, setCreateMenuOpen] = useState(false);
const [manageMenuOpen, setManageMenuOpen] = useState(false);
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
const [folderDialogOpen, setFolderDialogOpen] = useState(false);
const [renameProfileDialogOpen, setRenameProfileDialogOpen] = useState(false);
const [renameFolderDialogOpen, setRenameFolderDialogOpen] = useState(false);
const [deleteProfileDialogOpen, setDeleteProfileDialogOpen] = useState(false);
const [deleteFolderDialogOpen, setDeleteFolderDialogOpen] = useState(false);
const [manualAppDialogOpen, setManualAppDialogOpen] = useState(false);
const socketRef = useRef<WebSocket | null>(null);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
useEffect(() => {
api.state().then(next => {
setState(next);
setSelectedButtonId(next.buttons.find(button => button.folder_id === next.settings.active_folder_id)?.id ?? "");
setStatus("Ready");
}).catch(error => setStatus(error.message));
}, []);
useEffect(() => {
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const socket = new WebSocket(`${protocol}://${window.location.host}/ws`);
socketRef.current = socket;
socket.onopen = () => setWsOpen(true);
socket.onclose = () => setWsOpen(false);
socket.onmessage = event => {
const message = JSON.parse(event.data);
if (message.type === "state.updated") {
setState(message.payload);
setStatus("Synced");
}
if (message.type === "button.down" || message.type === "button.up") {
const physical = Number(message.payload?.event?.button);
setPressedButtons(current => ({ ...current, [physical]: message.type === "button.down" }));
}
if (message.type === "action.failed") {
setStatus(message.payload?.error ?? "Action failed");
}
};
return () => {
socketRef.current = null;
socket.close();
};
}, []);
const activeProfile = state.profiles.find(profile => profile.id === state.settings.active_profile_id);
const activeFolder = state.folders.find(folder => folder.id === state.settings.active_folder_id);
const activeProfileFolders = state.folders.filter(folder => folder.profile_id === activeProfile?.id);
const firstProfile = state.profiles[0];
const canEditLayout = state.layout?.canonical_folder_id === state.settings.active_folder_id;
const currentButtons = useMemo(
() => state.buttons.filter(button => button.folder_id === state.settings.active_folder_id).sort((a, b) => a.position - b.position),
[state.buttons, state.settings.active_folder_id]
);
const selectedButton = state.buttons.find(button => button.id === selectedButtonId) ?? currentButtons[0];
const folderPath = useMemo(() => buildFolderPath(state.folders, activeFolder), [state.folders, activeFolder]);
const manualApps = state.apps.filter(app => app.source === "manual");
useEffect(() => {
if (!currentButtons.length) return;
if (!currentButtons.some(button => button.id === selectedButtonId)) {
setSelectedButtonId(currentButtons[0].id);
}
}, [currentButtons, selectedButtonId]);
async function sync<T>(operation: Promise<T>, label = "Saved") {
setStatus("Saving");
try {
const result = await operation;
setStatus(label);
if (looksLikeState(result)) {
setState(result);
} else {
const next = await api.state();
setState(next);
}
} catch (error) {
setStatus(error instanceof Error ? error.message : "Request failed");
}
}
function updateSelected(payload: Partial<ButtonConfig>) {
if (!selectedButton) return;
const buttonId = selectedButton.id;
setState(current => ({
...current,
buttons: current.buttons.map(button => (
button.id === buttonId
? { ...button, ...payload, action_config: payload.action_config ?? button.action_config }
: button
))
}));
setStatus("Saving");
api.updateButton(buttonId, payload)
.then(saved => {
setState(current => ({
...current,
buttons: current.buttons.map(button => button.id === saved.id ? saved : button)
}));
setStatus("Saved");
})
.catch(error => {
setStatus(error instanceof Error ? error.message : "Save failed");
api.state().then(setState).catch(() => undefined);
});
}
function handleDragEnd(event: DragEndEvent) {
if (!canEditLayout) {
setStatus("Layout is locked here");
return;
}
const physical = Number(event.active.data.current?.physical);
const position = Number(event.over?.data.current?.position);
if (!physical || !position) return;
setStatus("Saving mapping");
const command = JSON.stringify({ type: "move_button", payload: { folder_id: state.settings.active_folder_id, physical_button: physical, position } });
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(command);
return;
}
const socket = new WebSocket(`${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ws`);
socket.onopen = () => {
socket.send(command);
socket.close();
};
}
return (
<div className="min-h-screen bg-[#090b10] text-slate-100">
<header className="flex h-16 items-center justify-between border-b border-slate-800 bg-[#0d1119] px-5">
<div className="flex items-center gap-3">
<div className="grid h-9 w-9 place-items-center rounded bg-cyan-500 text-slate-950">
<MousePointerClick size={20} />
</div>
<div>
<h1 className="text-lg font-semibold tracking-normal">Custom Streamdeck</h1>
<div className="flex items-center gap-2 text-xs text-slate-400">
{state.device.connected_port ? <Cable size={13} /> : <Unplug size={13} />}
<span>{state.device.connected_port ?? "Pico not connected"}</span>
<span className="text-slate-600">/</span>
<span>{wsOpen ? "Live" : "Offline"}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<div className="nav-select">
<Layers3 size={15} />
<select
title="Profile"
value={state.settings.active_profile_id}
onChange={event => sync(api.updateProfile(event.target.value, { active: true }), "Profile switched")}
>
{state.profiles.map(profile => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
</select>
</div>
<div className="nav-select">
<Route size={15} />
<select
title="Folder"
value={state.settings.active_folder_id}
onChange={event => sync(api.settings({ active_folder_id: event.target.value }), "Folder opened")}
>
{activeProfileFolders.map(folder => <option key={folder.id} value={folder.id}>{folder.name}</option>)}
</select>
</div>
<div className="create-menu-wrap">
<button className="icon-button" title="Create" onClick={() => setCreateMenuOpen(open => !open)}>
<Plus size={16} />
</button>
{createMenuOpen && (
<div className="create-menu">
<button onClick={() => { setCreateMenuOpen(false); setProfileDialogOpen(true); }}>New profile</button>
<button onClick={() => { setCreateMenuOpen(false); setFolderDialogOpen(true); }}>New folder</button>
</div>
)}
</div>
<div className="create-menu-wrap">
<button className="icon-button" title="Manage profile and folder" onClick={() => setManageMenuOpen(open => !open)}>
<MoreVertical size={16} />
</button>
{manageMenuOpen && (
<div className="create-menu">
<button onClick={() => { setManageMenuOpen(false); setRenameProfileDialogOpen(true); }}>
<Pencil size={14} />
<span>Rename profile</span>
</button>
<button
disabled={!activeProfile || activeProfile.id === firstProfile?.id}
title={activeProfile?.id === firstProfile?.id ? "The first profile owns the hardware layout" : "Delete profile"}
onClick={() => { setManageMenuOpen(false); setDeleteProfileDialogOpen(true); }}
>
<Trash2 size={14} />
<span>Delete profile</span>
</button>
<div className="menu-separator" />
<button onClick={() => { setManageMenuOpen(false); setRenameFolderDialogOpen(true); }}>
<Pencil size={14} />
<span>Rename folder</span>
</button>
<button
disabled={!activeFolder || Boolean(activeFolder.is_root)}
title={activeFolder?.is_root ? "Root folders cannot be deleted" : "Delete folder"}
onClick={() => { setManageMenuOpen(false); setDeleteFolderDialogOpen(true); }}
>
<Trash2 size={14} />
<span>Delete folder</span>
</button>
</div>
)}
</div>
<button className={`toolbar-button ${state.settings.click_check ? "active" : ""}`} onClick={() => sync(api.settings({ click_check: !state.settings.click_check }), "Click-check updated")}>
<ShieldCheck size={16} />
<span>Click-check</span>
</button>
<button className="icon-button" title="Reload plugins" onClick={() => sync(api.reloadPlugins(), "Plugins reloaded")}>
<RefreshCw size={16} />
</button>
<div className="nav-stats">
<StatPill label="Profiles" value={state.profiles.length} />
<StatPill label="Folders" value={activeProfileFolders.length} />
<StatPill label="Apps" value={state.apps.length} />
</div>
<div className="status-pill">
{status === "Saving" ? <Loader2 className="animate-spin" size={14} /> : <Save size={14} />}
<span>{status}</span>
</div>
</div>
</header>
<main className="grid min-h-[calc(100vh-4rem)] grid-cols-[minmax(0,1fr)_380px]">
<section className="flex flex-col gap-5 p-6">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-slate-400">{activeProfile?.name ?? "No Profile"}</div>
<div className="mt-1 flex items-center gap-2 text-2xl font-semibold">
{folderPath.map((folder, index) => (
<span className="flex items-center gap-2" key={folder.id}>
{index > 0 && <span className="text-slate-600">/</span>}
<button className="breadcrumb" onClick={() => sync(api.settings({ active_folder_id: folder.id }), "Folder opened")}>{folder.name}</button>
</span>
))}
</div>
<div className="mt-2 text-xs text-slate-500">
{canEditLayout ? "Hardware layout is editable here and syncs everywhere." : "Hardware layout is mirrored from the first profile's root folder."}
</div>
</div>
</div>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<div className="deck-strip">
{currentButtons.map(button => (
<DeckButton
key={button.id}
button={button}
selected={button.id === selectedButton?.id}
pressed={Boolean(pressedButtons[button.physical_button])}
clickCheck={state.settings.click_check}
canEditLayout={canEditLayout}
onSelect={() => setSelectedButtonId(button.id)}
/>
))}
</div>
</DndContext>
<div className="panel">
<div className="mb-3 flex items-center justify-between">
<h2 className="panel-title">Manual Launch Targets</h2>
<button className="secondary-button" onClick={() => setManualAppDialogOpen(true)}>
<Plus size={15} />
<span>Add Target</span>
</button>
</div>
<div className="app-list">
{manualApps.map(app => (
<button key={`${app.source}:${app.path}`} className="app-row" onClick={() => selectedButton && updateSelected({ action_type: "app_launch", action_config: { path: app.path, args: app.args ?? "" } })}>
<span>{app.name}</span>
<small>{app.path}</small>
</button>
))}
{!manualApps.length && (
<div className="empty-panel">No manual launch targets yet.</div>
)}
</div>
</div>
</section>
<Inspector
button={selectedButton}
folders={state.folders.filter(folder => folder.profile_id === activeProfile?.id)}
apps={state.apps}
plugins={state.plugins}
clickCheck={state.settings.click_check}
canEditLayout={canEditLayout}
onChange={updateSelected}
onTest={() => selectedButton && sync(api.testAction(selectedButton.action_type, selectedButton.action_config), "Action test sent")}
/>
</main>
<NameDialog
open={profileDialogOpen}
title="New Profile"
label="Profile name"
defaultValue="New Profile"
submitLabel="Create"
onClose={() => setProfileDialogOpen(false)}
onSubmit={name => sync(api.createProfile(name), "Profile created")}
/>
<NameDialog
open={folderDialogOpen}
title="New Folder"
label="Folder name"
defaultValue="New Folder"
submitLabel="Create"
onClose={() => setFolderDialogOpen(false)}
onSubmit={name => {
if (!activeProfile) return;
sync(api.createFolder(activeProfile.id, state.settings.active_folder_id, name), "Folder created");
}}
/>
<NameDialog
open={renameProfileDialogOpen}
title="Rename Profile"
label="Profile name"
defaultValue={activeProfile?.name ?? ""}
submitLabel="Save"
onClose={() => setRenameProfileDialogOpen(false)}
onSubmit={name => {
if (!activeProfile) return;
sync(api.updateProfile(activeProfile.id, { name }), "Profile renamed");
}}
/>
<NameDialog
open={renameFolderDialogOpen}
title="Rename Folder"
label="Folder name"
defaultValue={activeFolder?.name ?? ""}
submitLabel="Save"
onClose={() => setRenameFolderDialogOpen(false)}
onSubmit={name => {
if (!activeFolder) return;
sync(api.updateFolder(activeFolder.id, { name }), "Folder renamed");
}}
/>
<ConfirmDialog
open={deleteProfileDialogOpen}
title="Delete Profile"
body={`Delete "${activeProfile?.name ?? "this profile"}"? This removes its folders and button setup.`}
confirmLabel="Delete Profile"
destructive
onClose={() => setDeleteProfileDialogOpen(false)}
onConfirm={() => {
if (!activeProfile) return;
sync(api.deleteProfile(activeProfile.id), "Profile deleted");
}}
/>
<ConfirmDialog
open={deleteFolderDialogOpen}
title="Delete Folder"
body={`Delete "${activeFolder?.name ?? "this folder"}"? This removes the buttons configured inside it.`}
confirmLabel="Delete Folder"
destructive
onClose={() => setDeleteFolderDialogOpen(false)}
onConfirm={() => {
if (!activeFolder) return;
sync(api.deleteFolder(activeFolder.id), "Folder deleted");
}}
/>
<ManualAppDialog
open={manualAppDialogOpen}
onClose={() => setManualAppDialogOpen(false)}
onSubmit={(name, path, args) => sync(api.addManualApp(name, path, args), "Manual app added")}
/>
</div>
);
}
function NameDialog(props: {
open: boolean;
title: string;
label: string;
defaultValue: string;
submitLabel: string;
onClose: () => void;
onSubmit: (value: string) => void;
}) {
const [value, setValue] = useState(props.defaultValue);
useEffect(() => {
if (props.open) setValue(props.defaultValue);
}, [props.open, props.defaultValue]);
if (!props.open) return null;
return (
<div className="modal-backdrop" role="presentation" onMouseDown={props.onClose}>
<form className="modal" onMouseDown={event => event.stopPropagation()} onSubmit={event => {
event.preventDefault();
const trimmed = value.trim();
if (!trimmed) return;
props.onSubmit(trimmed);
props.onClose();
}}>
<h2>{props.title}</h2>
<Field label={props.label}>
<input className="input" autoFocus value={value} onChange={event => setValue(event.target.value)} />
</Field>
<div className="modal-actions">
<button type="button" className="secondary-button" onClick={props.onClose}>Cancel</button>
<button type="submit" className="primary-button">{props.submitLabel}</button>
</div>
</form>
</div>
);
}
function ConfirmDialog(props: {
open: boolean;
title: string;
body: string;
confirmLabel: string;
destructive?: boolean;
onClose: () => void;
onConfirm: () => void;
}) {
if (!props.open) return null;
return (
<div className="modal-backdrop" role="presentation" onMouseDown={props.onClose}>
<div className="modal" role="dialog" aria-modal="true" onMouseDown={event => event.stopPropagation()}>
<h2>{props.title}</h2>
<p className="modal-body">{props.body}</p>
<div className="modal-actions">
<button type="button" className="secondary-button" onClick={props.onClose}>Cancel</button>
<button type="button" className={props.destructive ? "danger-button" : "primary-button"} onClick={() => {
props.onConfirm();
props.onClose();
}}>
{props.confirmLabel}
</button>
</div>
</div>
</div>
);
}
function ManualAppDialog(props: {
open: boolean;
onClose: () => void;
onSubmit: (name: string, path: string, args?: string) => void;
}) {
const [name, setName] = useState("");
const [path, setPath] = useState("");
const [args, setArgs] = useState("");
useEffect(() => {
if (!props.open) return;
setName("");
setPath("");
setArgs("");
}, [props.open]);
if (!props.open) return null;
return (
<div className="modal-backdrop" role="presentation" onMouseDown={props.onClose}>
<form className="modal" onMouseDown={event => event.stopPropagation()} onSubmit={event => {
event.preventDefault();
const trimmedName = name.trim();
const trimmedPath = path.trim();
if (!trimmedName || !trimmedPath) return;
props.onSubmit(trimmedName, trimmedPath, args.trim() || undefined);
props.onClose();
}}>
<h2>Add Manual App</h2>
<Field label="App name">
<input className="input" autoFocus value={name} onChange={event => setName(event.target.value)} />
</Field>
<Field label="Executable, shortcut, or file path">
<input className="input" value={path} onChange={event => setPath(event.target.value)} />
</Field>
<Field label="Arguments">
<input className="input" value={args} onChange={event => setArgs(event.target.value)} />
</Field>
<div className="modal-actions">
<button type="button" className="secondary-button" onClick={props.onClose}>Cancel</button>
<button type="submit" className="primary-button">Add App</button>
</div>
</form>
</div>
);
}
function StatPill(props: { label: string; value: number }) {
return (
<div className="stat-pill">
<span>{props.label}</span>
<strong>{props.value}</strong>
</div>
);
}
function DeckButton(props: { button: ButtonConfig; selected: boolean; pressed: boolean; clickCheck: boolean; canEditLayout: boolean; onSelect: () => void }) {
const { setNodeRef: setDropRef, isOver } = useDroppable({
id: `position-${props.button.position}`,
data: { position: props.button.position }
});
const { attributes, listeners, setNodeRef: setDragRef, transform, isDragging } = useDraggable({
id: `physical-${props.button.physical_button}`,
data: { physical: props.button.physical_button },
disabled: !props.canEditLayout
});
const style: CSSProperties = {
transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined,
background: `linear-gradient(145deg, ${props.button.color}, #05070b)`
};
return (
<button
ref={setDropRef}
className={`deck-button ${props.selected ? "selected" : ""} ${props.pressed ? "pressed" : ""} ${isOver ? "over" : ""}`}
onClick={props.onSelect}
>
<div ref={setDragRef} style={style} className={`deck-face ${isDragging ? "dragging" : ""} ${props.canEditLayout ? "" : "layout-locked"}`} {...attributes} {...listeners}>
<div className="flex items-center justify-between text-xs text-slate-400">
<span>Slot {props.button.position}</span>
<span>#{props.button.physical_button}</span>
</div>
<div className="grid flex-1 place-items-center">
<div className="grid h-10 w-10 place-items-center rounded bg-white/10 text-cyan-200">
{actionIcons[props.button.action_type]}
</div>
</div>
<div className="truncate text-sm font-medium">{props.button.label || "Untitled"}</div>
{props.clickCheck && <div className="click-check-badge">Check</div>}
</div>
</button>
);
}
function Inspector(props: {
button?: ButtonConfig;
folders: Folder[];
apps: AppEntry[];
plugins: PluginInfo[];
clickCheck: boolean;
canEditLayout: boolean;
onChange: (payload: Partial<ButtonConfig>) => void;
onTest: () => void;
}) {
const button = props.button;
if (!button) {
return <aside className="border-l border-slate-800 bg-[#0b0f16] p-5">Select a button</aside>;
}
const updateConfig = (patch: Record<string, any>) => props.onChange({ action_config: { ...button.action_config, ...patch } });
return (
<aside className="overflow-y-auto border-l border-slate-800 bg-[#0b0f16] p-5">
<div className="mb-4">
<div className="text-xs uppercase text-slate-500">Inspector</div>
<h2 className="mt-1 text-xl font-semibold">Slot {button.position}</h2>
</div>
<div className="space-y-4">
<Field label="Label">
<DraftInput value={button.label} onCommit={value => props.onChange({ label: value })} />
</Field>
<Field label="Color">
<div className="flex gap-2">
<input className="h-10 w-14 rounded border border-slate-700 bg-transparent" type="color" value={button.color} onChange={event => props.onChange({ color: event.target.value })} />
<DraftInput value={button.color} onCommit={value => props.onChange({ color: value })} />
</div>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Physical">
<select className="input" value={button.physical_button} disabled={!props.canEditLayout} onChange={event => props.onChange({ physical_button: Number(event.target.value) })}>
{Array.from({ length: 10 }, (_, index) => index + 1).map(number => <option key={number} value={number}>Button {number}</option>)}
</select>
</Field>
<Field label="Trigger">
<select className="input" value={button.trigger_mode} onChange={event => props.onChange({ trigger_mode: event.target.value as any })}>
<option value="down">Press down</option>
<option value="up">Release up</option>
</select>
</Field>
</div>
<Field label="Action">
<select className="input" value={button.action_type} onChange={event => props.onChange({ action_type: event.target.value as ActionType, action_config: {} })}>
{(Object.keys(actionLabels) as ActionType[]).map(type => <option key={type} value={type}>{actionLabels[type]}</option>)}
</select>
</Field>
<ActionForm button={button} folders={props.folders} apps={props.apps} plugins={props.plugins} updateConfig={updateConfig} onChange={props.onChange} />
<button className="primary-button w-full" disabled={props.clickCheck} onClick={props.onTest}>
<Play size={16} />
<span>{props.clickCheck ? "Blocked by click-check" : "Test Action"}</span>
</button>
</div>
</aside>
);
}
function ActionForm(props: {
button: ButtonConfig;
folders: Folder[];
apps: AppEntry[];
plugins: PluginInfo[];
updateConfig: (patch: Record<string, any>) => void;
onChange: (payload: Partial<ButtonConfig>) => void;
}) {
const { button } = props;
if (button.action_type === "noop") return <p className="hint">This button only shows live press state.</p>;
if (button.action_type === "key_combo") {
return <Field label="Key combo"><DraftInput placeholder="ctrl+shift+s" value={button.action_config.combo ?? ""} onCommit={value => props.updateConfig({ combo: value })} /></Field>;
}
if (button.action_type === "app_launch") {
return (
<Field label="Launch app">
<select className="input" value={button.action_config.path ?? ""} onChange={event => props.updateConfig({ path: event.target.value })}>
<option value="">Choose an app</option>
{props.apps.map(app => <option key={`${app.source}:${app.path}`} value={app.path}>{app.name}</option>)}
</select>
</Field>
);
}
if (button.action_type === "folder") {
return (
<Field label="Open folder">
<select className="input" value={button.action_config.folder_id ?? ""} onChange={event => props.updateConfig({ folder_id: event.target.value })}>
<option value="">Choose a folder</option>
{props.folders.map(folder => <option key={folder.id} value={folder.id}>{folder.name}</option>)}
</select>
</Field>
);
}
if (button.action_type === "folder_rotation") {
return (
<Field label="Rotation direction">
<select className="input" value={button.action_config.direction ?? "next"} onChange={event => props.updateConfig({ direction: event.target.value })}>
<option value="next">Next folder</option>
<option value="previous">Previous folder</option>
</select>
</Field>
);
}
if (button.action_type === "chain") {
const steps = Array.isArray(button.action_config.steps) ? button.action_config.steps : [];
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="label">Steps</label>
<button className="secondary-button" onClick={() => props.updateConfig({ steps: [...steps, { action_type: "key_combo", action_config: { combo: "" }, delay_ms: 100 }] })}>
<Plus size={14} />
<span>Add</span>
</button>
</div>
{steps.map((step: any, index: number) => (
<div key={index} className="chain-step">
<select className="input" value={step.action_type} onChange={event => {
const next = [...steps];
next[index] = { ...step, action_type: event.target.value, action_config: {} };
props.updateConfig({ steps: next });
}}>
<option value="key_combo">Key Press</option>
<option value="app_launch">App Launch</option>
<option value="noop">No-op</option>
</select>
{step.action_type === "app_launch" ? (
<select className="input" value={step.action_config?.path ?? ""} onChange={event => {
const next = [...steps];
next[index] = { ...step, action_config: { path: event.target.value } };
props.updateConfig({ steps: next });
}}>
<option value="">App</option>
{props.apps.map(app => <option key={`${index}:${app.path}`} value={app.path}>{app.name}</option>)}
</select>
) : (
<DraftInput placeholder="ctrl+c" value={step.action_config?.combo ?? ""} onCommit={value => {
const next = [...steps];
next[index] = { ...step, action_config: { combo: value } };
props.updateConfig({ steps: next });
}} />
)}
<DraftInput type="number" value={step.delay_ms ?? 0} onCommit={value => {
const next = [...steps];
next[index] = { ...step, delay_ms: Number(value) };
props.updateConfig({ steps: next });
}} />
</div>
))}
</div>
);
}
return <PluginForm button={button} plugins={props.plugins} updateConfig={props.updateConfig} />;
}
function PluginForm(props: { button: ButtonConfig; plugins: PluginInfo[]; updateConfig: (patch: Record<string, any>) => void }) {
const plugin = props.plugins.find(item => item.id === props.button.action_config.plugin_id);
const action = plugin?.actions.find(item => item.id === props.button.action_config.action_id);
const enabledPlugins = props.plugins.filter(item => item.enabled);
return (
<div className="space-y-4">
<Field label="Plugin">
<select className="input" value={props.button.action_config.plugin_id ?? ""} onChange={event => props.updateConfig({ plugin_id: event.target.value, action_id: "", fields: {} })}>
<option value="">Choose plugin</option>
{enabledPlugins.map(plugin => <option key={plugin.id} value={plugin.id}>{plugin.name}</option>)}
</select>
</Field>
{plugin && (
<Field label="Plugin action">
<select className="input" value={props.button.action_config.action_id ?? ""} onChange={event => props.updateConfig({ action_id: event.target.value, fields: {} })}>
<option value="">Choose action</option>
{plugin.actions.map(action => <option key={action.id} value={action.id}>{action.name}</option>)}
</select>
</Field>
)}
{action?.fields.map(field => (
<PluginFieldInput
key={field.id}
field={field}
value={props.button.action_config.fields?.[field.id] ?? field.default ?? ""}
onChange={value => props.updateConfig({ fields: { ...(props.button.action_config.fields ?? {}), [field.id]: value } })}
/>
))}
{props.plugins.some(plugin => !plugin.enabled) && <p className="hint">Some plugins failed to load. Reload after fixing their backend code.</p>}
</div>
);
}
function PluginFieldInput(props: { field: PluginField; value: any; onChange: (value: any) => void }) {
if (props.field.type === "boolean") {
return <label className="toggle-row"><input type="checkbox" checked={Boolean(props.value)} onChange={event => props.onChange(event.target.checked)} />{props.field.label}</label>;
}
if (props.field.type === "key_value") {
return <KeyValueField field={props.field} value={props.value} onChange={props.onChange} />;
}
if (props.field.type === "select") {
return (
<Field label={props.field.label}>
<select className="input" value={props.value} onChange={event => props.onChange(event.target.value)}>
{(props.field.options ?? []).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</Field>
);
}
if (props.field.type === "textarea" || props.field.type === "json") {
return (
<Field label={props.field.label}>
<DraftTextArea
placeholder={props.field.placeholder}
value={props.value}
onCommit={value => props.onChange(value)}
/>
</Field>
);
}
return (
<Field label={props.field.label}>
<DraftInput
placeholder={props.field.placeholder}
type={inputTypeForPluginField(props.field)}
value={props.value}
onCommit={value => props.onChange(props.field.type === "number" ? Number(value) : value)}
/>
</Field>
);
}
function KeyValueField(props: { field: PluginField; value: any; onChange: (value: any) => void }) {
const rows = normalizeKeyValueRows(props.value);
const updateRow = (index: number, patch: Partial<{ key: string; value: string }>) => {
const next = rows.map((row, rowIndex) => rowIndex === index ? { ...row, ...patch } : row);
props.onChange(next);
};
const removeRow = (index: number) => props.onChange(rows.filter((_, rowIndex) => rowIndex !== index));
return (
<div>
<div className="flex items-center justify-between">
<span className="label">{props.field.label}</span>
<button type="button" className="secondary-button compact-button" onClick={() => props.onChange([...rows, { key: "", value: "" }])}>
<Plus size={13} />
<span>Add</span>
</button>
</div>
<div className="key-value-list">
{rows.map((row, index) => (
<div className="key-value-row" key={index}>
<input className="input" placeholder="Header" value={row.key} onChange={event => updateRow(index, { key: event.target.value })} />
<input className="input" placeholder="Value" value={row.value} onChange={event => updateRow(index, { value: event.target.value })} />
<button type="button" className="icon-button" title="Remove header" onClick={() => removeRow(index)}>
<Trash2 size={14} />
</button>
</div>
))}
{!rows.length && <div className="empty-inline">No headers configured.</div>}
</div>
</div>
);
}
function normalizeKeyValueRows(value: any): { key: string; value: string }[] {
if (Array.isArray(value)) {
return value.map(row => ({ key: String(row?.key ?? ""), value: String(row?.value ?? "") }));
}
if (value && typeof value === "object") {
return Object.entries(value).map(([key, rowValue]) => ({ key, value: String(rowValue ?? "") }));
}
return [];
}
function inputTypeForPluginField(field: PluginField): "text" | "number" | "url" | "password" {
if (field.type === "number" || field.type === "url" || field.type === "password") return field.type;
return "text";
}
function DraftTextArea(props: {
value: string | number;
placeholder?: string;
onCommit: (value: string) => void;
}) {
const [draft, setDraft] = useState(String(props.value ?? ""));
useEffect(() => {
setDraft(String(props.value ?? ""));
}, [props.value]);
function commit() {
const current = String(props.value ?? "");
if (draft !== current) {
props.onCommit(draft);
}
}
return (
<textarea
className="input textarea-input"
placeholder={props.placeholder}
value={draft}
onBlur={commit}
onChange={event => setDraft(event.target.value)}
/>
);
}
function DraftInput(props: {
value: string | number;
type?: "text" | "number" | "url" | "password";
placeholder?: string;
disabled?: boolean;
onCommit: (value: string) => void;
}) {
const [draft, setDraft] = useState(String(props.value ?? ""));
useEffect(() => {
setDraft(String(props.value ?? ""));
}, [props.value]);
function commit() {
const current = String(props.value ?? "");
if (draft !== current) {
props.onCommit(draft);
}
}
return (
<input
className="input"
disabled={props.disabled}
placeholder={props.placeholder}
type={props.type ?? "text"}
value={draft}
onBlur={commit}
onChange={event => setDraft(event.target.value)}
onKeyDown={event => {
if (event.key === "Enter") {
commit();
event.currentTarget.blur();
}
}}
/>
);
}
function Field(props: { label: string; children: ReactNode }) {
return <label className="block"><span className="label">{props.label}</span>{props.children}</label>;
}
function buildFolderPath(folders: Folder[], active?: Folder): Folder[] {
if (!active) return [];
const byId = new Map(folders.map(folder => [folder.id, folder]));
const path: Folder[] = [];
let cursor: Folder | undefined = active;
while (cursor) {
path.unshift(cursor);
cursor = cursor.parent_id ? byId.get(cursor.parent_id) : undefined;
}
return path;
}
function looksLikeState(value: unknown): value is DeckState {
return Boolean(value && typeof value === "object" && "settings" in value && "buttons" in value);
}

36
frontend/src/api.ts Normal file
View File

@@ -0,0 +1,36 @@
import type { ActionType, ButtonConfig, DeckState } from "./types";
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
...init
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.detail ?? response.statusText);
}
return response.json();
}
export const api = {
state: () => request<DeckState>("/api/state"),
settings: (payload: Record<string, unknown>) =>
request<DeckState>("/api/settings", { method: "PUT", body: JSON.stringify(payload) }),
createProfile: (name: string) =>
request<DeckState>("/api/profiles", { method: "POST", body: JSON.stringify({ name }) }),
updateProfile: (id: string, payload: Record<string, unknown>) =>
request<DeckState>(`/api/profiles/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
deleteProfile: (id: string) => request<DeckState>(`/api/profiles/${id}`, { method: "DELETE" }),
createFolder: (profile_id: string, parent_id: string | null, name: string) =>
request<DeckState>("/api/folders", { method: "POST", body: JSON.stringify({ profile_id, parent_id, name }) }),
updateFolder: (id: string, payload: Record<string, unknown>) =>
request<DeckState>(`/api/folders/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
deleteFolder: (id: string) => request<DeckState>(`/api/folders/${id}`, { method: "DELETE" }),
updateButton: (id: string, payload: Partial<ButtonConfig>) =>
request<ButtonConfig>(`/api/buttons/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
addManualApp: (name: string, path: string, args?: string) =>
request("/api/apps/manual", { method: "POST", body: JSON.stringify({ name, path, args }) }),
reloadPlugins: () => request("/api/plugins/reload", { method: "POST" }),
testAction: (action_type: ActionType, action_config: Record<string, unknown>) =>
request("/api/actions/test", { method: "POST", body: JSON.stringify({ action_type, action_config }) })
};

26
frontend/src/lucide-react.d.ts vendored Normal file
View File

@@ -0,0 +1,26 @@
declare module "lucide-react" {
import type { ComponentType, SVGProps } from "react";
export type Icon = ComponentType<SVGProps<SVGSVGElement> & { size?: number | string }>;
export const AppWindow: Icon;
export const Boxes: Icon;
export const Cable: Icon;
export const CheckCircle2: Icon;
export const FolderPlus: Icon;
export const Keyboard: Icon;
export const Layers3: Icon;
export const Loader2: Icon;
export const MoreVertical: Icon;
export const MousePointerClick: Icon;
export const Pencil: Icon;
export const Play: Icon;
export const Plus: Icon;
export const Power: Icon;
export const RefreshCw: Icon;
export const RotateCw: Icon;
export const Route: Icon;
export const Save: Icon;
export const ShieldCheck: Icon;
export const Trash2: Icon;
export const Unplug: Icon;
}

11
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

469
frontend/src/styles.css Normal file
View File

@@ -0,0 +1,469 @@
@import "tailwindcss";
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #090b10;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
.toolbar-button,
.secondary-button,
.primary-button,
.danger-button,
.icon-button,
.status-pill,
.stat-pill,
.nav-select {
align-items: center;
border: 1px solid #263244;
border-radius: 8px;
display: inline-flex;
gap: 8px;
min-height: 38px;
padding: 0 12px;
}
.toolbar-button,
.secondary-button,
.icon-button,
.status-pill,
.nav-select {
background: #121824;
color: #cbd5e1;
}
.nav-stats {
align-items: center;
display: flex;
gap: 6px;
}
.stat-pill {
background: #0b1220;
color: #cbd5e1;
gap: 6px;
min-height: 32px;
padding: 0 9px;
}
.stat-pill span {
color: #94a3b8;
font-size: 11px;
}
.stat-pill strong {
color: #f8fafc;
font-size: 13px;
}
.nav-select {
max-width: 230px;
padding: 0 10px;
}
.nav-select select {
appearance: none;
background: transparent;
color: #e2e8f0;
min-width: 120px;
outline: none;
}
.nav-select option {
background: #111827;
color: #e2e8f0;
}
.create-menu-wrap {
position: relative;
}
.create-menu {
background: #111827;
border: 1px solid #334155;
border-radius: 8px;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.45);
display: grid;
gap: 4px;
min-width: 160px;
padding: 6px;
position: absolute;
right: 0;
top: calc(100% + 8px);
z-index: 30;
}
.create-menu button {
align-items: center;
border-radius: 6px;
color: #e2e8f0;
display: flex;
gap: 8px;
min-height: 34px;
padding: 0 10px;
text-align: left;
}
.create-menu button:hover {
background: #1f2937;
}
.create-menu button:disabled {
color: #64748b;
cursor: not-allowed;
}
.create-menu button:disabled:hover {
background: transparent;
}
.menu-separator {
background: #263244;
height: 1px;
margin: 4px;
}
.toolbar-button.active,
.primary-button {
background: #06b6d4;
border-color: #67e8f9;
color: #061016;
font-weight: 700;
}
.danger-button {
background: #dc2626;
border-color: #f87171;
color: #fff;
font-weight: 700;
}
.primary-button:disabled {
background: #1f2937;
border-color: #334155;
color: #94a3b8;
cursor: not-allowed;
}
.icon-button {
justify-content: center;
min-width: 38px;
padding: 0;
}
.sidebar-title,
.panel-title,
.label {
color: #94a3b8;
display: block;
font-size: 12px;
font-weight: 700;
letter-spacing: 0;
margin-bottom: 8px;
text-transform: uppercase;
}
.nav-row {
align-items: center;
border: 1px solid transparent;
border-radius: 8px;
color: #cbd5e1;
display: flex;
gap: 8px;
min-height: 38px;
overflow: hidden;
padding: 0 10px;
width: 100%;
}
.nav-row:hover,
.nav-row.active {
background: #111827;
border-color: #263244;
color: #f8fafc;
}
.breadcrumb {
color: #e2e8f0;
}
.deck-strip {
align-items: stretch;
display: grid;
gap: 10px;
grid-template-columns: repeat(10, minmax(86px, 1fr));
min-height: 150px;
overflow-x: auto;
padding-bottom: 6px;
}
.deck-button {
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
min-height: 142px;
min-width: 86px;
padding: 3px;
}
.deck-button.selected {
border-color: #22d3ee;
}
.deck-button.over {
background: rgba(34, 211, 238, 0.12);
}
.deck-face {
border: 1px solid #263244;
border-radius: 8px;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08), 0 12px 30px rgba(0,0,0,0.35);
display: flex;
flex-direction: column;
gap: 8px;
height: 100%;
overflow: hidden;
padding: 10px;
position: relative;
text-align: left;
}
.deck-button.pressed .deck-face {
border-color: #67e8f9;
box-shadow: inset 0 0 0 2px rgba(103,232,249,0.35), 0 0 28px rgba(34,211,238,0.25);
transform: translateY(2px);
}
.deck-face.dragging {
opacity: 0.75;
z-index: 20;
}
.deck-face.layout-locked {
cursor: default;
}
.click-check-badge {
background: #22d3ee;
border-radius: 6px;
bottom: 8px;
color: #061016;
font-size: 11px;
font-weight: 800;
padding: 2px 6px;
position: absolute;
right: 8px;
}
.panel {
background: #0d1119;
border: 1px solid #1f2937;
border-radius: 8px;
}
.panel {
padding: 16px;
}
.input {
background: #111827;
border: 1px solid #334155;
border-radius: 8px;
color: #e2e8f0;
min-height: 40px;
outline: none;
padding: 0 10px;
width: 100%;
}
.input:focus {
border-color: #22d3ee;
box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.15);
}
.input:disabled {
color: #64748b;
cursor: not-allowed;
opacity: 0.75;
}
.textarea-input {
line-height: 1.45;
min-height: 130px;
padding: 10px;
resize: vertical;
}
.compact-button {
min-height: 30px;
padding: 0 9px;
}
.key-value-list {
display: grid;
gap: 8px;
}
.key-value-row {
display: grid;
gap: 8px;
grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr) 38px;
}
.empty-inline {
border: 1px dashed #334155;
border-radius: 8px;
color: #64748b;
font-size: 13px;
padding: 10px;
}
.hint {
border: 1px dashed #334155;
border-radius: 8px;
color: #94a3b8;
font-size: 13px;
padding: 12px;
}
.app-list {
display: grid;
gap: 8px;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
max-height: 230px;
overflow: auto;
}
.app-row {
background: #111827;
border: 1px solid #1f2937;
border-radius: 8px;
color: #e2e8f0;
display: flex;
flex-direction: column;
min-height: 56px;
min-width: 0;
padding: 9px;
text-align: left;
}
.app-row span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-row small {
color: #64748b;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty-panel {
border: 1px dashed #334155;
border-radius: 8px;
color: #64748b;
padding: 18px;
text-align: center;
}
.chain-step {
background: #0d1119;
border: 1px solid #263244;
border-radius: 8px;
display: grid;
gap: 8px;
grid-template-columns: 1fr 1fr 92px;
padding: 10px;
}
.toggle-row {
align-items: center;
color: #cbd5e1;
display: flex;
gap: 10px;
}
.modal-backdrop {
align-items: center;
background: rgba(2, 6, 23, 0.72);
display: flex;
inset: 0;
justify-content: center;
padding: 20px;
position: fixed;
z-index: 50;
}
.modal {
background: #0d1119;
border: 1px solid #334155;
border-radius: 8px;
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
display: grid;
gap: 16px;
max-width: 460px;
padding: 18px;
width: min(100%, 460px);
}
.modal h2 {
font-size: 20px;
font-weight: 700;
margin: 0;
}
.modal-body {
color: #cbd5e1;
line-height: 1.5;
margin: 0;
}
.modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
@media (max-width: 1180px) {
main {
grid-template-columns: minmax(0, 1fr);
}
main > aside:last-child {
grid-column: 1 / -1;
border-left: 0;
border-top: 1px solid #1f2937;
}
}
@media (max-width: 760px) {
main {
display: block;
}
header {
height: auto;
flex-wrap: wrap;
gap: 12px;
padding-bottom: 14px;
padding-top: 14px;
}
}

89
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,89 @@
export type TriggerMode = "down" | "up";
export type ActionType = "noop" | "key_combo" | "chain" | "app_launch" | "folder" | "folder_rotation" | "plugin";
export type Settings = {
serial_port: string | null;
click_check: boolean;
active_profile_id: string;
active_folder_id: string;
};
export type Profile = {
id: string;
name: string;
created_at: string;
};
export type Folder = {
id: string;
profile_id: string;
parent_id: string | null;
name: string;
is_root: number;
created_at: string;
};
export type ButtonConfig = {
id: string;
profile_id: string;
folder_id: string;
position: number;
physical_button: number;
label: string;
color: string;
icon: string;
trigger_mode: TriggerMode;
action_type: ActionType;
action_config: Record<string, any>;
updated_at: string;
};
export type AppEntry = {
id: string;
name: string;
path: string;
args?: string | null;
source: string;
};
export type PluginField = {
id: string;
label: string;
type: "text" | "number" | "boolean" | "select" | "app" | "key_combo" | "url" | "password" | "textarea" | "json" | "key_value";
required?: boolean;
default?: any;
placeholder?: string;
options?: { label: string; value: string }[];
};
export type PluginAction = {
id: string;
name: string;
desc?: string;
fields: PluginField[];
};
export type PluginInfo = {
id: string;
name: string;
desc: string;
version: string;
actions: PluginAction[];
enabled: boolean;
error?: string | null;
};
export type DeckState = {
settings: Settings;
profiles: Profile[];
folders: Folder[];
buttons: ButtonConfig[];
apps: AppEntry[];
plugins: PluginInfo[];
device: { connected_port: string | null };
layout?: {
canonical_profile_id: string;
canonical_folder_id: string;
mapping: Record<string, number>;
};
};

22
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": []
}

18
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
"/api": "http://127.0.0.1:8000",
"/ws": {
target: "ws://127.0.0.1:8000",
ws: true
}
}
}
});