initial Commit
This commit is contained in:
13
frontend/index.html
Normal file
13
frontend/index.html
Normal 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
1901
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
frontend/package.json
Normal file
27
frontend/package.json
Normal 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
970
frontend/src/App.tsx
Normal 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
36
frontend/src/api.ts
Normal 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
26
frontend/src/lucide-react.d.ts
vendored
Normal 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
11
frontend/src/main.tsx
Normal 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
469
frontend/src/styles.css
Normal 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
89
frontend/src/types.ts
Normal 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
22
frontend/tsconfig.json
Normal 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
18
frontend/vite.config.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user