647 lines
28 KiB
TypeScript
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; |