Updated list of commands to v4.3. Added groups & permissions. Added connections. Updated redemptions and actions to v4.3.
This commit is contained in:
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>
|
||||
*/
|
76
app/(protected)/settings/api/keys/page.tsx
Normal file
76
app/(protected)/settings/api/keys/page.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import axios from "axios";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from "react";
|
||||
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
|
||||
const ApiKeyPage = () => {
|
||||
const [apiKeyViewable, setApiKeyViewable] = useState<number>(-1)
|
||||
const [apiKeys, setApiKeys] = useState<{ id: string, label: string, userId: string }[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
await axios.get("/api/tokens")
|
||||
.then(d => setApiKeys(d.data ?? []))
|
||||
.catch(console.error)
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const onApiKeyAdd = async (label: string) => {
|
||||
await axios.post("/api/token", { label })
|
||||
.then(d => setApiKeys(apiKeys.concat([d.data])))
|
||||
.catch(console.error)
|
||||
}
|
||||
|
||||
const onApiKeyDelete = async (id: string) => {
|
||||
await axios.delete("/api/token/" + id)
|
||||
.then((d) => setApiKeys(apiKeys.filter(k => k.id != d.data.id)))
|
||||
.catch(console.error)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="px-10 py-5 mx-5 my-10">
|
||||
<div>
|
||||
<div className="text-xl justify-left mt-10 text-center">API Keys</div>
|
||||
<Table>
|
||||
<TableCaption>A list of your secret API keys.</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Label</TableHead>
|
||||
<TableHead>Token</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apiKeys.map((key, index) => (
|
||||
<TableRow key={key.id}>
|
||||
<TableCell className="font-medium">{key.label}</TableCell>
|
||||
<TableCell>{apiKeyViewable == index ? key.id : "*".repeat(key.id.length)}</TableCell>
|
||||
<TableCell>
|
||||
<Button onClick={() => setApiKeyViewable((v) => v != index ? index : -1)}>
|
||||
{apiKeyViewable == index ? "HIDE" : "VIEW"}
|
||||
</Button>
|
||||
<Button onClick={() => onApiKeyDelete(key.id)} className="ml-[10px] bg-red-500 hover:bg-red-700">DELETE</Button>
|
||||
</TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow key="ADD">
|
||||
<TableCell className="font-medium"></TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell><Button onClick={() => onApiKeyAdd("Key label")}>ADD</Button></TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ApiKeyPage;
|
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;
|
27
app/(protected)/settings/layout.tsx
Normal file
27
app/(protected)/settings/layout.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import SettingsNavigation from "@/components/navigation/settings";
|
||||
import { cn } from "@/lib/utils";
|
||||
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 (
|
||||
<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]"}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsLayout;
|
8
app/(protected)/settings/page.tsx
Normal file
8
app/(protected)/settings/page.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
const SettingsPage = async () => {
|
||||
return (
|
||||
<div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsPage;
|
176
app/(protected)/settings/redemptions/page.tsx
Normal file
176
app/(protected)/settings/redemptions/page.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import axios from "axios";
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
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" },
|
||||
]
|
||||
|
||||
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 [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 }) {
|
||||
setActions([...actions, { name, type, data }])
|
||||
}
|
||||
|
||||
function removeAction(action: { name: string, type: string, data: any }) {
|
||||
setActions(actions.filter(a => a.name != action.name))
|
||||
}
|
||||
|
||||
function addRedemption(id: string, actionName: string, redemptionId: string, order: number) {
|
||||
setRedemptions([...redemptions, { id, redemptionId, actionName, order }])
|
||||
}
|
||||
|
||||
function removeRedemption(redemption: { id: string, redemptionId: string, actionName: string, order: number }) {
|
||||
setRedemptions(redemptions.filter(r => r.id != redemption.id))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "authenticated")
|
||||
return
|
||||
|
||||
axios.get('/api/connection')
|
||||
.then(d => {
|
||||
console.log(d.data.data)
|
||||
setConnections(d.data.data)
|
||||
})
|
||||
|
||||
axios.get("/api/settings/redemptions/actions")
|
||||
.then(d => {
|
||||
setActions(d.data)
|
||||
})
|
||||
|
||||
axios.get("/api/account/redemptions")
|
||||
.then(d => {
|
||||
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 => {
|
||||
setRedemptions(d.data)
|
||||
})
|
||||
})
|
||||
}, [session])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-2xl text-center pt-[50px]">Redemption Actions</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} />
|
||||
{actions.map(action =>
|
||||
<div
|
||||
className="px-10 py-3 w-full h-full flex-grow inset-y-1/2"
|
||||
key={action.name}>
|
||||
<RedeemptionAction
|
||||
name={action.name}
|
||||
type={action.type}
|
||||
data={action.data}
|
||||
edit={false}
|
||||
showEdit={true}
|
||||
isNew={false}
|
||||
obsTransformations={obsTransformations}
|
||||
connections={connections}
|
||||
adder={addAction}
|
||||
remover={removeAction} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="px-10 py-3 w-full h-full flex-grow inset-y-1/2">
|
||||
<RedeemptionAction
|
||||
name=""
|
||||
type={undefined}
|
||||
data={{}}
|
||||
edit={true}
|
||||
showEdit={false}
|
||||
isNew={true}
|
||||
obsTransformations={obsTransformations}
|
||||
connections={connections}
|
||||
adder={addAction}
|
||||
remover={removeAction} />
|
||||
</div>
|
||||
|
||||
<div className="text-2xl text-center pt-[50px]">Redemptions</div>
|
||||
<InfoNotice
|
||||
message="Redemptions are just a way to link specific actions to actual Twitch channel point redeems."
|
||||
hidden={false} />
|
||||
{redemptions.map(redemption =>
|
||||
<div
|
||||
className="px-10 py-3 w-full h-full flex-grow inset-y-1/2"
|
||||
key={redemption.id}>
|
||||
<OBSRedemption
|
||||
id={redemption.id}
|
||||
redemptionId={redemption.redemptionId}
|
||||
actionName={redemption.actionName}
|
||||
numbering={redemption.order}
|
||||
edit={false}
|
||||
showEdit={true}
|
||||
isNew={false}
|
||||
actions={actions.map(a => a.name)}
|
||||
twitchRedemptions={twitchRedemptions}
|
||||
adder={addRedemption}
|
||||
remover={removeRedemption} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="px-10 py-3 w-full h-full flex-grow inset-y-1/2">
|
||||
<OBSRedemption
|
||||
id={undefined}
|
||||
redemptionId={undefined}
|
||||
actionName=""
|
||||
numbering={0}
|
||||
edit={true}
|
||||
showEdit={false}
|
||||
isNew={true}
|
||||
actions={actions.map(a => a.name)}
|
||||
twitchRedemptions={twitchRedemptions}
|
||||
adder={addRedemption}
|
||||
remover={removeRedemption} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RedemptionsPage;
|
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;
|
135
app/(protected)/settings/tts/voices/page.tsx
Normal file
135
app/(protected)/settings/tts/voices/page.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import axios from "axios";
|
||||
import * as React from 'react';
|
||||
import { Check, ChevronsUpDown } from "lucide-react"
|
||||
import { useEffect, useReducer, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import voices from "@/data/tts";
|
||||
import InfoNotice from "@/components/elements/info-notice";
|
||||
|
||||
const TTSVoiceFiltersPage = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [defaultVoice, setDefaultVoice] = useState("")
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
function enabledVoicesReducer(enabledVoices: { [voice: string]: boolean }, action: { type: string, value: string }) {
|
||||
if (action.type == "enable") {
|
||||
return { ...enabledVoices, [action.value]: true }
|
||||
} else if (action.type == "disable") {
|
||||
return { ...enabledVoices, [action.value]: false }
|
||||
}
|
||||
return enabledVoices
|
||||
}
|
||||
|
||||
const [enabledVoices, dispatchEnabledVoices] = useReducer(enabledVoicesReducer, Object.assign({}, ...voices.map(v => ({[v]: false}) )))
|
||||
|
||||
useEffect(() => {
|
||||
axios.get("/api/settings/tts/default")
|
||||
.then((voice) => {
|
||||
setDefaultVoice(voice.data)
|
||||
})
|
||||
|
||||
axios.get("/api/settings/tts")
|
||||
.then((d) => {
|
||||
const data: string[] = d.data;
|
||||
data.forEach(d => dispatchEnabledVoices({ type: "enable", value: d }))
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const onDefaultChange = (voice: string) => {
|
||||
try {
|
||||
axios.post("/api/settings/tts/default", { voice })
|
||||
.catch(e => console.error(e))
|
||||
} catch (error) {
|
||||
console.log("[TTS/DEFAULT]", error);
|
||||
}
|
||||
}
|
||||
|
||||
const onEnabledChanged = (voice: string, state: boolean) => {
|
||||
try {
|
||||
axios.post("/api/settings/tts", { voice: voice, state: state })
|
||||
.catch(e => console.error(e))
|
||||
} catch (error) {
|
||||
console.log("[TTS/ENABLED]", error);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-2xl text-center pt-[50px]">TTS Voices</div>
|
||||
<div className="px-10 py-10 w-full h-full flex-grow">
|
||||
<div className="flex flex-row justify-evenly">
|
||||
<div>
|
||||
<div className="inline-block text-lg">Default Voice</div>
|
||||
<Label className="pl-[10px] inline-block">Voice used without any voice modifiers</Label>
|
||||
</div>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-[200px] justify-between">
|
||||
{defaultVoice ? voices.find(v => v == defaultVoice) : "Select voice..."}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search voice..." />
|
||||
<CommandEmpty>No voices found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{voices.map((voice) => (
|
||||
<CommandItem
|
||||
key={voice}
|
||||
value={voice}
|
||||
onSelect={(currentVoice) => {
|
||||
setDefaultVoice(voice)
|
||||
onDefaultChange(voice)
|
||||
setOpen(false)
|
||||
}}>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
defaultVoice === voice ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{voice}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="w-full pt-[50px]">
|
||||
<p className="text-xl text-center justify-center">Voices Enabled</p>
|
||||
<InfoNotice message="Voices can be disabled from being used. Default voice will always work." hidden={false} />
|
||||
<div className="grid grid-cols-4 grid-flow-row gap-4 pt-[20px]">
|
||||
{voices.map((v, i) => (
|
||||
<div key={v + "-enabled"} className="h-[30px] row-span-1 col-span-1 align-middle flex items-center justify-center">
|
||||
<Checkbox onClick={() => {
|
||||
dispatchEnabledVoices({ type: enabledVoices[v] ? "disable" : "enable", value: v })
|
||||
onEnabledChanged(v, !enabledVoices[v])
|
||||
}}
|
||||
disabled={loading}
|
||||
checked={enabledVoices[v]} />
|
||||
<div className="pl-[5px]">{v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TTSVoiceFiltersPage;
|
Reference in New Issue
Block a user