Compare commits

..

18 Commits

Author SHA1 Message Date
0a41b1072a Add LICENSE.md 2025-06-14 22:14:36 +02:00
d7b8ad9976 feat: add editing of remotes 2025-04-15 18:47:43 +02:00
6a9c69a535 feat: add edit command 2025-04-15 16:04:38 +02:00
8f3832657b feat: add download remotes as file 2025-04-15 00:26:34 +02:00
e09d42ecf6 feat: add toggle to display remote control 2025-04-14 23:56:51 +02:00
23245dd549 feat: add remote control to integration view 2025-04-14 23:39:34 +02:00
b0c3a48da6 refactor: improve command constants 2025-04-14 23:38:51 +02:00
f83ffac0ce feat: add get remotes in web interface 2025-04-14 23:37:07 +02:00
83ab71f845 feat: add get remote api endpoint 2025-04-14 23:34:59 +02:00
6940782024 feat: add data channel to webrtc connection 2025-04-14 13:59:59 +02:00
6cefa7392c feat: persisting remotes and commands in database 2025-04-12 16:35:03 +02:00
db1beac033 feat: add sub menu navigation 2025-04-10 22:22:42 +02:00
318185bd4a feat: change objects to instances passed to remotes service 2025-04-10 20:02:10 +02:00
7a20ae1536 feat: add import commands modal 2025-04-10 19:46:54 +02:00
3ba3f5efd5 feat: change dropdown to select in create command 2025-04-08 20:54:16 +02:00
820caf137f feat: add delete remotes 2025-04-08 20:39:34 +02:00
0c6a302a92 fix: new command not listed in create remote modal 2025-04-08 20:07:42 +02:00
01aff1635d feat: implement list-manager 2025-04-08 19:52:57 +02:00
38 changed files with 2837 additions and 533 deletions

21
LICENSE.md Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Fritz Heiden
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

75
data/command.go Normal file
View File

@ -0,0 +1,75 @@
package data
const SAMSUNG_PROTOCOL = "samsung"
const NEC_PROTOCOL = "nec"
const ONKYO_PROTOCOL = "onkyo"
const APPLE_PROTOCOL = "apple"
const DENON_PROTOCOL = "denon"
const SHARP_PROTOCOL = "sharp"
const PANASONIC_PROTOCOL = "panasonic"
const KASEIKYO_PROTOCOL = "kaseikyo"
const JVC_PROTOCOL = "jvc"
const LG_PROTOCOL = "lg"
const SONY_PROTOCOL = "sony"
const RC5_PROTOCOL = "rc5"
const RC6_PROTOCOL = "rc6"
const UNIVERSAL_PULSE_DISTANCE_PROTOCOL = "universal_pulse_distance"
const UNIVERSAL_PULSE_WIDTH_PROTOCOL = "universal_pulse_width"
const UNIVERSAL_PULSE_DISTANCE_WIDTH_PROTOCOL = "universal_pulse_distance_width"
const HASH_PROTOCOL = "hash"
const PRONTO_PROTOCOL = "pronto"
const BOSE_WAVE_PROTOCOL = "bose_wave"
const BANG_OLUFSEN_PROTOCOL = "bang_olufsen"
const LEGO_PROTOCOL = "lego"
const FAST_PROTOCOL = "fast"
const WHYNTER_PROTOCOL = "whynter"
const MAGIQUEST_PROTOCOL = "magiquest"
const POWER_COMMAND_TYPE = "power"
const INPUT_COMMAND_TYPE = "input"
const ONE_COMMAND_TYPE = "1"
const TWO_COMMAND_TYPE = "2"
const THREE_COMMAND_TYPE = "3"
const FOUR_COMMAND_TYPE = "4"
const FIVE_COMMAND_TYPE = "5"
const SIX_COMMAND_TYPE = "6"
const SEVEN_COMMAND_TYPE = "7"
const EIGHT_COMMAND_TYPE = "8"
const NINE_COMMAND_TYPE = "9"
const ZERO_COMMAND_TYPE = "0"
const VOLUME_UP_COMMAND_TYPE = "volume_up"
const VOLUME_DOWN_COMMAND_TYPE = "volume_down"
const MUTE_COMMAND_TYPE = "mute"
const CHANNEL_UP_COMMAND_TYPE = "channel_up"
const CHANNEL_DOWN_COMMAND_TYPE = "channel_down"
const MENU_COMMAND_TYPE = "menu"
const HOME_COMMAND_TYPE = "home"
const SETTINGS_COMMAND_TYPE = "settings"
const OPTIONS_COMMAND_TYPE = "options"
const UP_COMMAND_TYPE = "up"
const DOWN_COMMAND_TYPE = "down"
const LEFT_COMMAND_TYPE = "left"
const RIGHT_COMMAND_TYPE = "right"
const ENTER_COMMAND_TYPE = "enter"
const INFO_COMMAND_TYPE = "info"
const RETURN_COMMAND_TYPE = "return"
const EXIT_COMMAND_TYPE = "exit"
const RED_COMMAND_TYPE = "red"
const GREEN_COMMAND_TYPE = "green"
const YELLOW_COMMAND_TYPE = "yellow"
const BLUE_COMMAND_TYPE = "blue"
const REWIND_COMMAND_TYPE = "rewind"
const PLAY_COMMAND_TYPE = "play"
const PAUSE_COMMAND_TYPE = "pause"
const STOP_COMMAND_TYPE = "stop"
const FORWARD_COMMAND_TYPE = "forward"
const OTHER_COMMAND_TYPE = "other"
type Command struct {
Id string `json:"id"`
Protocol string `json:"protocol"`
CommandNumber int `json:"commandNumber"`
Device int `json:"device"`
CommandType string `json:"commandType"`
Title string `json:"title"`
}

7
data/remote.go Normal file
View File

@ -0,0 +1,7 @@
package data
type Remote struct {
Id string `json:"id"`
Title string `json:"title"`
Commands []Command `json:"commands"`
}

255
data/remote_database.go Normal file
View File

@ -0,0 +1,255 @@
package data
import (
"database/sql"
"fmt"
"path/filepath"
gonanoid "github.com/matoous/go-nanoid"
)
type RemoteDatabase struct {
Connection *sql.DB
databaseDirectory string
}
func (db *RemoteDatabase) Initialize() error {
connection, error := sql.Open("sqlite3", filepath.Join(db.databaseDirectory, "remotes.db"))
if error != nil {
return error
}
db.Connection = connection
_, error = db.Connection.Exec(`PRAGMA foreign_keys = ON;`)
if error != nil {
return fmt.Errorf("error enabling foreign keys: %s", error)
}
_, error = db.Connection.Exec(`
CREATE TABLE IF NOT EXISTS Remotes (
id TEXT PRIMARY KEY,
title TEXT NOT NULL
);`)
if error != nil {
return fmt.Errorf("error creating remotes table: %s", error)
}
_, error = db.Connection.Exec(`
CREATE TABLE IF NOT EXISTS Commands (
id TEXT PRIMARY KEY,
protocol TEXT,
commandNumber INTEGER,
device INTEGER,
commandType TEXT,
title TEXT
);`)
if error != nil {
return fmt.Errorf("error creating commands table: %s", error)
}
_, error = db.Connection.Exec(`
CREATE TABLE IF NOT EXISTS RemoteCommands (
remote_id TEXT NOT NULL,
command_id TEXT NOT NULL,
PRIMARY KEY (remote_id, command_id),
FOREIGN KEY (remote_id) REFERENCES Remotes(id) ON DELETE CASCADE,
FOREIGN KEY (command_id) REFERENCES Commands(id) ON DELETE CASCADE
);`)
if error != nil {
return fmt.Errorf("error creating remote-commands table: %s", error)
}
return nil
}
func (db *RemoteDatabase) Close() error {
return db.Connection.Close()
}
func (db *RemoteDatabase) CreateRemote(remote Remote) (string, error) {
remoteId, err := gonanoid.Nanoid(8)
if err != nil {
return "", err
}
queryString := "INSERT INTO Remotes (id, title) VALUES (?, ?)"
_, err = db.Connection.Exec(queryString, remoteId, remote.Title)
if err != nil {
return "", err
}
commandIds := []string{}
for _, command := range remote.Commands {
commandIds = append(commandIds, command.Id)
}
err = db.CreateRemoteCommands(remoteId, commandIds)
if err != nil {
return "", err
}
return remoteId, nil
}
func (db *RemoteDatabase) CreateRemoteCommands(remoteId string, commandIds []string) error {
for _, commandId := range commandIds {
queryString := "INSERT INTO RemoteCommands (remote_id, command_id) VALUES (?, ?)"
_, err := db.Connection.Exec(queryString, remoteId, commandId)
if err != nil {
return err
}
}
return nil
}
func (db *RemoteDatabase) UpdateRemoteCommands(remoteId string, commandIds []string) error {
queryString := "DELETE FROM RemoteCommands WHERE remote_id = ?"
_, err := db.Connection.Exec(queryString, remoteId)
if err != nil {
return err
}
err = db.CreateRemoteCommands(remoteId, commandIds)
if err != nil {
return err
}
return nil
}
func (db *RemoteDatabase) GetRemote(remoteId string) (Remote, error) {
var remote Remote
queryString := "SELECT id, title FROM Remotes WHERE id = ?"
row := db.Connection.QueryRow(queryString, remoteId)
error := row.Scan(&remote.Id, &remote.Title)
if error != nil {
return Remote{}, fmt.Errorf("error scanning remote: %s", error)
}
commands, error := db.GetCommandsByRemoteId(remoteId)
if error != nil {
return Remote{}, error
}
remote.Commands = commands
return remote, nil
}
func (db *RemoteDatabase) GetRemotes() ([]Remote, error) {
rows, error := db.Connection.Query("SELECT id, title FROM Remotes")
if error != nil {
return nil, fmt.Errorf("error querying remotes: %s", error)
}
defer rows.Close()
remotes := []Remote{}
for rows.Next() {
var remote Remote
error = rows.Scan(&remote.Id, &remote.Title)
if error != nil {
return nil, fmt.Errorf("error scanning remote: %s", error)
}
remotes = append(remotes, remote)
}
return remotes, nil
}
func (db *RemoteDatabase) UpdateRemote(remote Remote) error {
queryString := "UPDATE Remotes SET title = ? WHERE id = ?"
_, error := db.Connection.Exec(queryString, remote.Title, remote.Id)
if error != nil {
return fmt.Errorf("error updating remote %s: %s", remote.Id, error)
}
commandIds := []string{}
for _, command := range remote.Commands {
commandIds = append(commandIds, command.Id)
}
err := db.UpdateRemoteCommands(remote.Id, commandIds)
if err != nil {
return err
}
return nil
}
func (db *RemoteDatabase) DeleteRemote(remoteId string) error {
queryString := "DELETE FROM Remotes WHERE id = ?"
_, error := db.Connection.Exec(queryString, remoteId)
if error != nil {
return fmt.Errorf("error deleting remote %s: %s", remoteId, error)
}
return nil
}
func (db *RemoteDatabase) GetCommandsByRemoteId(remoteId string) ([]Command, error) {
rows, error := db.Connection.Query("SELECT id, protocol, commandNumber, device, commandType, title FROM Commands WHERE id IN (SELECT command_id FROM RemoteCommands WHERE remote_id = ?)", remoteId)
if error != nil {
return nil, fmt.Errorf("error querying commands for remote %s: %s", remoteId, error)
}
defer rows.Close()
commands := []Command{}
for rows.Next() {
var command Command
error = rows.Scan(&command.Id, &command.Protocol, &command.CommandNumber, &command.Device, &command.CommandType, &command.Title)
if error != nil {
return nil, fmt.Errorf("error scanning command: %s", error)
}
commands = append(commands, command)
}
return commands, nil
}
func (db *RemoteDatabase) CreateCommand(command Command) (string, error) {
commandId, err := gonanoid.Nanoid(8)
if err != nil {
return "", err
}
queryString := "INSERT INTO Commands (id, protocol, commandNumber, device, commandType, title) VALUES (?, ?, ?, ?, ?, ?)"
_, err = db.Connection.Exec(queryString, commandId, command.Protocol, command.CommandNumber, command.Device, command.CommandType, command.Title)
if err != nil {
return "", err
}
return commandId, nil
}
func (db *RemoteDatabase) CreateCommands(commands []Command) error {
for _, command := range commands {
_, err := db.CreateCommand(command)
if err != nil {
return err
}
}
return nil
}
func (db *RemoteDatabase) GetCommands() ([]Command, error) {
rows, error := db.Connection.Query("SELECT id, protocol, commandNumber, device, commandType, title FROM Commands")
if error != nil {
return nil, fmt.Errorf("error querying commands: %s", error)
}
defer rows.Close()
commands := []Command{}
for rows.Next() {
var command Command
error = rows.Scan(&command.Id, &command.Protocol, &command.CommandNumber, &command.Device, &command.CommandType, &command.Title)
if error != nil {
return nil, fmt.Errorf("error scanning command: %s", error)
}
commands = append(commands, command)
}
return commands, nil
}
func (db *RemoteDatabase) UpdateCommand(command Command) error {
queryString := "UPDATE Commands SET protocol = ?, commandNumber = ?, device = ?, commandType = ?, title = ? WHERE id = ?"
_, error := db.Connection.Exec(queryString, command.Protocol, command.CommandNumber, command.Device, command.CommandType, command.Title, command.Id)
if error != nil {
return fmt.Errorf("error updating command %s: %s", command.Id, error)
}
return nil
}
func (db *RemoteDatabase) DeleteCommand(commandId string) error {
queryString := "DELETE FROM Commands WHERE id = ?"
_, error := db.Connection.Exec(queryString, commandId)
if error != nil {
return fmt.Errorf("error deleting command %s: %s", commandId, error)
}
return nil
}
func (db *RemoteDatabase) SetDirectory(directory string) {
db.databaseDirectory = directory
}

View File

@ -39,6 +39,15 @@ func main() {
} }
defer deviceDatabase.Close() defer deviceDatabase.Close()
remoteDatabase := data.RemoteDatabase{}
remoteDatabase.SetDirectory(configuration.DatabaseDirectory)
err = remoteDatabase.Initialize()
if err != nil {
log.Error().Err(err).Msg("failed to initialize remote database")
os.Exit(1)
}
defer remoteDatabase.Close()
userManager := management.UserManager{} userManager := management.UserManager{}
err = userManager.Initialize(&userDatabase) err = userManager.Initialize(&userDatabase)
if err != nil { if err != nil {
@ -51,6 +60,12 @@ func main() {
log.Error().Err(err).Msg("failed to initialize device manager") log.Error().Err(err).Msg("failed to initialize device manager")
} }
remoteManager := management.RemoteManager{}
err = remoteManager.Initialize(&remoteDatabase)
if err != nil {
log.Error().Err(err).Msg("failed to initialize remote manager")
}
webServer := server.WebServer{} webServer := server.WebServer{}
webServer.SetWebAppDirectoryPath("www") webServer.SetWebAppDirectoryPath("www")
webServer.SetPort(configuration.Port) webServer.SetPort(configuration.Port)
@ -71,6 +86,11 @@ func main() {
deviceApiHandler.SetRouter(webServer.Router()) deviceApiHandler.SetRouter(webServer.Router())
deviceApiHandler.Initialize(&authenticator) deviceApiHandler.Initialize(&authenticator)
remoteApiHandler := server.RemoteApiHandler{}
remoteApiHandler.SetRemoteManager(&remoteManager)
remoteApiHandler.SetRouter(webServer.Router())
remoteApiHandler.Initialize(&authenticator)
webSocketServer := server.WebsocketServer{} webSocketServer := server.WebsocketServer{}
webSocketServer.SetRouter(webServer.Router()) webSocketServer.SetRouter(webServer.Router())
webSocketServer.Initialize(&authenticator) webSocketServer.Initialize(&authenticator)

View File

@ -0,0 +1,66 @@
package management
import (
d "playback-device-server/data"
)
type RemoteManager struct {
remoteDatabase *d.RemoteDatabase
}
func (rm *RemoteManager) Initialize(remoteDatabase *d.RemoteDatabase) error {
rm.remoteDatabase = remoteDatabase
return nil
}
func (rm *RemoteManager) CreateRemote(remote d.Remote) (string, error) {
return rm.remoteDatabase.CreateRemote(remote)
}
func (rm *RemoteManager) GetRemote(remoteID string) (d.Remote, error) {
remote, err := rm.remoteDatabase.GetRemote(remoteID)
if err != nil {
return d.Remote{}, err
}
return remote, nil
}
func (rm *RemoteManager) GetRemotes() ([]d.Remote, error) {
remotes, err := rm.remoteDatabase.GetRemotes()
if err != nil {
return nil, err
}
return remotes, nil
}
func (rm *RemoteManager) UpdateRemote(remote d.Remote) error {
return rm.remoteDatabase.UpdateRemote(remote)
}
func (rm *RemoteManager) DeleteRemote(remoteID string) error {
return rm.remoteDatabase.DeleteRemote(remoteID)
}
func (rm *RemoteManager) CreateCommand(command d.Command) (string, error) {
return rm.remoteDatabase.CreateCommand(command)
}
func (rm *RemoteManager) CreateCommands(commands []d.Command) error {
return rm.remoteDatabase.CreateCommands(commands)
}
func (rm *RemoteManager) GetCommands() ([]d.Command, error) {
return rm.remoteDatabase.GetCommands()
}
func (rm *RemoteManager) UpdateCommand(command d.Command) error {
return rm.remoteDatabase.UpdateCommand(command)
}
func (rm *RemoteManager) SetRemoteDatabase(remoteDatabase *d.RemoteDatabase) {
rm.remoteDatabase = remoteDatabase
}
func (rm *RemoteManager) DeleteCommand(commandID string) error {
return rm.remoteDatabase.DeleteCommand(commandID)
}

View File

@ -0,0 +1,146 @@
package server
import (
d "playback-device-server/data"
m "playback-device-server/management"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
)
type RemoteApiHandler struct {
router *echo.Echo
remoteManager *m.RemoteManager
}
func (r *RemoteApiHandler) Initialize(authenticator *Authenticator) {
r.router.Use(authenticator.Authenticate("/api/remotes", []string{}))
remotesApi := r.router.Group("/api/remotes")
remotesApi.GET("", r.handleGetRemotes)
remotesApi.GET("/:id", r.handleGetRemote)
remotesApi.POST("", r.handleCreateRemote)
remotesApi.PUT("/:id", r.handleUpdateRemote)
remotesApi.DELETE("/:id", r.handleDeleteRemote)
r.router.Use(authenticator.Authenticate("/api/commands", []string{}))
commandsApi := r.router.Group("/api/commands")
commandsApi.GET("", r.handleGetCommands)
commandsApi.POST("", r.handleCreateCommands)
commandsApi.PUT("/:id", r.handleUpdateCommand)
commandsApi.DELETE("/:id", r.handleDeleteCommand)
}
func (r *RemoteApiHandler) handleCreateRemote(context echo.Context) error {
remote := d.Remote{}
if err := context.Bind(&remote); err != nil {
SendError(400, context, err.Error())
return nil
}
id, err := r.remoteManager.CreateRemote(remote)
if err != nil {
SendError(500, context, err.Error())
return err
}
remote.Id = id
return context.JSON(200, remote)
}
func (r *RemoteApiHandler) handleGetRemote(context echo.Context) error {
id := context.Param("id")
remote, err := r.remoteManager.GetRemote(id)
if err != nil {
SendError(500, context, err.Error())
log.Error().Err(err)
return err
}
return context.JSON(200, remote)
}
func (r *RemoteApiHandler) handleGetRemotes(context echo.Context) error {
remotes, err := r.remoteManager.GetRemotes()
if err != nil {
SendError(500, context, err.Error())
return err
}
return context.JSON(200, remotes)
}
func (r *RemoteApiHandler) handleUpdateRemote(context echo.Context) error {
remote := d.Remote{}
if err := context.Bind(&remote); err != nil {
SendError(400, context, err.Error())
return nil
}
err := r.remoteManager.UpdateRemote(remote)
if err != nil {
SendError(500, context, err.Error())
return err
}
return context.JSON(200, "")
}
func (r *RemoteApiHandler) handleDeleteRemote(context echo.Context) error {
id := context.Param("id")
err := r.remoteManager.DeleteRemote(id)
if err != nil {
SendError(500, context, err.Error())
return err
}
return context.JSON(200, "")
}
func (r *RemoteApiHandler) handleCreateCommands(context echo.Context) error {
commands := []d.Command{}
if err := context.Bind(&commands); err != nil {
SendError(400, context, err.Error())
return nil
}
err := r.remoteManager.CreateCommands(commands)
if err != nil {
SendError(500, context, err.Error())
return err
}
return context.JSON(200, "")
}
func (r *RemoteApiHandler) handleGetCommands(context echo.Context) error {
commands, err := r.remoteManager.GetCommands()
if err != nil {
SendError(500, context, err.Error())
return err
}
return context.JSON(200, commands)
}
func (r *RemoteApiHandler) handleUpdateCommand(context echo.Context) error {
command := d.Command{}
if err := context.Bind(&command); err != nil {
SendError(400, context, err.Error())
return nil
}
err := r.remoteManager.UpdateCommand(command)
if err != nil {
SendError(500, context, err.Error())
return err
}
return context.JSON(200, "")
}
func (r *RemoteApiHandler) handleDeleteCommand(context echo.Context) error {
id := context.Param("id")
err := r.remoteManager.DeleteCommand(id)
if err != nil {
SendError(500, context, err.Error())
return err
}
return context.JSON(200, "")
}
func (r *RemoteApiHandler) SetRouter(router *echo.Echo) {
r.router = router
}
func (r *RemoteApiHandler) SetRemoteManager(remoteManager *m.RemoteManager) {
r.remoteManager = remoteManager
}

View File

@ -14,6 +14,7 @@
href="./src/lib/bootstrap-icons-1.11.3/font/bootstrap-icons.css" href="./src/lib/bootstrap-icons-1.11.3/font/bootstrap-icons.css"
rel="stylesheet" rel="stylesheet"
/> />
<script src="./lib/papaparse-5.5.2.min.js"></script>
<script src="./lib/fusejs-7.1.0.min.js"></script> <script src="./lib/fusejs-7.1.0.min.js"></script>
<script src="./lib/popper.min.js"></script> <script src="./lib/popper.min.js"></script>
<script src="./lib/bootstrap-5.3.3-dist/js/bootstrap.bundle.min.js"></script> <script src="./lib/bootstrap-5.3.3-dist/js/bootstrap.bundle.min.js"></script>

View File

@ -1,21 +1,23 @@
import { createEffect, createMemo, createSignal, mergeProps } from "solid-js"; import { createMemo, createSignal, mergeProps } from "solid-js";
function ListManager(props) { function ListManager(props) {
props = mergeProps( props = mergeProps(
{ {
items: [], items: [],
availableItems: [], availableItems: [],
itemToString: () => "",
style: "", style: "",
onItemSelect: () => {},
onItemDeselect: () => {},
itemsTitle: "Selected items", itemsTitle: "Selected items",
availableItemsTitle: "Available items", availableItemsTitle: "Available items",
itemToString: () => "",
onItemSelect: () => {},
onItemDeselect: () => {},
itemsEqual: (a, b) => a === b,
}, },
props props
); );
const itemToString = (item) => props.itemToString(item); const itemToString = (item) => props.itemToString(item);
const byLabel = (a, b) => itemToString(a).localeCompare(itemToString(b));
const [selectedAvailableItemIndex, setSelectedAvailableItemIndex] = const [selectedAvailableItemIndex, setSelectedAvailableItemIndex] =
createSignal(-1); createSignal(-1);
const [selectedItemIndex, setSelectedItemIndex] = createSignal(-1); const [selectedItemIndex, setSelectedItemIndex] = createSignal(-1);
@ -36,21 +38,22 @@ function ListManager(props) {
}) })
); );
createEffect(() =>
console.log(availableItemsFuse().search(availableItemsSearchString()))
);
const selectableAvailableItems = createMemo(() => const selectableAvailableItems = createMemo(() =>
(availableItemsSearchString() (availableItemsSearchString()
? availableItemsFuse() ? availableItemsFuse()
.search(availableItemsSearchString()) .search(availableItemsSearchString())
.map((item) => item.item) .map((item) => item.item)
: props.availableItems : props.availableItems
).filter((item) => !props.items.includes(item)) ).filter(
(availableItem) =>
!props.items.find((item) => props.itemsEqual(item, availableItem))
)
); );
const selectableItems = createMemo(() => const selectableItems = createMemo(() =>
itemsSearchString() itemsSearchString()
? itemsFuse().search(itemsSearchString()).map((item) => item.item) ? itemsFuse()
.search(itemsSearchString())
.map((item) => item.item)
: props.items : props.items
); );
const canSelect = createMemo( const canSelect = createMemo(
@ -124,7 +127,7 @@ function ListManager(props) {
/> />
</div> </div>
<div class="rounded rounded-top-0 border bg-body flex-fill overflow-y-scroll"> <div class="rounded rounded-top-0 border bg-body flex-fill overflow-y-scroll">
{props.items.map((item, index) => ( {props.items.sort(byLabel).map((item, index) => (
<ListItem <ListItem
onClick={() => props.onItemSelected(index)} onClick={() => props.onItemSelected(index)}
selected={index === props.selectedItemIndex} selected={index === props.selectedItemIndex}

View File

@ -8,7 +8,17 @@ import {
} from "solid-js"; } from "solid-js";
function List(props) { function List(props) {
props = mergeProps({ items: [], showHeader: true, selectable: true }, props); props = mergeProps(
{
items: [],
showHeader: true,
selectable: true,
onListItemsSelect: () => {},
onLazyLoad: () => {},
onListItemClick: () => {},
},
props
);
const [listItems, setListItems] = createSignal([]); const [listItems, setListItems] = createSignal([]);
const selectedItems = createMemo(() => const selectedItems = createMemo(() =>
listItems() listItems()
@ -31,16 +41,13 @@ function List(props) {
); );
}); });
createEffect(() => { createEffect(() => {
if (!props.onListItemsSelect) return;
props.onListItemsSelect(selectedItems()); props.onListItemsSelect(selectedItems());
}); });
const observer = new IntersectionObserver((entries) => { const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => { entries.forEach((entry) => {
if (entry.isIntersecting) { if (entry.isIntersecting) {
if (props.onLazyLoad) { props.onLazyLoad();
props.onLazyLoad();
}
} }
}); });
}); });
@ -61,7 +68,6 @@ function List(props) {
}); });
function handleListItemClick(item) { function handleListItemClick(item) {
if (!props.onListItemClick) return;
props.onListItemClick(item); props.onListItemClick(item);
} }

View File

@ -0,0 +1,180 @@
import { createEffect, createMemo, mergeProps } from "solid-js";
import Remote from "../data/remote";
import Command from "../data/command";
function RemoteControl(props) {
props = mergeProps(
{
remote: new Remote(),
onCommand: () => {},
},
props
);
const BUTTON_SIZE_REGULAR = "regular";
const BUTTON_SIZE_SMALL = "small";
const BUTTON_HEIGHT = 2;
const BUTTON_WIDTH = BUTTON_HEIGHT * 1.4;
const SMALL_BUTTON_HEIGHT = 1.5;
const SMALL_BUTTON_WIDTH = SMALL_BUTTON_HEIGHT * 1.3334;
const TYPES = Command.TYPES;
const commandMap = createMemo(() =>
props.remote
.getCommands()
.reduce(
(map, command) => ({ ...map, [command.getCommandType()]: command }),
{}
)
);
let layout = [
[TYPES.POWER, null, TYPES.INPUT],
[TYPES.ONE, TYPES.TWO, TYPES.THREE],
[TYPES.FOUR, TYPES.FIVE, TYPES.SIX],
[TYPES.SEVEN, TYPES.EIGHT, TYPES.NINE],
[TYPES.VOLUME_UP, TYPES.ZERO, TYPES.CHANNEL_UP],
[TYPES.VOLUME_DOWN, TYPES.MUTE, TYPES.CHANNEL_DOWN],
[TYPES.MENU, TYPES.HOME, TYPES.SETTINGS],
[TYPES.OPTIONS, TYPES.UP, TYPES.INFO],
[TYPES.LEFT, TYPES.ENTER, TYPES.RIGHT],
[TYPES.RETURN, TYPES.DOWN, TYPES.EXIT],
[TYPES.RED, TYPES.GREEN, TYPES.YELLOW, TYPES.BLUE],
[TYPES.PLAY, TYPES.PAUSE, TYPES.STOP],
[TYPES.REWIND, null, TYPES.FORWARD],
];
function toButtonProps(type) {
let mapping = {
[TYPES.POWER]: () => ({ icon: "bi-power", text: "" }),
[TYPES.INPUT]: () => ({ icon: "bi-box-arrow-in-right", text: "" }),
[TYPES.VOLUME_UP]: () => ({ icon: "bi-volume-up", text: "" }),
[TYPES.VOLUME_DOWN]: () => ({ icon: "bi-volume-down", text: "" }),
[TYPES.MUTE]: () => ({ icon: "bi-volume-mute", text: "" }),
[TYPES.CHANNEL_UP]: () => ({ icon: "bi-chevron-up", text: "" }),
[TYPES.CHANNEL_DOWN]: () => ({ icon: "bi-chevron-down", text: "" }),
[TYPES.HOME]: () => ({ icon: "bi-house-door", text: "" }),
[TYPES.SETTINGS]: () => ({ icon: "bi-gear", text: "" }),
[TYPES.INFO]: () => ({ icon: "bi-info-circle", text: "" }),
[TYPES.UP]: () => ({ icon: "bi-arrow-up", text: "" }),
[TYPES.DOWN]: () => ({ icon: "bi-arrow-down", text: "" }),
[TYPES.LEFT]: () => ({ icon: "bi-arrow-left", text: "" }),
[TYPES.RIGHT]: () => ({ icon: "bi-arrow-right", text: "" }),
[TYPES.ENTER]: () => ({ icon: "", text: "OK" }),
[TYPES.EXIT]: () => ({ icon: "", text: "EXIT" }),
[TYPES.OPTIONS]: () => ({ icon: "bi-list-task", text: "" }),
[TYPES.RETURN]: () => ({ icon: "bi-arrow-return-left", text: "" }),
[TYPES.ONE]: () => ({ icon: "", text: "1" }),
[TYPES.TWO]: () => ({ icon: "", text: "2" }),
[TYPES.THREE]: () => ({ icon: "", text: "3" }),
[TYPES.FOUR]: () => ({ icon: "", text: "4" }),
[TYPES.FIVE]: () => ({ icon: "", text: "5" }),
[TYPES.SIX]: () => ({ icon: "", text: "6" }),
[TYPES.SEVEN]: () => ({ icon: "", text: "7" }),
[TYPES.EIGHT]: () => ({ icon: "", text: "8" }),
[TYPES.NINE]: () => ({ icon: "", text: "9" }),
[TYPES.ZERO]: () => ({ icon: "", text: "0" }),
[TYPES.MENU]: () => ({ icon: "", text: "MENU" }),
[TYPES.PLAY]: () => ({ icon: "bi-play", text: "" }),
[TYPES.PAUSE]: () => ({ icon: "bi-pause", text: "" }),
[TYPES.STOP]: () => ({ icon: "bi-stop", text: "" }),
[TYPES.REWIND]: () => ({ icon: "bi-rewind", text: "" }),
[TYPES.FORWARD]: () => ({ icon: "bi-fast-forward", text: "" }),
[TYPES.RED]: () => ({
class: "bg-danger",
buttonSize: BUTTON_SIZE_SMALL,
}),
[TYPES.GREEN]: () => ({
class: "bg-success",
buttonSize: BUTTON_SIZE_SMALL,
}),
[TYPES.YELLOW]: () => ({
class: "bg-warning",
buttonSize: BUTTON_SIZE_SMALL,
}),
[TYPES.BLUE]: () => ({
class: "bg-primary",
buttonSize: BUTTON_SIZE_SMALL,
}),
};
if (!(type in mapping)) return {};
let props = mapping[type]();
props.type = type;
props.text = props.text || "";
return props;
}
function PlaceholderButton() {
return (
<div
style={`width: ${BUTTON_WIDTH}em; height: ${BUTTON_HEIGHT}em; margin: 0.2em;`}
></div>
);
}
function RemoteButton(props) {
props = mergeProps(
{
onClick: () => {},
disabled: false,
class: "",
buttonSize: BUTTON_SIZE_REGULAR,
icon: "",
text: "",
},
props
);
let buttonWidth = BUTTON_WIDTH;
let buttonHeight = BUTTON_HEIGHT;
if (props.buttonSize === BUTTON_SIZE_SMALL) {
buttonWidth = SMALL_BUTTON_WIDTH;
buttonHeight = SMALL_BUTTON_HEIGHT;
}
let buttonFontSize = buttonHeight * 0.4;
let iconSize = buttonHeight * 0.5;
return (
<button
class={
"btn btn-dark d-flex justify-content-center align-items-center " +
props.class
}
style={`width: ${buttonWidth}em; height: ${buttonHeight}em; margin: 0.2em;`}
onClick={props.onClick}
disabled={props.disabled}
>
<i style={`font-size: ${iconSize}em;`} class={"bi " + props.icon} />
<span style={`font-size: ${buttonFontSize}em;`}>{props.text}</span>
</button>
);
}
return (
<div class="d-flex flex-column justify-content-center align-items-center p-1 bg-secondary rounded">
{layout.map((row) => (
<div class="d-flex flex-row justify-content-center align-items-center">
{row
.map(toButtonProps)
.map(({ type, class: className, buttonSize, icon, text }) =>
!type ? (
<PlaceholderButton />
) : (
<RemoteButton
class={className}
buttonSize={buttonSize}
onClick={() => props.onCommand(commandMap()[type])}
disabled={!commandMap()[type]}
icon={icon}
text={text}
/>
)
)}
</div>
))}
</div>
);
}
export default RemoteControl;

View File

@ -2,7 +2,13 @@ import { createSignal, mergeProps } from "solid-js";
function ValidatedTextInput(props) { function ValidatedTextInput(props) {
props = mergeProps( props = mergeProps(
{ type: "text", valid: true, onInput: () => {}, errorText: "" }, {
type: "text",
valid: true,
onInput: () => {},
errorText: "",
placeholder: "",
},
props props
); );
let [isActive, setActive] = createSignal(false); let [isActive, setActive] = createSignal(false);
@ -15,6 +21,7 @@ function ValidatedTextInput(props) {
} }
id={props.id} id={props.id}
value={props.value} value={props.value}
placeholder={props.placeholder}
onInput={props.onInput} onInput={props.onInput}
onFocusOut={() => setActive(true)} onFocusOut={() => setActive(true)}
/> />

View File

@ -1,11 +1,14 @@
function Command({ function Command({
id, id = "",
protocol, protocol = "",
command: commandNumber, commandNumber = -1,
device, device = -1,
commandType, commandType = "",
title, title = "",
} = {}) { } = {}) {
if (typeof commandNumber !== "number")
throw new Error("Command number must be a number");
if (typeof device !== "number") throw new Error("Device must be a number");
let _id = id; let _id = id;
let _protocol = protocol; let _protocol = protocol;
let _commandNumber = commandNumber; let _commandNumber = commandNumber;
@ -34,6 +37,8 @@ function Command({
} }
function setCommandNumber(commandNumber) { function setCommandNumber(commandNumber) {
if (typeof commandNumber !== "number")
throw new Error("Command number must be a number");
_commandNumber = commandNumber; _commandNumber = commandNumber;
} }
@ -42,6 +47,7 @@ function Command({
} }
function setDevice(device) { function setDevice(device) {
if (typeof device !== "number") throw new Error("Device must be a number");
_device = device; _device = device;
} }
@ -54,7 +60,8 @@ function Command({
} }
function getTitle() { function getTitle() {
return _title; if (_title) return _title;
return `${Command.getTypeString(_commandType)} (${Command.getProtocolString(_protocol)})`;
} }
function setTitle(title) { function setTitle(title) {
@ -77,73 +84,148 @@ function Command({
}; };
} }
Command.Protocols = { Command.PROTOCOLS = {
samsung: "Samsung", SAMSUNG: "samsung",
nec: "NEC", NEC: "nec",
onkyo: "Onkyo", ONKYO: "onkyo",
apple: "Apple", APPLE: "apple",
denon: "Denon", DENON: "denon",
sharp: "Sharp", SHARP: "sharp",
panasonic: "Panasonic", PANASONIC: "panasonic",
kaseikyo: "Kaseikyo", KASEIKYO: "kaseikyo",
jvc: "JVC", JVC: "jvc",
lg: "LG", LG: "lg",
sony: "Sony", SONY: "sony",
rc5: "RC5", RC5: "rc5",
rc6: "RC6", RC6: "rc6",
universal_pulse_distance: "Universal Pulse Distance", UNIVERSAL_PULSE_DISTANCE: "universal_pulse_distance",
universal_pulse_width: "Universal Pulse Width", UNIVERSAL_PULSE_WIDTH: "universal_pulse_width",
universal_pulse_distance_width: "Universal Pulse Distance Width", UNIVERSAL_PULSE_DISTANCE_WIDTH: "universal_pulse_distance_width",
hash: "Hash", HASH: "hash",
pronto: "Pronto", PRONTO: "pronto",
bose_wave: "BoseWave", BOSE_WAVE: "bose_wave",
bang_olufsen: "Bang & Olufsen", BANG_OLUFSEN: "bang_olufsen",
lego: "Lego", LEGO: "lego",
fast: "FAST", FAST: "fast",
whynter: "Whynter", WHYNTER: "whynter",
magiquest: "MagiQuest", MAGIQUEST: "magiquest",
} }
Command.CommandTypes = { Command.getProtocolString = (protocol) => {
power: "Power", let mapping = {
input: "Input", [Command.PROTOCOLS.SAMSUNG]: "Samsung",
one: "1", [Command.PROTOCOLS.NEC]: "NEC",
two: "2", [Command.PROTOCOLS.ONKYO]: "Onkyo",
three: "3", [Command.PROTOCOLS.APPLE]: "Apple",
four: "4", [Command.PROTOCOLS.DENON]: "Denon",
five: "5", [Command.PROTOCOLS.SHARP]: "Sharp",
six: "6", [Command.PROTOCOLS.PANASONIC]: "Panasonic",
seven: "7", [Command.PROTOCOLS.KASEIKYO]: "Kaseikyo",
eight: "8", [Command.PROTOCOLS.JVC]: "JVC",
nine: "9", [Command.PROTOCOLS.LG]: "LG",
zero: "0", [Command.PROTOCOLS.SONY]: "Sony",
volume_up: "Volume Up", [Command.PROTOCOLS.RC5]: "RC5",
volume_down: "Volume Down", [Command.PROTOCOLS.RC6]: "RC6",
mute: "Mute", [Command.PROTOCOLS.UNIVERSAL_PULSE_DISTANCE]: "Universal Pulse Distance",
channel_up: "Channel Up", [Command.PROTOCOLS.UNIVERSAL_PULSE_WIDTH]: "Universal Pulse Width",
channel_down: "Channel Down", [Command.PROTOCOLS.UNIVERSAL_PULSE_DISTANCE_WIDTH]: "Universal Pulse Distance Width",
menu: "Menu", [Command.PROTOCOLS.HASH]: "Hash",
home: "Home", [Command.PROTOCOLS.PRONTO]: "Pronto",
settings: "Settings", [Command.PROTOCOLS.BOSE_WAVE]: "BoseWave",
options: "Options", [Command.PROTOCOLS.BANG_OLUFSEN]: "Bang & Olufsen",
up_arrow: "Up Arrow", [Command.PROTOCOLS.LEGO]: "Lego",
down_arrow: "Down Arrow", [Command.PROTOCOLS.FAST]: "FAST",
left_arrow: "Left Arrow", [Command.PROTOCOLS.WHYNTER]: "Whynter",
right_arrow: "Right Arrow", [Command.PROTOCOLS.MAGIQUEST]: "MagiQuest",
select: "Select", }
info: "Info", return mapping[protocol]
back: "Back", }
exit: "Exit",
red: "Red", Command.TYPES = {
green: "Green", POWER: "power",
yellow: "Yellow", INPUT: "input",
blue: "Blue", ONE: "one",
rewind: "Rewind", TWO: "two",
play: "Play", THREE: "three",
pause: "Pause", FOUR: "four",
stop: "Stop", FIVE: "five",
forward: "Forward", SIX: "six",
other: "Other", SEVEN: "seven",
EIGHT: "eight",
NINE: "nine",
ZERO: "zero",
VOLUME_UP: "volume_up",
VOLUME_DOWN: "volume_down",
MUTE: "mute",
CHANNEL_UP: "channel_up",
CHANNEL_DOWN: "channel_down",
MENU: "menu",
HOME: "home",
SETTINGS: "settings",
OPTIONS: "options",
UP: "up",
DOWN: "down",
LEFT: "left",
RIGHT: "right",
ENTER: "enter",
INFO: "info",
RETURN: "return",
EXIT: "exit",
RED: "red",
GREEN: "green",
YELLOW: "yellow",
BLUE: "blue",
REWIND: "rewind",
PLAY: "play",
PAUSE: "pause",
STOP: "stop",
FORWARD: "forward",
OTHER: "other",
}
Command.getTypeString = (type) => {
let mapping = {
[Command.TYPES.POWER]: "Power",
[Command.TYPES.INPUT]: "Input",
[Command.TYPES.ONE]: "1",
[Command.TYPES.TWO]: "2",
[Command.TYPES.THREE]: "3",
[Command.TYPES.FOUR]: "4",
[Command.TYPES.FIVE]: "5",
[Command.TYPES.SIX]: "6",
[Command.TYPES.SEVEN]: "7",
[Command.TYPES.EIGHT]: "8",
[Command.TYPES.NINE]: "9",
[Command.TYPES.ZERO]: "0",
[Command.TYPES.VOLUME_UP]: "Volume Up",
[Command.TYPES.VOLUME_DOWN]: "Volume Down",
[Command.TYPES.MUTE]: "Mute",
[Command.TYPES.CHANNEL_UP]: "Channel Up",
[Command.TYPES.CHANNEL_DOWN]: "Channel Down",
[Command.TYPES.MENU]: "Menu",
[Command.TYPES.HOME]: "Home",
[Command.TYPES.SETTINGS]: "Settings",
[Command.TYPES.OPTIONS]: "Options",
[Command.TYPES.UP]: "Up",
[Command.TYPES.DOWN]: "Down",
[Command.TYPES.LEFT]: "Left",
[Command.TYPES.RIGHT]: "Right",
[Command.TYPES.ENTER]: "Enter",
[Command.TYPES.INFO]: "Info",
[Command.TYPES.RETURN]: "Return",
[Command.TYPES.EXIT]: "Exit",
[Command.TYPES.RED]: "Red",
[Command.TYPES.GREEN]: "Green",
[Command.TYPES.YELLOW]: "Yellow",
[Command.TYPES.BLUE]: "Blue",
[Command.TYPES.REWIND]: "Rewind",
[Command.TYPES.PLAY]: "Play",
[Command.TYPES.PAUSE]: "Pause",
[Command.TYPES.STOP]: "Stop",
[Command.TYPES.FORWARD]: "Forward",
[Command.TYPES.OTHER]: "Other",
}
return mapping[type]
} }
export default Command; export default Command;

View File

@ -1,4 +1,4 @@
function Remote({ id, title, commands } = {}) { function Remote({ id = "", title = "", commands = [] } = {}) {
let _id = id; let _id = id;
let _title = title; let _title = title;
let _commands = commands; let _commands = commands;

View File

@ -31,20 +31,48 @@ const Serializer = (function () {
if (!objects) return []; if (!objects) return [];
return objects.map((object) => deserializeIntegration(object)); return objects.map((object) => deserializeIntegration(object));
} }
function serializeCommand(command) {
return {
id: command.getId(),
protocol: command.getProtocol(),
commandNumber: command.getCommandNumber(),
device: command.getDevice(),
commandType: command.getCommandType(),
title: command.getTitle(),
};
}
function serializeCommands(commands) {
if (!commands) return [];
return commands.map((command) => serializeCommand(command));
}
function deserializeCommand(object) { function deserializeCommand(object) {
return new Command(object); return new Command(object);
} }
function deserializeCommands(objects) { function deserializeCommands(objects) {
if (!objects) return []; if (!objects) return [];
return objects.map((object) => deserializeCommand(object)); return objects.map((object) => deserializeCommand(object));
} }
function deserializeRemote(object) { function serializeRemote(remote) {
return new Remote(object); return {
id: remote.getId(),
title: remote.getTitle(),
commands: serializeCommands(remote.getCommands()),
};
} }
function deserializeRemote(object) {
return new Remote({
id: object.id,
title: object.title,
commands: deserializeCommands(object.commands),
});
}
function deserializeRemotes(objects) { function deserializeRemotes(objects) {
if (!objects) return []; if (!objects) return [];
return objects.map((object) => deserializeRemote(object)); return objects.map((object) => deserializeRemote(object));
@ -57,8 +85,11 @@ const Serializer = (function () {
deserializeDevices, deserializeDevices,
deserializeIntegration, deserializeIntegration,
deserializeIntegrations, deserializeIntegrations,
serializeCommand,
serializeCommands,
deserializeCommand, deserializeCommand,
deserializeCommands, deserializeCommands,
serializeRemote,
deserializeRemote, deserializeRemote,
deserializeRemotes, deserializeRemotes,
}; };

7
www/src/lib/papaparse-5.5.2.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -24,13 +24,9 @@ function CreateCommandModal(props) {
); );
const isDeviceValid = createMemo(() => device() !== "" && !isNaN(device())); const isDeviceValid = createMemo(() => device() !== "" && !isNaN(device()));
const isCommandTypeValid = createMemo(() => commandType() !== ""); const isCommandTypeValid = createMemo(() => commandType() !== "");
const isTitleValid = createMemo(() => title().length >= MIN_TITLE_LENGTH); const isTitleValid = createMemo(
() => title() === "" || title().length >= MIN_TITLE_LENGTH
createEffect(() => { );
let commandString = commandType() ? Command.CommandTypes[commandType()] : "";
let protocolString = protocol() ? Command.Protocols[protocol()] : "";
setTitle(commandString + " " + protocolString);
});
const isFormValid = createMemo( const isFormValid = createMemo(
() => () =>
@ -44,13 +40,15 @@ function CreateCommandModal(props) {
async function handleCreateCommand() { async function handleCreateCommand() {
let command; let command;
try { try {
command = await RemotesService.createCommand({ command = await RemotesService.createCommand(
protocol: protocol(), new Command({
commandNumber: commandNumber(), protocol: protocol(),
device: device(), commandNumber: parseInt(commandNumber()),
commandType: commandType(), device: parseInt(device()),
title: title(), commandType: commandType(),
}); title: title(),
})
);
} catch (e) { } catch (e) {
setError(e.message); setError(e.message);
return; return;
@ -87,31 +85,19 @@ function CreateCommandModal(props) {
Protocol Protocol
</label> </label>
<div class="col-sm-9"> <div class="col-sm-9">
<div class="btn-group"> <select
<button class="form-select"
type="button" onChange={(e) => setProtocol(e.target.value)}
class="btn btn-secondary dropdown-toggle" >
data-bs-toggle="dropdown" <option value="" selected>
aria-expanded="false" Please select
> </option>
{protocol() ? Command.Protocols[protocol()] : "Please select"} {Object.values(Command.PROTOCOLS).map((protocol) => (
</button> <option value={protocol}>
<ul {Command.getProtocolString(protocol)}
class="dropdown-menu" </option>
style="max-height: 10em; overflow-y: auto;" ))}
> </select>
{Object.keys(Command.Protocols).map((protocol) => (
<li>
<a
class="dropdown-item pe-auto"
onClick={() => setProtocol(protocol)}
>
{Command.Protocols[protocol]}
</a>
</li>
))}
</ul>
</div>
</div> </div>
</div> </div>
<div class="mb-3 row"> <div class="mb-3 row">
@ -148,33 +134,19 @@ function CreateCommandModal(props) {
Command Type Command Type
</label> </label>
<div class="col-sm-9"> <div class="col-sm-9">
<div class="btn-group"> <select
<button class="form-select"
type="button" onChange={(e) => setCommandType(e.target.value)}
class="btn btn-secondary dropdown-toggle" >
data-bs-toggle="dropdown" <option value="" selected>
aria-expanded="false" Please select
> </option>
{commandType() {Object.values(Command.TYPES).map((commandType) => (
? Command.CommandTypes[commandType()] <option value={commandType}>
: "Please select"} {Command.getTypeString(commandType)}
</button> </option>
<ul ))}
class="dropdown-menu" </select>
style="max-height: 10em; overflow-y: auto;"
>
{Object.keys(Command.CommandTypes).map((commandType) => (
<li>
<a
class="dropdown-item pe-auto"
onClick={() => setCommandType(commandType)}
>
{Command.CommandTypes[commandType]}
</a>
</li>
))}
</ul>
</div>
</div> </div>
</div> </div>
<div class="mb-3 row"> <div class="mb-3 row">

View File

@ -1,34 +1,43 @@
import { createMemo, createResource, createSignal } from "solid-js"; import { createMemo, createResource, createSignal, onMount } from "solid-js";
import ValidatedTextInput from "../components/validated-text-input.jsx"; import ValidatedTextInput from "../components/validated-text-input.jsx";
import RemotesService from "../services/remotes-service.js"; import RemotesService from "../services/remotes-service.js";
import EventEmitter from "../tools/event-emitter.js"; import EventEmitter from "../tools/event-emitter.js";
import ModalHandler from "./modal-handler.js"; import ModalHandler from "./modal-handler.js";
import Modal from "./modal.jsx"; import Modal from "./modal.jsx";
import ListManager from "../components/list-manager.jsx"; import ListManager from "../components/list-manager.jsx";
import Remote from "../data/remote.js";
const eventEmitter = new EventEmitter(); const eventEmitter = new EventEmitter();
const REMOTE_CREATED_EVENT = "success"; const REMOTE_CREATED_EVENT = "success";
const MIN_TITLE_LENGTH = 3; const MIN_TITLE_LENGTH = 3;
const modalHandler = new ModalHandler();
function CreateRemoteModal(props) { function CreateRemoteModal(props) {
const [title, setTitle] = createSignal(""); const [title, setTitle] = createSignal("");
const [commands, setCommands] = createSignal([]); const [commands, setCommands] = createSignal([]);
const [availableCommands] = createResource(RemotesService.getCommands); const [availableCommands, { refetch: refetchAvailableCommands }] =
createResource(RemotesService.getCommands);
const [error, setError] = createSignal(""); const [error, setError] = createSignal("");
const isTitleValid = createMemo(() => title().length >= MIN_TITLE_LENGTH); const isTitleValid = createMemo(() => title().length >= MIN_TITLE_LENGTH);
const isFormValid = createMemo(() => isTitleValid()); const isFormValid = createMemo(() => isTitleValid());
modalHandler.onShow(() => {
refetchAvailableCommands();
});
async function handleCreateRemote() { async function handleCreateRemote() {
let remote; let remote;
try { try {
remote = await RemotesService.createRemote({ remote = await RemotesService.createRemote(
title: title(), new Remote({
commands: commands(), title: title(),
}); commands: commands(),
})
);
} catch (e) { } catch (e) {
setError(e.message); setError(e.message);
console.error(e);
return; return;
} }
resetFields(); resetFields();
@ -66,11 +75,11 @@ function CreateRemoteModal(props) {
</div> </div>
</Show> </Show>
<div class="mb-3 row"> <div class="mb-3 row">
<label for="new_remote_title" class="col-form-label col-sm-3"> <label for="new_remote_title" class="col-form-label col-sm-1">
Title Title
</label> </label>
<ValidatedTextInput <ValidatedTextInput
class="col-sm-9" class="col-sm-11"
id="new_remote_title" id="new_remote_title"
valid={isTitleValid()} valid={isTitleValid()}
value={title()} value={title()}
@ -108,7 +117,7 @@ function CreateRemoteModal(props) {
); );
} }
CreateRemoteModal.Handler = new ModalHandler(); CreateRemoteModal.Handler = modalHandler;
CreateRemoteModal.onRemoteCreated = (callback) => CreateRemoteModal.onRemoteCreated = (callback) =>
eventEmitter.on(REMOTE_CREATED_EVENT, callback); eventEmitter.on(REMOTE_CREATED_EVENT, callback);

View File

@ -0,0 +1,66 @@
import { createSignal } from "solid-js";
import Remote from "../data/remote.js";
import RemotesService from "../services/remotes-service.js";
import EventEmitter from "../tools/event-emitter.js";
import ModalHandler from "./modal-handler.js";
import Modal from "./modal.jsx";
const [remote, setRemote] = createSignal(new Remote());
const eventEmitter = new EventEmitter();
const REMOTE_DELETED_EVENT = "success";
function DeleteRemoteModal(props) {
const [error, setError] = createSignal("");
async function handleDeleteRemote() {
try {
await RemotesService.deleteRemote(remote().getId());
} catch (e) {
setError(e.message);
throw e;
}
DeleteRemoteModal.Handler.hide();
eventEmitter.dispatchEvent(REMOTE_DELETED_EVENT, remote);
}
return (
<Modal
ref={props.ref}
id="deleteRemoteModal"
modalTitle="Delete remote"
centered={true}
>
<div class="modal-body">
<Show when={error() !== ""}>
<div class="alert alert-danger" role="alert">
{error()}
</div>
</Show>
<div class="mb-3 row">
<span>
Do you really want to delete the remote {remote().getTitle()} ({remote().getId()})?
</span>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel
</button>
<button
type="button"
onClick={handleDeleteRemote}
class="btn btn-danger"
>
Delete
</button>
</div>
</Modal>
);
}
DeleteRemoteModal.Handler = new ModalHandler();
DeleteRemoteModal.setRemote = setRemote;
DeleteRemoteModal.onRemoteDeleted = (callback) =>
eventEmitter.on(REMOTE_DELETED_EVENT, callback);
export default DeleteRemoteModal;

View File

@ -0,0 +1,199 @@
import { createEffect, createMemo, createSignal } from "solid-js";
import ValidatedTextInput from "../components/validated-text-input.jsx";
import EventEmitter from "../tools/event-emitter.js";
import ModalHandler from "./modal-handler.js";
import Modal from "./modal.jsx";
import RemotesService from "../services/remotes-service.js";
import Command from "../data/command.js";
const eventEmitter = new EventEmitter();
const COMMAND_EDITED_EVENT = "success";
const MIN_TITLE_LENGTH = 3;
const [command, setCommand] = createSignal(new Command());
function EditCommandModal(props) {
const [protocol, setProtocol] = createSignal("");
const [commandNumber, setCommandNumber] = createSignal("");
const [device, setDevice] = createSignal("");
const [commandType, setCommandType] = createSignal("");
const [title, setTitle] = createSignal("");
const [error, setError] = createSignal("");
const isProtocolValid = createMemo(() => protocol() !== "");
const isCommandNumberValid = createMemo(
() => commandNumber() !== "" && !isNaN(commandNumber())
);
const isDeviceValid = createMemo(() => device() !== "" && !isNaN(device()));
const isCommandTypeValid = createMemo(() => commandType() !== "");
const isTitleValid = createMemo(
() => title() === "" || title().length >= MIN_TITLE_LENGTH
);
createEffect(() => {
setProtocol(command().getProtocol());
setCommandNumber(command().getCommandNumber());
setDevice(command().getDevice());
setCommandType(command().getCommandType());
setTitle(command().getTitle());
});
const isFormValid = createMemo(
() =>
isProtocolValid() &&
isCommandNumberValid() &&
isDeviceValid() &&
isCommandTypeValid() &&
isTitleValid()
);
async function handleEditCommand() {
try {
await RemotesService.updateCommand(
new Command({
id: command().getId(),
protocol: protocol(),
commandNumber: parseInt(commandNumber()),
device: parseInt(device()),
commandType: commandType(),
title: title(),
})
);
} catch (e) {
console.error(e);
setError(e.message);
return;
}
resetFields();
EditCommandModal.Handler.hide();
eventEmitter.dispatchEvent(COMMAND_EDITED_EVENT);
}
function resetFields() {
setProtocol("");
setCommandNumber("");
setDevice("");
setCommandType("");
setTitle("");
setError("");
}
return (
<Modal
ref={props.ref}
id="editCommandModal"
modalTitle="Edit Command"
centered={true}
>
<div class="modal-body" style="overflow-y:inherit !important;">
<Show when={error() !== ""}>
<div class="alert alert-danger" role="alert">
{error()}
</div>
</Show>
<div class="mb-3 row">
<label for="edit_command_protocol" class="col-form-label col-sm-3">
Protocol
</label>
<div class="col-sm-9">
<select
class="form-select"
onChange={(e) => setProtocol(e.target.value)}
>
{Object.values(Command.PROTOCOLS).map((protocol) => (
<option
value={protocol}
selected={protocol === command().getProtocol()}
>
{Command.getProtocolString(protocol)}
</option>
))}
</select>
</div>
</div>
<div class="mb-3 row">
<label for="edit_command_number" class="col-form-label col-sm-3">
Command Number
</label>
<ValidatedTextInput
class="col-sm-9"
id="edit_command_number"
valid={isCommandNumberValid()}
value={commandNumber()}
onInput={(e) => setCommandNumber(e.target.value)}
errorText={"Command number must be a number"}
/>
</div>
<div class="mb-3 row">
<label
for="edit_command_device_number"
class="col-form-label col-sm-3"
>
Device Number
</label>
<ValidatedTextInput
class="col-sm-9"
id="edit_command_device_number"
valid={isDeviceValid()}
value={device()}
onInput={(e) => setDevice(e.target.value)}
errorText={"Device number must be a number"}
/>
</div>
<div class="mb-3 row">
<label for="edit_command_protocol" class="col-form-label col-sm-3">
Command Type
</label>
<div class="col-sm-9">
<select
class="form-select"
onChange={(e) => setCommandType(e.target.value)}
>
{Object.values(Command.TYPES).map((commandType) => (
<option
value={commandType}
selected={commandType === command().getCommandType()}
>
{Command.getTypeString(commandType)}
</option>
))}
</select>
</div>
</div>
<div class="mb-3 row">
<label for="edit_command_title" class="col-form-label col-sm-3">
Title
</label>
<ValidatedTextInput
class="col-sm-9"
id="edit_command_title"
valid={isTitleValid()}
value={title()}
onInput={(e) => setTitle(e.target.value)}
errorText={`Title must be at least ${MIN_TITLE_LENGTH} characters long`}
/>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel
</button>
<button
type="button"
onClick={handleEditCommand}
class="btn btn-primary"
disabled={!isFormValid()}
>
Edit
</button>
</div>
</Modal>
);
}
EditCommandModal.Handler = new ModalHandler();
EditCommandModal.onCommandEdited = (callback) =>
eventEmitter.on(COMMAND_EDITED_EVENT, callback);
EditCommandModal.setCommand = setCommand;
export default EditCommandModal;

View File

@ -0,0 +1,145 @@
import {
createEffect,
createMemo,
createResource,
createSignal,
} from "solid-js";
import ListManager from "../components/list-manager.jsx";
import ValidatedTextInput from "../components/validated-text-input.jsx";
import Remote from "../data/remote.js";
import RemoteService from "../services/remotes-service.js";
import EventEmitter from "../tools/event-emitter.js";
import ModalHandler from "./modal-handler.js";
import Modal from "./modal.jsx";
const eventEmitter = new EventEmitter();
const REMOTE_EDITED_EVENT = "success";
const MIN_TITLE_LENGTH = 3;
const modalHandler = new ModalHandler();
const [remoteId, setRemoteId] = createSignal(null);
function EditRemoteModal(props) {
const [title, setTitle] = createSignal("");
const [commands, setCommands] = createSignal([]);
const [availableCommands, { refetch: refetchAvailableCommands }] =
createResource(RemoteService.getCommands);
const [remote, {}] = createResource(
remoteId,
() => RemoteService.getRemote(remoteId()),
{ initialValue: new Remote() }
);
const [error, setError] = createSignal("");
const isTitleValid = createMemo(() => title().length >= MIN_TITLE_LENGTH);
const isFormValid = createMemo(() => isTitleValid());
createEffect(() => {
if (!remote()) return;
setTitle(remote().getTitle());
setCommands(remote().getCommands());
});
modalHandler.onShow(() => {
refetchAvailableCommands();
});
async function handleEditRemote() {
try {
await RemoteService.updateRemote(
new Remote({
id: remote().getId(),
title: title(),
commands: commands(),
})
);
} catch (e) {
setError(e.message);
console.error(e);
return;
}
resetFields();
EditRemoteModal.Handler.hide();
eventEmitter.dispatchEvent(REMOTE_EDITED_EVENT);
}
function resetFields() {
setTitle("");
setCommands([]);
setRemoteId("");
setError("");
}
function handleCommandSelect(item) {
setCommands([...commands(), item]);
}
function handleCommandDeselect(item) {
setCommands(
commands().filter((command) => command.getId() !== item.getId())
);
}
return (
<Modal
ref={props.ref}
id="editRemoteModal"
modalTitle="New Remote"
centered={true}
>
<div class="modal-body bg-body-tertiary">
<Show when={error() !== ""}>
<div class="alert alert-danger" role="alert">
{error()}
</div>
</Show>
<div class="mb-3 row">
<label for="edit_remote_title" class="col-form-label col-sm-1">
Title
</label>
<ValidatedTextInput
class="col-sm-11"
id="edit_remote_title"
valid={isTitleValid()}
value={title()}
onInput={(e) => setTitle(e.target.value)}
errorText={`Title must be at least ${MIN_TITLE_LENGTH} characters long`}
/>
</div>
<ListManager
style="height: 20em;"
items={commands()}
availableItems={availableCommands()}
itemToString={(command) =>
`${command.getTitle()} (${command.getProtocol()}:${command.getCommandType()})`
}
onItemSelect={handleCommandSelect}
onItemDeselect={handleCommandDeselect}
itemsTitle="Selected Commands"
availableItemsTitle="Available Commands"
itemsEqual={(a, b) => a.getId() === b.getId()}
/>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel
</button>
<button
type="button"
onClick={handleEditRemote}
class="btn btn-primary"
disabled={!isFormValid()}
>
Edit
</button>
</div>
</Modal>
);
}
EditRemoteModal.Handler = modalHandler;
EditRemoteModal.onRemoteEdited = (callback) =>
eventEmitter.on(REMOTE_EDITED_EVENT, callback);
EditRemoteModal.setRemoteId = setRemoteId;
export default EditRemoteModal;

View File

@ -0,0 +1,500 @@
import {
createEffect,
createMemo,
createSignal,
Match,
on,
Show,
Switch,
} from "solid-js";
import RemotesService from "../services/remotes-service.js";
import EventEmitter from "../tools/event-emitter.js";
import ModalHandler from "./modal-handler.js";
import Modal from "./modal.jsx";
import Command from "../data/command.js";
const eventEmitter = new EventEmitter();
const COMMANDS_IMPORTED_EVENT = "success";
const ENTER_CSV_STEP = 1;
const MAP_FIELDS_STEP = 2;
const MAP_VALUES_STEP = 3;
const CONFIRM_STEP = 4;
const TOTAL_STEPS = 4;
const PROTOCOL_FIELD = "protocol";
const COMMAND_NUMBER_FIELD = "commandNumber";
const DEVICE_FIELD = "device";
const COMMAND_TYPE_FIELD = "commandType";
const TITLE_FIELD = "title";
let FieldTitles = {};
FieldTitles[PROTOCOL_FIELD] = "Protocol";
FieldTitles[COMMAND_NUMBER_FIELD] = "Command";
FieldTitles[DEVICE_FIELD] = "Device";
FieldTitles[COMMAND_TYPE_FIELD] = "Type";
FieldTitles[TITLE_FIELD] = "Title";
const CommandFieldsRequiringValueMapping = ["protocol", "commandType"];
const commandTypeFuse = new Fuse(
Object.values(Command.TYPES).map((type) => Command.getTypeString(type))
);
const protocolsFuse = new Fuse(
Object.values(Command.PROTOCOLS).map((protocol) =>
Command.getProtocolString(protocol)
)
);
function ImportCommandsModal(props) {
const [csvString, setCsvString] = createSignal("");
const [currentStep, setCurrentStep] = createSignal(ENTER_CSV_STEP);
const [error, setError] = createSignal("");
const [csvArray, setCsvArray] = createSignal([]);
const [commands, setCommands] = createSignal([]);
const [fieldMapping, setFieldMapping] = createSignal(
{},
{ equals: () => false }
);
const [isComputingValueMapping, setComputingValueMapping] =
createSignal(false);
const [valueMapping, setValueMapping] = createSignal(
(() => {
let mapping = {};
mapping[PROTOCOL_FIELD] = {};
mapping[COMMAND_TYPE_FIELD] = {};
return mapping;
})(),
{ equals: () => false }
);
const canMakeNextStep = createMemo(() => {
switch (currentStep()) {
case ENTER_CSV_STEP:
return csvString() !== "";
default:
return true;
}
});
const fields = createMemo(() =>
Object.keys(csvArray().find(() => true) || {})
);
createEffect(() => {
switch (currentStep()) {
case MAP_VALUES_STEP:
onMapValuesStep();
break;
}
});
function handleNextStep() {
switch (currentStep()) {
case ENTER_CSV_STEP:
handleEnterCsvStep();
break;
case MAP_FIELDS_STEP:
handleMapFieldsStep();
break;
case MAP_VALUES_STEP:
handleMapValuesStep();
break;
case CONFIRM_STEP:
handleImportCommands();
break;
default:
nextStep();
}
}
async function handleImportCommands() {
let newCommands = [];
try {
newCommands = await RemotesService.createCommands(commands());
} catch (e) {
setError(e.message);
return;
}
resetFields();
ImportCommandsModal.Handler.hide();
eventEmitter.dispatchEvent(COMMANDS_IMPORTED_EVENT, newCommands);
}
function EnterCsvStep() {
return (
<div class="mb-3">
<label for="csvTextarea" class="form-label">
Enter CSV:
</label>
<textarea
class="form-control"
id="csvTextarea"
rows="10"
value={csvString()}
onInput={(e) => setCsvString(e.target.value)}
></textarea>
</div>
);
}
function handleEnterCsvStep() {
if (!csvString()) {
setError("Please enter a CSV string.");
return;
}
let result = Papa.parse(csvString(), { header: true });
if (result.errors.length > 0) {
setError(result.errors[0].message);
return;
}
let csvArray = result.data;
setCsvArray(csvArray);
nextStep();
}
function MapFieldsStep() {
function setMapping(field, commandField) {
let mapping = fieldMapping();
mapping[field] = commandField;
setFieldMapping(mapping);
}
return (
<div class="mb-3">
<div>Map Fields:</div>
<div class="d-flex flex-column">
{fields().map((field) => (
<div class="d-flex flex-row align-items-center justify-content-center text-end mb-2">
<div class="col-sm-4">
<div>{field}</div>
<div class="fw-light lh-1">(e.g. {csvArray()[0][field]})</div>
</div>
<div style="font-size: 1.5rem; line-height: 1;">
<i class="bi bi-arrow-right-short"></i>
</div>
<div class="col-sm-4">
<select
class="form-select"
onChange={(e) => setMapping(field, e.target.value)}
>
<option value="" selected>
Please select
</option>
{Object.keys(FieldTitles).map((commandField) => (
<option
value={commandField}
selected={fieldMapping()[field] === commandField}
>
{FieldTitles[commandField]}
</option>
))}
</select>
</div>
</div>
))}
</div>
</div>
);
}
function handleMapFieldsStep() {
let usedCommandFields = [];
let mapping = fieldMapping();
for (let field in mapping) {
if (usedCommandFields.includes(mapping[field])) {
setError("Duplicate mapping found.");
return false;
}
usedCommandFields.push(mapping[field]);
}
nextStep();
}
function MapValuesStep() {
function setMapping(field, value, commandValue) {
let mapping = valueMapping();
if (!mapping[field]) mapping[field] = {};
mapping[field][value] = commandValue;
setValueMapping(mapping);
}
return (
<div class="mb-3">
<div>Map Values:</div>
{Object.keys(fieldMapping())
.filter((field) =>
CommandFieldsRequiringValueMapping.includes(fieldMapping()[field])
)
.map((csvField) => {
let field = fieldMapping()[csvField];
return (
<div>
<div>{FieldTitles[field]}:</div>
<div class="d-flex flex-column">
{csvArray()
.map((row) => row[csvField])
.filter(
(value, index, array) => array.indexOf(value) === index
)
.map((value) => (
<div class="d-flex flex-row align-items-center justify-content-center text-end mb-2">
<div class="col-sm-4">
<div>{value}</div>
</div>
<div style="font-size: 1.5rem; line-height: 1;">
<i class="bi bi-arrow-right-short"></i>
</div>
<div class="col-sm-4">
<Switch>
<Match when={field === "protocol"}>
<select
class="form-select"
onChange={(e) =>
setMapping(field, value, e.target.value)
}
>
<option value="" selected>
Please select
</option>
{Object.values(Command.PROTOCOLS).map(
(protocol) => (
<option
value={protocol}
selected={
valueMapping()[PROTOCOL_FIELD][
value
] === protocol
}
>
{Command.getProtocolString(protocol)}
</option>
)
)}
</select>
</Match>
<Match when={field === "commandType"}>
<select
class="form-select"
onChange={(e) =>
setMapping(field, value, e.target.value)
}
>
<option value="" selected>
Please select
</option>
{Object.values(Command.TYPES).map(
(commandType) => (
<option
value={commandType}
selected={
valueMapping()[COMMAND_TYPE_FIELD][
value
] === commandType
}
>
{Command.getTypeString(commandType)}
</option>
)
)}
</select>
</Match>
</Switch>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
);
}
function onMapValuesStep() {
setComputingValueMapping(true);
(async () => {
let valueMapping = {};
valueMapping[PROTOCOL_FIELD] = {};
valueMapping[COMMAND_TYPE_FIELD] = {};
let protocolField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === PROTOCOL_FIELD
);
let commandTypeField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === COMMAND_TYPE_FIELD
);
csvArray().forEach((row) => {
let protocolValue = row[protocolField];
let commandTypeValue = row[commandTypeField];
if (!valueMapping[PROTOCOL_FIELD][protocolValue]) {
let result = protocolsFuse.search(protocolValue).shift();
if (result) {
let protocol = Object.values(Command.PROTOCOLS).find(
(protocol) => Command.getProtocolString(protocol) === result.item
);
valueMapping[PROTOCOL_FIELD][protocolValue] = protocol;
}
}
if (!valueMapping[COMMAND_TYPE_FIELD][commandTypeValue]) {
let result = commandTypeFuse.search(commandTypeValue).shift();
if (result) {
let commandType = Object.values(Command.TYPES).find(
(commandType) =>
Command.getTypeString(commandType) === result.item
);
valueMapping[COMMAND_TYPE_FIELD][commandTypeValue] = commandType;
}
}
});
setValueMapping(valueMapping);
setComputingValueMapping(false);
})();
}
function handleMapValuesStep() {
let protocolField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === PROTOCOL_FIELD
);
let commandNumberField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === COMMAND_NUMBER_FIELD
);
let deviceField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === DEVICE_FIELD
);
let commandTypeField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === COMMAND_TYPE_FIELD
);
let titleField = Object.keys(fieldMapping()).find(
(field) => fieldMapping()[field] === TITLE_FIELD
);
let commands = csvArray()
.map((row) => {
let protocol = valueMapping()[PROTOCOL_FIELD][row[protocolField]];
if (!protocol) return null;
let commandNumber = row[commandNumberField];
if (isNaN(commandNumber)) return null;
commandNumber = parseInt(commandNumber);
let device = row[deviceField];
if (isNaN(device)) return null;
device = parseInt(device);
let commandType =
valueMapping()[COMMAND_TYPE_FIELD][row[commandTypeField]];
if (!commandType) return null;
let title = row[titleField];
let command = new Command({
protocol,
commandNumber,
device,
commandType,
title,
});
return command;
})
.filter((command) => command !== null);
setCommands(commands);
nextStep();
}
function ConfirmStep() {
return (
<div class="mb-3">
<div>Confirm:</div>
<div class="d-flex flex-column">
{commands().map((command) => (
<div class="d-flex flex-row align-items-center justify-content-center mb-2">
<div class="col-sm-2">{command.getProtocol()}</div>
<div class="col-sm-2">{command.getCommandNumber()}</div>
<div class="col-sm-2">{command.getDevice()}</div>
<div class="col-sm-2">{command.getCommandType()}</div>
<div class="col-sm-2">{command.getTitle()}</div>
</div>
))}
</div>
</div>
);
}
function nextStep() {
if (currentStep() >= TOTAL_STEPS) return;
setError("");
setCurrentStep(currentStep() + 1);
}
function previousStep() {
if (currentStep() <= 1) return;
setError("");
setCurrentStep(currentStep() - 1);
}
function resetFields() {
setCsvString("");
setError("");
}
return (
<Modal
ref={props.ref}
id="importCommandsModal"
modalTitle="Import Commands from CSV"
centered={true}
>
<div class="modal-body">
<Show when={error() !== ""}>
<div class="alert alert-danger" role="alert">
{error()}
</div>
</Show>
<Switch>
<Match when={currentStep() === ENTER_CSV_STEP}>
<EnterCsvStep />
</Match>
<Match when={currentStep() === MAP_FIELDS_STEP}>
<MapFieldsStep />
</Match>
<Match when={currentStep() === MAP_VALUES_STEP}>
<MapValuesStep />
</Match>
<Match when={currentStep() === CONFIRM_STEP}>
<ConfirmStep />
</Match>
</Switch>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel
</button>
<Show when={currentStep() > 1}>
<button
type="button"
onClick={previousStep}
class="btn btn-secondary"
disabled={false}
>
Back
</button>
</Show>
<Show when={currentStep() < TOTAL_STEPS}>
<button
type="button"
onClick={handleNextStep}
class="btn btn-secondary"
disabled={!canMakeNextStep()}
>
Next
</button>
</Show>
<Show when={currentStep() === TOTAL_STEPS}>
<button
type="button"
onClick={handleImportCommands}
class="btn btn-primary"
disabled={false}
>
Import
</button>
</Show>
</div>
</Modal>
);
}
ImportCommandsModal.Handler = new ModalHandler();
ImportCommandsModal.onCommandsImported = (callback) =>
eventEmitter.on(COMMANDS_IMPORTED_EVENT, callback);
export default ImportCommandsModal;

View File

@ -1,11 +1,23 @@
import EventEmitter from "../tools/event-emitter";
const SHOW_EVENT = "show";
const HIDE_EVENT = "hide";
function ModalHandler() { function ModalHandler() {
let _ref; let _ref;
let _modalRef; let _modalRef;
let _modalId; let _modalId;
let eventEmitter = new EventEmitter();
function setRef(ref) { function setRef(ref) {
_ref = ref; _ref = ref;
_modalRef = new bootstrap.Modal(ref); _modalRef = new bootstrap.Modal(ref);
_ref.addEventListener('hidden.bs.modal', () => {
eventEmitter.dispatchEvent(HIDE_EVENT);
});
_ref.addEventListener('show.bs.modal', () => {
eventEmitter.dispatchEvent(SHOW_EVENT);
});
} }
function show() { function show() {
@ -21,15 +33,11 @@ function ModalHandler() {
} }
function onHidden(callback) { function onHidden(callback) {
_ref.addEventListener('hidden.bs.modal', () => { eventEmitter.on(HIDE_EVENT, callback);
callback();
});
} }
function onShow(callback) { function onShow(callback) {
_ref.addEventListener('show.bs.modal', () => { eventEmitter.on(SHOW_EVENT, callback);
callback();
});
} }
return { return {

View File

@ -9,6 +9,10 @@ import DeleteIntegrationModal from "./delete-integration-modal.jsx";
import CreateCommandModal from "./create-command-modal.jsx"; import CreateCommandModal from "./create-command-modal.jsx";
import DeleteCommandModal from "./delete-command-modal.jsx"; import DeleteCommandModal from "./delete-command-modal.jsx";
import CreateRemoteModal from "./create-remote-modal.jsx"; import CreateRemoteModal from "./create-remote-modal.jsx";
import DeleteRemoteModal from "./delete-remote-modal.jsx";
import ImportCommandsModal from "./import-commands-modal.jsx";
import EditCommandModal from "./edit-command-modal.jsx";
import EditRemoteModal from "./edit-remote-modal.jsx";
const ModalRegistry = (function () { const ModalRegistry = (function () {
const modals = [ const modals = [
@ -57,6 +61,26 @@ const ModalRegistry = (function () {
component: CreateRemoteModal, component: CreateRemoteModal,
ref: null, ref: null,
}, },
{
id: "deleteRemoteModal",
component: DeleteRemoteModal,
ref: null,
},
{
id: "importCommandsModal",
component: ImportCommandsModal,
ref: null,
},
{
id: "editCommandModal",
component: EditCommandModal,
ref: null,
},
{
id: "editRemoteModal",
component: EditRemoteModal,
ref: null,
}
]; ];
function getModals(props) { function getModals(props) {

View File

@ -79,6 +79,23 @@ function DeviceService() {
return codeObject.code; return codeObject.code;
} }
async function getIntegration(id) {
if (!id) return null;
let response = await Net.sendRequest({
method: "GET",
url: "/api/integrations/" + id,
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
let integration = JSON.parse(response.data);
integration = Serializer.deserializeIntegration(integration);
return integration;
}
async function getIntegrations() { async function getIntegrations() {
let response = await Net.sendRequest({ let response = await Net.sendRequest({
method: "GET", method: "GET",
@ -113,6 +130,7 @@ function DeviceService() {
updateDevice, updateDevice,
deleteDevice, deleteDevice,
getRegistrationCode, getRegistrationCode,
getIntegration,
getIntegrations, getIntegrations,
deleteIntegration, deleteIntegration,
}; };

View File

@ -1,75 +1,163 @@
import Command from "../data/command";
import Serializer from "../data/serializer"; import Serializer from "../data/serializer";
import Net from "../tools/net";
import WebRTCService from "./webrtc-service";
function RemotesService() { const MESSAGE_TYPE_COMMAND = "command";
let commands = [
new Command({ function RemoteService() {
id: 1, async function getRemote(remoteId) {
protocol: "samsung", if (!remoteId) return null;
commandNumber: 1, let response = await Net.sendRequest({
device: 7, method: "GET",
commandType: "power", url: "/api/remotes/" + remoteId,
title: "Power Samsung", });
}),
new Command({ if (response.status !== 200) {
id: 2, let responseData = JSON.parse(response.data);
protocol: "samsung", throw new Error(responseData.error);
commandNumber: 2, }
device: 7,
commandType: "input", let remoteObject = JSON.parse(response.data);
title: "Input Samsung", return Serializer.deserializeRemote(remoteObject);
}), }
new Command({
id: 3,
protocol: "samsung",
commandNumber: 3,
device: 7,
commandType: "volume_up",
title: "Volume Up Samsung",
}),
];
let remotes = [];
async function getRemotes() { async function getRemotes() {
return [].concat(remotes); let response = await Net.sendRequest({
method: "GET",
url: "/api/remotes",
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
let remoteObjects = JSON.parse(response.data);
return Serializer.deserializeRemotes(remoteObjects);
} }
async function createRemote(remoteObject) { async function createRemote(remote) {
let remote = Serializer.deserializeRemote(remoteObject); let remoteObject = Serializer.serializeRemote(remote);
let id = Math.random().toString(36).substr(2, 9); let response = await Net.sendJsonRequest({
remote.setId(id); method: "POST",
remotes.push(remote); url: "/api/remotes",
return remote; data: remoteObject,
} });
async function getCommands() { if (response.status !== 200) {
return [].concat(commands); let responseData = JSON.parse(response.data);
} throw new Error(responseData.error);
async function createCommand(commandObject) {
let command = Serializer.deserializeCommand(commandObject);
let id = Math.random().toString(36).substr(2, 9);
command.setId(id);
commands.push(command);
return command;
}
async function deleteCommand(commandId) {
let index = commands.findIndex((command) => command.getId() === commandId);
if (index >= 0) {
commands.splice(index, 1);
} }
} }
async function updateRemote(remote) {
let remoteObject = Serializer.serializeRemote(remote);
let response = await Net.sendJsonRequest({
method: "PUT",
url: "/api/remotes/" + remote.getId(),
data: remoteObject,
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
}
async function deleteRemote(remoteId) {
let response = await Net.sendRequest({
method: "DELETE",
url: "/api/remotes/" + remoteId,
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
}
async function getCommands() {
let response = await Net.sendRequest({
method: "GET",
url: "/api/commands",
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
let commandObjects = JSON.parse(response.data);
return Serializer.deserializeCommands(commandObjects);
}
async function createCommand(command) {
return createCommands([command]);
}
async function createCommands(commands) {
let commandObjects = Serializer.serializeCommands(commands);
let response = await Net.sendJsonRequest({
method: "POST",
url: "/api/commands",
data: commandObjects,
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
}
async function updateCommand(command) {
let commandObject = Serializer.serializeCommand(command);
let response = await Net.sendJsonRequest({
method: "PUT",
url: "/api/commands/" + command.getId(),
data: commandObject,
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
}
async function deleteCommand(commandId) {
let response = await Net.sendRequest({
method: "DELETE",
url: "/api/commands/" + commandId,
});
if (response.status !== 200) {
let responseData = JSON.parse(response.data);
throw new Error(responseData.error);
}
}
function sendCommand(command) {
let commandObject = Serializer.serializeCommand(command);
WebRTCService.sendDataJson({
type: MESSAGE_TYPE_COMMAND,
data: commandObject,
});
}
return { return {
getRemote,
getRemotes, getRemotes,
createRemote, createRemote,
updateRemote,
deleteRemote,
getCommands, getCommands,
createCommand, createCommand,
createCommands,
updateCommand,
deleteCommand, deleteCommand,
sendCommand,
}; };
} }
RemotesService = new RemotesService(); RemoteService = new RemoteService();
export default RemotesService; export default RemoteService;

View File

@ -8,10 +8,12 @@ function WebRTCService() {
const STATE_CLOSED = "closed"; const STATE_CLOSED = "closed";
const STATE_FAILED = "failed"; const STATE_FAILED = "failed";
const ICE_CONNECTION_STATE_CHANGE_EVENT = "iceconnectionstatechange"; const ICE_CONNECTION_STATE_CHANGE_EVENT = "iceconnectionstatechange";
const DATA_CHANNEL_OPEN_EVENT = "datachannelopen";
let videoElement; let videoElement;
let peerConnection; let peerConnection;
let peerId; let peerId;
let dataChannel;
let eventEmitter = new EventEmitter(); let eventEmitter = new EventEmitter();
@ -57,12 +59,19 @@ function WebRTCService() {
console.log("Negotiation needed"); console.log("Negotiation needed");
negotiate(targetId); negotiate(targetId);
}; };
dataChannel = peerConnection.createDataChannel("data");
dataChannel.addEventListener("open", () => {
eventEmitter.dispatchEvent(DATA_CHANNEL_OPEN_EVENT);
});
negotiate(targetId); negotiate(targetId);
} }
function disconnect() { function disconnect() {
peerConnection.close(); peerConnection.close();
eventEmitter.dispatchEvent(ICE_CONNECTION_STATE_CHANGE_EVENT, peerConnection.iceConnectionState); eventEmitter.dispatchEvent(
ICE_CONNECTION_STATE_CHANGE_EVENT,
peerConnection.iceConnectionState
);
} }
async function negotiate(targetId) { async function negotiate(targetId) {
@ -125,12 +134,27 @@ function WebRTCService() {
peerConnection.addIceCandidate(iceCandidate); peerConnection.addIceCandidate(iceCandidate);
} }
function sendDataString(data) {
if (!dataChannel) return;
if (dataChannel.readyState !== "open") return;
dataChannel.send(data);
}
function sendDataJson(data) {
let dataJson = JSON.stringify(data);
sendDataString(dataJson);
}
function onStateChanged(callback) { function onStateChanged(callback) {
eventEmitter.on(ICE_CONNECTION_STATE_CHANGE_EVENT, () => { eventEmitter.on(ICE_CONNECTION_STATE_CHANGE_EVENT, () => {
callback(peerConnection.iceConnectionState); callback(peerConnection.iceConnectionState);
}); });
} }
function onDataChannelOpen(callback) {
eventEmitter.on(DATA_CHANNEL_OPEN_EVENT, callback);
}
function getConfiguration() { function getConfiguration() {
return { return {
iceServers: [{ urls: "stun:stun.l.google.com:19302" }], iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
@ -155,7 +179,10 @@ function WebRTCService() {
disconnect, disconnect,
setVideoElement, setVideoElement,
getVideoElement, getVideoElement,
sendDataString,
sendDataJson,
onStateChanged, onStateChanged,
onDataChannelOpen,
}; };
} }

View File

@ -0,0 +1,30 @@
function FileUtils() {
function createJsonFile(jsonData, fileName) {
const json = JSON.stringify(jsonData, null, 2);
const file = new File([json], fileName, {
type: "text/json;charset=utf-8",
});
return file;
}
function downloadFile(file) {
console.log(file)
const url = URL.createObjectURL(file);
const link = document.createElement("a");
link.setAttribute("href", url);
link.setAttribute("download", file.name);
link.style.visibility = "hidden";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
return {
createJsonFile,
downloadFile,
};
}
FileUtils = new FileUtils();
export default FileUtils;

View File

@ -1,195 +0,0 @@
import {
createResource,
createSignal,
mergeProps,
onMount,
Show,
} from "solid-js";
import List from "../components/list";
import CreateDeviceModal from "../modals/create-device-modal";
import DeviceService from "../services/device-service";
import ShowRegistrationCodeModal from "../modals/show-registration-code-modal";
import DeleteIntegrationModal from "../modals/delete-integration-modal";
function DevicesView(props) {
props = mergeProps({ onIntegrationClicked: () => {} }, props);
const DEVICES_LIST_TYPE = "devices";
const INTEGRATION_LIST_TYPE = "integrations";
const [listType, setListType] = createSignal(INTEGRATION_LIST_TYPE);
const [devices, setDevices] = createSignal([]);
const [integrations, { refetch: refetchIntegrations }] = createResource(
DeviceService.getIntegrations
);
CreateDeviceModal.onDeviceCreated(() => {
handleRefreshDevices();
});
onMount(() => {
handleRefreshDevices();
DeleteIntegrationModal.onIntegrationDeleted(() => {
refetchIntegrations();
});
});
function handleNewDevice() {
CreateDeviceModal.Handler.show();
}
async function handleRefreshDevices() {
let devices = await DeviceService.getDevices();
setDevices(devices);
}
function handleRegisterIntegration() {
ShowRegistrationCodeModal.Handler.show();
}
function handleDeleteIntegration(integration) {
DeleteIntegrationModal.setIntegration(integration);
DeleteIntegrationModal.Handler.show();
}
function handleIntegrationItemClicked(item) {
props.onIntegrationClicked(item.integration);
}
return (
<div
class={
"d-flex flex-column overflow-hidden flex-fill px-3 pb-2 pt-3 " +
props.class
}
>
<div class="d-flex flex-row">
<div class="d-flex flex-row flex-fill">
<button
class={
"btn me-2 mb-3" +
(listType() === INTEGRATION_LIST_TYPE
? " btn-secondary"
: " btn-dark")
}
onClick={() => setListType(INTEGRATION_LIST_TYPE)}
>
<i class="bi bi-gear me-2"></i>
Integrations
</button>
<button
class={
"btn me-2 mb-3" +
(listType() === DEVICES_LIST_TYPE
? " btn-secondary"
: " btn-dark")
}
onClick={() => setListType(DEVICES_LIST_TYPE)}
>
<i class="bi bi-tv me-2"></i>
Devices
</button>
</div>
<div class="d-flex flex-row justify-content-end flex-fill">
<Show when={listType() === DEVICES_LIST_TYPE}>
<button class="btn btn-dark me-2 mb-3" onClick={handleNewDevice}>
<i class="bi bi-plus-square me-2"></i>
New Device
</button>
</Show>
<Show when={listType() === INTEGRATION_LIST_TYPE}>
<button
class="btn btn-dark me-2 mb-3"
onClick={handleRegisterIntegration}
>
<i class="bi bi-plus-square me-2"></i>
Register
</button>
</Show>
</div>
</div>
<Show when={listType() === DEVICES_LIST_TYPE}>
<List
onListItemClick={() => {}}
items={devices().map((device) => ({
id: {
html: <span class="font-monospace">{device.getId()}</span>,
},
name: {
text: device.getName(),
},
description: {
text: device.getDescription(),
},
device,
}))}
class={"flex-fill"}
columns={[
{
id: "id",
name: "id",
width: 6,
},
{
id: "name",
name: "Name",
width: 10,
},
{
id: "description",
name: "Description",
},
]}
></List>
</Show>
<Show when={listType() === INTEGRATION_LIST_TYPE}>
<List
onListItemClick={handleIntegrationItemClicked}
items={(integrations() || []).map((integration) => ({
id: {
html: <span class="font-monospace">{integration.getId()}</span>,
},
name: {
text: integration.getName(),
},
options: {
html: (
<>
<button
class="btn btn-outline-secondary me-2"
onClick={(event) => {
event.stopPropagation();
handleDeleteIntegration(integration);
}}
>
<i class="bi bi-trash-fill"></i>
</button>
</>
),
},
integration: integration,
}))}
class={"flex-fill"}
columns={[
{
id: "id",
name: "id",
width: 6,
},
{
id: "name",
name: "Name",
},
{
id: "options",
name: "",
width: 4,
},
]}
></List>
</Show>
</div>
);
}
export default DevicesView;

View File

@ -0,0 +1,76 @@
import { createSignal } from "solid-js";
import List from "../../components/list";
import CreateDeviceModal from "../../modals/create-device-modal";
import DeviceService from "../../services/device-service";
function DevicesListView(props) {
const [devices, setDevices] = createSignal([]);
handleRefreshDevices();
CreateDeviceModal.onDeviceCreated(() => {
handleRefreshDevices();
});
function handleNewDevice() {
CreateDeviceModal.Handler.show();
}
async function handleRefreshDevices() {
let devices = await DeviceService.getDevices();
setDevices(devices);
}
return (
<div
class={
"d-flex flex-column overflow-hidden flex-fill px-3 pb-2 pt-3 " +
props.class
}
>
<div class="d-flex flex-row">
{props.navigation}
<div class="d-flex flex-row justify-content-end flex-fill">
<button class="btn btn-dark me-2 mb-3" onClick={handleNewDevice}>
<i class="bi bi-plus-square me-2"></i>
New Device
</button>
</div>
</div>
<List
onListItemClick={() => {}}
items={devices().map((device) => ({
id: {
html: <span class="font-monospace">{device.getId()}</span>,
},
name: {
text: device.getName(),
},
description: {
text: device.getDescription(),
},
device,
}))}
class={"flex-fill"}
columns={[
{
id: "id",
name: "id",
width: 6,
},
{
id: "name",
name: "Name",
width: 10,
},
{
id: "description",
name: "Description",
},
]}
></List>
</div>
);
}
export default DevicesListView;

View File

@ -0,0 +1,105 @@
import {
createEffect,
createSignal,
Match,
mergeProps,
onCleanup,
Switch,
} from "solid-js";
import DevicesListView from "./devices-list-view";
import IntegrationListView from "./integration-list-view";
import UrlUtils from "../../tools/url-utils";
import IntegrationView from "./integration-view";
function DevicesView(props) {
props = mergeProps({ onIntegrationClicked: () => {} }, props);
const DEVICES_LIST_VIEW = "devices";
const INTEGRATION_LIST_VIEW = "integrations";
const INTEGRATION_VIEW = "integration";
const VIEWS = [DEVICES_LIST_VIEW, INTEGRATION_LIST_VIEW, INTEGRATION_VIEW];
const [currentView, setCurrentView] = createSignal(INTEGRATION_LIST_VIEW);
createEffect(() => {
let url = UrlUtils.getUrl();
url = UrlUtils.addQueryParameter(url, "tab", currentView());
UrlUtils.setUrl(url);
});
onCleanup(() => {
window.removeEventListener("popstate", setViewFromUrl);
});
setViewFromUrl();
window.addEventListener("popstate", setViewFromUrl);
function setViewFromUrl() {
let view = UrlUtils.getQueryParameter("tab");
if (!view) return;
if (!VIEWS.includes(view)) return;
setCurrentView(view);
}
function handleIntegrationClicked(integration) {
IntegrationView.setIntegration(integration);
setCurrentView(INTEGRATION_VIEW);
}
function Navigation() {
return (
<div class="d-flex flex-row flex-fill">
<button
class={
"btn me-2 mb-3" +
(currentView() === INTEGRATION_LIST_VIEW
? " btn-secondary"
: " btn-dark")
}
onClick={() => setCurrentView(INTEGRATION_LIST_VIEW)}
>
<i class="bi bi-gear me-2"></i>
Integrations
</button>
<button
class={
"btn me-2 mb-3" +
(currentView() === DEVICES_LIST_VIEW
? " btn-secondary"
: " btn-dark")
}
onClick={() => setCurrentView(DEVICES_LIST_VIEW)}
>
<i class="bi bi-tv me-2"></i>
Devices
</button>
</div>
);
}
return (
<div
class={
"d-flex flex-column overflow-hidden " +
props.class
}
>
<Switch>
<Match when={currentView() === INTEGRATION_LIST_VIEW}>
<IntegrationListView
navigation={<Navigation />}
onIntegrationClicked={handleIntegrationClicked}
/>
</Match>
<Match when={currentView() === DEVICES_LIST_VIEW}>
<DevicesListView navigation={<Navigation />} />
</Match>
<Match when={currentView() === INTEGRATION_VIEW}>
<IntegrationView />
</Match>
</Switch>
</div>
);
}
export default DevicesView;

View File

@ -0,0 +1,103 @@
import { createResource, mergeProps } from "solid-js";
import List from "../../components/list";
import ShowRegistrationCodeModal from "../../modals/show-registration-code-modal";
import DeleteIntegrationModal from "../../modals/delete-integration-modal";
import DeviceService from "../../services/device-service";
function IntegrationListView(props) {
props = mergeProps(
{
onIntegrationClicked: () => {},
},
props
);
const [integrations, { refetch: refetchIntegrations }] = createResource(
DeviceService.getIntegrations,
{ initialValue: [] }
);
function handleRegisterIntegration() {
ShowRegistrationCodeModal.Handler.show();
}
DeleteIntegrationModal.onIntegrationDeleted(() => {
refetchIntegrations();
});
function handleDeleteIntegration(integration) {
DeleteIntegrationModal.setIntegration(integration);
DeleteIntegrationModal.Handler.show();
}
function handleIntegrationItemClicked(item) {
props.onIntegrationClicked(item.integration);
}
return (
<div
class={
"d-flex flex-column overflow-hidden flex-fill px-3 pb-2 pt-3 " +
props.class
}
>
<div class="d-flex flex-row">
{props.navigation}
<div class="d-flex flex-row justify-content-end flex-fill">
<button
class="btn btn-dark me-2 mb-3"
onClick={handleRegisterIntegration}
>
<i class="bi bi-plus-square me-2"></i>
Register
</button>
</div>
</div>
<List
onListItemClick={handleIntegrationItemClicked}
items={integrations().map((integration) => ({
id: {
html: <span class="font-monospace">{integration.getId()}</span>,
},
name: {
text: integration.getName(),
},
options: {
html: (
<>
<button
class="btn btn-sm btn-outline-secondary me-2"
onClick={(event) => {
event.stopPropagation();
handleDeleteIntegration(integration);
}}
>
<i class="bi bi-trash-fill"></i>
</button>
</>
),
},
integration: integration,
}))}
class={"flex-fill"}
columns={[
{
id: "id",
name: "id",
width: 6,
},
{
id: "name",
name: "Name",
},
{
id: "options",
name: "",
width: 4,
},
]}
></List>
</div>
);
}
export default IntegrationListView;

View File

@ -0,0 +1,181 @@
import {
createEffect,
createMemo,
createResource,
createSignal,
onCleanup,
Show,
} from "solid-js";
import WebRTCService from "../../services/webrtc-service";
import Integration from "../../data/integration";
import DeviceService from "../../services/device-service";
import UrlUtils from "../../tools/url-utils";
import RemoteControl from "../../components/remote-control";
import RemoteService from "../../services/remotes-service";
const [integration, setIntegration] = createSignal(new Integration());
function IntegrationView(props) {
const title = createMemo(() =>
integration && typeof integration === "function"
? integration().getName()
: "Integration"
);
const [connectionState, setConnectionState] = createSignal(
WebRTCService.STATE_DISCONNECTED
);
const showConnectButton = createMemo(
() =>
connectionState() === WebRTCService.STATE_DISCONNECTED ||
connectionState() === WebRTCService.STATE_FAILED ||
connectionState() === WebRTCService.STATE_CLOSED
);
WebRTCService.onStateChanged(handleConnectionStateChanged);
WebRTCService.onDataChannelOpen(handleDataChannelOpen);
let videoElement = null;
const [availableRemotes] = createResource(RemoteService.getRemotes, {
initialValue: [],
});
const [selectedRemote, setSelectedRemote] = createSignal();
const [remoteControlVisible, setRemoteControlVisible] = createSignal(false);
createEffect(() =>
handleRemoteSelected(
availableRemotes()
.find(() => true)
?.getId()
)
);
createEffect(() => {
let url = UrlUtils.getUrl();
url = UrlUtils.addQueryParameter(url, "id", integration()?.getId());
UrlUtils.setUrl(url);
});
onCleanup(() => {
window.removeEventListener("popstate", setIntegrationFromUrl);
});
setIntegrationFromUrl();
window.addEventListener("popstate", setIntegrationFromUrl);
async function setIntegrationFromUrl() {
let integrationId = UrlUtils.getQueryParameter("id");
if (!integrationId) return;
let integration = await DeviceService.getIntegration(integrationId);
setIntegration(integration);
}
function handleConnectWebRTC() {
let integrationId = integration().getId();
WebRTCService.setVideoElement(videoElement);
WebRTCService.connect(integrationId);
}
function handleDisconnectWebRTC() {
WebRTCService.disconnect();
}
function handleConnectionStateChanged(state) {
setConnectionState(state);
}
function handleDataChannelOpen() {
setInterval(() => {
WebRTCService.sendDataJson({ message: "ping" });
}, 1000);
}
async function handleRemoteSelected(remoteId) {
if (!remoteId) return;
let remote = await RemoteService.getRemote(remoteId);
setSelectedRemote(remote);
}
function handleRemoteButtonPressed(command) {
RemoteService.sendCommand(command);
}
function toggleRemoteControl() {
setRemoteControlVisible(!remoteControlVisible());
}
return (
<div
class={
"d-flex flex-column overflow-hidden flex-fill px-3 pb-2 pt-3 " +
props.class
}
>
<a>
<i class="bi bi-arrow-left-short"></i>
<span>Integration</span>
</a>
<h1>{title}</h1>
<div class="d-flex flex-row">
<div class="d-flex flex-row justify-content-end flex-fill">
<Show when={showConnectButton()}>
<button
class="btn btn-dark me-2 mb-3"
onClick={handleConnectWebRTC}
>
<i class="bi bi-plug-fill me-2"></i>
Connect
</button>
</Show>
<Show when={!showConnectButton()}>
<button
class="btn btn-dark me-2 mb-3"
onClick={handleDisconnectWebRTC}
>
<i class="bi bi-x-lg me-2"></i>
Disconnect
</button>
</Show>
</div>
</div>
<div class="flex-fill d-flex flex-row overflow-hidden">
<div
class="flex-fill rounded overflow-hidden"
style="position:relative;"
>
<video
ref={videoElement}
class="w-100 h-100"
style="background-color: #000"
></video>
<button
class="btn btn-dark mt-2 me-2"
style="transform: rotate(180deg);position:absolute;top:0;right:0;z-index:1000;"
onClick={toggleRemoteControl}
>
<i class="bi bi-building"></i>
</button>
</div>
<Show when={remoteControlVisible()}>
<div class="d-flex flex-column ps-3">
<select
class="form-select mb-3"
onChange={(e) => handleRemoteSelected(e.target.value)}
>
{availableRemotes().map((remote, index) => (
<option value={remote.getId()} selected={index === 0}>
{remote.getTitle()}
</option>
))}
</select>
<RemoteControl
onCommand={handleRemoteButtonPressed}
remote={selectedRemote()}
/>
</div>
</Show>
</div>
</div>
);
}
IntegrationView.setIntegration = setIntegration;
export default IntegrationView;

View File

@ -1,82 +0,0 @@
import { createMemo, createSignal } from "solid-js";
import Integration from "../data/integration";
import WebRTCService from "../services/webrtc-service";
const [integration, setIntegration] = createSignal(null);
function IntegrationView(props) {
const title = createMemo(() =>
integration && typeof integration === "function"
? integration().getName()
: "Integration"
);
const [connectionState, setConnectionState] = createSignal(
WebRTCService.STATE_DISCONNECTED
);
const showConnectButton = createMemo(
() =>
connectionState() === WebRTCService.STATE_DISCONNECTED ||
connectionState() === WebRTCService.STATE_FAILED ||
connectionState() === WebRTCService.STATE_CLOSED
);
WebRTCService.onStateChanged(handleConnectionStateChanged);
let videoElement = null;
function handleConnectWebRTC() {
let integrationId = integration().getId();
WebRTCService.setVideoElement(videoElement);
WebRTCService.connect(integrationId);
}
function handleDisconnectWebRTC() {
WebRTCService.disconnect();
}
function handleConnectionStateChanged(state) {
setConnectionState(state);
}
return (
<div
class={
"d-flex flex-column overflow-hidden flex-fill px-3 pb-2 pt-3 " +
props.class
}
>
<span>Integration</span>
<h1>{title}</h1>
<div class="d-flex flex-row">
<div class="d-flex flex-row justify-content-end flex-fill">
<Show when={showConnectButton()}>
<button
class="btn btn-dark me-2 mb-3"
onClick={handleConnectWebRTC}
>
<i class="bi bi-plug-fill me-2"></i>
Connect
</button>
</Show>
<Show when={!showConnectButton()}>
<button
class="btn btn-dark me-2 mb-3"
onClick={handleDisconnectWebRTC}
>
<i class="bi bi-x-lg me-2"></i>
Disconnect
</button>
</Show>
</div>
</div>
<div class="flex-fill d-flex flex-column justify-content-center align-items-center rounded overflow-hidden">
<video
ref={videoElement}
class="w-100 h-100"
style="background-color: #000"
></video>
</div>
</div>
);
}
IntegrationView.setIntegration = setIntegration;
export default IntegrationView;

View File

@ -11,7 +11,7 @@ import {
import ModalRegistry from "../modals/modal-registry.jsx"; import ModalRegistry from "../modals/modal-registry.jsx";
import UserService from "../services/user-service.js"; import UserService from "../services/user-service.js";
import UrlUtils from "../tools/url-utils.js"; import UrlUtils from "../tools/url-utils.js";
import DevicesView from "./devices-view.jsx"; import DevicesView from "./devices/devices-view.jsx";
import SettingsView from "./settings-view.jsx"; import SettingsView from "./settings-view.jsx";
import { import {
@ -21,12 +21,12 @@ import {
REMOTES_VIEW, REMOTES_VIEW,
SETTINGS_VIEW, SETTINGS_VIEW,
} from "../data/constants.js"; } from "../data/constants.js";
import IntegrationView from "./integration-view.jsx"; import IntegrationView from "./devices/integration-view.jsx";
import RemotesView from "./remotes/remotes-view.jsx"; import RemotesView from "./remotes/remotes-view.jsx";
let [activeView, setActiveView] = createSignal(DEVICES_VIEW); let [activeView, setActiveView] = createSignal(DEVICES_VIEW);
const MainView = function (props) { function MainView(props) {
props = mergeProps({ onLogout: () => {} }, props); props = mergeProps({ onLogout: () => {} }, props);
const [userInfo] = createResource(() => UserService.getUserInfo()); const [userInfo] = createResource(() => UserService.getUserInfo());
@ -55,7 +55,6 @@ const MainView = function (props) {
function setViewFromUrl() { function setViewFromUrl() {
let view = UrlUtils.getQueryParameter("view"); let view = UrlUtils.getQueryParameter("view");
console.log(view)
if (view) { if (view) {
setActiveView(view); setActiveView(view);
} }
@ -100,7 +99,8 @@ const MainView = function (props) {
if (activeView() === view) return; if (activeView() === view) return;
setActiveView(view); setActiveView(view);
let url = UrlUtils.getUrl(); let url = UrlUtils.getUrl();
url = UrlUtils.addQueryParameter(url, "view", view); url = UrlUtils.addQueryParameter(url, "view", activeView());
url = UrlUtils.removeQueryParameter(url, "tab");
UrlUtils.setUrl(url); UrlUtils.setUrl(url);
} }
@ -114,7 +114,7 @@ const MainView = function (props) {
<div class="d-flex flex-column" style="height:100vh"> <div class="d-flex flex-column" style="height:100vh">
<Modals></Modals> <Modals></Modals>
<HeaderBar></HeaderBar> <HeaderBar></HeaderBar>
<ActiveView class={"bg-body-tertiary"}></ActiveView> <ActiveView class={"bg-body-tertiary flex-fill"}></ActiveView>
</div> </div>
); );
} }
@ -211,7 +211,10 @@ const MainView = function (props) {
<SettingsView {...props} /> <SettingsView {...props} />
</Match> </Match>
<Match when={activeView() === DEVICES_VIEW}> <Match when={activeView() === DEVICES_VIEW}>
<DevicesView {...props} onIntegrationClicked={handleIntegrationClicked} /> <DevicesView
{...props}
onIntegrationClicked={handleIntegrationClicked}
/>
</Match> </Match>
<Match when={activeView() === INTEGRATION_VIEW}> <Match when={activeView() === INTEGRATION_VIEW}>
<IntegrationView {...props} /> <IntegrationView {...props} />
@ -225,7 +228,7 @@ const MainView = function (props) {
} }
return render(); return render();
}; }
MainView.setActiveView = setActiveView; MainView.setActiveView = setActiveView;

View File

@ -3,6 +3,8 @@ import List from "../../components/list";
import RemotesService from "../../services/remotes-service"; import RemotesService from "../../services/remotes-service";
import CreateCommandModal from "../../modals/create-command-modal"; import CreateCommandModal from "../../modals/create-command-modal";
import DeleteCommandModal from "../../modals/delete-command-modal"; import DeleteCommandModal from "../../modals/delete-command-modal";
import ImportCommandsModal from "../../modals/import-commands-modal";
import EditCommandModal from "../../modals/edit-command-modal";
function CommandsList(props) { function CommandsList(props) {
const [commands, { refetch: refetchCommands }] = createResource( const [commands, { refetch: refetchCommands }] = createResource(
@ -16,21 +18,38 @@ function CommandsList(props) {
CreateCommandModal.onCommandCreated(() => { CreateCommandModal.onCommandCreated(() => {
refetchCommands(); refetchCommands();
}); });
ImportCommandsModal.onCommandsImported(() => {
refetchCommands();
});
DeleteCommandModal.onCommandDeleted(() => { DeleteCommandModal.onCommandDeleted(() => {
refetchCommands(); refetchCommands();
}); });
EditCommandModal.onCommandEdited(() => {
refetchCommands();
});
function handleNewCommand() { function handleNewCommand() {
refetchCommands(); refetchCommands();
CreateCommandModal.Handler.show(); CreateCommandModal.Handler.show();
} }
function handleDeleteCommand(command) { function handleDeleteCommand(command) {
DeleteCommandModal.setCommand(command); DeleteCommandModal.setCommand(command);
DeleteCommandModal.Handler.show(); DeleteCommandModal.Handler.show();
} }
function handleImportCommands() {
ImportCommandsModal.Handler.show();
}
function handleEditCommand(command) {
EditCommandModal.setCommand(command);
EditCommandModal.Handler.show();
}
return ( return (
<> <>
<div class="d-flex flex-row"> <div class="d-flex flex-row">
@ -38,9 +57,13 @@ function CommandsList(props) {
{props.navigation ? props.navigation : null} {props.navigation ? props.navigation : null}
</div> </div>
<div class="d-flex flex-row justify-content-end flex-fill"> <div class="d-flex flex-row justify-content-end flex-fill">
<button class="btn btn-dark me-2 mb-3" onClick={handleImportCommands}>
<i class="bi bi-box-arrow-in-down me-2"></i>
Import
</button>
<button class="btn btn-dark me-2 mb-3" onClick={handleNewCommand}> <button class="btn btn-dark me-2 mb-3" onClick={handleNewCommand}>
<i class="bi bi-plus-square me-2"></i> <i class="bi bi-plus-square me-2"></i>
New Command New
</button> </button>
</div> </div>
</div> </div>
@ -59,7 +82,7 @@ function CommandsList(props) {
{ {
id: "protocol", id: "protocol",
name: "Protocol", name: "Protocol",
width: 10, width: 8,
}, },
{ {
id: "type", id: "type",
@ -69,7 +92,7 @@ function CommandsList(props) {
{ {
id: "options", id: "options",
name: "", name: "",
width: 4, width: 6,
}, },
]} ]}
items={(commands() || []).map((command) => ({ items={(commands() || []).map((command) => ({
@ -88,6 +111,15 @@ function CommandsList(props) {
options: { options: {
html: ( html: (
<> <>
<button
class="btn btn-sm btn-outline-secondary me-2"
onClick={(event) => {
event.stopPropagation();
handleEditCommand(command);
}}
>
<i class="bi bi-pencil-fill"></i>
</button>
<button <button
class="btn btn-sm btn-outline-secondary me-2" class="btn btn-sm btn-outline-secondary me-2"
onClick={(event) => { onClick={(event) => {

View File

@ -1,12 +1,19 @@
import { createResource, onMount } from "solid-js"; import { createMemo, createResource, createSignal, onMount } from "solid-js";
import List from "../../components/list"; import List from "../../components/list";
import RemotesService from "../../services/remotes-service"; import RemotesService from "../../services/remotes-service";
import CreateRemoteModal from "../../modals/create-remote-modal"; import CreateRemoteModal from "../../modals/create-remote-modal";
import DeleteRemoteModal from "../../modals/delete-remote-modal";
import FileUtils from "../../tools/file-utils";
import Serializer from "../../data/serializer";
import RemoteService from "../../services/remotes-service";
import EditRemoteModal from "../../modals/edit-remote-modal";
function RemotesList(props) { function RemotesList(props) {
const [remotes, { refetch: refetchRemotes }] = createResource( const [remotes, { refetch: refetchRemotes }] = createResource(
RemotesService.getRemotes RemotesService.getRemotes
); );
const [selectedRemotes, setSelectedRemotes] = createSignal([]);
const canExport = createMemo(() => selectedRemotes().length > 0);
onMount(() => { onMount(() => {
refetchRemotes(); refetchRemotes();
@ -16,11 +23,46 @@ function RemotesList(props) {
refetchRemotes(); refetchRemotes();
}); });
DeleteRemoteModal.onRemoteDeleted(() => {
refetchRemotes();
});
EditRemoteModal.onRemoteEdited(() => {
refetchRemotes();
});
function handleNewRemote() { function handleNewRemote() {
CreateRemoteModal.Handler.show(); CreateRemoteModal.Handler.show();
} }
function handleDeleteRemote(remote) {} function handleEditRemote(remote) {
EditRemoteModal.setRemoteId(remote.getId());
EditRemoteModal.Handler.show();
}
function handleDeleteRemote(remote) {
DeleteRemoteModal.setRemote(remote);
DeleteRemoteModal.Handler.show();
}
function handleListItemsSelect(items) {
setSelectedRemotes(items.map((item) => item.remote));
}
async function handleExport() {
let remotes = await Promise.all(
selectedRemotes().map((remote) => RemoteService.getRemote(remote.getId()))
);
let files = remotes.map((remote) =>
FileUtils.createJsonFile(
Serializer.serializeRemote(remote),
`${remote.getTitle()}.remote.json`
)
);
if (files.length >= 1) {
FileUtils.downloadFile(files[0]);
}
}
return ( return (
<> <>
@ -29,9 +71,17 @@ function RemotesList(props) {
{props.navigation ? props.navigation : null} {props.navigation ? props.navigation : null}
</div> </div>
<div class="d-flex flex-row justify-content-end flex-fill"> <div class="d-flex flex-row justify-content-end flex-fill">
<button
class="btn btn-dark me-2 mb-3"
disabled={!canExport()}
onClick={handleExport}
>
<i class="bi bi-box-arrow-up me-2"></i>
Export
</button>
<button class="btn btn-dark me-2 mb-3" onClick={handleNewRemote}> <button class="btn btn-dark me-2 mb-3" onClick={handleNewRemote}>
<i class="bi bi-plus-square me-2"></i> <i class="bi bi-plus-square me-2"></i>
New Remote New
</button> </button>
</div> </div>
</div> </div>
@ -50,10 +100,11 @@ function RemotesList(props) {
{ {
id: "options", id: "options",
name: "", name: "",
width: 4, width: 6,
}, },
]} ]}
onListItemClick={() => {}} onListItemClick={() => {}}
onListItemsSelect={handleListItemsSelect}
items={(remotes() || []).map((remote) => ({ items={(remotes() || []).map((remote) => ({
id: { id: {
html: <span class="font-monospace">{remote.getId()}</span>, html: <span class="font-monospace">{remote.getId()}</span>,
@ -64,6 +115,15 @@ function RemotesList(props) {
options: { options: {
html: ( html: (
<> <>
<button
class="btn btn-sm btn-outline-secondary me-2"
onClick={(event) => {
event.stopPropagation();
handleEditRemote(remote);
}}
>
<i class="bi bi-pencil-fill"></i>
</button>
<button <button
class="btn btn-sm btn-outline-secondary me-2" class="btn btn-sm btn-outline-secondary me-2"
onClick={(event) => { onClick={(event) => {

View File

@ -1,10 +1,13 @@
import { import {
createEffect,
createSignal, createSignal,
Match, Match,
mergeProps, mergeProps,
Switch onCleanup,
Switch,
} from "solid-js"; } from "solid-js";
import UrlUtils from "../../tools/url-utils";
import CommandsList from "./commands-list-view"; import CommandsList from "./commands-list-view";
import RemotesList from "./remotes-list-view"; import RemotesList from "./remotes-list-view";
@ -12,16 +15,39 @@ function RemotesView(props) {
props = mergeProps({ onIntegrationClicked: () => {} }, props); props = mergeProps({ onIntegrationClicked: () => {} }, props);
const REMOTES_LIST_VIEW = "remotes_list"; const REMOTES_LIST_VIEW = "remotes_list";
const COMMANDS_LIST_VIEW = "commands_list"; const COMMANDS_LIST_VIEW = "commands_list";
const VIEWS = [REMOTES_LIST_VIEW, COMMANDS_LIST_VIEW];
const [currentView, setCurrentView] = createSignal(REMOTES_LIST_VIEW); const [currentView, setCurrentView] = createSignal(REMOTES_LIST_VIEW);
createEffect(() => {
let url = UrlUtils.getUrl();
url = UrlUtils.addQueryParameter(url, "tab", currentView());
UrlUtils.setUrl(url);
});
onCleanup(() => {
window.removeEventListener("popstate", setViewFromUrl);
});
setViewFromUrl();
window.addEventListener("popstate", setViewFromUrl);
function setViewFromUrl() {
let view = UrlUtils.getQueryParameter("tab");
if (!view) return;
if (!VIEWS.includes(view)) return;
setCurrentView(view);
}
function Navigation(props) { function Navigation(props) {
return ( return (
<div class="d-flex flex-row flex-fill"> <div class="d-flex flex-row flex-fill">
<button <button
class={ class={
"btn me-2 mb-3" + "btn me-2 mb-3" +
(currentView() === REMOTES_LIST_VIEW ? " btn-secondary" : " btn-dark") (currentView() === REMOTES_LIST_VIEW
? " btn-secondary"
: " btn-dark")
} }
onClick={() => setCurrentView(REMOTES_LIST_VIEW)} onClick={() => setCurrentView(REMOTES_LIST_VIEW)}
> >
@ -31,7 +57,9 @@ function RemotesView(props) {
<button <button
class={ class={
"btn me-2 mb-3" + "btn me-2 mb-3" +
(currentView() === COMMANDS_LIST_VIEW ? " btn-secondary" : " btn-dark") (currentView() === COMMANDS_LIST_VIEW
? " btn-secondary"
: " btn-dark")
} }
onClick={() => setCurrentView(COMMANDS_LIST_VIEW)} onClick={() => setCurrentView(COMMANDS_LIST_VIEW)}
> >