Updated list of commands to v4.3. Added groups & permissions. Added connections. Updated redemptions and actions to v4.3.

This commit is contained in:
Tom 2024-08-14 20:33:40 +00:00
parent 6548ce33e0
commit b92529d8c0
51 changed files with 3910 additions and 799 deletions

4
.gitignore vendored
View File

@ -37,4 +37,6 @@ next-env.d.ts
.env
package.json
package-lock.json
package-lock.json
.vscode/

View File

@ -0,0 +1,19 @@
import { headers } from 'next/headers';
import React from "react";
const SettingsLayout = async ({
children
}: {
children: React.ReactNode
}) => {
const headersList = headers();
const header_url = headersList.get('x-url') || "";
return (
<main className={"md:mt-[50px]"}>
{children}
</main>
);
}
export default SettingsLayout;

View File

@ -0,0 +1,371 @@
'use client';
import { cn } from "@/lib/utils";
interface ICommand {
name: string
description: string
syntax: string
permissions: string[]
version: string | undefined
examples: string[]
subcommands: ICommand[]
}
const COMMAND_PREFIX = '!'
const commands: ICommand[] = [
{
name: "nightbot",
description: "Interacts with Nightbot.",
syntax: "",
permissions: ["tts.commands.nightbot"],
version: "4.2",
examples: [],
subcommands: [
{
name: "play",
description: "Play the songs on the queue.",
syntax: "",
permissions: [],
version: undefined,
examples: [],
subcommands: [],
},
{
name: "pause",
description: "Pause the currently playing song.",
syntax: "",
permissions: [],
version: undefined,
examples: [],
subcommands: [],
},
{
name: "skip",
description: "Skip the currently playing song.",
syntax: "",
permissions: [],
version: undefined,
examples: [],
subcommands: [],
},
{
name: "volume",
description: "Skip the currently playing song.",
syntax: "<volume 0-100>",
permissions: [],
version: undefined,
examples: [],
subcommands: [],
},
{
name: "clear_queue",
description: "Clears the queue.",
syntax: "",
permissions: [],
version: undefined,
examples: [],
subcommands: [],
},
{
name: "clear_playlist",
description: "Clears the playlist.",
syntax: "",
permissions: [],
version: undefined,
examples: [],
subcommands: [],
},
]
},
{
name: "obs",
description: "Interacts with OBS.",
syntax: "<scene name> <source name>",
permissions: [],
version: "3.6",
examples: [],
subcommands: [
{
name: "rotate",
description: "Apply a rotational transformation",
syntax: "<rotation in degree>",
permissions: [],
version: undefined,
examples: [],
subcommands: [],
},
{
name: "x",
description: "Move element to a new X position",
syntax: "<x position in pixels>",
permissions: [],
version: undefined,
examples: [],
subcommands: [],
},
{
name: "y",
description: "Move element to a new Y position",
syntax: "<y position in pixels>",
permissions: [],
version: undefined,
examples: [],
subcommands: [],
},
]
},
{
name: "refresh",
description: "Refreshes certain data being stored on the client.",
syntax: "",
permissions: [],
version: "3.2",
examples: [],
subcommands: [
{
name: "tts_voice_enabled",
description: "Refreshes the list of enabled TTS voices used by chat",
syntax: "",
permissions: [],
version: undefined,
examples: [],
subcommands: [],
},
{
name: "word_filters",
description: "Refreshes the list of words filters",
syntax: "",
permissions: [],
version: undefined,
examples: [],
subcommands: [],
},
{
name: "default_voice",
description: "Refreshes the default voice",
syntax: "",
permissions: [],
version: undefined,
examples: [],
subcommands: [],
},
{
name: "redemptions",
description: "Refreshes the redemmptions",
syntax: "",
permissions: [],
version: "3.4",
examples: [],
subcommands: [],
},
{
name: "obs_cache",
description: "Refreshes the cache for OBS",
syntax: "",
permissions: [],
version: "3.7",
examples: [],
subcommands: [],
},
{
name: "permissions",
description: "Refreshes the group permissions",
syntax: "",
permissions: [],
version: "3.7",
examples: [],
subcommands: [],
}
]
},
{
name: "skip",
description: "Skips the currently playing message.",
syntax: "",
permissions: [],
version: undefined,
examples: [],
subcommands: [
{
name: "all",
description: "Clears everything in queue and skips the currently playing message. This effectively runs !skipall command.",
syntax: "",
permissions: ["tts.commands.skipall"],
version: "3.9",
examples: [],
subcommands: []
},
]
},
{
name: "skipall",
description: "Clears everything in queue and skips the currently playing message.",
syntax: "",
permissions: [],
version: undefined,
examples: [],
subcommands: []
},
{
name: "tts",
description: "Clears everything in queue and skips the currently playing message.",
syntax: "",
permissions: [],
version: "3.2",
examples: [],
subcommands: [
{
name: "enable",
description: "Enables a TTS voice.",
syntax: "<tts voice name>",
permissions: [],
version: undefined,
examples: [],
subcommands: [],
},
{
name: "disable",
description: "Disables a TTS voice",
syntax: "<tts voice name>",
permissions: [],
version: undefined,
examples: [],
subcommands: [],
},
{
name: "add",
description: "Adds a TTS voice to the list of available voices, case sensitive.",
syntax: "<name of tts voice>",
permissions: ["tom"],
version: "3.9",
examples: ["Brian"],
subcommands: []
},
{
name: "remove",
description: "Removes a TTS voice from the list of available voices.",
syntax: "<name of tts voice>",
permissions: ["tom"],
version: "3.9",
examples: [],
subcommands: []
},
{
name: "join",
description: "Voices the messages of another channel",
syntax: "<mention of other broadcaster>",
permissions: ["tts.commands.tts.join"],
version: "4.0",
examples: [],
subcommands: [],
},
{
name: "leave",
description: "Stop reading the messages of another channel",
syntax: "<mention of other broadcaster>",
permissions: ["tts.commands.tts.leave"],
version: "4.0",
examples: [],
subcommands: [],
}
]
},
{
name: "version",
description: "Sends a message to the console with version info.",
syntax: "",
permissions: [],
version: undefined,
examples: [],
subcommands: []
},
{
name: "voice",
description: "Change voice when reading messages for yourself.",
syntax: "<voice name>",
permissions: [],
version: undefined,
examples: ["brian"],
subcommands: [
{
name: "<mention of affected chatter>",
description: "Change chatter's voice when reading messages.",
syntax: "",
permissions: ["tts.commands.voice.admin"],
version: "4.0",
examples: ["brian @Nightbot"],
subcommands: []
}
]
}
]
const CommandsPage = () => {
return (
<div className="m-10">
<div className="text-center text-2xl">Commands</div>
<ul className="grid gap-3 p-6 w-full lg:grid-cols-[1fr_1fr]">
{commands.map((command) =>
<li key={command.name}
className="row-span-2">
<section
className="bg-background mt-5 p-2 rounded-lg align-top text-start flex h-full w-full select-none flex-col justify-start bg-gradient-to-b from-blue-400 to-blue-500 no-underline outline-none focus:shadow-md">
<div className="align-top items-center">
<p className="inline-block text-lg">{COMMAND_PREFIX}{command.name}</p>
{command.permissions.map(p =>
<div key={p}
className={"inline-block rounded-lg text-sm ml-0.5 mr-0.5 p-0.5 " + cn(
p == "tom" && "bg-white text-black",
p != "tom" && "bg-gray-400"
)}>
{p}
</div>
)}
{!!command.version &&
<div
className="inline-block rounded-lg text-sm ml-0.5 mr-0.5 p-0.5 bg-red-600">
version required: {command.version}
</div>
}
<div className="inline-block text-sm ml-0 md:ml-1 text-wrap">{command.description}</div>
</div>
{command.subcommands.length == 0 &&
<div>Syntax: {COMMAND_PREFIX}{command.name} {command.syntax}</div>
}
{command.examples.map(ex =>
<div key={command.name + " " + ex} className="bg-blend-darken">Example: {COMMAND_PREFIX}{command.name} {ex}</div>
)}
{command.subcommands.map(c =>
<div key={c.name} className="m-1 p-2 rounded-md bg-gradient-to-b from-blue-400 to-blue-500">
<div className="inline">
{COMMAND_PREFIX}{command.name} {command.syntax.length == 0 ? "" : command.syntax + " "}{c.name} {c.syntax}
</div>
{c.permissions.map(p =>
<div key={p}
className={"inline-block rounded-lg text-sm ml-0.5 mr-0.5 p-0.5 " + cn(
p == "tom" && "bg-white text-black",
p != "tom" && "bg-gray-400"
)}>
{p}
</div>
)}
{!!c.version &&
<div
className="inline rounded-lg text-sm ml-0.5 mr-0.5 p-0.5 bg-red-600">
version required: {c.version}
</div>
}
<div className="text-sm">{c.description}</div>
</div>
)}
</section>
</li>
)}
</ul>
</div>
);
}
export default CommandsPage;

View File

@ -0,0 +1,63 @@
"use client";
import { useRouter } from 'next/navigation'
import { useSession } from "next-auth/react";
import { useEffect, useState } from "react";
import axios from "axios";
export default function Home() {
const { data: session, status } = useSession();
const [loaded, setLoaded] = useState(false)
const router = useRouter()
useEffect(() => {
if (status == 'loading')
return
if (status != 'authenticated') {
router.push('/settings/connections')
return
}
if (loaded)
return;
const urlHash = window.location.hash
if (!urlHash || !urlHash.startsWith('#')) {
router.push('/settings/connections')
return
}
const parts = urlHash.substring(1).split('&')
const headers: { [key: string]: string } = {}
parts.map(p => p.split('='))
.forEach(p => headers[p[0]] = p[1])
axios.post('/api/connection/authorize', {
access_token: headers['access_token'],
token_type: headers['token_type'],
expires_in: headers['expires_in'],
scope: headers['scope'],
state: headers['state']
})
.then((d) => {
router.push('/settings/connections')
})
.catch((d) => {
if (d.response.data.message == 'Connection already saved.')
router.push('/settings/connections')
else
setLoaded(true)
})
}, [session])
return (
<main>
<div className="header">
{loaded &&
<div className='text-center align-middle h-full'>
Something went wrong while saving the connection.
</div>
}
</div>
</main>
);
}

View File

@ -0,0 +1,41 @@
import '@/app/globals.css'
import type { Metadata } from 'next'
import { Open_Sans } from 'next/font/google'
import AuthProvider from '@/app/context/auth-provider'
import { ThemeProvider } from '@/components/providers/theme-provider'
import { cn } from '@/lib/utils'
import MenuNavigation from '@/components/navigation/menu'
const font = Open_Sans({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Tom-to-Speech',
description: '',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<AuthProvider>
<html lang="en">
<body className={cn(
font.className,
"light:bg-white dark:bg-black bg-transparent"
)}>
<ThemeProvider
attribute="class"
defaultTheme='dark'
enableSystem={false}
storageKey='global-web-theme'>
<MenuNavigation />
{children}
</ThemeProvider>
</body>
</html>
</AuthProvider>
)
}

View File

@ -31,7 +31,7 @@ export default function Home() {
}
saveAccount().catch(console.error)
}, [session])
}, [])
return (
<main

View File

@ -0,0 +1,45 @@
"use client";
import axios from "axios";
import * as React from 'react';
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
const RedemptionsPage = () => {
const { data: session, status } = useSession();
const [previousUsername, setPreviousUsername] = useState<string | null>()
useEffect(() => {
if (status !== "authenticated" || previousUsername == session.user?.name) {
return
}
setPreviousUsername(session.user?.name)
}, [session])
return (
<div>
<div className="text-2xl text-center pt-[50px]">Admin Controls</div>
<div
className="flex">
<div
className="grow inline-block">
<p>test2</p>
</div>
<div
className="inline-block w-[300px]">
<p>lalalalalalalala</p>
</div>
</div>
</div>
);
}
export default RedemptionsPage;
/*
<RoleGate roles={["ADMIN"]}>
<AdminProfile />
</RoleGate>
*/

View File

@ -0,0 +1,78 @@
"use client";
import axios from "axios";
import * as React from 'react';
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { ConnectionElement, ConnectionAdderElement } from "@/components/elements/connection";
import { ConnectionDefaultElement } from "@/components/elements/connection-default";
const ConnectionsPage = () => {
const { data: session, status } = useSession();
const [loading, setLoading] = useState<boolean>(true)
const [connections, setConnections] = useState<{ name: string, clientId: string, token: string, type: string, scope: string, expiresAt: Date }[]>([])
useEffect(() => {
if (status != "authenticated")
return
const fetchData = async () => {
setLoading(true)
const response = await axios.get("/api/connection")
const data = response.data
setConnections(data.data)
}
fetchData().catch(console.error).finally(() => setLoading(false))
}, [session])
const OnConnectionDelete = async (name: string) => {
setConnections(connections.filter(c => c.name != name))
}
const OnDefaultConnectionUpdate = async (name: string) => {
if (!connections.some(c => c.name == name))
return
axios.put('/api/connection/default', { name: name })
.then(d => {
setConnections([...connections])
})
}
return (
<div>
<div className="text-2xl text-center pt-[50px]">Connections</div>
<div className="grid grid-cols-[1fr] xl:grid-cols-[1fr_1fr]">
{connections.map((connection) =>
<ConnectionElement
key={connection.name}
name={connection.name}
type={connection.type}
clientId={connection.clientId}
expiresAt={connection.expiresAt}
scope={connection.scope}
remover={OnConnectionDelete}
/>
)}
{!loading &&
<ConnectionAdderElement />
}
</div>
{connections.length > 0 &&
<div>
<p className="text-2xl text-center pt-[50px]">Default Connections</p>
<ConnectionDefaultElement
type={"nightbot"}
connections={connections} />
<ConnectionDefaultElement
type={"twitch"}
connections={connections} />
</div>
}
</div>
);
}
export default ConnectionsPage;

View File

@ -0,0 +1,28 @@
"use client";
import axios from "axios";
import * as React from 'react';
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
const RedemptionsPage = () => {
const { data: session, status } = useSession();
const [previousUsername, setPreviousUsername] = useState<string | null>()
useEffect(() => {
if (status !== "authenticated" || previousUsername == session.user?.name) {
return
}
setPreviousUsername(session.user?.name)
axios.get("/api/settings/redemptions/actions")
}, [session])
return (
<div>
</div>
);
}
export default RedemptionsPage;

View File

@ -0,0 +1,115 @@
"use client";
import axios from "axios";
import * as React from 'react';
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import GroupElement from "@/components/elements/group";
import RoleGate from "@/components/auth/role-gate";
const permissionPaths = [
{ path: "tts", description: "Anything to do with TTS" },
{ path: "tts.chat", description: "Anything to do with chat" },
{ path: "tts.chat.bits.read", description: "To read chat messages with bits via TTS" },
{ path: "tts.chat.messages.read", description: "To read chat messages via TTS" },
{ path: "tts.chat.redemptions.read", description: "To read channel point redemption messages via TTS" },
//{ path: "tts.chat.subscriptions.read", description: "To read chat messages from subscriptions via TTS" },
{ path: "tts.commands", description: "To execute commands for TTS" },
{ path: "tts.commands.nightbot", description: "To use !nightbot command" },
{ path: "tts.commands.obs", description: "To use !obs command" },
{ path: "tts.commands.refresh", description: "To use !refresh command" },
{ path: "tts.commands.skip", description: "To use !skip command" },
{ path: "tts.commands.skipall", description: "To use !skipall command" },
{ path: "tts.commands.tts", description: "To use !tts command" },
{ path: "tts.commands.tts.join", description: "To use !tts join command" },
{ path: "tts.commands.tts.leave", description: "To use !tts leave command" },
{ path: "tts.commands.version", description: "To use !version command" },
{ path: "tts.commands.voice", description: "To use !voice command" },
{ path: "tts.commands.voice.admin", description: "To use !voice command on others" },
].sort((a, b) => a.path.localeCompare(b.path))
const GroupPermissionPage = () => {
const { data: session, status } = useSession();
const [previousUsername, setPreviousUsername] = useState<string | null>()
const [groups, setGroups] = useState<{ id: string, name: string, priority: number }[]>([])
const [permissions, setPermissions] = useState<{ id: string, path: string, allow: boolean | null, groupId: string }[]>([])
const specialGroups = ["everyone", "subscribers", "vip", "moderators", "broadcaster"]
function addGroup(id: string, name: string, priority: number) {
setGroups([...groups, { id, name, priority }])
}
function removeGroup(group: { id: string, name: string, priority: number }) {
setGroups(groups.filter(g => g.id != group.id))
}
useEffect(() => {
if (status !== "authenticated" || previousUsername == session.user?.name)
return
setPreviousUsername(session.user?.name)
// TODO: fetch groups & permissions
axios.get('/api/settings/groups')
.then(d => {
for (let groupName of specialGroups)
if (!d.data.some((g: { id: string, name: string, priority: number }) => g.name == groupName))
d.data.push({ id: "$" + groupName, name: groupName, priority: 0 });
axios.get('/api/settings/groups/permissions')
.then(d2 => {
setPermissions(d2.data)
setGroups(d.data)
})
})
// TODO: filter permissions by group?
}, [session])
return (
<div>
<div className="text-2xl text-center pt-[50px]">Groups & Permissions</div>
{/* <InfoNotice
message="Redemption actions are activated when specific Twitch channel point redeems have been activated. Aforementioned redeem need to be linked in the redemption part, together with the action, for the action to activate."
hidden={false} /> */}
<div className="grid sm:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-3">
{groups.map(group =>
<div
className="col-span-1"
key={group.id}>
<GroupElement
id={group.id}
name={group.name}
priority={group.priority}
permissionsLoaded={permissions.filter(p => p.groupId == group.id)}
edit={group.id.startsWith('$')}
showEdit={true}
isNewGroup={group.id.startsWith('$')}
permissionPaths={permissionPaths}
specialGroups={specialGroups}
adder={addGroup}
remover={removeGroup} />
</div>
)}
<div
className="col-span-1">
<GroupElement
id={undefined}
name={""}
priority={0}
permissionsLoaded={[]}
edit={true}
showEdit={false}
isNewGroup={true}
permissionPaths={permissionPaths}
specialGroups={specialGroups}
adder={addGroup}
remover={removeGroup} />
</div>
</div>
</div>
);
}
export default GroupPermissionPage;

View File

@ -12,12 +12,12 @@ const SettingsLayout = async ({
const header_url = headersList.get('x-url') || "";
return (
<div className="h-full">
<div className={cn("hidden md:flex h-full w-[250px] z-30 flex-col fixed inset-y-0",
<div className="">
<div className={cn("hidden md:flex w-[250px] z-5 flex-col fixed inset-y-0 overflow-y-scroll",
header_url.endsWith("/settings") && "flex h-full w-full md:w-[250px] z-30 flex-col fixed inset-y-0")}>
<SettingsNavigation />
</div>
<main className={"md:pl-[250px] h-full"}>
<main className={"md:pl-[250px]"}>
{children}
</main>
</div>

View File

@ -8,22 +8,40 @@ import RedeemptionAction from "@/components/elements/redeemable-action";
import OBSRedemption from "@/components/elements/redemption";
import { ActionType } from "@prisma/client";
import InfoNotice from "@/components/elements/info-notice";
import { string } from "zod";
const obsTransformations = [
{ label: "scene_name", description: "", placeholder: "Name of the OBS scene" },
{ label: "scene_item_name", description: "", placeholder: "Name of the OBS scene item / source" },
{ label: "rotation", description: "", placeholder: "An expression using x as the previous value" },
{ label: "position_x", description: "", placeholder: "An expression using x as the previous value" },
{ label: "position_y", description: "", placeholder: "An expression using x as the previous value" }
{ label: "position_y", description: "", placeholder: "An expression using x as the previous value" },
]
const customTwitchRedemptions = [
{
id: 'adbreak',
title: 'Adbreak (TTS redemption)'
},
{
id: 'follow',
title: 'New Follower (TTS redemption)'
},
{
id: 'subscription',
title: 'Subscription (TTS redemption)'
},
{
id: 'subscription.gift',
title: 'Subscription Gifted (TTS redemption)'
},
]
const RedemptionsPage = () => {
const { data: session, status } = useSession();
const [previousUsername, setPreviousUsername] = useState<string | null>()
const [loading, setLoading] = useState<boolean>(true)
const [open, setOpen] = useState(false)
const [actions, setActions] = useState<{ name: string, type: string, data: any }[]>([])
const [twitchRedemptions, setTwitchRedemptions] = useState<{ id: string, title: string }[]>([])
const [connections, setConnections] = useState<{ name: string, clientId: string, type: string, scope: string, expiresAt: Date }[]>([])
const [redemptions, setRedemptions] = useState<{ id: string, redemptionId: string, actionName: string, order: number }[]>([])
function addAction(name: string, type: ActionType, data: { [key: string]: string }) {
@ -43,10 +61,14 @@ const RedemptionsPage = () => {
}
useEffect(() => {
if (status !== "authenticated" || previousUsername == session.user?.name) {
if (status !== "authenticated")
return
}
setPreviousUsername(session.user?.name)
axios.get('/api/connection')
.then(d => {
console.log(d.data.data)
setConnections(d.data.data)
})
axios.get("/api/settings/redemptions/actions")
.then(d => {
@ -55,8 +77,15 @@ const RedemptionsPage = () => {
axios.get("/api/account/redemptions")
.then(d => {
const rs = d.data.data?.map(r => ({ id: r.id, title: r.title })) ?? []
setTwitchRedemptions(rs)
let res : { id: string, title: string }[] = d.data?.data ?? []
res = [ ...res, ...customTwitchRedemptions ]
setTwitchRedemptions(res.sort((a, b) => {
if (a.title < b.title)
return -1
else if (a.title > b.title)
return 1
return 0
}))
axios.get("/api/settings/redemptions")
.then(d => {
@ -83,6 +112,7 @@ const RedemptionsPage = () => {
showEdit={true}
isNew={false}
obsTransformations={obsTransformations}
connections={connections}
adder={addAction}
remover={removeAction} />
</div>
@ -97,6 +127,7 @@ const RedemptionsPage = () => {
showEdit={false}
isNew={true}
obsTransformations={obsTransformations}
connections={connections}
adder={addAction}
remover={removeAction} />
</div>
@ -113,6 +144,7 @@ const RedemptionsPage = () => {
id={redemption.id}
redemptionId={redemption.redemptionId}
actionName={redemption.actionName}
numbering={redemption.order}
edit={false}
showEdit={true}
isNew={false}
@ -128,6 +160,7 @@ const RedemptionsPage = () => {
id={undefined}
redemptionId={undefined}
actionName=""
numbering={0}
edit={true}
showEdit={false}
isNew={true}

View File

@ -0,0 +1,347 @@
"use client";
import axios from "axios";
import * as React from 'react';
import { InfoIcon, MoreHorizontal, Plus, Save, Tags, Trash } from "lucide-react"
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { Button } from "@/components/ui/button"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
import { Input } from "@/components/ui/input";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
import * as z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label";
import { ToastAction } from "@/components/ui/toast"
import { useToast } from "@/components/ui/use-toast"
import InfoNotice from "@/components/elements/info-notice";
import { Toaster } from "@/components/ui/toaster";
import { stringifyError } from "next/dist/shared/lib/utils";
const TTSFiltersPage = () => {
const { data: session, status } = useSession();
const [moreOpen, setMoreOpen] = useState(0)
const [tag, setTag] = useState("blacklisted")
const [open, setOpen] = useState(false)
const [userTags, setUserTag] = useState<{ username: string, tag: string }[]>([])
const { toast } = useToast()
const [error, setError] = useState("")
const router = useRouter();
const tags = [
"blacklisted",
"priority"
]
const toasting = (title: string, error: Error) => {
toast({
title: title,
description: error.message,
variant: "error"
})
}
const success = (title: string, description: string) => {
toast({
title: title,
description: description,
variant: "success"
})
}
// Username blacklist
const usernameFilteredFormSchema = z.object({
username: z.string().trim().min(4).max(25).regex(new RegExp("[a-zA-Z0-9][a-zA-Z0-9\_]{3,24}"), "Must be a valid twitch username."),
tag: z.string().trim()
});
const usernameFilteredForm = useForm({
resolver: zodResolver(usernameFilteredFormSchema),
defaultValues: {
username: "",
tag: ""
}
});
useEffect(() => {
const fetchData = async () => {
try {
const userFiltersData = await axios.get("/api/settings/tts/filter/users")
setUserTag(userFiltersData.data ?? [])
} catch (error) {
toasting("Failed to fetch all the username filters.", error as Error)
}
try {
const replacementData = await axios.get("/api/settings/tts/filter/words")
setReplacements(replacementData.data ?? [])
} catch (error) {
toasting("Failed to fetch all the word filters.", error as Error)
}
};
fetchData().catch((error) => toasting("Failed to fetch all the username filters.", error as Error));
}, []);
const onDelete = () => {
const username = userTags[Math.log2(moreOpen)].username
axios.delete("/api/settings/tts/filter/users?username=" + username)
.then(() => {
setUserTag(userTags.filter((u) => u.username != username))
success("Username filter deleted", `"${username.toLowerCase()}" is now back to normal.`)
}).catch((error) => toasting("Failed to delete the username filter.", error as Error))
}
const isSubmitting = usernameFilteredForm.formState.isSubmitting;
const onAddExtended = (values: z.infer<typeof usernameFilteredFormSchema>, test: boolean = true) => {
const original = userTags.find(u => u.username.toLowerCase() == values.username.toLowerCase())
if (test)
values.tag = tag
axios.post("/api/settings/tts/filter/users", values)
.then((d) => {
if (original == null) {
userTags.push({ username: values.username.toLowerCase(), tag: values.tag })
} else {
original.tag = values.tag
}
setUserTag(userTags)
usernameFilteredForm.reset();
router.refresh();
if (values.tag == "blacklisted")
success("Username filter added", `"${values.username.toLowerCase()}" will be blocked.`)
else if (values.tag == "priority")
success("Username filter added", `"${values.username.toLowerCase()}" will be taking priority.`)
}).catch(error => toasting("Failed to add the username filter.", error as Error))
}
const onAdd = (values: z.infer<typeof usernameFilteredFormSchema>) => {
onAddExtended(values, true)
}
// Word replacement
const [replacements, setReplacements] = useState<{ id: string, search: string, replace: string, userId: string }[]>([])
const onReplaceAdd = async () => {
if (search.length <= 0) {
toasting("Unable to add the word filter.", new Error("Search must not be empty."))
return
}
await axios.post("/api/settings/tts/filter/words", { search, replace })
.then(d => {
replacements.push({ id: d.data.id, search: d.data.search, replace: d.data.replace, userId: d.data.userId })
setReplacements(replacements)
setSearch("")
success("Word filter added", `"${d.data.search}" will be replaced.`)
}).catch(error => toasting("Failed to add the word filter.", error as Error))
}
const onReplaceUpdate = async (data: { id: string, search: string, replace: string, userId: string }) => {
await axios.put("/api/settings/tts/filter/words", data)
.then(() => success("Word filter updated", ""))
.catch(error => toasting("Failed to update the word filter.", error as Error))
}
const onReplaceDelete = async (id: string) => {
await axios.delete("/api/settings/tts/filter/words?id=" + id)
.then(d => {
const r = replacements.filter(r => r.id != d.data.id)
setReplacements(r)
success("Word filter deleted", `No more filter for "${d.data.search}"`)
}).catch(error => toasting("Failed to delete the word filter.", error as Error))
}
let [search, setSearch] = useState("")
let [replace, setReplace] = useState("")
let [searchInfo, setSearchInfo] = useState("")
return (
// <div>
// <div className="text-2xl text-center pt-[50px]">TTS Filters</div>
// <div className="px-10 py-1 w-full h-full flex-grow inset-y-1/2">
// <InfoNotice message="You can tag certain labels to twitch users, allowing changes applied specifically to these users when using the text to speech feature." hidden={false} />
// <div>
// {userTags.map((user, index) => (
// <div key={user.username + "-tags"} className="flex w-full items-start justify-between rounded-md border px-4 py-2 mt-2">
// <p className="text-base font-medium">
// <span className="mr-2 rounded-lg bg-primary px-2 py-1 text-xs text-primary-foreground">
// {user.tag}
// </span>
// <span className="text-white">{user.username}</span>
// </p>
// <DropdownMenu open={(moreOpen & (1 << index)) > 0} onOpenChange={() => setMoreOpen(v => v ^ (1 << index))}>
// <DropdownMenuTrigger asChild>
// <Button variant="ghost" size="xs" className="bg-purple-500 hover:bg-purple-600">
// <MoreHorizontal className="h-4 w-4" />
// </Button>
// </DropdownMenuTrigger>
// <DropdownMenuContent align="end" className="w-[200px] bg-popover">
// <DropdownMenuLabel>Actions</DropdownMenuLabel>
// <DropdownMenuGroup>
// <DropdownMenuSub>
// <DropdownMenuSubTrigger>
// <Tags className="mr-2 h-4 w-4" />
// Apply label
// </DropdownMenuSubTrigger>
// <DropdownMenuSubContent className="p-0">
// <Command>
// <CommandInput
// placeholder="Filter label..."
// autoFocus={true}
// />
// <CommandList>
// <CommandEmpty>No label found.</CommandEmpty>
// <CommandGroup>
// {tags.map((tag) => (
// <CommandItem
// key={user.username + "-tag"}
// value={tag}
// onSelect={(value) => {
// onAddExtended({ username: userTags[index].username, tag: value}, false)
// setMoreOpen(0)
// }}
// >
// {tag}
// </CommandItem>
// ))}
// </CommandGroup>
// </CommandList>
// </Command>
// </DropdownMenuSubContent>
// </DropdownMenuSub>
// <DropdownMenuSeparator />
// <DropdownMenuItem key={user.username + "-delete"} onClick={onDelete} className="text-red-600">
// <Trash className="mr-2 h-4 w-4" />
// Delete
// </DropdownMenuItem>
// </DropdownMenuGroup>
// </DropdownMenuContent>
// </DropdownMenu>
// </div>
// ))}
// <Form {...usernameFilteredForm}>
// <form onSubmit={usernameFilteredForm.handleSubmit(onAdd)}>
// <div className="flex w-full items-center justify-between rounded-md border px-4 py-2 gap-3 mt-2">
// <Label className="rounded-lg bg-primary px-2 py-1 text-xs text-primary-foreground">
// {tag}
// </Label>
// <FormField
// control={usernameFilteredForm.control}
// name="username"
// render={({ field }) => (
// <FormItem key={"new-username"} className="flex-grow">
// <FormControl>
// <Input id="username" placeholder="Enter a twitch username" {...field} />
// </FormControl>
// <FormMessage />
// </FormItem>
// )}
// />
// <Button variant="ghost" size="sm" type="submit" className="bg-green-500 hover:bg-green-600 items-center align-middle" disabled={isSubmitting}>
// <Plus className="h-6 w-6" />
// </Button>
// <DropdownMenu open={open} onOpenChange={setOpen}>
// <DropdownMenuTrigger asChild>
// <Button size="sm" {...usernameFilteredForm} className="bg-purple-500 hover:bg-purple-600" disabled={isSubmitting}>
// <MoreHorizontal className="h-6 w-6" />
// </Button>
// </DropdownMenuTrigger>
// <DropdownMenuContent align="end" className="w-[200px] bg-popover">
// <DropdownMenuLabel>Actions</DropdownMenuLabel>
// <DropdownMenuGroup>
// <DropdownMenuSub>
// <DropdownMenuSubTrigger>
// <Tags className="mr-2 h-4 w-4" />
// Apply label
// </DropdownMenuSubTrigger>
// <DropdownMenuSubContent className="p-0">
// <Command>
// <CommandInput
// placeholder="Filter label..."
// autoFocus={true}
// />
// <CommandList>
// <CommandEmpty>No label found.</CommandEmpty>
// <CommandGroup>
// {tags.map((tag) => (
// <CommandItem
// value={tag}
// key={tag + "-tag"}
// onSelect={(value) => {
// setTag(value)
// setOpen(false)
// }}
// >
// {tag}
// </CommandItem>
// ))}
// </CommandGroup>
// </CommandList>
// </Command>
// </DropdownMenuSubContent>
// </DropdownMenuSub>
// </DropdownMenuGroup>
// </DropdownMenuContent>
// </DropdownMenu>
// </div>
// </form>
// </Form>
// </div>
<div>
<div>
<div>
<p className="text-center text-2xl text-white pt-[80px]">Regex Replacement</p>
<div>
{replacements.map((term: { id: string, search: string, replace: string, userId: string }) => (
<div key={term.id} className="flex flex-row w-full items-start justify-between rounded-lg border px-4 py-3 gap-3 mt-[15px]">
<Input id="search" placeholder={term.search} className="flex" onChange={e => term.search = e.target.value} defaultValue={term.search} />
<Input id="replace" placeholder={term.replace} className="flex" onChange={e => term.replace = e.target.value} defaultValue={term.replace} />
<Button className="bg-blue-500 hover:bg-blue-600 items-center align-middle" onClick={_ => onReplaceUpdate(term)}>
<Save className="h-4 w-4" />
</Button>
<Button className="bg-red-500 hover:bg-red-600 items-center align-middle" onClick={_ => onReplaceDelete(term.id)}>
<Trash className="h-4 w-4" />
</Button>
</div>
))}
<div className="flex flex-row w-full items-center justify-center rounded-lg border px-3 py-3 mt-[15px]">
<div className="flex flex-col flex-grow">
<div className="flex flex-row w-full items-center justify-center gap-3">
<Input id="search" placeholder="Enter a term to search for" onChange={e => {
setSearch(e.target.value);
try {
new RegExp(e.target.value)
setSearchInfo("Valid regular expression.")
} catch (e) {
setSearchInfo("Invalid regular expression. Regular search will be used instead.")
}
}} />
<Input id="replace" placeholder="Enter a term to replace with" onChange={e => setReplace(e.target.value)} />
<Button className="bg-green-500 hover:bg-green-600 items-center align-middle" onClick={onReplaceAdd}>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className={searchInfo.length == 0 ? "hidden" : ""}>
<InfoIcon className="inline-block h-4 w-4" />
<p className="inline-block text-orange-400 text-sm pl-[7px]">{searchInfo}</p>
</div>
</div>
</div>
</div>
</div>
</div>
<Toaster />
</div>
);
}
export default TTSFiltersPage;

View File

@ -15,22 +15,22 @@ export async function GET(req: Request) {
// Verify state against user id in user table.
const user = await db.user.findFirst({
where: {
id: state
}
where: {
id: state
}
})
if (!user) {
return new NextResponse("Bad Request", { status: 400 });
return new NextResponse("Bad Request", { status: 400 });
}
// Post to https://id.twitch.tv/oauth2/token
const token: { access_token:string, expires_in:number, refresh_token:string, token_type:string, scope:string[] } = (await axios.post("https://id.twitch.tv/oauth2/token", {
const token: { access_token: string, expires_in: number, refresh_token: string, token_type: string, scope: string[] } = (await axios.post("https://id.twitch.tv/oauth2/token", {
client_id: process.env.TWITCH_BOT_CLIENT_ID,
client_secret: process.env.TWITCH_BOT_CLIENT_SECRET,
code: code,
grant_type: "authorization_code",
redirect_uri: "https://hermes.goblincaves.com/api/account/authorize"
redirect_uri: "https://tomtospeech.com/api/account/authorize"
})).data
// Fetch values from token.
@ -49,17 +49,17 @@ export async function GET(req: Request) {
const broadcasterId = info.data.data[0]['id']
await db.twitchConnection.create({
data: {
broadcasterId: broadcasterId,
accessToken: access_token,
refreshToken: refresh_token,
userId: state
}
data: {
broadcasterId: broadcasterId,
accessToken: access_token,
refreshToken: refresh_token,
userId: state
}
})
return new NextResponse("", { status: 200 });
} catch (error) {
console.log("[ACCOUNT]", error);
console.log("[ACCOUNT/AUTHORIZE]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}

View File

@ -39,7 +39,8 @@ export async function GET(req: Request) {
user_id: user.id,
access_token: data?.accessToken,
refresh_token: data?.refreshToken,
broadcaster_id: connection.broadcasterId
broadcaster_id: connection.broadcasterId,
expires_in
}
return NextResponse.json(dataFormatted, { status: 201 });
}
@ -75,7 +76,8 @@ export async function GET(req: Request) {
user_id: user.id,
access_token,
refresh_token,
broadcaster_id: connection.broadcasterId
broadcaster_id: connection.broadcasterId,
expires_in
}
return NextResponse.json(data)

View File

@ -18,16 +18,21 @@ export async function GET(req: Request) {
if (!auth)
return new NextResponse("Bad Request", { status: 400 })
const redemptions = await axios.get("https://api.twitch.tv/helix/channel_points/custom_rewards?broadcaster_id=" + auth.broadcaster_id,
{
headers: {
"Client-Id": process.env.TWITCH_BOT_CLIENT_ID,
"Authorization": "Bearer " + auth.access_token
try {
const redemptions = await axios.get("https://api.twitch.tv/helix/channel_points/custom_rewards?broadcaster_id=" + auth.broadcaster_id,
{
headers: {
"Client-Id": process.env.TWITCH_BOT_CLIENT_ID,
"Authorization": "Bearer " + auth.access_token
}
}
}
)
)
return NextResponse.json(redemptions.data);
return NextResponse.json(redemptions.data);
} catch (error: any) {
console.error('Fetching Twitch channel redemptions:', error.response.data)
}
return NextResponse.json([]);
} catch (error) {
console.log("[REDEMPTIONS/ACTIONS]", error);
return new NextResponse("Internal Error", { status: 500 });

View File

@ -6,9 +6,16 @@ import fetchUser from "@/lib/fetch-user";
export async function GET(req: Request) {
try {
const user = await fetchUser(req)
if (!user) return new NextResponse("Internal Error", { status: 401 })
return NextResponse.json(user)
const user = await fetchUser(req)
if (!user) return new NextResponse("Internal Error", { status: 401 })
const account = await db.account.findFirst({
where: {
userId: user.id
}
});
return NextResponse.json({ ... user, broadcasterId: account?.providerAccountId })
} catch (error) {
console.log("[ACCOUNT]", error);
return new NextResponse("Internal Error", { status: 500 });
@ -22,29 +29,29 @@ export async function POST(req: Request) {
if (!user) {
return new NextResponse("Internal Error", { status: 401 })
}
const exist = await db.user.findFirst({
where: {
name: user
}
where: {
name: user
}
});
if (exist) {
return NextResponse.json({
id: exist.id,
username: exist.name
id: exist.id,
username: exist.name
});
}
const newUser = await db.user.create({
data: {
name: user,
}
data: {
name: user,
}
});
return NextResponse.json({
id: newUser.id,
username: newUser.name
id: newUser.id,
username: newUser.name
});
} catch (error) {
console.log("[ACCOUNT]", error);

View File

@ -0,0 +1,92 @@
import { db } from "@/lib/db"
import { NextResponse } from "next/server";
import fetchUserWithImpersonation from '@/lib/fetch-user-impersonation';
import axios from "axios";
export async function POST(req: Request) {
try {
const user = await fetchUserWithImpersonation(req);
if (!user)
return NextResponse.json({ error: null, message: "Unauthorized", success: false }, { status: 401 });
let { access_token, expires_in, token_type, scope, state } = await req.json();
if (!token_type)
return NextResponse.json({ error: null, message: 'No token type given for the authorization.', success: false }, { status: 400 })
if (!access_token)
return NextResponse.json({ error: null, message: 'No access token given for the authorization.', success: false }, { status: 400 })
if (!scope)
return NextResponse.json({ error: null, message: 'No scope given for the authorization.', success: false }, { status: 400 })
if (!state)
return NextResponse.json({ error: null, message: 'No state given for the authorization.', success: false }, { status: 400 })
// Fetch connection state data
const info = await db.connectionState.findUnique({
where: {
state: state
}
})
if (!info)
return NextResponse.json({ error: null, message: 'No authorization code was received previously.', success: false }, { status: 400 })
if (info.type == "twitch") {
const response = await axios.get("https://id.twitch.tv/oauth2/validate", {
headers: {
Authorization: 'OAuth ' + access_token
}
})
expires_in = response.data.expires_in
}
if (!expires_in)
return NextResponse.json({ error: null, message: 'No expiration given for the authorization.', success: false }, { status: 400 })
let expiration = new Date()
expiration.setSeconds(expiration.getSeconds() + parseInt(expires_in) - 600);
await db.connection.upsert({
where: {
userId_name: {
userId: info.userId,
name: info.name
}
},
create: {
userId: info.userId,
name: info.name,
type: info.type,
clientId: info.clientId,
accessToken: access_token,
scope,
grantType: token_type,
expiresAt: expiration
},
update: {
clientId: info.clientId,
accessToken: access_token,
scope,
grantType: token_type,
expiresAt: expiration
}
})
await db.connectionState.delete({
where: {
userId_name: {
userId: user.id,
name: info.name
}
}
})
return NextResponse.json({ error: null, message: "", success: true }, { status: 200 });
} catch (error: any) {
if (error.name == 'PrismaClientKnownRequestError') {
if (error.code == 'P2002')
return NextResponse.json({ error, message: "Connection already saved.", success: false }, { status: 500 });
}
return NextResponse.json({ error, message: "Failed to save connection", success: false }, { status: 500 });
}
}

View File

@ -0,0 +1,75 @@
import { db } from "@/lib/db"
import { NextResponse } from "next/server";
import fetchUserWithImpersonation from '@/lib/fetch-user-impersonation';
export async function GET(req: Request) {
try {
const user = await fetchUserWithImpersonation(req);
if (!user) {
return NextResponse.json({ error: null, message: "Unauthorized", success: false }, { status: 401 });
}
const data = await db.connection.findMany({
where: {
userId: user.id,
default: true
}
})
return NextResponse.json({ error: null, message: "", success: true, data }, { status: 200 });
} catch (error: any) {
return NextResponse.json({ error, message: "Failed to get default connection", success: false }, { status: 500 });
}
}
export async function PUT(req: Request) {
try {
const user = await fetchUserWithImpersonation(req);
if (!user) {
return NextResponse.json({ error: null, message: "Unauthorized", success: false }, { status: 401 });
}
const { name } = await req.json();
if (!name)
return NextResponse.json({ error: null, message: 'Requires "name" param to be passed in - name of the connection.', success: false }, { status: 400 })
const connection = await db.connection.findFirst({
where: {
userId: user.id,
name
}
})
if (!connection) {
return NextResponse.json({ error: null, message: 'Connection with that name does not exist.', success: false }, { status: 400 })
}
await db.connection.updateMany({
where: {
userId: user.id,
type: connection.type,
default: true
},
data: {
default: false
}
})
const data = await db.connection.update({
where: {
userId_name: {
userId: user.id,
name,
}
},
data: {
default: true as boolean
}
})
return NextResponse.json({ error: null, message: "", success: true, data }, { status: 200 });
} catch (error: any) {
return NextResponse.json({ error, message: "Failed to update default connection", success: false }, { status: 500 });
}
}

View File

@ -0,0 +1,59 @@
import { db } from "@/lib/db"
import { NextResponse } from "next/server";
import fetchUserWithImpersonation from '@/lib/fetch-user-impersonation';
export async function POST(req: Request) {
try {
const user = await fetchUserWithImpersonation(req);
if (!user) {
return NextResponse.json({ error: null, message: "Unauthorized", success: false }, { status: 401 });
}
let { state, name, type, grantType, clientId } = await req.json();
if (!clientId)
return NextResponse.json({ error: null, message: 'No client id given for the authorization.', success: false }, { status: 400 })
if (!type)
return NextResponse.json({ error: null, message: 'No type given for the authorization.', success: false }, { status: 400 })
if (!state)
return NextResponse.json({ error: null, message: 'No state given for the authorization.', success: false }, { status: 400 })
if (!name)
return NextResponse.json({ error: null, message: 'No name given for the authorization.', success: false }, { status: 400 })
if (!grantType)
return NextResponse.json({ error: null, message: 'No grant type given for the authorization.', success: false }, { status: 400 })
await db.connectionState.upsert({
where: {
userId_name: {
userId: user.id,
name
}
},
create: {
userId: user.id,
name,
type,
state,
grantType,
clientId
},
update: {
name,
state,
grantType,
}
})
return NextResponse.json({ error: null, message: "", success: true }, { status: 200 });
} catch (error: any) {
if (error.name == 'PrismaClientKnownRequestError') {
if (error.code == 'P2002')
return NextResponse.json({ error, message: "Connection already prepared.", success: false }, { status: 500 });
}
return NextResponse.json({ error, message: "Failed to prepare connection", success: false }, { status: 500 });
}
}

View File

@ -0,0 +1,74 @@
import { db } from "@/lib/db"
import { NextResponse } from "next/server";
import fetchUserWithImpersonation from '@/lib/fetch-user-impersonation';
export async function GET(req: Request) {
try {
const user = await fetchUserWithImpersonation(req);
if (!user) {
return NextResponse.json({ error: null, message: "Unauthorized", success: false }, { status: 401 });
}
//const { searchParams } = new URL(req.url)
const data = await db.connection.findMany({
where: {
userId: user.id
}
})
return NextResponse.json({ error: null, message: "", success: true, data }, { status: 200 });
} catch (error: any) {
return NextResponse.json({ error, message: "Failed to fetch connections", success: false }, { status: 500 });
}
}
export async function DELETE(req: Request) {
try {
const user = await fetchUserWithImpersonation(req);
if (!user) {
return NextResponse.json({ error: null, message: "Unauthorized", success: false }, { status: 401 });
}
const { searchParams } = new URL(req.url)
const name = searchParams.get('name') as string
if (!name)
return NextResponse.json({ error: null, message: 'Requires "name" param to be passed in - name of the service.', success: false }, { status: 400 })
const data = await db.connection.delete({
where: {
userId_name: {
userId: user.id,
name: name
}
}
})
const connections = await db.connection.findMany({
where: {
userId: user.id,
type: data.type
}
})
if (connections.length > 0 && connections.every(c => !c.default)) {
const connectionName = connections[0].name
await db.connection.update({
where: {
userId_name: {
userId: user.id,
name: connectionName
}
},
data: {
default: true
}
})
}
return NextResponse.json({ error: null, message: "", success: true, data }, { status: 200 });
} catch (error: any) {
return NextResponse.json({ error, message: "Failed to delete connection", success: false }, { status: 500 });
}
}

View File

@ -2,10 +2,22 @@ import { NextResponse } from "next/server";
export async function GET(req: Request) {
return NextResponse.json({
major_version: 3,
major_version: 4,
minor_version: 3,
download: "https://drive.proton.me/urls/KVGW0ZKE9C#2Y0WGGt5uHFZ",
changelog: "Revised the redeem system, activated via channel point redeems.\nAdded OBS transformation to redeems.\nLogs changed & writes to logs folder as well."
download: "https://drive.proton.me/urls/YH86153EWM#W6VTyaoAVHKP",
changelog: "Reconnecting should be fixed.\nNew TTS messages when queue is empty will be played faster, up to 200 ms.\nRemoved subscriptions errors when reconnecting on Twitch\nAdded !refresh connections"
//changelog: "When using multiple voices (ex: brian: hello amy: world), TTS messages are now merged as a single TTS message.\nNightbot integration\nTwitch authentication changed. Need to refresh connection every 30-60 days.\nFixed raid spam, probably."
//changelog: "Added raid spam prevention (lasts 30 seconds; works on joined chats as well).\nAdded permission check for chat messages with bits."
//changelog: "Fixed group permissions.\nRemoved default permissions.\nSome commands may have additional permission requirements, which are more strict.\nAdded support for redeemable actions via adbreak, follow, subscription.\nMessage deletion and bans automatically remove TTS messages from queue and playing.\nAdded support to read TTS from multiple chats via !tts join.\nFixed some reconnection problems."
//changelog: "Fixed several requests for data.\nRe-did the command system.\nAdded !skip all (!skipall still works)"
//changelog: "Fixed initial TTS voice change.\nRandom TTS voice ensures a different voice.\nBetter connection handling."
//changelog: "Fixed permissions.\nInternal changes."
//changelog: "Added groups (custom + subscribers/vip/moderators/etc).\nAdded group permissions.\nFixed 7tv reconnection.\nNew website link. Old one will be on July 19, 2024.\nAdded !refresh permissions & !refresh username_filters"
//changelog: "Fixed toggle OBS scene item visibility. Added more logs. Added version data to login."
//changelog: "Added ability to change or toggle OBS visibility via actions.\nAdded sleep via actions.\nAdded ability to change OBS index."
//changelog: "Fix OBS crashing the app.\nAdded '!refresh redemptions' command.\nAdded TTS voice randomizer and specifc TTS voice redemptions."
//changelog: "Revised the redeem system, activated via channel point redeems.\nAdded OBS transformation to redeems.\nLogs changed & writes to logs folder as well."
//changelog: "Added new command for mods: !refresh <username_filters|word_filters|default_voice> - Used to refresh data if done via website.\nAdded new command for mods: !tts <voice_name> <remove|enable|disable> - To delete, enable, or disable a specific voice."
//changelog: "Save TTS voices set by chatters.\nAdded more options for TTS voices." 3.1
//changelog: "Added a message when new updates are available.\nFixed 7tv renames not being applied correctly." 3.0

View File

@ -0,0 +1,127 @@
import { db } from "@/lib/db"
import { NextResponse } from "next/server";
import fetchUserWithImpersonation from "@/lib/fetch-user-impersonation";
import axios from "axios";
import { env } from "process";
import { TwitchUpdateAuthorization } from "@/lib/twitch";
export async function GET(req: Request) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user)
return new NextResponse("Unauthorized", { status: 401 });
const { searchParams } = new URL(req.url)
const groupId = searchParams.get('groupId') as string
const pageString = searchParams.get('page') as string
const search = searchParams.get('search') as string
if (!groupId && search != 'all')
return new NextResponse("Bad Request", { status: 400 })
let page = parseInt(pageString)
if (isNaN(page) || page === undefined || page === null)
page = 0
let chatters: { userId: string, groupId: string, chatterId: bigint, chatterLabel: string }[]
if (search != 'all')
chatters = await db.chatterGroup.findMany({
where: {
userId: user.id,
groupId
}
})
else
chatters = await db.chatterGroup.findMany({
where: {
userId: user.id
}
})
const paginated = search == 'all' ? chatters : chatters.slice(page * 50, (page + 1) * 50)
if (!paginated || paginated.length == 0) {
console.log('No users returned from db')
return NextResponse.json([])
}
const ids = chatters.map(c => c.chatterId)
const idsString = 'id=' + ids.map(i => i.toString()).reduce((a, b) => a + '&id=' + b)
const auth = await TwitchUpdateAuthorization(user.id)
if (!auth) {
return new NextResponse("", { status: 403 })
}
const users = await axios.get("https://api.twitch.tv/helix/users?" + idsString, {
headers: {
"Authorization": "Bearer " + auth.access_token,
"Client-Id": env.TWITCH_BOT_CLIENT_ID
}
})
if (!users) {
return new NextResponse("", { status: 400 })
}
if (users.data.data.length == 0) {
console.log('No users returned from twitch')
return NextResponse.json([])
}
return NextResponse.json(users.data.data.map((u: any) => ({ id: u.id, username: u.login })));
} catch (error) {
console.log("[GROUPS/USERS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}
export async function POST(req: Request) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user)
return new NextResponse("Unauthorized", { status: 401 });
const { groupId, users }: { groupId: string, users: { id: number, username: string }[] } = await req.json();
if (!groupId || !users)
return new NextResponse("Bad Request", { status: 400 });
const chatters = await db.chatterGroup.createMany({
data: users.map(u => ({ userId: user.id, chatterId: u.id, groupId, chatterLabel: u.username }))
});
return NextResponse.json(chatters, { status: 200 });
} catch (error) {
console.log("[GROUPS/USERS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}
export async function DELETE(req: Request) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user)
return new NextResponse("Unauthorized", { status: 401 });
const { searchParams } = new URL(req.url)
const groupId = searchParams.get('groupId') as string
const ids = searchParams.get('ids') as string
if (!groupId || !ids)
return new NextResponse("Bad Request", { status: 400 });
const chatters = await db.chatterGroup.deleteMany({
where: {
userId: user.id,
chatterId: {
in: ids.split(',').map(i => parseInt(i)).filter(i => !i || !isNaN(i))
},
groupId
}
})
return NextResponse.json(chatters);
} catch (error) {
console.log("[GROUPS/USERS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}

View File

@ -0,0 +1,101 @@
import { db } from "@/lib/db"
import { NextResponse } from "next/server";
import fetchUserWithImpersonation from "@/lib/fetch-user-impersonation";
import { ActionType, Prisma } from "@prisma/client";
export async function GET(req: Request) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user)
return new NextResponse("Unauthorized", { status: 401 });
const commands = await db.groupPermission.findMany({
where: {
userId: user.id
}
})
return NextResponse.json(commands.map(({userId, ...attrs}) => attrs));
} catch (error) {
console.log("[GROUPS/PERMISSIONS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}
export async function POST(req: Request) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user)
return new NextResponse("Unauthorized", { status: 401 });
const { path, allow, groupId }: { path: string, allow: boolean, groupId: string } = await req.json();
if (!path)
return new NextResponse("Bad Request", { status: 400 });
const permission = await db.groupPermission.create({
data: {
userId: user.id,
path,
allow,
groupId
}
});
return NextResponse.json(permission, { status: 200 });
} catch (error) {
console.log("[GROUPS/PERMISSIONS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}
export async function PUT(req: Request) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user)
return new NextResponse("Unauthorized", { status: 401 });
const { id, path, allow }: { id: string, path: string, allow: boolean|null } = await req.json();
if (!id)
return new NextResponse("Bad Request", { status: 400 });
if (!path)
return new NextResponse("Bad Request", { status: 400 });
let data: any = {}
if (!!path)
data = { ...data, path }
data = { ...data, allow }
const permission = await db.groupPermission.update({
where: {
id
},
data: data
});
return NextResponse.json(permission, { status: 200 });
} catch (error) {
console.log("[GROUPS/PERMISSIONS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}
export async function DELETE(req: Request) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user)
return new NextResponse("Unauthorized", { status: 401 });
const { searchParams } = new URL(req.url)
const id = searchParams.get('id') as string
const permission = await db.groupPermission.delete({
where: {
id
}
})
return NextResponse.json(permission);
} catch (error) {
console.log("[GROUPS/PERMISSIONS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}

View File

@ -0,0 +1,105 @@
import { db } from "@/lib/db"
import { NextResponse } from "next/server";
import fetchUserWithImpersonation from "@/lib/fetch-user-impersonation";
import { ActionType, Prisma } from "@prisma/client";
export async function GET(req: Request) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const actions = await db.group.findMany({
where: {
userId: user.id
}
})
return NextResponse.json(actions.map(({userId, ...attrs}) => attrs));
} catch (error) {
console.log("[GROUPS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}
export async function POST(req: Request) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { name, priority }: { name: string, priority: number } = await req.json();
if (!name)
return new NextResponse("Bad Request", { status: 400 });
const group = await db.group.create({
data: {
userId: user.id,
name: name.toLowerCase(),
priority
}
});
return NextResponse.json(group, { status: 200 });
} catch (error) {
console.log("[GROUPS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}
export async function PUT(req: Request) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { id, name, priority }: { id: string, name: string, priority: number } = await req.json();
if (!id)
return new NextResponse("Bad Request", { status: 400 });
if (!name && !priority)
return new NextResponse("Bad Request", { status: 400 });
let data: any = {}
if (!!name)
data = { ...data, name: name.toLowerCase() }
if (!!priority)
data = { ...data, priority }
const group = await db.group.update({
where: {
id
},
data: data
});
return NextResponse.json(group, { status: 200 });
} catch (error) {
console.log("[GROUPS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}
export async function DELETE(req: Request) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { searchParams } = new URL(req.url)
const id = searchParams.get('id') as string
const group = await db.group.delete({
where: {
id
}
})
return NextResponse.json(group);
} catch (error) {
console.log("[GROUPS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}

View File

@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import fetchUserWithImpersonation from "@/lib/fetch-user-impersonation";
import axios from "axios";
import { env } from "process";
import { TwitchUpdateAuthorization } from "@/lib/twitch";
export async function GET(req: Request) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user)
return new NextResponse("Unauthorized", { status: 401 });
const { searchParams } = new URL(req.url)
const logins = (searchParams.get('logins') as string)?.split(',').reduce((a,b) => a + ',&login=' + b)
const ids = (searchParams.get('ids') as string)?.split(',').reduce((a,b) => a + ',&id=' + b)
if (!logins && !ids) {
return new NextResponse("Bad Request", { status: 400 });
}
let suffix = ""
if (!!logins)
suffix += "&login=" + logins
if (!!ids)
suffix += "&id=" + ids
if (!!suffix)
suffix = "?" + suffix.substring(1)
const auth = await TwitchUpdateAuthorization(user.id)
if (!auth) {
return new NextResponse("", { status: 403 })
}
console.log('TWITCH URL:', 'https://api.twitch.tv/helix/users' + suffix)
console.log("AUTH", auth)
const users = await axios.get("https://api.twitch.tv/helix/users" + suffix, {
headers: {
"Authorization": "Bearer " + auth.access_token,
"Client-Id": env.TWITCH_BOT_CLIENT_ID
}
})
if (!users || !users.data) {
return new NextResponse("", { status: 400 })
}
if (users.data.data.length == 0) {
return NextResponse.json([])
}
return NextResponse.json(users.data.data.map((u: any) => ({ id: u.id, username: u.login })));
} catch (error) {
console.log("[GROUPS/USERS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}

View File

@ -0,0 +1,40 @@
import { db } from "@/lib/db"
import { NextResponse } from "next/server";
import fetchUserWithImpersonation from "@/lib/fetch-user-impersonation";
import axios from "axios";
import { env } from "process";
import { TwitchUpdateAuthorization } from "@/lib/twitch";
export async function GET(req: Request) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user)
return new NextResponse("Unauthorized", { status: 401 });
const { searchParams } = new URL(req.url)
const groupId = searchParams.get('groupId') as string
let chatters: { userId: string, groupId: string, chatterId: bigint, chatterLabel: string }[]
if (!!groupId)
chatters = await db.chatterGroup.findMany({
where: {
userId: user.id,
groupId
}
})
else
chatters = await db.chatterGroup.findMany({
where: {
userId: user.id
}
})
return NextResponse.json(chatters.map(u => ({ ...u, chatterId: Number(u.chatterId) }))
.map(({userId, chatterLabel, ...attrs}) => attrs))
} catch (error) {
console.log("[GROUPS/USERS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}

View File

@ -2,7 +2,6 @@ import { db } from "@/lib/db"
import { NextResponse } from "next/server";
import fetchUserWithImpersonation from "@/lib/fetch-user-impersonation";
import { ActionType, Prisma } from "@prisma/client";
import { JsonSerializer } from "typescript-json-serializer";
export async function GET(req: Request) {
try {
@ -17,111 +16,104 @@ export async function GET(req: Request) {
}
})
return NextResponse.json(actions.map(({userId, ...attrs}) => attrs));
return NextResponse.json(actions.map(({ userId, ...attrs }) => attrs));
} catch (error) {
console.log("[REDEMPTIONS/ACTIONS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}
async function common(req: Request, action: (id: string, name: string, type: ActionType, data: any) => void) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { name, type, scene_name, scene_item_name, rotation, position_x, position_y, file_path, file_content, tts_voice, obs_visible, obs_index, sleep, oauth_name, oauth_type }:
{
name: string, type: ActionType, scene_name: string, scene_item_name: string, rotation: string, position_x: string, position_y: string, file_path: string, file_content: string, tts_voice: string, obs_visible: boolean, obs_index: number, sleep: number,
oauth_name: string, oauth_type: string
} = await req.json();
if (!name && !type)
return new NextResponse("Bad Request", { status: 400 });
if (type == ActionType.OBS_TRANSFORM && (!scene_name || !scene_item_name || !rotation && !position_x && !position_y))
return new NextResponse("Bad Request", { status: 400 });
if ((type == ActionType.WRITE_TO_FILE || type == ActionType.APPEND_TO_FILE) && (!file_path || !file_content))
return new NextResponse("Bad Request", { status: 400 });
if (type == ActionType.AUDIO_FILE && !file_path)
return new NextResponse("Bad Request", { status: 400 });
if ([ActionType.OAUTH, ActionType.NIGHTBOT_PLAY, ActionType.NIGHTBOT_PAUSE, ActionType.NIGHTBOT_SKIP, ActionType.NIGHTBOT_CLEAR_PLAYLIST, ActionType.NIGHTBOT_CLEAR_QUEUE, ActionType.TWITCH_OAUTH].some(t => t == type) && (!oauth_name || !oauth_type))
return new NextResponse("Bad Request", { status: 400 });
let data: any = {}
if (type == ActionType.WRITE_TO_FILE || type == ActionType.APPEND_TO_FILE) {
data = { file_path, file_content }
} else if (type == ActionType.OBS_TRANSFORM) {
data = { scene_name, scene_item_name }
if (!!rotation)
data = { rotation, ...data }
if (!!position_x)
data = { position_x, ...data }
if (!!position_y)
data = { position_y, ...data }
} else if (type == ActionType.AUDIO_FILE) {
data = { file_path }
} else if (type == ActionType.SPECIFIC_TTS_VOICE) {
data = { tts_voice }
} else if (type == ActionType.TOGGLE_OBS_VISIBILITY) {
data = { scene_name, scene_item_name }
} else if (type == ActionType.SPECIFIC_OBS_VISIBILITY) {
data = { scene_name, scene_item_name, obs_visible }
} else if (type == ActionType.SPECIFIC_OBS_INDEX) {
data = { scene_name, scene_item_name, obs_index }
} else if (type == ActionType.SLEEP) {
data = { sleep }
} else if ([ActionType.OAUTH, ActionType.NIGHTBOT_PLAY, ActionType.NIGHTBOT_PAUSE, ActionType.NIGHTBOT_SKIP, ActionType.NIGHTBOT_CLEAR_PLAYLIST, ActionType.NIGHTBOT_CLEAR_QUEUE, ActionType.TWITCH_OAUTH].some(t => t == type)) {
data = {
oauth_name,
oauth_type
}
}
action(user.id, name, type, data)
return new NextResponse("", { status: 200 });
} catch (error: any) {
//console.log("[REDEMPTIONS/ACTIONS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}
export async function POST(req: Request) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { name, type, scene_name, scene_item_name, rotation, position_x, position_y, file_path, file_content }: { name: string, type: ActionType, scene_name: string, scene_item_name: string, rotation: string, position_x: string, position_y: string, file_path: string, file_content: string } = await req.json();
if (!name && !type)
return new NextResponse("Bad Request", { status: 400 });
if (type == ActionType.OBS_TRANSFORM && (!scene_name || !scene_item_name || !rotation && !position_x && !position_y))
return new NextResponse("Bad Request", { status: 400 });
if ((type == ActionType.WRITE_TO_FILE || type == ActionType.APPEND_TO_FILE) && (!file_path || !file_content))
return new NextResponse("Bad Request", { status: 400 });
if (type == ActionType.AUDIO_FILE && !file_path)
return new NextResponse("Bad Request", { status: 400 });
let data:any = { }
if (type == ActionType.WRITE_TO_FILE || type == ActionType.APPEND_TO_FILE) {
data = { file_path, file_content, ...data }
} else if (type == ActionType.OBS_TRANSFORM) {
data = { scene_name, scene_item_name, ...data }
if (!!rotation)
data = { rotation, ...data }
if (!!position_x)
data = { position_x, ...data }
if (!!position_y)
data = { position_y, ...data }
} else if (type == ActionType.AUDIO_FILE) {
data = { file_path, ...data }
}
return common(req, async (id, name, type, data) => {
await db.action.create({
data: {
userId: user.id,
userId: id,
name,
type,
data: data as Prisma.JsonObject
}
});
return new NextResponse("", { status: 200 });
} catch (error) {
console.log("[REDEMPTIONS/ACTIONS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
})
}
export async function PUT(req: Request) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { name, type, scene_name, scene_item_name, rotation, position_x, position_y, file_path, file_content }: { name: string, type: ActionType, scene_name: string, scene_item_name: string, rotation: string, position_x: string, position_y: string, file_path: string, file_content: string } = await req.json();
if (!name && !type)
return new NextResponse("Bad Request", { status: 400 });
if (type == ActionType.OBS_TRANSFORM && (!scene_name || !scene_item_name || !rotation && !position_x && !position_y))
return new NextResponse("Bad Request", { status: 400 });
if ((type == ActionType.WRITE_TO_FILE || type == ActionType.APPEND_TO_FILE) && (!file_path || !file_content))
return new NextResponse("Bad Request", { status: 400 });
if (type == ActionType.AUDIO_FILE && !file_path)
return new NextResponse("Bad Request", { status: 400 });
let data:any = { }
if (type == ActionType.WRITE_TO_FILE || type == ActionType.APPEND_TO_FILE) {
data = { file_path, file_content, ...data }
} else if (type == ActionType.OBS_TRANSFORM) {
data = { scene_name, scene_item_name, ...data }
if (!!rotation)
data = { rotation, ...data }
if (!!position_x)
data = { position_x, ...data }
if (!!position_y)
data = { position_y, ...data }
} else if (type == ActionType.AUDIO_FILE) {
data = { file_path, ...data }
}
return common(req, async (id, name, type, data) => {
await db.action.update({
where: {
userId_name: {
userId: user.id,
userId: id,
name
}
},
data: {
name,
type,
data: data as Prisma.JsonObject
}
});
return new NextResponse("", { status: 200 });
} catch (error) {
console.log("[REDEMPTIONS/ACTIONS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
})
})
}
export async function DELETE(req: Request) {

View File

@ -4,24 +4,20 @@ import { NextResponse } from "next/server";
export async function GET(req: Request) {
try {
console.log("ABC 1")
const user = await fetchUserWithImpersonation(req);
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
console.log("ABC 2")
const api = await db.twitchConnection.findFirst({
where: {
userId: user.id
}
})
console.log("ABC 3")
if (!api) {
return new NextResponse("Forbidden", { status: 403 });
}
console.log("ABC 4")
const data = {
client_id: process.env.TWITCH_BOT_CLIENT_ID,
client_secret: process.env.TWITCH_BOT_CLIENT_SECRET,
@ -29,7 +25,6 @@ export async function GET(req: Request) {
refresh_token: api.refreshToken,
broadcaster_id: api.broadcasterId
}
console.log("ABC 5", data)
return NextResponse.json(data);
} catch (error) {
console.log("[TOKENS/GET]", error);

View File

@ -1,38 +1,21 @@
import './globals.css'
import '@/app/globals.css'
import type { Metadata } from 'next'
import { Open_Sans } from 'next/font/google'
import AuthProvider from './context/auth-provider'
import { ThemeProvider } from '@/components/providers/theme-provider'
import { cn } from '@/lib/utils'
const font = Open_Sans({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Hermes',
description: '',
title: 'Tom-to-Speech',
description: '',
}
export default function RootLayout({
children,
children,
}: {
children: React.ReactNode
children: React.ReactNode
}) {
return (
<AuthProvider>
<html lang="en">
<body className={cn(
font.className,
"bg-white dark:bg-[#000000]"
)}>
<ThemeProvider
attribute="class"
defaultTheme='dark'
enableSystem={false}
storageKey='global-web-theme'>
{children}
</ThemeProvider>
</body>
</html>
</AuthProvider>
)
}
return (
<html lang="en">
<body>
{children}
</body>
</html>
)
}

View File

@ -1,100 +0,0 @@
"use client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import axios from "axios";
import * as React from 'react';
import { ApiKey, TwitchConnection, User } from "@prisma/client";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
const ConnectionsPage = () => {
const { data: session, status } = useSession();
const [previousUsername, setPreviousUsername] = useState<string>()
const [userId, setUserId] = useState<string>()
const [loading, setLoading] = useState<boolean>(true)
useEffect(() => {
if (status !== "authenticated" || previousUsername == session.user?.name) {
return
}
setPreviousUsername(session.user?.name as string)
if (session.user?.name) {
const fetchData = async () => {
let connection: User = (await axios.get("/api/account")).data
setUserId(connection.id)
setLoading(false)
}
fetchData().catch(console.error)
}
}, [session])
const [twitchUser, setTwitchUser] = useState<TwitchConnection | null>(null)
useEffect(() => {
const fetchData = async () => {
let connection: TwitchConnection = (await axios.get("/api/settings/connections/twitch")).data
setTwitchUser(connection)
}
fetchData().catch(console.error);
}, [])
const OnTwitchConnectionDelete = async () => {
try {
await axios.post("/api/settings/connections/twitch/delete")
setTwitchUser(null)
} catch (error) {
console.log("ERROR", error)
}
}
return (
<div>
<div className="text-2xl text-center pt-[50px]">Connections</div>
<div className="px-10 py-10 w-full h-full flex-grow inset-y-1/2">
<div>
<div className="px-10 py-6 rounded-md bg-purple-500 overflow-hidden wrap">
<div className={cn("hidden", !loading && "inline-block w-5/6")}>
<div className="inline-block">
<Avatar>
<AvatarImage src="https://cdn2.iconfinder.com/data/icons/social-aquicons/512/Twitch.png" alt="twitch" />
<AvatarFallback></AvatarFallback>
</Avatar>
</div>
<div className="inline-block ml-5">
<div className="inline-block text-lg">Twitch</div>
<div className={cn("hidden", twitchUser == null && "text-s flex")}>
<Link href={(process.env.NEXT_PUBLIC_TWITCH_OAUTH_URL as string) + userId}>Connect your Twitch account!</Link>
</div>
<div className={cn("hidden", twitchUser != null && "text-s flex")}>
<p>{twitchUser?.broadcasterId}</p>
</div>
</div>
</div>
<div className={cn("hidden", !loading && "inline-block")}>
<button onClick={OnTwitchConnectionDelete} className={cn("hidden", twitchUser != null && "flex")}>
<Avatar>
<AvatarImage src="https://upload.wikimedia.org/wikipedia/en/b/ba/Red_x.svg" alt="delete" />
<AvatarFallback></AvatarFallback>
</Avatar>
</button>
</div>
<Skeleton className={cn("visible rounded-full flex items-center bg-transparent", !loading && "hidden")}>
<Skeleton className="h-12 w-12 rounded-full" />
<div className="space-y-2">
<Skeleton className="pl-[10px] h-5 w-[100px]" />
<Skeleton className="pl-[10px] h-4 w-[200px]" />
</div>
</Skeleton>
</div>
</div>
</div>
</div>
);
}
export default ConnectionsPage;

View File

@ -1,345 +0,0 @@
"use client";
import axios from "axios";
import * as React from 'react';
import { InfoIcon, MoreHorizontal, Plus, Save, Tags, Trash } from "lucide-react"
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { Button } from "@/components/ui/button"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
import { Input } from "@/components/ui/input";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
import * as z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label";
import { ToastAction } from "@/components/ui/toast"
import { useToast } from "@/components/ui/use-toast"
import InfoNotice from "@/components/elements/info-notice";
import { Toaster } from "@/components/ui/toaster";
import { stringifyError } from "next/dist/shared/lib/utils";
const TTSFiltersPage = () => {
const { data: session, status } = useSession();
const [moreOpen, setMoreOpen] = useState(0)
const [tag, setTag] = useState("blacklisted")
const [open, setOpen] = useState(false)
const [userTags, setUserTag] = useState<{ username: string, tag: string }[]>([])
const { toast } = useToast()
const [error, setError] = useState("")
const router = useRouter();
const tags = [
"blacklisted",
"priority"
]
const toasting = (title: string, error: Error) => {
toast({
title: title,
description: error.message,
variant: "error"
})
}
const success = (title: string, description: string) => {
toast({
title: title,
description: description,
variant: "success"
})
}
// Username blacklist
const usernameFilteredFormSchema = z.object({
username: z.string().trim().min(4).max(25).regex(new RegExp("[a-zA-Z0-9][a-zA-Z0-9\_]{3,24}"), "Must be a valid twitch username."),
tag: z.string().trim()
});
const usernameFilteredForm = useForm({
resolver: zodResolver(usernameFilteredFormSchema),
defaultValues: {
username: "",
tag: ""
}
});
useEffect(() => {
const fetchData = async () => {
try {
const userFiltersData = await axios.get("/api/settings/tts/filter/users")
setUserTag(userFiltersData.data ?? [])
} catch (error) {
toasting("Failed to fetch all the username filters.", error as Error)
}
try {
const replacementData = await axios.get("/api/settings/tts/filter/words")
setReplacements(replacementData.data ?? [])
} catch (error) {
toasting("Failed to fetch all the word filters.", error as Error)
}
};
fetchData().catch((error) => toasting("Failed to fetch all the username filters.", error as Error));
}, []);
const onDelete = () => {
const username = userTags[Math.log2(moreOpen)].username
axios.delete("/api/settings/tts/filter/users?username=" + username)
.then(() => {
setUserTag(userTags.filter((u) => u.username != username))
success("Username filter deleted", `"${username.toLowerCase()}" is now back to normal.`)
}).catch((error) => toasting("Failed to delete the username filter.", error as Error))
}
const isSubmitting = usernameFilteredForm.formState.isSubmitting;
const onAddExtended = (values: z.infer<typeof usernameFilteredFormSchema>, test: boolean = true) => {
const original = userTags.find(u => u.username.toLowerCase() == values.username.toLowerCase())
if (test)
values.tag = tag
axios.post("/api/settings/tts/filter/users", values)
.then((d) => {
if (original == null) {
userTags.push({ username: values.username.toLowerCase(), tag: values.tag })
} else {
original.tag = values.tag
}
setUserTag(userTags)
usernameFilteredForm.reset();
router.refresh();
if (values.tag == "blacklisted")
success("Username filter added", `"${values.username.toLowerCase()}" will be blocked.`)
else if (values.tag == "priority")
success("Username filter added", `"${values.username.toLowerCase()}" will be taking priority.`)
}).catch(error => toasting("Failed to add the username filter.", error as Error))
}
const onAdd = (values: z.infer<typeof usernameFilteredFormSchema>) => {
onAddExtended(values, true)
}
// Word replacement
const [replacements, setReplacements] = useState<{ id: string, search: string, replace: string, userId: string }[]>([])
const onReplaceAdd = async () => {
if (search.length <= 0) {
toasting("Unable to add the word filter.", new Error("Search must not be empty."))
return
}
await axios.post("/api/settings/tts/filter/words", { search, replace })
.then(d => {
replacements.push({ id: d.data.id, search: d.data.search, replace: d.data.replace, userId: d.data.userId })
setReplacements(replacements)
setSearch("")
success("Word filter added", `"${d.data.search}" will be replaced.`)
}).catch(error => toasting("Failed to add the word filter.", error as Error))
}
const onReplaceUpdate = async (data: { id: string, search: string, replace: string, userId: string }) => {
await axios.put("/api/settings/tts/filter/words", data)
.then(() => success("Word filter updated", ""))
.catch(error => toasting("Failed to update the word filter.", error as Error))
}
const onReplaceDelete = async (id: string) => {
await axios.delete("/api/settings/tts/filter/words?id=" + id)
.then(d => {
const r = replacements.filter(r => r.id != d.data.id)
setReplacements(r)
success("Word filter deleted", `No more filter for "${d.data.search}"`)
}).catch(error => toasting("Failed to delete the word filter.", error as Error))
}
let [search, setSearch] = useState("")
let [replace, setReplace] = useState("")
let [searchInfo, setSearchInfo] = useState("")
return (
<div>
<div className="text-2xl text-center pt-[50px]">TTS Filters</div>
<div className="px-10 py-1 w-full h-full flex-grow inset-y-1/2">
<InfoNotice message="You can tag certain labels to twitch users, allowing changes applied specifically to these users when using the text to speech feature." hidden={false} />
<div>
{userTags.map((user, index) => (
<div key={user.username + "-tags"} className="flex w-full items-start justify-between rounded-md border px-4 py-2 mt-2">
<p className="text-base font-medium">
<span className="mr-2 rounded-lg bg-primary px-2 py-1 text-xs text-primary-foreground">
{user.tag}
</span>
<span className="text-white">{user.username}</span>
</p>
<DropdownMenu open={(moreOpen & (1 << index)) > 0} onOpenChange={() => setMoreOpen(v => v ^ (1 << index))}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="xs" className="bg-purple-500 hover:bg-purple-600">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px] bg-popover">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuGroup>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Tags className="mr-2 h-4 w-4" />
Apply label
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="p-0">
<Command>
<CommandInput
placeholder="Filter label..."
autoFocus={true}
/>
<CommandList>
<CommandEmpty>No label found.</CommandEmpty>
<CommandGroup>
{tags.map((tag) => (
<CommandItem
key={user.username + "-tag"}
value={tag}
onSelect={(value) => {
onAddExtended({ username: userTags[index].username, tag: value}, false)
setMoreOpen(0)
}}
>
{tag}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem key={user.username + "-delete"} onClick={onDelete} className="text-red-600">
<Trash className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
<Form {...usernameFilteredForm}>
<form onSubmit={usernameFilteredForm.handleSubmit(onAdd)}>
<div className="flex w-full items-center justify-between rounded-md border px-4 py-2 gap-3 mt-2">
<Label className="rounded-lg bg-primary px-2 py-1 text-xs text-primary-foreground">
{tag}
</Label>
<FormField
control={usernameFilteredForm.control}
name="username"
render={({ field }) => (
<FormItem key={"new-username"} className="flex-grow">
<FormControl>
<Input id="username" placeholder="Enter a twitch username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button variant="ghost" size="sm" type="submit" className="bg-green-500 hover:bg-green-600 items-center align-middle" disabled={isSubmitting}>
<Plus className="h-6 w-6" />
</Button>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button size="sm" {...usernameFilteredForm} className="bg-purple-500 hover:bg-purple-600" disabled={isSubmitting}>
<MoreHorizontal className="h-6 w-6" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px] bg-popover">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuGroup>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Tags className="mr-2 h-4 w-4" />
Apply label
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="p-0">
<Command>
<CommandInput
placeholder="Filter label..."
autoFocus={true}
/>
<CommandList>
<CommandEmpty>No label found.</CommandEmpty>
<CommandGroup>
{tags.map((tag) => (
<CommandItem
value={tag}
key={tag + "-tag"}
onSelect={(value) => {
setTag(value)
setOpen(false)
}}
>
{tag}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</form>
</Form>
</div>
<div>
<p className="text-center text-2xl text-white pt-[80px]">Regex Replacement</p>
<div>
{replacements.map((term: { id: string, search: string, replace: string, userId: string }) => (
<div key={term.id} className="flex flex-row w-full items-start justify-between rounded-lg border px-4 py-3 gap-3 mt-[15px]">
<Input id="search" placeholder={term.search} className="flex" onChange={e => term.search = e.target.value } defaultValue={term.search} />
<Input id="replace" placeholder={term.replace} className="flex" onChange={e => term.replace = e.target.value } defaultValue={term.replace} />
<Button className="bg-blue-500 hover:bg-blue-600 items-center align-middle" onClick={_ => onReplaceUpdate(term)}>
<Save className="h-4 w-4" />
</Button>
<Button className="bg-red-500 hover:bg-red-600 items-center align-middle" onClick={_ => onReplaceDelete(term.id)}>
<Trash className="h-4 w-4" />
</Button>
</div>
))}
<div className="flex flex-row w-full items-center justify-center rounded-lg border px-3 py-3 mt-[15px]">
<div className="flex flex-col flex-grow">
<div className="flex flex-row w-full items-center justify-center gap-3">
<Input id="search" placeholder="Enter a term to search for" onChange={e => {
setSearch(e.target.value);
try {
new RegExp(e.target.value)
setSearchInfo("Valid regular expression.")
} catch (e) {
setSearchInfo("Invalid regular expression. Regular search will be used instead.")
}
}} />
<Input id="replace" placeholder="Enter a term to replace with" onChange={e => setReplace(e.target.value)} />
<Button className="bg-green-500 hover:bg-green-600 items-center align-middle" onClick={onReplaceAdd}>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className={searchInfo.length == 0 ? "hidden" : ""}>
<InfoIcon className="inline-block h-4 w-4" />
<p className="inline-block text-orange-400 text-sm pl-[7px]">{searchInfo}</p>
</div>
</div>
</div>
</div>
</div>
</div>
<Toaster />
</div>
);
}
export default TTSFiltersPage;

View File

@ -0,0 +1,82 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "../ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Label } from "../ui/label";
import axios from "axios";
export interface ConnectionDefault {
type: string,
connections: { name: string, clientId: string, token: string, type: string, scope: string, expiresAt: Date }[]
}
export const ConnectionDefaultElement = ({
type,
connections,
}: ConnectionDefault) => {
const [connection, setConnection] = useState<{ name: string, clientId: string, token: string, type: string, scope: string, expiresAt: Date } | undefined>(undefined)
const [open, setOpen] = useState(false)
const OnDefaultConnectionUpdate = function (con: { name: string, clientId: string, token: string, type: string, scope: string, expiresAt: Date }) {
if (connection && con.name == connection.name)
return;
axios.put('/api/connection/default', { name: con.name, type: con.type })
.then(d => {
setConnection(con)
})
}
useEffect(() => {
const con = connections.filter((c: any) => c.type == type && c.default)
if (con.length > 0)
OnDefaultConnectionUpdate(con[0])
console.log('default', type, connections.filter(c => c.type == type).length > 0)
}, [])
return (
<div
className={"bg-green-200 p-4 rounded-lg block m-5 max-w-[230px] " + (connections.filter(c => c.type == type).length > 0 ? 'visible' : 'hidden')}>
<Popover
open={open}
onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div className="flex flex-col flex-1">
<Label className="text-base text-black">{type.charAt(0).toUpperCase() + type.substring(1)}</Label>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={"w-[200px] justify-between"}
>{!connection ? "Select " + type.charAt(0).toUpperCase() + type.substring(1) + " connection..." : connection.name}</Button>
</div>
</PopoverTrigger>
<PopoverContent>
<Command>
<CommandInput
placeholder="Filter connections..."
autoFocus={true} />
<CommandList>
<CommandEmpty>No action found.</CommandEmpty>
<CommandGroup>
{connections.filter(c => c.type == type).map(c => (
<CommandItem
value={c.name}
key={c.name}
onSelect={(value) => {
OnDefaultConnectionUpdate(c)
setOpen(false)
}}>
{c.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}

View File

@ -0,0 +1,223 @@
"use client";
import axios from "axios";
import { useState } from "react";
import { Button } from "../ui/button";
import { useRouter } from "next/navigation";
import { v4 } from "uuid";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Input } from "../ui/input";
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
import { env } from "process";
export interface Connection {
name: string
type: string
clientId: string
scope: string
expiresAt: Date
remover: (name: string) => void
}
const AUTHORIZATION_DATA: { [service: string]: { type: string, endpoint: string, grantType: string, scopes: string[], redirect: string } } = {
'nightbot': {
type: 'nightbot',
endpoint: 'https://api.nightbot.tv/oauth2/authorize',
grantType: 'token',
scopes: ['song_requests', 'song_requests_queue', 'song_requests_playlist'],
redirect: 'https://tomtospeech.com/connection/authorize'
},
'twitch': {
type: 'twitch',
endpoint: 'https://id.twitch.tv/oauth2/authorize',
grantType: 'token',
scopes: [
'chat:read',
'bits:read',
'channel:read:polls',
'channel:read:predictions',
'channel:read:subscriptions',
'channel:read:vips',
'moderator:read:blocked_terms',
'chat:read',
'channel:moderate',
'channel:read:redemptions',
'channel:manage:redemptions',
'channel:manage:predictions',
'user:read:chat',
'channel:bot',
'moderator:read:followers',
'channel:read:ads',
'moderator:read:chatters',
],
redirect: 'https://tomtospeech.com/connection/authorize'
},
// 'twitch tts bot': {
// type: 'twitch',
// endpoint: 'https://id.twitch.tv/oauth2/authorize',
// grantType: 'token',
// scopes: [
// 'chat:read',
// 'bits:read',
// 'channel:read:polls',
// 'channel:read:predictions',
// 'channel:read:subscriptions',
// 'channel:read:vips',
// 'moderator:read:blocked_terms',
// 'chat:read',
// 'channel:moderate',
// 'channel:read:redemptions',
// 'channel:manage:redemptions',
// 'channel:manage:predictions',
// 'user:read:chat',
// 'channel:bot',
// 'moderator:read:followers',
// 'channel:read:ads',
// 'moderator:read:chatters',
// ],
// redirect: 'https://tomtospeech.com/connection/authorize'
// }
}
function AddOrRenew(name: string, type: string | undefined, clientId: string, router: AppRouterInstance) {
if (type === undefined)
return
if (!(type in AUTHORIZATION_DATA))
return
console.log(type)
const data = AUTHORIZATION_DATA[type]
const state = v4()
const clientIdUpdated = type == 'twitch tts bot' ? process.env.NEXT_PUBLIC_TWITCH_TTS_CLIENT_ID : clientId
axios.post("/api/connection/prepare", {
name: name,
type: data.type,
clientId: clientIdUpdated,
grantType: data.grantType,
state: state
}).then(_ => {
const url = data.endpoint + '?client_id=' + clientIdUpdated + '&redirect_uri=' + data.redirect + '&response_type=' + data.grantType
+ '&scope=' + data.scopes.join('%20') + '&state=' + state + '&force_verify=true'
router.push(url)
})
}
export const ConnectionElement = ({
name,
type,
clientId,
expiresAt,
remover,
}: Connection) => {
const router = useRouter()
const expirationHours = (new Date(expiresAt).getTime() - new Date().getTime()) / 1000 / 60 / 60
const expirationDays = expirationHours / 24
function Delete() {
axios.delete("/api/connection?name=" + name)
.then(d => {
remover(d.data.data.name)
})
}
return (
<div
className="bg-green-300 p-3 border-2 border-green-400 rounded-lg flex text-black m-1">
<div
className="justify-between flex-1 font-bold text-xl">
{name}
<div className="text-base font-normal">
{expirationDays > 1 && Math.floor(expirationDays) + " days - " + type}
{expirationDays <= 1 && Math.floor(expirationHours) + " hours - " + type}
</div>
</div>
<div
className="float-right align-middle flex flex-row items-center">
<Button
className="bg-blue-500 mr-3"
onClick={() => AddOrRenew(name, type, clientId, router)}>
Renew
</Button>
<Button
className="bg-red-500"
onClick={Delete}>
Delete
</Button>
</div>
</div>
);
}
export const ConnectionAdderElement = () => {
const router = useRouter()
const [name, setName] = useState<string>('')
const [type, setType] = useState<string | undefined>(undefined)
const [clientId, setClientId] = useState('')
const [open, setOpen] = useState(false)
return (
<div
className="bg-green-300 p-3 border-2 border-green-300 rounded-lg flex m-1">
<div
className="justify-between flex-1">
<Popover
open={open}
onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[120px] justify-between"
>{!type ? "Select service..." : type}</Button>
</PopoverTrigger>
<PopoverContent>
<Command>
<CommandInput
placeholder="Filter services..."
autoFocus={true} />
<CommandList>
<CommandEmpty>No action found.</CommandEmpty>
<CommandGroup>
{Object.keys(AUTHORIZATION_DATA).map((authType: string) => (
<CommandItem
value={authType}
key={authType}
onSelect={(value) => {
setType(authType)
setOpen(false)
}}>
{authType}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Input
className='w-[200px] inline m-1'
placeholder="Name"
value={name}
onChange={e => setName(e.target.value.toLowerCase())} />
{!!type && type != 'twitch tts bot' &&
<Input
className='w-[250px] m-1'
placeholder="Client Id"
value={clientId}
onChange={e => setClientId(e.target.value)} />
}
</div>
<div
className="float-right flex flex-row items-center">
<Button
className="bg-green-500"
onClick={() => AddOrRenew(name, type, clientId, router)}>
Add
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,211 @@
import axios from "axios";
import { useEffect, 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 "../ui/label";
import { HelpCircleIcon, Trash2Icon } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip"
import { Checkbox } from "../ui/checkbox";
interface Permission {
id: string|undefined
path: string
allow: boolean|null
groupId: string
edit: boolean
showEdit: boolean
isNew: boolean
permissionPaths: { path: string, description: string }[]
adder: (id: string, path: string, allow: boolean|null) => void
remover: (redemption: { id: string, path: string, allow: boolean|null }) => void
}
const GroupPermission = ({
id,
path,
allow,
groupId,
edit,
showEdit,
isNew,
permissionPaths,
adder,
remover
}: Permission) => {
const [pathOpen, setPathOpen] = useState(false)
const [isEditable, setIsEditable] = useState(edit)
const [oldData, setOldData] = useState<{ path: string, allow: boolean|null } | undefined>(undefined)
const [permission, setPermission] = useState<{ id: string|undefined, path: string, allow: boolean|null }>({ id, path, allow });
function Save() {
if (!permission || !permission.path)
return
if (isNew) {
axios.post("/api/settings/groups/permissions", {
path: permission.path,
allow: permission.allow,
groupId: groupId
}).then(d => {
if (!d || !d.data)
return
adder(d.data.id, permission.path, permission.allow)
setPermission({ id: undefined, path: "", allow: true })
})
} else {
axios.put("/api/settings/groups/permissions", {
id: permission.id,
path: permission.path,
allow: permission.allow
}).then(d => {
setIsEditable(false)
})
}
}
function Cancel() {
if (!oldData)
return
setPermission({ ...oldData, id: permission.id })
setIsEditable(false)
setOldData(undefined)
}
function Delete() {
axios.delete("/api/settings/groups/permissions?id=" + permission.id)
.then(d => {
remover(d.data)
})
}
return (
<div
className="bg-green-400 p-2 border-2 border-green-500 rounded-lg grid grid-flow-row">
<div
className="pb-3 flex grow">
{!isEditable &&
<Input
className="flex grow ml-1"
id="path"
value={permission.path}
readOnly />
|| isEditable &&
<Popover
open={pathOpen}
onOpenChange={setPathOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
id="path"
role="combobox"
className="flex grow justify-between"
>{!permission.path ? "Select a permission" : permission.path}</Button>
</PopoverTrigger>
<PopoverContent>
<Command>
<CommandInput
placeholder="Search..."
inputMode="search"
autoFocus={true} />
<CommandList>
<CommandEmpty>No permission found.</CommandEmpty>
<CommandGroup>
{permissionPaths.map((p) => (
<CommandItem
value={p.path}
key={p.path}
onSelect={(value) => {
setPermission({ ...permission, path: permissionPaths.find(v => v.path.toLowerCase() == value.toLowerCase())?.path ?? value.toLowerCase()})
setPathOpen(false)
}}>
<div>
<div className="text-lg">
{p.path}
</div>
<div className="text-xs text-green-200">
{p.description}
</div>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
}
</div>
<div
className="grid grid-cols-2 gap-1">
<Label>
Inherit from parent
</Label>
<Checkbox
checked={permission.allow === null}
onCheckedChange={(e) => {
if (permission.allow === null)
setPermission({ ...permission, allow: false })
else
setPermission({ ...permission, allow: null })
}}
disabled={!isEditable} />
<Label
htmlFor="inherit">
Allow
</Label>
<Checkbox
id="inherit"
checked={permission.allow === true}
onCheckedChange={(e) => {
setPermission({ ...permission, allow: !permission.allow })
}}
disabled={!isEditable || permission === undefined} />
</div>
<div>
{isEditable &&
<Button
className="m-3"
onClick={() => Save()}>
{isNew ? "Add" : "Save"}
</Button>
}
{isEditable && !isNew &&
<Button
className="m-3"
onClick={() => Cancel()}>
Cancel
</Button>
}
{showEdit && !isEditable &&
<Button
className="m-3"
onClick={() => {
setOldData({ ...permission })
setIsEditable(true)
}}>
Edit
</Button>
}
{!isEditable &&
<Button
className="m-3 bg-red-500 hover:bg-red-600 align-bottom"
onClick={() => Delete()}>
<Trash2Icon />
</Button>
}
</div>
</div>
);
}
export default GroupPermission;

View File

@ -0,0 +1,276 @@
import axios from "axios";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "../ui/label";
import { Maximize2, Minimize2, Trash2Icon } from "lucide-react";
import GroupPermission from "./group-permission";
import { z } from "zod";
import UserList from "./user-list-group";
interface Group {
id: string | undefined
name: string
priority: number
permissionsLoaded: { id: string, path: string, allow: boolean | null }[]
edit: boolean
showEdit: boolean
isNewGroup: boolean
permissionPaths: { path: string, description: string }[]
specialGroups: string[]
adder: (id: string, name: string, priority: number) => void
remover: (group: { id: string, name: string, priority: number }) => void
}
const GroupElement = ({
id,
name,
priority,
permissionsLoaded,
edit,
showEdit,
isNewGroup,
permissionPaths,
specialGroups,
adder,
remover
}: Group) => {
const [isEditable, setIsEditable] = useState(edit)
const [isNew, setIsNew] = useState(isNewGroup)
const [isMinimized, setIsMinimized] = useState(true)
const [oldData, setOldData] = useState<{ name: string, priority: number } | undefined>(undefined)
const [group, setGroup] = useState<{ id: string | undefined, name: string, priority: number }>({ id, name, priority })
const [permissions, setPermissions] = useState<{ id: string, path: string, allow: boolean | null }[]>(permissionsLoaded);
const isSpecial = (isEditable || oldData === undefined) && !!group && specialGroups.includes(group?.name)
const [error, setError] = useState<string | undefined>(undefined)
function addPermission(id: string, path: string, allow: boolean | null) {
setPermissions([...permissions, { id, path, allow }])
}
function removePermission(permission: { id: string, path: string, allow: boolean | null }) {
setPermissions(permissions.filter(p => p.id != permission.id))
}
const nameSchema = z.string({
required_error: "Name is required.",
invalid_type_error: "Name must be a string"
}).regex(/^[\w\-\s]{1,20}$/, "Name must contain only letters, numbers, dashes, and underscores.")
const prioritySchema = z.string().regex(/^-?\d{1,5}$/, "Priority must be a valid number.")
function Save() {
setError(undefined)
if (!isNew && !id)
return
const nameValidation = nameSchema.safeParse(group.name)
if (!nameValidation.success) {
setError(JSON.parse(nameValidation.error['message'])[0].message)
return
}
const priorityValidation = prioritySchema.safeParse(group.priority.toString())
if (!priorityValidation.success) {
setError(JSON.parse(priorityValidation.error['message'])[0].message)
return
}
if (isNew || group.id?.startsWith('$')) {
axios.post("/api/settings/groups", {
name: group.name,
priority: group.priority
}).then(d => {
if (!d) {
setError("Something went wrong.")
return
}
console.log("DATA", d.data)
if (specialGroups.includes(group.name)) {
setIsNew(false)
setIsEditable(false)
setGroup({ id: d.data.id, name: d.data.name, priority: d.data.priority })
} else {
adder(d.data.id, group.name.toLowerCase(), group.priority)
setGroup({ id: undefined, name: "", priority: 0 })
}
}).catch(() => {
setError("Potential group name duplicate.")
})
} else {
axios.put("/api/settings/groups", {
id: group.id,
name: group.name,
priority: group.priority
}).then(d => {
console.log("DATA", d.data)
setIsEditable(false)
}).catch(() => {
setError("Potential group name duplicate.")
})
}
}
function Cancel() {
setError(undefined)
if (!oldData)
return
setGroup({ ...oldData, id: group.id })
setIsEditable(false)
setOldData(undefined)
}
function Delete() {
axios.delete("/api/settings/groups?id=" + group.id)
.then(d => {
if (specialGroups.includes(group.name)) {
setPermissions([])
setIsMinimized(true)
setOldData(undefined)
setIsNew(true)
setIsEditable(true)
} else
remover(d.data)
})
}
return (
<div
className="bg-green-300 p-5 border-2 border-green-400 rounded-lg">
<div
className="pb-4">
<div
className="justify-between">
<Label
className="mr-2 text-black"
htmlFor="path">
Group Name
</Label>
{isSpecial &&
<div className="bg-white text-muted text-xs p-1 rounded m-1 inline-block">
auto-generated
</div>
}
<Input
value={group.name}
id="path"
onChange={e => setGroup({ ...group, name: e.target.value })}
readOnly={isSpecial || !isEditable} />
</div>
<div
className="justify-between">
<Label
className="mr-2 text-black"
htmlFor="priority">
TTS Priority
</Label>
<Input
name="priority"
value={group.priority}
onChange={e => setGroup(d => {
let temp = { ...group }
const v = parseInt(e.target.value)
if (e.target.value.length == 0) {
temp.priority = 0
} else if (!Number.isNaN(v) && Number.isSafeInteger(v)) {
temp.priority = v
} else if (Number.isNaN(v)) {
temp.priority = 0
}
return temp
})}
readOnly={!isEditable} />
</div>
</div>
<p className="w-[380px] text-red-500 text-wrap text-sm">
{error}
</p>
<div>
{isEditable &&
<Button
className="ml-1 mr-1 align-middle"
onClick={() => Save()}>
{isNew ? "Add" : "Save"}
</Button>
}
{isEditable && !isNew &&
<Button
className="ml-1 mr-1 align-middle"
onClick={() => Cancel()}>
Cancel
</Button>
}
{showEdit && !isEditable &&
<Button
className="ml-1 mr-1 align-middle"
onClick={() => {
setOldData({ ...group })
setIsEditable(true)
}}>
Edit
</Button>
}
{!isEditable && !isNew &&
<Button
className="ml-1 mr-1 align-middle bg-red-500 hover:bg-red-600"
onClick={() => Delete()}>
<Trash2Icon />
</Button>
}
{!isNew && !group?.id?.startsWith('$') &&
<Button
className="ml-1 mr-1 align-middle"
onClick={e => setIsMinimized(!isMinimized)}>
{isMinimized ? <Maximize2 /> : <Minimize2 />}
</Button>
}
{!isNew && !isSpecial &&
<UserList
groupId={group.id!}
groupName={group.name} />
}
</div>
{!isNew && !isMinimized &&
<div>
{permissions.map(permission =>
<div
className="m-3 mb-0"
key={permission.id}>
<GroupPermission
id={permission.id}
path={permission.path}
allow={permission.allow}
groupId={group.id!}
edit={false}
showEdit={true}
isNew={false}
permissionPaths={permissionPaths}
adder={addPermission}
remover={removePermission} />
</div>
)}
<div
className="m-3 mb-0">
<GroupPermission
id={undefined}
path={""}
allow={true}
groupId={group.id!}
edit={true}
showEdit={false}
isNew={true}
permissionPaths={permissionPaths}
adder={addPermission}
remover={removePermission} />
</div>
</div>
}
</div>
);
}
export default GroupElement;

View File

@ -7,24 +7,218 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Label } from "../ui/label";
import { Maximize2, Minimize2, Trash2Icon } from "lucide-react";
import { ActionType } from "@prisma/client";
import { boolean } from "zod";
const actionTypes = [
{
"name": "Overwrite local file content",
"value": ActionType.WRITE_TO_FILE
"value": ActionType.WRITE_TO_FILE,
"inputs": [
{
"type": "text",
"label": "File path",
"key": "file_path",
"placeholder": "Enter local file path, relative or full."
},
{
"type": "text",
"label": "File content",
"key": "file_content",
"placeholder": "Enter the text to write to the file."
}
]
},
{
"name": "Append to local file",
"value": ActionType.APPEND_TO_FILE
"value": ActionType.APPEND_TO_FILE,
"inputs": [
{
"type": "text",
"label": "File path",
"key": "file_path",
"placeholder": "Enter local file path, relative or full."
},
{
"type": "text",
"label": "File content",
"key": "file_content",
"placeholder": "Enter the text to append to the file."
}
]
},
{
"name": "Cause a transformation on OBS scene item",
"value": ActionType.OBS_TRANSFORM
"value": ActionType.OBS_TRANSFORM,
"inputs": []
},
{
"name": "Play an audio file locally",
"value": ActionType.AUDIO_FILE
"value": ActionType.AUDIO_FILE,
"inputs": [
{
"type": "text",
"label": "File path",
"key": "file_path",
"placeholder": "Enter local file path, relative or full."
}
]
},
{
"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",
}
]
},
{
"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"
},
{
"type": "text",
"label": "Scene Item Name",
"key": "scene_item_name",
"placeholder": "Name of the OBS scene item / source"
}
]
},
{
"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"
},
{
"type": "text",
"label": "Scene Item Name",
"key": "scene_item_name",
"placeholder": "Name of the OBS scene item / source"
},
{
"type": "text-values",
"label": "Visible",
"key": "obs_visible",
"placeholder": "true for visible; false otherwise",
"values": ["true", "false"]
}
]
},
{
"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"
},
{
"type": "text",
"label": "Scene Item Name",
"key": "scene_item_name",
"placeholder": "Name of the OBS scene item / source"
},
{
"type": "number",
"label": "Index",
"key": "obs_index",
"placeholder": "index, starting from 0."
}
]
},
{
"name": "Sleep - do nothing",
"value": ActionType.SLEEP,
"inputs": [
{
"type": "number",
"label": "Sleep",
"key": "sleep",
"placeholder": "Time in milliseconds to do nothing",
}
]
},
{
"name": "Nightbot - Play",
"value": ActionType.NIGHTBOT_PLAY,
"inputs": [
{
"type": "oauth.nightbot.play",
"label": "nightbot.play",
"key": "nightbot_play",
"placeholder": "",
}
]
},
{
"name": "Nightbot - Pause",
"value": ActionType.NIGHTBOT_PAUSE,
"inputs": [
{
"type": "oauth.nightbot.pause",
"label": "nightbot.pause",
"key": "nightbot_pause",
"placeholder": "",
}
]
},
{
"name": "Nightbot - Skip",
"value": ActionType.NIGHTBOT_SKIP,
"inputs": [
{
"type": "oauth.nightbot.skip",
"label": "nightbot.skip",
"key": "nightbot_skip",
"placeholder": "",
}
]
},
{
"name": "Nightbot - Clear Playlist",
"value": ActionType.NIGHTBOT_CLEAR_PLAYLIST,
"inputs": [
{
"type": "oauth.nightbot.clear_playlist",
"label": "nightbot.clear_playlist",
"key": "nightbot_clear_playlist",
"placeholder": "",
}
]
},
{
"name": "Nightbot - Clear Queue",
"value": ActionType.NIGHTBOT_CLEAR_QUEUE,
"inputs": [
{
"type": "oauth.nightbot.clear_queue",
"label": "nightbot.clear_queue",
"key": "nightbot_clear_queue",
"placeholder": "",
}
]
},
]
@ -37,6 +231,7 @@ interface RedeemableAction {
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
}
@ -50,12 +245,13 @@ const RedemptionAction = ({
showEdit = true,
isNew = false,
obsTransformations = [],
connections = [],
adder,
remover
}: RedeemableAction) => {
const [open, setOpen] = useState(false)
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 } | undefined>(actionTypes.find(a => a.value == type?.toUpperCase()))
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)
@ -66,6 +262,7 @@ const RedemptionAction = ({
if (!name) {
return
}
console.log('typeeee', type)
if (!type) {
return
}
@ -96,7 +293,7 @@ const RedemptionAction = ({
}
}
function Cancel(data: { n: string, t: ActionType | undefined, d: { [k: string]: string } } | undefined) {
function Cancel(data: { n: string, t: ActionType | undefined, d: { [k: string]: any } } | undefined) {
if (!data)
return
@ -126,7 +323,7 @@ const RedemptionAction = ({
{actionName}
</Label>
<Button
className="flex inline-block self-end"
className="flex self-end"
onClick={e => setIsMinimized(!isMinimized)}>
{isMinimized ? <Maximize2 /> : <Minimize2 />}
</Button>
@ -160,13 +357,13 @@ const RedemptionAction = ({
readOnly />
|| isEditable &&
<Popover
open={open}
onOpenChange={setOpen}>
open={open['actions']}
onOpenChange={() => setOpen({ ...open, 'actions': !open['actions'] })}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
aria-expanded={open['actions']}
className="w-[300px] justify-between"
>{!actionType ? "Select one..." : actionType.name}</Button>
</PopoverTrigger>
@ -184,7 +381,7 @@ const RedemptionAction = ({
key={action.value}
onSelect={(value) => {
setActionType(actionTypes.find(v => v.name.toLowerCase() == value.toLowerCase()))
setOpen(false)
setOpen({ ...open, 'actions': false })
}}>
{action.name}
</CommandItem>
@ -197,38 +394,138 @@ const RedemptionAction = ({
}
</div>
<div>
{actionType && (actionType.value == ActionType.WRITE_TO_FILE || actionType.value == ActionType.APPEND_TO_FILE) &&
{actionType &&
<div>
<Label
className="mr-2"
htmlFor="file_path">
File path
</Label>
<Input
className="w-[300px] justify-between inline-block"
name="file_path"
placeholder={actionType.value == ActionType.WRITE_TO_FILE ? "Enter the local file path to the file to overwrite" : "Enter the local file path to the file to append to"}
value={actionData["file_path"]}
onChange={e => setActionData({ ...actionData, "file_path": e.target.value })}
readOnly={!isEditable} />
<Label
className="ml-10 mr-2"
htmlFor="file_content">
File content
</Label>
<Input
className="w-[300px] justify-between inline-block"
name="file_content"
placeholder="Enter the content that should be written"
value={actionData["file_content"]}
onChange={e => setActionData({ ...actionData, "file_content": e.target.value })}
readOnly={!isEditable} />
{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) {
abc[i.key] = "0"
} else if (!Number.isNaN(v) && Number.isSafeInteger(v)) {
abc[i.key] = v.toString()
} else if (Number.isNaN(v)) {
abc[i.key] = "0"
}
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>
}
return <div key={i.key}></div>
})}
</div>
}
{actionType && actionType.value == ActionType.OBS_TRANSFORM &&
<div>
{obsTransformations.map(t =>
<div
key={t.label.toLowerCase()}
className="mt-3">
<Label
className="mr-2"
@ -250,22 +547,6 @@ const RedemptionAction = ({
)}
</div>
}
{actionType && actionType.value == ActionType.AUDIO_FILE &&
<div>
<Label
className="mr-2"
htmlFor="file_path">
File path
</Label>
<Input
className="w-[300px] justify-between inline-block"
name="file_path"
placeholder={"Enter the local file path where the audio file is at"}
value={actionData["file_path"]}
onChange={e => setActionData({ ...actionData, "file_path": e.target.value })}
readOnly={!isEditable} />
</div>
}
</div>
<div>
{isEditable &&

View File

@ -17,6 +17,7 @@ interface Redemption {
id: string | undefined
redemptionId: string | undefined
actionName: string
numbering: number,
edit: boolean
showEdit: boolean
isNew: boolean
@ -30,6 +31,7 @@ const OBSRedemption = ({
id,
redemptionId,
actionName,
numbering,
edit,
showEdit,
isNew,
@ -42,12 +44,11 @@ const OBSRedemption = ({
const [redemptionOpen, setRedemptionOpen] = useState(false)
const [twitchRedemption, setTwitchRedemption] = useState<{ id: string, title: string } | undefined>(undefined)
const [action, setAction] = useState<string | undefined>(actionName)
const [order, setOrder] = useState<number>(0)
const [order, setOrder] = useState<number>(numbering)
const [isEditable, setIsEditable] = useState(edit)
const [oldData, setOldData] = useState<{ r: { id: string, title: string } | undefined, a: string | undefined, o: number } | undefined>(undefined)
useEffect(() => {
console.log("TR:", twitchRedemptions, redemptionId, twitchRedemptions.find(r => r.id == redemptionId))
setTwitchRedemption(twitchRedemptions.find(r => r.id == redemptionId))
}, [])
@ -65,7 +66,7 @@ const OBSRedemption = ({
order: order,
state: true
}).then(d => {
adder(d.data.id, action, twitchRedemption.id, 0)
adder(d.data.id, action, twitchRedemption.id, order)
setAction(undefined)
setTwitchRedemption(undefined)
setOrder(0)

View File

@ -0,0 +1,268 @@
import axios from "axios";
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
import { z } from "zod";
import { Trash2 } from "lucide-react";
import RoleGate from "@/components/auth/role-gate";
interface UsersGroup {
groupId: string
groupName: string
//userList: { id: number, username: string }[]
//knownUsers: { id: number, username: string }[]
}
const ITEMS_PER_PAGE: number = 10;
const UserList = ({
groupId,
groupName,
//userList,
//knownUsers
}: UsersGroup) => {
const [usersListOpen, setUsersListOpen] = useState(false)
const [users, setUsers] = useState<{ id: number, username: string }[]>([])
const [addedUsers, setAddedUsers] = useState<{ id: number, username: string }[]>([])
const [deletedUsers, setDeletedUsers] = useState<{ id: number, username: string }[]>([])
const [newUser, setNewUser] = useState<string>("")
const [knownUsers, setKnownUsers] = useState<{ id: number, username: string }[]>([])
const [error, setError] = useState<string | undefined>(undefined)
const [page, setPage] = useState<number>(0)
const [maxPages, setMaxPages] = useState<number>(1)
useEffect(() => {
axios.get('/api/settings/groups/chatters', {
params: {
groupId,
page
}
}).then(d => {
setUsers(d.data)
setKnownUsers(d.data)
setMaxPages(Math.ceil(d.data.length / ITEMS_PER_PAGE))
})
}, [groupId, page])
function close() {
setUsers([...users.filter(u => !addedUsers.find(a => a.id == u.id)), ...deletedUsers])
setUsersListOpen(false)
}
const usernameSchema = z.string({
required_error: "Name is required.",
invalid_type_error: "Name must be a string"
}).regex(/^[\w\-]{4,25}$/, "Invalid Twitch username.")
function AddUsername() {
setError(undefined)
const nameValidation = usernameSchema.safeParse(newUser)
if (!nameValidation.success) {
setError(JSON.parse(nameValidation.error['message'])[0].message)
return
}
if (users.find(u => u.username == newUser.toLowerCase())) {
setError("Username is already in this group.")
return;
}
let user = knownUsers.find(u => u.username == newUser.toLowerCase())
if (!user) {
axios.get('/api/settings/groups/twitchchatters', {
params: {
logins: newUser
}
}).then(d => {
if (!d.data)
return
user = d.data[0]
if (!user)
return
if (deletedUsers.find(u => u.id == user!.id))
setDeletedUsers(deletedUsers.filter(u => u.id != user!.id))
else
setAddedUsers([...addedUsers, user])
setUsers([...users, user])
setKnownUsers([...users, user])
setNewUser("")
setMaxPages(Math.ceil((users.length + 1) / ITEMS_PER_PAGE))
}).catch(e => {
setError("Username does not exist.")
})
return
}
if (deletedUsers.find(u => u.id == user!.id))
setDeletedUsers(deletedUsers.filter(u => u.id != user!.id))
else
setAddedUsers([...addedUsers, user])
setUsers([...users, user])
setNewUser("")
setMaxPages(Math.ceil((users.length + 1) / ITEMS_PER_PAGE))
if (deletedUsers.find(u => u.id == user!.id)) {
setAddedUsers(addedUsers.filter(u => u.username != newUser.toLowerCase()))
}
}
function DeleteUser(user: { id: number, username: string }) {
if (addedUsers.find(u => u.id == user.id)) {
setAddedUsers(addedUsers.filter(u => u.id != user.id))
} else {
setDeletedUsers([...deletedUsers, user])
}
setUsers(users.filter(u => u.id != user.id))
}
function save() {
setError(undefined)
if (addedUsers.length > 0) {
axios.post("/api/settings/groups/chatters", {
groupId,
users: addedUsers
}).then(d => {
setAddedUsers([])
if (deletedUsers.length > 0)
axios.delete("/api/settings/groups/chatters", {
params: {
groupId,
ids: deletedUsers.map(i => i.id.toString()).reduce((a, b) => a + ',' + b)
}
}).then(d => {
setDeletedUsers([])
}).catch(() => {
setError("Something went wrong.")
})
}).catch(() => {
setError("Something went wrong.")
})
return
}
if (deletedUsers.length > 0)
axios.delete("/api/settings/groups/chatters", {
params: {
groupId,
ids: deletedUsers.map(i => i.id.toString()).reduce((a, b) => a + ',' + b)
}
}).then(d => {
setDeletedUsers([])
}).catch(() => {
setError("Something went wrong.")
})
}
return (
<Sheet>
<SheetTrigger asChild>
<Button
className="ml-3 mr-3 align-middle"
onClick={() => setUsersListOpen(true)}>
Users
</Button>
</SheetTrigger>
<SheetContent className="w-[700px]">
<SheetHeader>
<SheetTitle>Edit group - {groupName}</SheetTitle>
<SheetDescription>
Make changes to this group&#39;s list of users.
</SheetDescription>
</SheetHeader>
{!!error &&
<p className="text-red-500">{error}</p>
}
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Username
</Label>
<Input
id="name"
value={newUser}
type="text"
onChange={e => setNewUser(e.target.value)}
className="col-span-3" />
<Button
className="bg-white"
onClick={() => AddUsername()}>
Add
</Button>
</div>
</div>
<hr className="mt-4" />
<Table>
<TableHeader>
<TableRow>
<RoleGate roles={['ADMIN']}><TableHead>Id</TableHead></RoleGate>
<TableHead>Username</TableHead>
<TableHead>Delete</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length ? (
users.slice(ITEMS_PER_PAGE * page, ITEMS_PER_PAGE * (page + 1)).map((user) => (
<TableRow
key={user.id}>
<RoleGate roles={['ADMIN']}><TableCell colSpan={1} className="text-xs">{user.id}</TableCell></RoleGate>
<TableCell colSpan={1}>{user.username}</TableCell>
<TableCell>
<Button
className="bg-red-500 h-9"
onClick={() => DeleteUser(user)}>
<Trash2 />
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={3}
className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<SheetFooter>
<SheetClose asChild>
<Button onClick={() => save()} type="submit">Save changes</Button>
</SheetClose>
<SheetClose asChild>
<Button onClick={() => close()} type="submit">Close</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
export default UserList;

View File

@ -0,0 +1,80 @@
'use client';
import Link from "next/link";
import RoleGate from "@/components/auth/role-gate";
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu"
const components: { title: string; href: string; description: string }[] = [
{
title: "Alert Dialog",
href: "/docs/primitives/alert-dialog",
description:
"A modal dialog that interrupts the user with important content and expects a response.",
},
]
const MenuNavigation = () => {
return (
<NavigationMenu
className="absolute top-0 flex justify-center left-auto z-51 flex-wrap">
<p className="w-[300px] text-3xl text-center align-middle invisible md:visible">Tom To Speech</p>
<NavigationMenuList>
{/* <NavigationMenuItem>
<NavigationMenuTrigger>Getting started</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid gap-3 p-6 md:w-[400px] lg:w-[500px] lg:grid-cols-[.75fr_1fr]">
<li className="row-span-3">
<NavigationMenuLink asChild>
<a
className="flex h-full w-full select-none flex-col justify-end rounded-md bg-gradient-to-b from-muted/50 to-muted p-6 no-underline outline-none focus:shadow-md"
href="/">
<div className="mb-2 mt-4 text-lg font-medium">
Tom-to-Speech
</div>
<p className="text-sm leading-tight text-muted-foreground">
Text to speech software
</p>
</a>
</NavigationMenuLink>
</li>
</ul>
</NavigationMenuContent>
</NavigationMenuItem> */}
<NavigationMenuItem>
<Link href="/commands" legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
Commands
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<Link href="/settings" legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
Settings
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
<RoleGate
roles={["ADMIN"]}>
<NavigationMenuItem>
<Link href="/admin" legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
Admin
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
</RoleGate>
</NavigationMenuList>
</NavigationMenu>
);
}
export default MenuNavigation;

View File

@ -5,73 +5,78 @@ import AdminProfile from "./adminprofile";
import RoleGate from "@/components/auth/role-gate";
const SettingsNavigation = async () => {
return (
<div>
<div className="text-4xl flex pl-[15px] pb-[33px]">Hermes</div>
return (
<div className="mt-[100px]">
<div className="w-full pl-[30px] pr-[30px] pb-[50px]">
<UserProfile />
<RoleGate roles={["ADMIN"]}>
<AdminProfile />
</RoleGate>
</div>
<div className="w-full pl-[30px] pr-[30px] pb-[50px]">
<UserProfile />
<RoleGate roles={["ADMIN"]}>
<AdminProfile />
</RoleGate>
</div>
<div className="flex flex-grow h-full w-full">
<ul className="rounded-lg shadow-md flex flex-col w-full justify-between text-center align-center">
<li className="text-xs text-gray-200">
Settings
</li>
<li>
<Link href={"/settings/connections"} className="m-0 p-0 gap-0">
<Button variant="ghost" className="w-full text-lg">
Connections
</Button>
</Link>
</li>
<div className="flex flex-grow h-full w-full">
<ul className="rounded-lg shadow-md flex flex-col w-full justify-between text-center align-center">
<li className="text-xs text-gray-200">
Settings
</li>
<li>
<Link href={"/settings/connections"} className="m-0 p-0 gap-0">
<Button variant="ghost" className="w-full text-lg">
Connections
</Button>
</Link>
</li>
<li className="text-xs text-gray-200">
Text to Speech
</li>
<li className="">
<Link href={"/settings/tts/voices"}>
<Button variant="ghost" className="w-full text-lg">
Voices
</Button>
</Link>
</li>
<li className="">
<Link href={"/settings/tts/filters"}>
<Button variant="ghost" className="w-full text-lg">
Filters
</Button>
</Link>
</li>
<li className="">
<Link href={"/settings/groups/permissions"}>
<Button variant="ghost" className="w-full text-lg">
Permissions
</Button>
</Link>
</li>
<li className="text-xs text-gray-200">
Text to Speech
</li>
<li className="">
<Link href={"/settings/tts/voices"}>
<Button variant="ghost" className="w-full text-lg">
Voices
</Button>
</Link>
</li>
<li className="">
<Link href={"/settings/tts/filters"}>
<Button variant="ghost" className="w-full text-lg">
Filters
</Button>
</Link>
</li>
<li className="text-xs text-gray-200">
Twitch
</li>
<li className="">
<Link href={"/settings/redemptions"}>
<Button variant="ghost" className="w-full text-lg">
Channel Redemptions
</Button>
</Link>
</li>
<li className="text-xs text-gray-200">
Twitch
</li>
<li className="">
<Link href={"/settings/redemptions"}>
<Button variant="ghost" className="w-full text-lg">
Channel Redemptions
</Button>
</Link>
</li>
<li className="text-xs text-gray-200">
API
</li>
<li className="">
<Link href={"/settings/api/keys"}>
<Button variant="ghost" className="w-full text-lg">
Keys
</Button>
</Link>
</li>
</ul>
</div>
</div>
);
<li className="text-xs text-gray-200">
API
</li>
<li className="">
<Link href={"/settings/api/keys"}>
<Button variant="ghost" className="w-full text-lg">
Keys
</Button>
</Link>
</li>
</ul>
</div>
</div>
);
}
export default SettingsNavigation;

View File

@ -18,7 +18,6 @@ const NavigationMenu = React.forwardRef<
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
@ -30,7 +29,7 @@ const NavigationMenuList = React.forwardRef<
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
"group/navList flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props}
@ -41,7 +40,7 @@ NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
"group/navTrig inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
)
const NavigationMenuTrigger = React.forwardRef<
@ -50,12 +49,12 @@ const NavigationMenuTrigger = React.forwardRef<
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
className={cn(navigationMenuTriggerStyle(), "group/navTrig", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]/navTrig:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
@ -69,7 +68,8 @@ const NavigationMenuContent = React.forwardRef<
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
"bg-background rounded-xl mt-1 top-full w-auto data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto",
+ "origin-top-center relative mt-2.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
{...props}
@ -83,10 +83,10 @@ const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<div className={cn("absolute left-0 z-30 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
"z-10 origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}

140
components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,140 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -1,50 +0,0 @@
type Listener = (value: any) => void;
type Topics = {
[name: string]: Listener[];
};
export const createPubSub = () => {
let topics: Topics = {};
let destroyed = false;
const getTopic = (name: string) => {
if (!topics[name]) {
topics[name] = [];
}
return topics[name];
};
return {
subscribe(topic: string, fn: Listener) {
const listeners = getTopic(topic);
listeners.push(fn);
const unsubscribe = () => {
const index = listeners.indexOf(fn);
listeners.splice(index, 1);
};
return unsubscribe;
},
publish(topic: string, value: any) {
const listeners = getTopic(topic);
const currentListeners = listeners.slice();
currentListeners.forEach((listener) => {
if (!destroyed) {
listener(value);
}
});
},
destroy() {
topics = {};
destroyed = true;
},
};
};

79
lib/twitch.ts Normal file
View File

@ -0,0 +1,79 @@
import axios from 'axios'
import { db } from "@/lib/db"
export async function TwitchUpdateAuthorization(userId: string) {
try {
const connection = await db.twitchConnection.findFirst({
where: {
userId
}
})
if (!connection) {
return null
}
try {
const { expires_in }: { client_id:string, login:string, scopes:string[], user_id:string, expires_in:number } = (await axios.get("https://id.twitch.tv/oauth2/validate", {
headers: {
Authorization: 'OAuth ' + connection.accessToken
}
})).data;
if (expires_in > 3600) {
const data = await db.twitchConnection.findFirst({
where: {
userId
}
})
const dataFormatted = {
user_id: userId,
access_token: data?.accessToken,
refresh_token: data?.refreshToken,
broadcaster_id: connection.broadcasterId,
expires_in
}
return dataFormatted;
}
} catch (error) {
}
// Post to https://id.twitch.tv/oauth2/token
const token: { access_token:string, expires_in:number, refresh_token:string, token_type:string, scope:string[] } = (await axios.post("https://id.twitch.tv/oauth2/token", {
client_id: process.env.TWITCH_BOT_CLIENT_ID,
client_secret: process.env.TWITCH_BOT_CLIENT_SECRET,
grant_type: "refresh_token",
refresh_token: connection.refreshToken
})).data
// Fetch values from token.
const { access_token, expires_in, refresh_token, token_type } = token
if (!access_token || !refresh_token || token_type !== "bearer") {
return null
}
await db.twitchConnection.update({
where: {
userId
},
data: {
accessToken: access_token,
refreshToken: refresh_token
}
})
const data = {
user_id: userId,
access_token,
refresh_token,
broadcaster_id: connection.broadcasterId,
expires_in
}
return data
} catch (error) {
console.log("[ACCOUNT]", error);
return null
}
}

View File

@ -21,7 +21,7 @@ export default auth((req) => {
requestHeaders.set('x-url', req.url);
const isApiRoute = nextUrl.pathname.startsWith(API_PREFIX)
const isPublicRoute = PUBLIC_ROUTES.includes(nextUrl.pathname)
const isPublicRoute = PUBLIC_ROUTES.includes(nextUrl.pathname) || nextUrl.pathname.startsWith("/overlay")
const isAuthRoute = AUTH_ROUTES.includes(nextUrl.pathname)
const response = NextResponse.next({
request: {

View File

@ -33,12 +33,17 @@ model User {
apiKeys ApiKey[]
accounts Account[]
twitchConnections TwitchConnection[]
Connection Connection[]
ConnectionState ConnectionState[]
ttsUsernameFilter TtsUsernameFilter[]
ttsWordFilter TtsWordFilter[]
ttsChatVoices TtsChatVoice[]
ttsVoiceStates TtsVoiceState[]
actions Action[]
redemptions Redemption[]
groups Group[]
chatterGroups ChatterGroup[]
groupPermissions GroupPermission[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -92,6 +97,36 @@ model TwitchConnection {
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
}
model Connection {
name String
type String
clientId String
accessToken String
grantType String
scope String
expiresAt DateTime
default Boolean @default(false)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@id([userId, name])
}
model ConnectionState {
state String
name String
type String
grantType String
clientId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@id([userId, name])
@@unique([state])
}
model TtsUsernameFilter {
username String
tag String
@ -144,6 +179,50 @@ model TtsVoiceState {
@@id([userId, ttsVoiceId])
}
model Group {
id String @id @default(uuid()) @db.Uuid
userId String
name String
priority Int
chatters ChatterGroup[]
permissions GroupPermission[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, name])
@@index([userId])
}
model ChatterGroup {
//id String @id @default(uuid()) @db.Uuid
userId String
groupId String @db.Uuid
chatterId BigInt
chatterLabel String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
@@id([userId, groupId, chatterId])
@@unique([userId, groupId, chatterId])
@@index([userId])
}
model GroupPermission {
id String @id @default(uuid()) @db.Uuid
userId String
groupId String @db.Uuid
path String
allow Boolean?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
@@unique([userId, groupId, path])
@@index([userId])
}
model Chatter {
id BigInt
name String
@ -161,6 +240,7 @@ model Emote {
//history EmoteUsageHistory[]
@@id([id])
@@unique([id, name])
}
model EmoteUsageHistory {
@ -180,20 +260,34 @@ enum ActionType {
APPEND_TO_FILE
AUDIO_FILE
OBS_TRANSFORM
RANDOM_TTS_VOICE
SPECIFIC_TTS_VOICE
TOGGLE_OBS_VISIBILITY
SPECIFIC_OBS_VISIBILITY
SPECIFIC_OBS_INDEX
SLEEP
OAUTH
NIGHTBOT_PLAY
NIGHTBOT_PAUSE
NIGHTBOT_SKIP
NIGHTBOT_CLEAR_PLAYLIST
NIGHTBOT_CLEAR_QUEUE
TWITCH_OAUTH
}
model Action {
userId String
name String @unique
name String @unique
type ActionType
data Json
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([userId, name])
}
model Redemption {
id String @id @default(uuid()) @db.Uuid
id String @id @default(uuid()) @db.Uuid
userId String
redemptionId String
actionName String

View File

@ -1,5 +1,5 @@
export const PUBLIC_ROUTES = [
"/"
"/",
]
export const AUTH_ROUTES = [