hermes-web/components/elements/redeemable-action.tsx

647 lines
28 KiB
TypeScript

import axios from "axios";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Label } from "@/components/ui/label";
import { Maximize2, Minimize2, Trash2Icon } from "lucide-react";
import { ActionType } from "@prisma/client";
import { boolean, z } from "zod";
const actionTypes = [
{
"name": "Overwrite local file content",
"value": ActionType.WRITE_TO_FILE,
"inputs": [
{
"type": "text",
"label": "File path",
"key": "file_path",
"placeholder": "Enter local file path, relative or full.",
"required": true
},
{
"type": "text",
"label": "File content",
"key": "file_content",
"placeholder": "Enter the text to write to the file.",
"required": true
}
]
},
{
"name": "Append to local file",
"value": ActionType.APPEND_TO_FILE,
"inputs": [
{
"type": "text",
"label": "File path",
"key": "file_path",
"placeholder": "Enter local file path, relative or full.",
"required": true
},
{
"type": "text",
"label": "File content",
"key": "file_content",
"placeholder": "Enter the text to append to the file.",
"required": true
}
]
},
{
"name": "Cause a transformation on OBS scene item",
"value": ActionType.OBS_TRANSFORM,
"inputs": []
},
{
"name": "Play an audio file locally",
"value": ActionType.AUDIO_FILE,
"inputs": [
{
"type": "text",
"label": "File path",
"key": "file_path",
"placeholder": "Enter local file path, relative or full.",
"required": true
}
]
},
{
"name": "User gets a random TTS voice that is enabled",
"value": ActionType.RANDOM_TTS_VOICE,
"inputs": []
},
{
"name": "User gets a specific TTS voice",
"value": ActionType.SPECIFIC_TTS_VOICE,
"inputs": [
{
"type": "text",
"label": "TTS Voice",
"key": "tts_voice",
"placeholder": "Name of an enabled TTS voice",
"required": true
}
]
},
{
"name": "Toggle OBS scene item visibility",
"value": ActionType.TOGGLE_OBS_VISIBILITY,
"inputs": [
{
"type": "text",
"label": "Scene Name",
"key": "scene_name",
"placeholder": "Name of the OBS scene",
"required": true
},
{
"type": "text",
"label": "Scene Item Name",
"key": "scene_item_name",
"placeholder": "Name of the OBS scene item / source",
"required": true
}
]
},
{
"name": "Set OBS scene item visibility",
"value": ActionType.SPECIFIC_OBS_VISIBILITY,
"inputs": [
{
"type": "text",
"label": "Scene Name",
"key": "scene_name",
"placeholder": "Name of the OBS scene",
"required": true
},
{
"type": "text",
"label": "Scene Item Name",
"key": "scene_item_name",
"placeholder": "Name of the OBS scene item / source",
"required": true
},
{
"type": "text-values",
"label": "Visible",
"key": "obs_visible",
"placeholder": "true for visible; false otherwise",
"values": ["true", "false"],
"required": true
}
]
},
{
"name": "Set OBS scene item's index",
"value": ActionType.SPECIFIC_OBS_INDEX,
"inputs": [
{
"type": "text",
"label": "Scene Name",
"key": "scene_name",
"placeholder": "Name of the OBS scene",
"required": true
},
{
"type": "text",
"label": "Scene Item Name",
"key": "scene_item_name",
"placeholder": "Name of the OBS scene item / source",
"required": true
},
{
"type": "number",
"label": "Index",
"key": "obs_index",
"placeholder": "index, starting from 0.",
"required": true
}
]
},
{
"name": "Sleep - do nothing",
"value": ActionType.SLEEP,
"inputs": [
{
"type": "number",
"label": "Sleep",
"key": "sleep",
"placeholder": "Time in milliseconds to do nothing",
"required": true
}
]
},
{
"name": "Nightbot - Play",
"value": ActionType.NIGHTBOT_PLAY,
"inputs": [
{
"type": "oauth.nightbot.play",
"label": "nightbot.play",
"key": "nightbot_play",
"placeholder": "",
"required": true
}
]
},
{
"name": "Nightbot - Pause",
"value": ActionType.NIGHTBOT_PAUSE,
"inputs": [
{
"type": "oauth.nightbot.pause",
"label": "nightbot.pause",
"key": "nightbot_pause",
"placeholder": "",
"required": true
}
]
},
{
"name": "Nightbot - Skip",
"value": ActionType.NIGHTBOT_SKIP,
"inputs": [
{
"type": "oauth.nightbot.skip",
"label": "nightbot.skip",
"key": "nightbot_skip",
"placeholder": "",
"required": true
}
]
},
{
"name": "Nightbot - Clear Playlist",
"value": ActionType.NIGHTBOT_CLEAR_PLAYLIST,
"inputs": [
{
"type": "oauth.nightbot.clear_playlist",
"label": "nightbot.clear_playlist",
"key": "nightbot_clear_playlist",
"placeholder": "",
"required": true
}
]
},
{
"name": "Nightbot - Clear Queue",
"value": ActionType.NIGHTBOT_CLEAR_QUEUE,
"inputs": [
{
"type": "oauth.nightbot.clear_queue",
"label": "nightbot.clear_queue",
"key": "nightbot_clear_queue",
"placeholder": "",
"required": true
}
]
},
]
const nameSchema = z.string({
required_error: "Name is required.",
invalid_type_error: "Name must be a string"
}).regex(/^[\w\-\s]{1,32}$/, "Name must contain only letters, numbers, spaces, dashes, and underscores.")
interface RedeemableAction {
name: string
type: string | undefined
data: { [key: string]: string }
edit?: boolean
showEdit?: boolean
isNew: boolean
obsTransformations: { label: string, placeholder: string, description: string }[]
connections: { name: string, type: string }[]
adder: (name: string, type: ActionType, data: { [key: string]: string }) => void
remover: (action: { name: string, type: string, data: any }) => void
}
const RedemptionAction = ({
name,
type,
data,
edit,
showEdit = true,
isNew = false,
obsTransformations = [],
connections = [],
adder,
remover
}: RedeemableAction) => {
const [open, setOpen] = useState<{ [key: string]: boolean }>({ 'actions': false, 'oauth': false, 'oauth.nightbot': false, 'oauth.twitch': false })
const [actionName, setActionName] = useState(name)
const [actionType, setActionType] = useState<{ name: string, value: ActionType, inputs: any[] } | undefined>(actionTypes.find(a => a.value == type?.toUpperCase()))
const [actionData, setActionData] = useState<{ [key: string]: string }>(data)
const [isEditable, setIsEditable] = useState(edit)
const [isMinimized, setIsMinimized] = useState(!isNew)
const [oldData, setOldData] = useState<{ n: string, t: ActionType | undefined, d: { [k: string]: string } } | undefined>(undefined)
const [error, setError] = useState<string | undefined>(undefined)
function Save(isNew: boolean) {
setError(undefined)
if (!actionName) {
setError("Name is required.")
return
}
const nameValidation = nameSchema.safeParse(actionName)
if (!nameValidation.success) {
setError(JSON.parse(nameValidation.error['message'])[0].message)
return
}
if (!actionType) {
setError("Action type is required.")
return
}
if (!actionTypes.some(t => t.value == actionType.value)) {
setError("Invalid action type given.")
return
}
if (!actionData) {
setError("Something went wrong with the data.")
return
}
const inputs = actionTypes.find(a => a.value == actionType.value && a.name == actionType.name)!.inputs
const required = inputs.filter(i => i.required)
for (const input of required) {
if (!(input.key in actionData)) {
setError("The field '" + input.label + "' is required.")
return
}
}
let info: any = {
name: actionName,
type: actionType.value,
}
info.data = actionData
if (isNew) {
axios.post("/api/settings/redemptions/actions", info)
.then(d => {
adder(actionName, actionType.value, actionData)
setActionName("")
setActionType(undefined)
setActionData({})
})
.catch(error => setError(error.response.data.message))
} else {
axios.put("/api/settings/redemptions/actions", info)
.then(d => {
setIsEditable(false)
})
.catch(error => setError(error.response.data.message))
}
}
function Cancel(data: { n: string, t: ActionType | undefined, d: { [k: string]: any } } | undefined) {
setError(undefined)
if (!data)
return
setActionName(data.n)
setActionType(actionTypes.find(a => a.value == data.t))
setActionData(data.d)
setIsEditable(false)
setOldData(undefined)
}
function Delete() {
axios.delete("/api/settings/redemptions/actions?action_name=" + actionName)
.then(d => {
remover(d.data)
})
}
return (
<div
className="bg-orange-300 p-3 border-2 border-orange-400 rounded-lg w-[830px]">
{isMinimized &&
<div
className="flex">
<Label
className="mr-2 grow text-lg align-middle"
htmlFor="name">
{actionName}
</Label>
<Button
className="flex self-end"
onClick={e => setIsMinimized(!isMinimized)}>
{isMinimized ? <Maximize2 /> : <Minimize2 />}
</Button>
</div>
|| !isMinimized &&
<div>
<div
className="pb-3">
<Label
className="mr-2"
htmlFor="name">
Action name
</Label>
<Input
className="inline-block w-[300px]"
id="name"
placeholder="Enter a name for this action"
onChange={e => setActionName(e.target.value)}
value={actionName}
readOnly={!isNew} />
<Label
className="ml-10 mr-2"
htmlFor="type">
Action type
</Label>
{!isEditable &&
<Input
className="inline-block w-[300px] justify-between"
name="type"
value={actionType?.name}
readOnly />
|| isEditable &&
<Popover
open={open['actions']}
onOpenChange={() => setOpen({ ...open, 'actions': !open['actions'] })}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open['actions']}
className="w-[300px] justify-between"
>{!actionType ? "Select one..." : actionType.name}</Button>
</PopoverTrigger>
<PopoverContent>
<Command>
<CommandInput
placeholder="Filter actions..."
autoFocus={true} />
<CommandList>
<CommandEmpty>No action found.</CommandEmpty>
<CommandGroup>
{actionTypes.map((action) => (
<CommandItem
value={action.name}
key={action.value}
onSelect={(value) => {
setActionType(actionTypes.find(v => v.name.toLowerCase() == value.toLowerCase()))
setOpen({ ...open, 'actions': false })
}}>
{action.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
}
</div>
<div>
{actionType &&
<div>
{actionType.inputs.map(i => {
if (i.type == "text") {
return <div key={i.key} className="mt-3">
<Label
className="mr-2"
htmlFor={i.key}>
{i.label}
</Label>
<Input
className="w-[300px] justify-between inline-block"
name={i.key}
placeholder={i.placeholder}
value={actionData[i.key]}
onChange={e => setActionData(d => {
let abc = { ...actionData }
abc[i.key] = e.target.value;
return abc
})}
readOnly={!isEditable} />
</div>
} else if (i.type == "number") {
return <div key={i.key} className="mt-3">
<Label
className="mr-2"
htmlFor={i.key}>
{i.label}
</Label>
<Input
className="w-[300px] justify-between inline-block"
name={i.key}
placeholder={i.placeholder}
value={actionData[i.key]}
onChange={e => setActionData(d => {
let abc = { ...actionData }
const v = parseInt(e.target.value)
if (e.target.value.length == 0 || Number.isNaN(v)) {
abc[i.key] = "0"
} else if (!Number.isNaN(v) && Number.isSafeInteger(v)) {
abc[i.key] = v.toString()
}
return abc
})}
readOnly={!isEditable} />
</div>
} else if (i.type == "text-values") {
return <div key={i.key} className="mt-3">
<Label
className="mr-2"
htmlFor={i.key}>
{i.label}
</Label>
<Input
className="w-[300px] justify-between inline-block"
name={i.key}
placeholder={i.placeholder}
value={actionData[i.key]}
onChange={e => setActionData(d => {
let abc = { ...actionData }
abc[i.key] = i.values.map((v: string) => v.startsWith(e.target.value)).some((v: boolean) => v) ? e.target.value : abc[i.key]
return abc
})}
readOnly={!isEditable} />
</div>
} else {
return <div key={i.key}>
<Label
className="mr-2"
htmlFor={i.key}>
Connection
</Label>
<Popover
open={open[i.type]}
onOpenChange={() => { const temp = { ...open }; temp[i.type] = !temp[i.type]; setOpen(temp) }}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open[i.type]}
className="w-[300px] justify-between"
>{!('oauth_name' in actionData) ? "Select connection..." : actionData.oauth_name}</Button>
</PopoverTrigger>
<PopoverContent>
<Command>
<CommandInput
placeholder="Search connections..."
autoFocus={true} />
<CommandList>
<CommandEmpty>No connection found.</CommandEmpty>
<CommandGroup>
{connections.filter(c => !i.type.includes('.') || c.type == i.type.split('.')[1])
.map((connection) => (
<CommandItem
value={connection.name}
key={connection.name}
onSelect={(value) => {
const connection = connections.find(v => v.name.toLowerCase() == value.toLowerCase())
if (!!connection) {
setActionData({
'oauth_name': connection.name,
'oauth_type': connection.type
})
}
else
setActionData({})
const temp = { ...open }
temp[i.type] = false
setOpen(temp)
}}>
{connection.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
}
})}
</div>
}
{actionType && actionType.value == ActionType.OBS_TRANSFORM &&
<div>
{obsTransformations.map(t =>
<div
key={t.label.toLowerCase()}
className="mt-3">
<Label
className="mr-2"
htmlFor={t.label.toLowerCase()}>
{t.label.split("_").map(w => w.substring(0, 1).toUpperCase() + w.substring(1).toLowerCase()).join(" ")}
</Label>
<Input
className="w-[300px] justify-between inline-block"
name={t.label.toLowerCase()}
placeholder={t.placeholder}
value={actionData[t.label]}
onChange={e => {
let c = { ...actionData }
c[t.label] = e.target.value
setActionData(c)
}}
readOnly={!isEditable} />
</div>
)}
</div>
}
</div>
{error &&
<div className="text-red-600 font-bold">
{error}
</div>
}
<div>
{isEditable &&
<Button
className="m-3"
onClick={() => Save(isNew)}>
{isNew ? "Add" : "Save"}
</Button>
}
{isEditable && !isNew &&
<Button
className="m-3"
onClick={() => Cancel(oldData)}>
Cancel
</Button>
}
{showEdit && !isEditable &&
<Button
className="m-3"
onClick={() => {
setOldData({ n: actionName, t: actionType?.value, d: actionData })
setIsEditable(true)
}}>
Edit
</Button>
}
{!isEditable &&
<Button
className="m-3 bg-red-500 hover:bg-red-600 align-bottom"
onClick={() => Delete()}>
<Trash2Icon />
</Button>
}
{!isNew &&
<Button
className="m-3 align-middle"
onClick={e => setIsMinimized(!isMinimized)}>
{isMinimized ? <Maximize2 /> : <Minimize2 />}
</Button>
}
</div>
</div>
}
</div>
);
}
export default RedemptionAction;