Updated list of commands to v4.3. Added groups & permissions. Added connections. Updated redemptions and actions to v4.3.
This commit is contained in:
parent
6548ce33e0
commit
b92529d8c0
2
.gitignore
vendored
2
.gitignore
vendored
@ -38,3 +38,5 @@ next-env.d.ts
|
||||
.env
|
||||
package.json
|
||||
package-lock.json
|
||||
|
||||
.vscode/
|
19
app/(protected)/commands/layout.tsx
Normal file
19
app/(protected)/commands/layout.tsx
Normal 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;
|
371
app/(protected)/commands/page.tsx
Normal file
371
app/(protected)/commands/page.tsx
Normal 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;
|
63
app/(protected)/connection/authorize/page.tsx
Normal file
63
app/(protected)/connection/authorize/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
41
app/(protected)/layout.tsx
Normal file
41
app/(protected)/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -31,7 +31,7 @@ export default function Home() {
|
||||
}
|
||||
|
||||
saveAccount().catch(console.error)
|
||||
}, [session])
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<main
|
45
app/(protected)/settings/admin/test/page.tsx
Normal file
45
app/(protected)/settings/admin/test/page.tsx
Normal 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>
|
||||
*/
|
78
app/(protected)/settings/connections/page.tsx
Normal file
78
app/(protected)/settings/connections/page.tsx
Normal 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;
|
28
app/(protected)/settings/emotes/page.tsx
Normal file
28
app/(protected)/settings/emotes/page.tsx
Normal 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;
|
115
app/(protected)/settings/groups/permissions/page.tsx
Normal file
115
app/(protected)/settings/groups/permissions/page.tsx
Normal 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;
|
@ -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>
|
@ -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}
|
347
app/(protected)/settings/tts/filters/page.tsx
Normal file
347
app/(protected)/settings/tts/filters/page.tsx
Normal 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;
|
@ -25,12 +25,12 @@ export async function GET(req: Request) {
|
||||
}
|
||||
|
||||
// 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.
|
||||
@ -59,7 +59,7 @@ export async function GET(req: Request) {
|
||||
|
||||
return new NextResponse("", { status: 200 });
|
||||
} catch (error) {
|
||||
console.log("[ACCOUNT]", error);
|
||||
console.log("[ACCOUNT/AUTHORIZE]", error);
|
||||
return new NextResponse("Internal Error", { status: 500 });
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -18,6 +18,7 @@ export async function GET(req: Request) {
|
||||
if (!auth)
|
||||
return new NextResponse("Bad Request", { status: 400 })
|
||||
|
||||
try {
|
||||
const redemptions = await axios.get("https://api.twitch.tv/helix/channel_points/custom_rewards?broadcaster_id=" + auth.broadcaster_id,
|
||||
{
|
||||
headers: {
|
||||
@ -28,6 +29,10 @@ export async function GET(req: Request) {
|
||||
)
|
||||
|
||||
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 });
|
||||
|
@ -8,7 +8,14 @@ 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 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 });
|
||||
|
92
app/api/connection/authorize/route.ts
Normal file
92
app/api/connection/authorize/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
75
app/api/connection/default/route.ts
Normal file
75
app/api/connection/default/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
59
app/api/connection/prepare/route.ts
Normal file
59
app/api/connection/prepare/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
74
app/api/connection/route.ts
Normal file
74
app/api/connection/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
@ -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
|
||||
|
127
app/api/settings/groups/chatters/route.ts
Normal file
127
app/api/settings/groups/chatters/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
101
app/api/settings/groups/permissions/route.ts
Normal file
101
app/api/settings/groups/permissions/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
105
app/api/settings/groups/route.ts
Normal file
105
app/api/settings/groups/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
55
app/api/settings/groups/twitchchatters/route.ts
Normal file
55
app/api/settings/groups/twitchchatters/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
40
app/api/settings/groups/users/route.ts
Normal file
40
app/api/settings/groups/users/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
@ -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) {
|
||||
|
@ -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);
|
||||
|
@ -1,14 +1,8 @@
|
||||
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',
|
||||
title: 'Tom-to-Speech',
|
||||
description: '',
|
||||
}
|
||||
|
||||
@ -18,21 +12,10 @@ export default function RootLayout({
|
||||
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'>
|
||||
<body>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
@ -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;
|
@ -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;
|
82
components/elements/connection-default.tsx
Normal file
82
components/elements/connection-default.tsx
Normal 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>
|
||||
);
|
||||
}
|
223
components/elements/connection.tsx
Normal file
223
components/elements/connection.tsx
Normal 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>
|
||||
);
|
||||
}
|
211
components/elements/group-permission.tsx
Normal file
211
components/elements/group-permission.tsx
Normal 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;
|
276
components/elements/group.tsx
Normal file
276
components/elements/group.tsx
Normal 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;
|
@ -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>
|
||||
{actionType.inputs.map(i => {
|
||||
if (i.type == "text") {
|
||||
return <div key={i.key} className="mt-3">
|
||||
<Label
|
||||
className="mr-2"
|
||||
htmlFor="file_path">
|
||||
File path
|
||||
htmlFor={i.key}>
|
||||
{i.label}
|
||||
</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 })}
|
||||
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="ml-10 mr-2"
|
||||
htmlFor="file_content">
|
||||
File content
|
||||
className="mr-2"
|
||||
htmlFor={i.key}>
|
||||
{i.label}
|
||||
</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 })}
|
||||
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 &&
|
||||
|
@ -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)
|
||||
|
268
components/elements/user-list-group.tsx
Normal file
268
components/elements/user-list-group.tsx
Normal 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'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;
|
80
components/navigation/menu.tsx
Normal file
80
components/navigation/menu.tsx
Normal 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;
|
@ -6,9 +6,7 @@ import RoleGate from "@/components/auth/role-gate";
|
||||
|
||||
const SettingsNavigation = async () => {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-4xl flex pl-[15px] pb-[33px]">Hermes</div>
|
||||
|
||||
<div className="mt-[100px]">
|
||||
<div className="w-full pl-[30px] pr-[30px] pb-[50px]">
|
||||
<UserProfile />
|
||||
<RoleGate roles={["ADMIN"]}>
|
||||
@ -46,6 +44,13 @@ const SettingsNavigation = async () => {
|
||||
</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">
|
||||
Twitch
|
||||
|
@ -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
140
components/ui/sheet.tsx
Normal 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,
|
||||
}
|
@ -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
79
lib/twitch.ts
Normal 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
|
||||
}
|
||||
}
|
@ -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: {
|
||||
|
@ -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,6 +260,19 @@ 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 {
|
||||
@ -187,6 +280,7 @@ model Action {
|
||||
name String @unique
|
||||
type ActionType
|
||||
data Json
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userId, name])
|
||||
|
Loading…
Reference in New Issue
Block a user