diff --git a/.gitignore b/.gitignore index 9c17b53..822ae4c 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,6 @@ next-env.d.ts .env package.json -package-lock.json \ No newline at end of file +package-lock.json + +.vscode/ \ No newline at end of file diff --git a/app/(protected)/commands/layout.tsx b/app/(protected)/commands/layout.tsx new file mode 100644 index 0000000..73adbac --- /dev/null +++ b/app/(protected)/commands/layout.tsx @@ -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 ( +
+ {children} +
+ ); +} + +export default SettingsLayout; \ No newline at end of file diff --git a/app/(protected)/commands/page.tsx b/app/(protected)/commands/page.tsx new file mode 100644 index 0000000..52b7c34 --- /dev/null +++ b/app/(protected)/commands/page.tsx @@ -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: "", + 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: " ", + permissions: [], + version: "3.6", + examples: [], + subcommands: [ + { + name: "rotate", + description: "Apply a rotational transformation", + syntax: "", + permissions: [], + version: undefined, + examples: [], + subcommands: [], + }, + { + name: "x", + description: "Move element to a new X position", + syntax: "", + permissions: [], + version: undefined, + examples: [], + subcommands: [], + }, + { + name: "y", + description: "Move element to a new Y position", + syntax: "", + 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: "", + permissions: [], + version: undefined, + examples: [], + subcommands: [], + }, + { + name: "disable", + description: "Disables a TTS voice", + syntax: "", + permissions: [], + version: undefined, + examples: [], + subcommands: [], + }, + { + name: "add", + description: "Adds a TTS voice to the list of available voices, case sensitive.", + syntax: "", + permissions: ["tom"], + version: "3.9", + examples: ["Brian"], + subcommands: [] + }, + { + name: "remove", + description: "Removes a TTS voice from the list of available voices.", + syntax: "", + permissions: ["tom"], + version: "3.9", + examples: [], + subcommands: [] + }, + { + name: "join", + description: "Voices the messages of another channel", + syntax: "", + permissions: ["tts.commands.tts.join"], + version: "4.0", + examples: [], + subcommands: [], + }, + { + name: "leave", + description: "Stop reading the messages of another channel", + syntax: "", + 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: "", + permissions: [], + version: undefined, + examples: ["brian"], + subcommands: [ + { + name: "", + description: "Change chatter's voice when reading messages.", + syntax: "", + permissions: ["tts.commands.voice.admin"], + version: "4.0", + examples: ["brian @Nightbot"], + subcommands: [] + } + ] + } +] + +const CommandsPage = () => { + return ( +
+
Commands
+
    + {commands.map((command) => +
  • +
    +
    +

    {COMMAND_PREFIX}{command.name}

    + {command.permissions.map(p => +
    + {p} +
    + )} + {!!command.version && +
    + version required: {command.version} +
    + } +
    {command.description}
    +
    + {command.subcommands.length == 0 && +
    Syntax: {COMMAND_PREFIX}{command.name} {command.syntax}
    + } + {command.examples.map(ex => +
    Example: {COMMAND_PREFIX}{command.name} {ex}
    + )} + + {command.subcommands.map(c => +
    +
    + {COMMAND_PREFIX}{command.name} {command.syntax.length == 0 ? "" : command.syntax + " "}{c.name} {c.syntax} +
    + {c.permissions.map(p => +
    + {p} +
    + )} + {!!c.version && +
    + version required: {c.version} +
    + } +
    {c.description}
    +
    + )} +
    +
  • + )} +
+
+ ); +} + +export default CommandsPage; \ No newline at end of file diff --git a/app/(protected)/connection/authorize/page.tsx b/app/(protected)/connection/authorize/page.tsx new file mode 100644 index 0000000..763698f --- /dev/null +++ b/app/(protected)/connection/authorize/page.tsx @@ -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 ( +
+
+ {loaded && +
+ Something went wrong while saving the connection. +
+ } +
+
+ ); +} \ No newline at end of file diff --git a/app/(protected)/layout.tsx b/app/(protected)/layout.tsx new file mode 100644 index 0000000..832d12c --- /dev/null +++ b/app/(protected)/layout.tsx @@ -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 ( + + + + + + + {children} + + + + + ) +} diff --git a/app/page.tsx b/app/(protected)/page.tsx similarity index 98% rename from app/page.tsx rename to app/(protected)/page.tsx index 9934004..0a87c22 100644 --- a/app/page.tsx +++ b/app/(protected)/page.tsx @@ -31,7 +31,7 @@ export default function Home() { } saveAccount().catch(console.error) - }, [session]) + }, []) return (
{ + const { data: session, status } = useSession(); + const [previousUsername, setPreviousUsername] = useState() + + useEffect(() => { + if (status !== "authenticated" || previousUsername == session.user?.name) { + return + } + setPreviousUsername(session.user?.name) + + + }, [session]) + + return ( +
+
Admin Controls
+
+
+

test2

+
+
+

lalalalalalalala

+
+
+
+ ); +} + +export default RedemptionsPage; + +/* + + + +*/ \ No newline at end of file diff --git a/app/settings/api/keys/page.tsx b/app/(protected)/settings/api/keys/page.tsx similarity index 100% rename from app/settings/api/keys/page.tsx rename to app/(protected)/settings/api/keys/page.tsx diff --git a/app/(protected)/settings/connections/page.tsx b/app/(protected)/settings/connections/page.tsx new file mode 100644 index 0000000..e080570 --- /dev/null +++ b/app/(protected)/settings/connections/page.tsx @@ -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(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 ( +
+
Connections
+
+ {connections.map((connection) => + + )} + + {!loading && + + } +
+ {connections.length > 0 && +
+

Default Connections

+ + +
+ } +
+ ); +} + +export default ConnectionsPage; \ No newline at end of file diff --git a/app/(protected)/settings/emotes/page.tsx b/app/(protected)/settings/emotes/page.tsx new file mode 100644 index 0000000..82c4c26 --- /dev/null +++ b/app/(protected)/settings/emotes/page.tsx @@ -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() + + useEffect(() => { + if (status !== "authenticated" || previousUsername == session.user?.name) { + return + } + setPreviousUsername(session.user?.name) + + axios.get("/api/settings/redemptions/actions") + }, [session]) + + return ( +
+ +
+ ); +} + +export default RedemptionsPage; \ No newline at end of file diff --git a/app/(protected)/settings/groups/permissions/page.tsx b/app/(protected)/settings/groups/permissions/page.tsx new file mode 100644 index 0000000..6204110 --- /dev/null +++ b/app/(protected)/settings/groups/permissions/page.tsx @@ -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() + 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 ( +
+
Groups & Permissions
+ {/*
+ ); +} + +export default GroupPermissionPage; \ No newline at end of file diff --git a/app/settings/layout.tsx b/app/(protected)/settings/layout.tsx similarity index 77% rename from app/settings/layout.tsx rename to app/(protected)/settings/layout.tsx index d67bda8..2b7a794 100644 --- a/app/settings/layout.tsx +++ b/app/(protected)/settings/layout.tsx @@ -12,12 +12,12 @@ const SettingsLayout = async ({ const header_url = headersList.get('x-url') || ""; return ( -
-
+
-
+
{children}
diff --git a/app/settings/page.tsx b/app/(protected)/settings/page.tsx similarity index 100% rename from app/settings/page.tsx rename to app/(protected)/settings/page.tsx diff --git a/app/settings/redemptions/page.tsx b/app/(protected)/settings/redemptions/page.tsx similarity index 80% rename from app/settings/redemptions/page.tsx rename to app/(protected)/settings/redemptions/page.tsx index 117b643..90c2b08 100644 --- a/app/settings/redemptions/page.tsx +++ b/app/(protected)/settings/redemptions/page.tsx @@ -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() - const [loading, setLoading] = useState(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} />
@@ -97,6 +127,7 @@ const RedemptionsPage = () => { showEdit={false} isNew={true} obsTransformations={obsTransformations} + connections={connections} adder={addAction} remover={removeAction} /> @@ -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} diff --git a/app/(protected)/settings/tts/filters/page.tsx b/app/(protected)/settings/tts/filters/page.tsx new file mode 100644 index 0000000..d1336af --- /dev/null +++ b/app/(protected)/settings/tts/filters/page.tsx @@ -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, 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) => { + 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 ( + //
+ //
TTS Filters
+ //
+ //
- {actionType && (actionType.value == ActionType.WRITE_TO_FILE || actionType.value == ActionType.APPEND_TO_FILE) && + {actionType &&
- - setActionData({ ...actionData, "file_path": e.target.value })} - readOnly={!isEditable} /> - - setActionData({ ...actionData, "file_content": e.target.value })} - readOnly={!isEditable} /> + {actionType.inputs.map(i => { + if (i.type == "text") { + return
+ + setActionData(d => { + let abc = { ...actionData } + abc[i.key] = e.target.value; + return abc + })} + readOnly={!isEditable} /> +
+ } else if (i.type == "number") { + return
+ + 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} /> +
+ } else if (i.type == "text-values") { + return
+ + 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} /> +
+ } else { + return
+ + { const temp = { ...open }; temp[i.type] = !temp[i.type]; setOpen(temp) }}> + + + + + + + + No connection found. + + {connections.filter(c => !i.type.includes('.') || c.type == i.type.split('.')[1]) + .map((connection) => ( + { + 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} + + ))} + + + + + +
+ } + return
+ })}
} {actionType && actionType.value == ActionType.OBS_TRANSFORM &&
{obsTransformations.map(t =>
{isEditable && diff --git a/components/elements/redemption.tsx b/components/elements/redemption.tsx index 21f85d7..147872f 100644 --- a/components/elements/redemption.tsx +++ b/components/elements/redemption.tsx @@ -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(actionName) - const [order, setOrder] = useState(0) + const [order, setOrder] = useState(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) diff --git a/components/elements/user-list-group.tsx b/components/elements/user-list-group.tsx new file mode 100644 index 0000000..45a54c0 --- /dev/null +++ b/components/elements/user-list-group.tsx @@ -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("") + const [knownUsers, setKnownUsers] = useState<{ id: number, username: string }[]>([]) + const [error, setError] = useState(undefined) + const [page, setPage] = useState(0) + const [maxPages, setMaxPages] = useState(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 ( + + + + + + + Edit group - {groupName} + + Make changes to this group's list of users. + + + {!!error && +

{error}

+ } +
+
+ + setNewUser(e.target.value)} + className="col-span-3" /> + + +
+
+
+ + + + Id + Username + Delete + + + + {users.length ? ( + users.slice(ITEMS_PER_PAGE * page, ITEMS_PER_PAGE * (page + 1)).map((user) => ( + + {user.id} + {user.username} + + + + + )) + ) : ( + + + No results. + + + )} + +
+ + + + + + + + +
+
+ ); +} + +export default UserList; \ No newline at end of file diff --git a/components/navigation/menu.tsx b/components/navigation/menu.tsx new file mode 100644 index 0000000..420e4f5 --- /dev/null +++ b/components/navigation/menu.tsx @@ -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 ( + +

Tom To Speech

+ + {/* + Getting started + + + + */} + + + + Commands + + + + + + + Settings + + + + + + + + Admin + + + + + +
+ ); +} + +export default MenuNavigation; \ No newline at end of file diff --git a/components/navigation/settings.tsx b/components/navigation/settings.tsx index d903031..5871f51 100644 --- a/components/navigation/settings.tsx +++ b/components/navigation/settings.tsx @@ -5,73 +5,78 @@ import AdminProfile from "./adminprofile"; import RoleGate from "@/components/auth/role-gate"; const SettingsNavigation = async () => { - return ( -
-
Hermes
+ return ( +
+
+ + + + +
-
- - - - -
+
+
    +
  • + Settings +
  • +
  • + + + +
  • -
    -
      -
    • - Settings -
    • -
    • - - - -
    • +
    • + Text to Speech +
    • +
    • + + + +
    • +
    • + + + +
    • +
    • + + + +
    • -
    • - Text to Speech -
    • -
    • - - - -
    • -
    • - - - -
    • +
    • + Twitch +
    • +
    • + + + +
    • -
    • - Twitch -
    • -
    • - - - -
    • - -
    • - API -
    • -
    • - - - -
    • -
    -
    -
- ); +
  • + API +
  • +
  • + + + +
  • + +
    +
    + ); } - + export default SettingsNavigation; \ No newline at end of file diff --git a/components/ui/navigation-menu.tsx b/components/ui/navigation-menu.tsx index 1419f56..773b571 100644 --- a/components/ui/navigation-menu.tsx +++ b/components/ui/navigation-menu.tsx @@ -18,7 +18,6 @@ const NavigationMenu = React.forwardRef< {...props} > {children} - )) NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName @@ -30,7 +29,7 @@ const NavigationMenuList = React.forwardRef< (({ className, children, ...props }, ref) => ( {children}{" "} @@ -69,7 +68,8 @@ const NavigationMenuContent = React.forwardRef< , React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( -
    +
    , + React.ComponentPropsWithoutRef +>(({ className, ...props }, 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, + VariantProps {} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = "right", className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
    +) +SheetHeader.displayName = "SheetHeader" + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
    +) +SheetFooter.displayName = "SheetFooter" + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/lib/pubsub.ts b/lib/pubsub.ts deleted file mode 100644 index 3755a5c..0000000 --- a/lib/pubsub.ts +++ /dev/null @@ -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; - }, - }; -}; diff --git a/lib/twitch.ts b/lib/twitch.ts new file mode 100644 index 0000000..d5b8bd9 --- /dev/null +++ b/lib/twitch.ts @@ -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 + } +} \ No newline at end of file diff --git a/middleware.ts b/middleware.ts index 46ab94f..aec23f6 100644 --- a/middleware.ts +++ b/middleware.ts @@ -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: { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0afb326..dc9e707 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,12 +33,17 @@ model User { apiKeys ApiKey[] accounts Account[] twitchConnections TwitchConnection[] + Connection Connection[] + ConnectionState ConnectionState[] ttsUsernameFilter TtsUsernameFilter[] ttsWordFilter TtsWordFilter[] ttsChatVoices TtsChatVoice[] ttsVoiceStates TtsVoiceState[] actions Action[] redemptions Redemption[] + groups Group[] + chatterGroups ChatterGroup[] + groupPermissions GroupPermission[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -92,6 +97,36 @@ model TwitchConnection { user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) } +model Connection { + name String + type String + clientId String + accessToken String + grantType String + scope String + expiresAt DateTime + default Boolean @default(false) + + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + + @@id([userId, name]) +} + +model ConnectionState { + state String + name String + type String + grantType String + clientId String + + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + + @@id([userId, name]) + @@unique([state]) +} + model TtsUsernameFilter { username String tag String @@ -144,6 +179,50 @@ model TtsVoiceState { @@id([userId, ttsVoiceId]) } +model Group { + id String @id @default(uuid()) @db.Uuid + userId String + name String + priority Int + + chatters ChatterGroup[] + permissions GroupPermission[] + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, name]) + @@index([userId]) +} + +model ChatterGroup { + //id String @id @default(uuid()) @db.Uuid + userId String + groupId String @db.Uuid + chatterId BigInt + chatterLabel String + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) + + @@id([userId, groupId, chatterId]) + @@unique([userId, groupId, chatterId]) + @@index([userId]) +} + +model GroupPermission { + id String @id @default(uuid()) @db.Uuid + userId String + groupId String @db.Uuid + path String + allow Boolean? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) + + @@unique([userId, groupId, path]) + @@index([userId]) +} + model Chatter { id BigInt name String @@ -161,6 +240,7 @@ model Emote { //history EmoteUsageHistory[] @@id([id]) + @@unique([id, name]) } model EmoteUsageHistory { @@ -180,20 +260,34 @@ enum ActionType { APPEND_TO_FILE AUDIO_FILE OBS_TRANSFORM + RANDOM_TTS_VOICE + SPECIFIC_TTS_VOICE + TOGGLE_OBS_VISIBILITY + SPECIFIC_OBS_VISIBILITY + SPECIFIC_OBS_INDEX + SLEEP + OAUTH + NIGHTBOT_PLAY + NIGHTBOT_PAUSE + NIGHTBOT_SKIP + NIGHTBOT_CLEAR_PLAYLIST + NIGHTBOT_CLEAR_QUEUE + TWITCH_OAUTH } model Action { userId String - name String @unique + name String @unique type ActionType data Json - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@id([userId, name]) } model Redemption { - id String @id @default(uuid()) @db.Uuid + id String @id @default(uuid()) @db.Uuid userId String redemptionId String actionName String diff --git a/routes.ts b/routes.ts index 0e7bda7..e28d90d 100644 --- a/routes.ts +++ b/routes.ts @@ -1,5 +1,5 @@ export const PUBLIC_ROUTES = [ - "/" + "/", ] export const AUTH_ROUTES = [