Added redemptions & redeemable actions. Fixed a few bugs.

This commit is contained in:
Tom 2024-06-24 22:16:55 +00:00
parent 68df045c54
commit 6548ce33e0
35 changed files with 1787 additions and 471 deletions

View File

@ -1,22 +1,20 @@
import axios from 'axios'
import { db } from "@/lib/db"
import { NextResponse } from "next/server";
import fetchUser from '@/lib/fetch-user';
import fetchUserWithImpersonation from '@/lib/fetch-user-impersonation';
export async function GET(req: Request) {
try {
// Verify state against user id in user table.
const key = await db.apiKey.findFirst({
where: {
id: req.headers.get('x-api-key') as string
}
})
if (!key) {
return new NextResponse("Forbidden", { status: 403 });
const user = await fetchUserWithImpersonation(req)
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const connection = await db.twitchConnection.findFirst({
where: {
userId: key.userId
userId: user.id
}
})
if (!connection) {
@ -29,8 +27,22 @@ export async function GET(req: Request) {
Authorization: 'OAuth ' + connection.accessToken
}
})).data;
if (expires_in > 3600)
return new NextResponse("", { status: 201 });
if (expires_in > 3600) {
let data = await db.twitchConnection.findFirst({
where: {
userId: user.id
}
})
let dataFormatted = {
user_id: user.id,
access_token: data?.accessToken,
refresh_token: data?.refreshToken,
broadcaster_id: connection.broadcasterId
}
return NextResponse.json(dataFormatted, { status: 201 });
}
} catch (error) {
}
@ -51,14 +63,22 @@ export async function GET(req: Request) {
await db.twitchConnection.update({
where: {
userId: key.userId
userId: user.id
},
data: {
accessToken: access_token
accessToken: access_token,
refreshToken: refresh_token
}
})
const data = {
user_id: user.id,
access_token,
refresh_token,
broadcaster_id: connection.broadcasterId
}
return new NextResponse("", { status: 200 });
return NextResponse.json(data)
} catch (error) {
console.log("[ACCOUNT]", error);
return new NextResponse("Internal Error", { status: 500 });

View File

@ -0,0 +1,35 @@
import { db } from "@/lib/db"
import { NextResponse } from "next/server";
import fetchUserWithImpersonation from '@/lib/fetch-user-impersonation';
import axios from "axios";
import { updateTwitchToken } from "@/data/twitch-reauthorize";
export async function GET(req: Request) {
try {
if (!process.env.TWITCH_BOT_CLIENT_ID)
return new NextResponse("Internal Error", { status: 500 });
const user = await fetchUserWithImpersonation(req)
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const auth = await updateTwitchToken(user.id)
if (!auth)
return new NextResponse("Bad Request", { status: 400 })
const redemptions = await axios.get("https://api.twitch.tv/helix/channel_points/custom_rewards?broadcaster_id=" + auth.broadcaster_id,
{
headers: {
"Client-Id": process.env.TWITCH_BOT_CLIENT_ID,
"Authorization": "Bearer " + auth.access_token
}
}
)
return NextResponse.json(redemptions.data);
} catch (error) {
console.log("[REDEMPTIONS/ACTIONS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}

View File

@ -6,7 +6,9 @@ import fetchUser from "@/lib/fetch-user";
export async function GET(req: Request) {
try {
return NextResponse.json(await fetchUser(req))
const user = await fetchUser(req)
if (!user) return new NextResponse("Internal Error", { status: 401 })
return NextResponse.json(user)
} catch (error) {
console.log("[ACCOUNT]", error);
return new NextResponse("Internal Error", { status: 500 });

View File

@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
export async function GET(req: Request) {
return NextResponse.json({
major_version: 3,
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."
//changelog: "Added new command for mods: !refresh <username_filters|word_filters|default_voice> - Used to refresh data if done via website.\nAdded new command for mods: !tts <voice_name> <remove|enable|disable> - To delete, enable, or disable a specific voice."
//changelog: "Save TTS voices set by chatters.\nAdded more options for TTS voices." 3.1
//changelog: "Added a message when new updates are available.\nFixed 7tv renames not being applied correctly." 3.0
});
}

View File

@ -0,0 +1,150 @@
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 {
const user = await fetchUserWithImpersonation(req)
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const actions = await db.action.findMany({
where: {
userId: user.id
}
})
return NextResponse.json(actions.map(({userId, ...attrs}) => attrs));
} catch (error) {
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 }
}
await db.action.create({
data: {
userId: user.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 }
}
await db.action.update({
where: {
userId_name: {
userId: user.id,
name
}
},
data: {
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) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { searchParams } = new URL(req.url)
const name = searchParams.get('action_name') as string
const redemptions = await db.action.delete({
where: {
userId_name: {
userId: user.id,
name
}
}
})
return NextResponse.json(redemptions);
} catch (error) {
console.log("[REDEMPTIONS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}

View File

@ -0,0 +1,143 @@
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 new NextResponse("Unauthorized", { status: 401 });
}
const redemptions = await db.redemption.findMany({
where: {
userId: user.id
}
})
return NextResponse.json(redemptions.map(({userId, ...attrs}) => attrs));
} catch (error) {
console.log("[REDEMPTIONS]", 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 { actionName, redemptionId, order, state }: { actionName: string, redemptionId: string, order: number, state: boolean } = await req.json();
if (!redemptionId || !actionName && !order && !state)
return new NextResponse("Bad Request", { status: 400 });
const action = await db.action.findFirst({
where: {
name: actionName
}
})
if (!action)
return new NextResponse("Bad Request", { status: 400 });
let data:any = {
actionName,
order,
state
}
if (!data.actionName)
data = { actionName, ...data }
if (!data.order)
data = { order, ...data }
if (!data.state)
data = { state, ...data }
const res = await db.redemption.create({
data: {
userId: user.id,
redemptionId,
order,
state: true,
...data
}
});
return NextResponse.json(res, { status: 200 });
} catch (error) {
console.log("[REDEMPTIONS]", 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, actionName, redemptionId, order, state }: { id: string, actionName: string, redemptionId: string, order: number, state: boolean } = await req.json();
if (!redemptionId || !actionName && !order && !state)
return new NextResponse("Bad Request", { status: 400 });
const action = await db.action.findFirst({
where: {
name: actionName
}
})
if (!action)
return new NextResponse("Bad Request", { status: 400 });
let data:any = {
actionName,
redemptionId,
order,
state
}
if (!data.actionName)
data = { actionName, ...data }
if (!data.order)
data = { order, ...data }
if (!data.state)
data = { state, ...data }
if (!data.redemptionId)
data = { redemptionId, ...data }
const res = await db.redemption.update({
where: {
id
},
data: data
});
return NextResponse.json(res, { status: 200 });
} catch (error) {
console.log("[REDEMPTIONS]", 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 redemptions = await db.redemption.delete({
where: {
id
}
})
return NextResponse.json(redemptions);
} catch (error) {
console.log("[REDEMPTIONS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}

View File

@ -5,19 +5,16 @@ import voices from "@/data/tts";
export async function GET(req: Request) {
try {
if (!voices) {
return new NextResponse("Voices not available.", { status: 500 });
}
const user = await fetchUserWithImpersonation(req)
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const u = await db.user.findFirst({
where: {
id: user.id
}
});
const voice = voices.find(v => v.value == new String(u?.ttsDefaultVoice))
return NextResponse.json(voice);
return NextResponse.json(user.ttsDefaultVoice);
} catch (error) {
console.log("[TTS/FILTER/DEFAULT]", error);
return new NextResponse("Internal Error", { status: 500 });
@ -26,28 +23,32 @@ export async function GET(req: Request) {
export async function POST(req: Request) {
try {
if (!voices) {
return new NextResponse("Voices not available.", { status: 500 });
}
const user = await fetchUserWithImpersonation(req)
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { voice } = await req.json();
if (!voice || !voices.map(v => v.label.toLowerCase()).includes(voice.toLowerCase())) return new NextResponse("Bad Request", { status: 400 });
if (!voice || !voices.map(v => v.toLowerCase()).includes(voice.toLowerCase())) return new NextResponse("Bad Request", { status: 400 });
const v = voices.find(v => v.label.toLowerCase() == voice.toLowerCase())
const v = voices.find(v => v.toLowerCase() == voice.toLowerCase())
await db.user.update({
where: {
id: user.id
},
data: {
ttsDefaultVoice: Number.parseInt(v.value)
ttsDefaultVoice: v
}
});
return new NextResponse("", { status: 200 });
} catch (error) {
console.log("[TTS/FILTER/DEFAULT]", error);
return new NextResponse("Internal Error", { status: 500 });
}
return new NextResponse("Internal Error", { status: 500 });
}

View File

@ -31,7 +31,7 @@ export async function POST(req: Request) {
const { search, replace } = await req.json();
if (!search || search.length < 4 || search.length > 200) return new NextResponse("Bad Request", { status: 400 });
if (!replace) return new NextResponse("Bad Request", { status: 400 });
if (replace == null) return new NextResponse("Bad Request", { status: 400 });
const filter = await db.ttsWordFilter.create({
data: {
@ -58,7 +58,7 @@ export async function PUT(req: Request) {
const { id, search, replace } = await req.json();
if (!id || id.length < 1) return new NextResponse("Bad Request", { status: 400 });
if (!search || search.length < 4 || search.length > 200) return new NextResponse("Bad Request", { status: 400 });
if (!replace) return new NextResponse("Bad Request", { status: 400 });
if (replace == null) return new NextResponse("Bad Request", { status: 400 });
const filter = await db.ttsWordFilter.update({
where: {

View File

@ -1,7 +1,6 @@
import { db } from "@/lib/db"
import { NextResponse } from "next/server";
import fetchUserWithImpersonation from "@/lib/fetch-user-impersonation";
import voices from "@/data/tts";
export async function GET(req: Request) {
try {
@ -10,23 +9,18 @@ export async function GET(req: Request) {
return new NextResponse("Unauthorized", { status: 401 });
}
let list : {
value: string;
label: string;
gender: string;
language: string;
}[] = []
const enabled = user.ttsEnabledVoice
for (let v of voices) {
var n = Number.parseInt(v.value) - 1
if ((enabled & (1 << n)) > 0) {
list.push(v)
}
}
const voiceStates = await db.ttsVoiceState.findMany({
where: {
userId: user.id
}
});
return NextResponse.json(list);
const voiceNames = await db.ttsVoice.findMany();
const voiceNamesMapped: { [id: string]: string } = Object.assign({}, ...voiceNames.map(v => ({ [v.id]: v.name })))
return NextResponse.json(voiceStates.filter(v => v.state).map(v => voiceNamesMapped[v.ttsVoiceId]));
} catch (error) {
console.log("[TTS/FILTER/USER]", error);
console.log("[TTS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}
@ -38,16 +32,29 @@ export async function POST(req: Request) {
return new NextResponse("Unauthorized", { status: 401 });
}
let { voice } = await req.json();
voice = voice & ((1 << voices.length) - 1)
const { voice, state }: { voice: string, state: boolean } = await req.json();
await db.user.update({
where: {
id: user.id
},
data: {
ttsEnabledVoice: voice
}
const voiceIds = await db.ttsVoice.findMany();
const voiceIdsMapped: { [voice: string]: string } = Object.assign({}, ...voiceIds.map(v => ({ [v.name.toLowerCase()]: v.id })));
if (!voiceIdsMapped[voice.toLowerCase()]) {
return new NextResponse("Bad Request", { status: 400 });
}
await db.ttsVoiceState.upsert({
where: {
userId_ttsVoiceId: {
userId: user.id,
ttsVoiceId: voiceIdsMapped[voice.toLowerCase()]
}
},
update: {
state
},
create: {
userId: user.id,
ttsVoiceId: voiceIdsMapped[voice.toLowerCase()],
state
}
});
return new NextResponse("", { status: 200 });

View File

@ -0,0 +1,71 @@
import { db } from "@/lib/db"
import { NextResponse } from "next/server";
import fetchUserWithImpersonation from "@/lib/fetch-user-impersonation";
import voices from "@/data/tts";
export async function GET(req: Request) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const selected = await db.ttsChatVoice.findMany({
where: {
userId: user.id
}
})
const voices = await db.ttsVoice.findMany();
const voiceNamesMapped: { [id: string]: string } = Object.assign({}, ...voices.map(v => ({ [v.id]: v.name })))
const data = selected.map(s => ({ chatter_id: new Number(s.chatterId), voice: voiceNamesMapped[s.ttsVoiceId] }))
return NextResponse.json(data);
} catch (error) {
console.log("[TTS/SELECTED]", 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 { voice, chatterId }: { voice: string, chatterId: number } = await req.json();
if (!voice || !voices.map(v => v.toLowerCase()).includes(voice.toLowerCase())) return new NextResponse("Bad Request", { status: 400 });
const v = voices.find(v => v.toLowerCase() == voice.toLowerCase())
const voiceData = await db.ttsVoice.findFirst({
where: {
name: v
}
})
if (!voiceData)
return new NextResponse("Bad Request", { status: 400 });
await db.ttsChatVoice.upsert({
where: {
userId_chatterId: {
userId: user.id,
chatterId
}
},
create: {
userId: user.id,
chatterId,
ttsVoiceId: voiceData.id
},
update: {
ttsVoiceId: voiceData.id
}
});
return new NextResponse("", { status: 200 });
} catch (error) {
console.log("[TTS/SELECTED]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}

View File

@ -30,7 +30,6 @@ export async function GET(req: Request, { params } : { params: { id: string } })
export async function DELETE(req: Request, { params } : { params: { id: string } }) {
try {
const user = await fetchUserWithImpersonation(req)
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}

View File

@ -4,20 +4,24 @@ 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,
@ -25,6 +29,7 @@ export async function GET(req: Request) {
refresh_token: api.refreshToken,
broadcaster_id: api.broadcasterId
}
console.log("ABC 5", data)
return NextResponse.json(data);
} catch (error) {
console.log("[TOKENS/GET]", error);

View File

@ -20,11 +20,11 @@ export async function POST(req: Request) {
const id = generateToken()
const token = await db.apiKey.create({
data: {
id,
label,
userId: userId as string
}
data: {
id,
label,
userId: userId as string
}
});
return NextResponse.json(token);
@ -41,16 +41,16 @@ export async function DELETE(req: Request) {
return new NextResponse("Unauthorized", { status: 401 });
}
let { id } = await req.json();
const { id } = await req.json();
if (!id) {
return NextResponse.json(null)
}
const token = await db.apiKey.delete({
where: {
id,
userId: user?.id
}
where: {
id,
userId: user?.id
}
});
return NextResponse.json(token);
@ -60,12 +60,12 @@ export async function DELETE(req: Request) {
}
}
export function generateToken() {
var chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz";
var string_length = 32;
var randomstring = '';
for (var i = 0; i < string_length; i++) {
var rnum = Math.floor(Math.random() * chars.length);
function generateToken() {
let chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz";
let string_length = 32;
let randomstring = '';
for (let i = 0; i < string_length; i++) {
let rnum = Math.floor(Math.random() * chars.length);
randomstring += chars[rnum];
}
return randomstring;

View File

@ -1,28 +1,23 @@
import fetchUser from "@/lib/fetch-user";
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 { searchParams } = new URL(req.url)
let userId = searchParams.get('userId')
if (userId == null) {
const user = await fetchUser(req);
if (user != null) {
userId = user.id as string;
}
const user = await fetchUserWithImpersonation(req)
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const tokens = await db.apiKey.findMany({
where: {
userId: userId as string
userId: user.id
}
});
return NextResponse.json(tokens);
} catch (error) {
console.log("[TOKENS/GET]", error);
return new NextResponse("Internal Error", { status: 500});
return new NextResponse("Internal Error", { status: 500 });
}
}

View File

@ -24,7 +24,7 @@ export async function GET(req: Request) {
return NextResponse.json(users)
}
if (id) {
const users = await db.user.findUnique({
const users = await db.user.findFirst({
where: {
id: id
}
@ -35,7 +35,7 @@ export async function GET(req: Request) {
const users = await db.user.findMany();
return NextResponse.json(users)
} catch (error) {
console.log("[AUTH/ACCOUNT/IMPERSONATION]", error);
console.log("[USERS]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}

View File

@ -3,63 +3,46 @@
import axios from "axios";
import { Button } from "@/components/ui/button";
import * as React from 'react';
import { ApiKey, User } from "@prisma/client";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
const SettingsPage = () => {
const { data: session, status } = useSession();
const [apiKeyViewable, setApiKeyViewable] = useState(0)
const [apiKeyChanges, setApiKeyChanges] = useState(0)
const [apiKeys, setApiKeys] = useState<ApiKey[]>([])
const ApiKeyPage = () => {
const [apiKeyViewable, setApiKeyViewable] = useState<number>(-1)
const [apiKeys, setApiKeys] = useState<{ id: string, label: string, userId: string }[]>([])
useEffect(() => {
const fetchData = async () => {
try {
const keys = (await axios.get("/api/tokens")).data ?? {};
setApiKeys(keys)
} catch (error) {
console.log("ERROR", error)
}
await axios.get("/api/tokens")
.then(d => setApiKeys(d.data ?? []))
.catch(console.error)
};
fetchData().catch(console.error);
}, [apiKeyChanges]);
fetchData();
}, []);
const onApiKeyAdd = async () => {
try {
await axios.post("/api/token", {
label: "Key label"
});
setApiKeyChanges(apiKeyChanges + 1)
} catch (error) {
console.log("ERROR", error)
}
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) => {
try {
await axios.delete("/api/token/" + id);
setApiKeyChanges(apiKeyChanges - 1)
} catch (error) {
console.log("ERROR", error)
}
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">API Keys</div>
<Table className="max-w-2xl">
<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>View</TableHead>
<TableHead>Action</TableHead>
</TableRow>
</TableHeader>
@ -67,20 +50,20 @@ const SettingsPage = () => {
{apiKeys.map((key, index) => (
<TableRow key={key.id}>
<TableCell className="font-medium">{key.label}</TableCell>
<TableCell>{(apiKeyViewable & (1 << index)) > 0 ? key.id : "*".repeat(key.id.length)}</TableCell>
<TableCell>{apiKeyViewable == index ? key.id : "*".repeat(key.id.length)}</TableCell>
<TableCell>
<Button onClick={() => setApiKeyViewable((v) => v ^ (1 << index))}>
{(apiKeyViewable & (1 << index)) > 0 ? "HIDE" : "VIEW"}
<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><Button onClick={() => onApiKeyDelete(key.id)}>DEL</Button></TableCell>
<TableCell></TableCell>
</TableRow>
))}
<TableRow key="ADD">
<TableCell className="font-medium"></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell><Button onClick={onApiKeyAdd}>ADD</Button></TableCell>
<TableCell><Button onClick={() => onApiKeyAdd("Key label")}>ADD</Button></TableCell>
</TableRow>
</TableBody>
</Table>
@ -90,4 +73,4 @@ const SettingsPage = () => {
);
}
export default SettingsPage;
export default ApiKeyPage;

View File

@ -10,7 +10,7 @@ import Link from "next/link";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
const SettingsPage = () => {
const ConnectionsPage = () => {
const { data: session, status } = useSession();
const [previousUsername, setPreviousUsername] = useState<string>()
const [userId, setUserId] = useState<string>()
@ -24,7 +24,7 @@ const SettingsPage = () => {
setPreviousUsername(session.user?.name as string)
if (session.user?.name) {
const fetchData = async () => {
var connection: User = (await axios.get("/api/account")).data
let connection: User = (await axios.get("/api/account")).data
setUserId(connection.id)
setLoading(false)
}
@ -36,7 +36,7 @@ const SettingsPage = () => {
const [twitchUser, setTwitchUser] = useState<TwitchConnection | null>(null)
useEffect(() => {
const fetchData = async () => {
var connection: TwitchConnection = (await axios.get("/api/settings/connections/twitch")).data
let connection: TwitchConnection = (await axios.get("/api/settings/connections/twitch")).data
setTwitchUser(connection)
}
@ -97,4 +97,4 @@ const SettingsPage = () => {
);
}
export default SettingsPage;
export default ConnectionsPage;

View File

@ -3,8 +3,11 @@ import { cn } from "@/lib/utils";
import { headers } from 'next/headers';
import React from "react";
const SettingsLayout = async (
{ children } : { children:React.ReactNode } ) => {
const SettingsLayout = async ({
children
} : {
children:React.ReactNode
} ) => {
const headersList = headers();
const header_url = headersList.get('x-url') || "";
@ -14,7 +17,7 @@ const SettingsLayout = async (
header_url.endsWith("/settings") && "flex h-full w-full md:w-[250px] z-30 flex-col fixed inset-y-0")}>
<SettingsNavigation />
</div>
<main className={cn("md:pl-[250px] h-full", header_url.endsWith("/settings") && "hidden")}>
<main className={"md:pl-[250px] h-full"}>
{children}
</main>
</div>

View File

@ -0,0 +1,143 @@
"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";
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 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 [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" || previousUsername == session.user?.name) {
return
}
setPreviousUsername(session.user?.name)
axios.get("/api/settings/redemptions/actions")
.then(d => {
setActions(d.data)
})
axios.get("/api/account/redemptions")
.then(d => {
const rs = d.data.data?.map(r => ({ id: r.id, title: r.title })) ?? []
setTwitchRedemptions(rs)
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}
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}
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}
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=""
edit={true}
showEdit={false}
isNew={true}
actions={actions.map(a => a.name)}
twitchRedemptions={twitchRedemptions}
adder={addRedemption}
remover={removeRedemption} />
</div>
</div>
);
}
export default RedemptionsPage;

View File

@ -230,7 +230,7 @@ const TTSFiltersPage = () => {
<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 ">
<Label className="rounded-lg bg-primary px-2 py-1 text-xs text-primary-foreground">
{tag}
</Label>
<FormField

View File

@ -3,10 +3,8 @@
import axios from "axios";
import * as React from 'react';
import { Check, ChevronsUpDown } from "lucide-react"
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { useEffect, useReducer, useState } from "react";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
@ -16,49 +14,50 @@ import voices from "@/data/tts";
import InfoNotice from "@/components/elements/info-notice";
const TTSVoiceFiltersPage = () => {
const { data: session, status } = useSession();
const [loading, setLoading] = useState<boolean>(true)
const [open, setOpen] = useState(false)
const [value, setValue] = useState(0)
const [enabled, setEnabled] = useState(0)
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) => {
setValue(Number.parseInt(voice.data.value))
setDefaultVoice(voice.data)
})
axios.get("/api/settings/tts")
.then((d) => {
const total = d.data.reduce((acc: number, item: {value: number, label: string, gender: string, language: string}) => acc |= 1 << (item.value - 1), 0)
setEnabled(total)
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 })
.then(d => {
console.log(d)
})
.catch(e => console.error(e))
} catch (error) {
console.log("[TTS/DEFAULT]", error);
return;
}
}
const onEnabledChanged = (val: number) => {
const onEnabledChanged = (voice: string, state: boolean) => {
try {
axios.post("/api/settings/tts", { voice: val })
.then(d => {
console.log(d)
})
axios.post("/api/settings/tts", { voice: voice, state: state })
.catch(e => console.error(e))
} catch (error) {
console.log("[TTS]", error);
return;
console.log("[TTS/ENABLED]", error);
}
}
@ -78,7 +77,7 @@ const TTSVoiceFiltersPage = () => {
role="combobox"
aria-expanded={open}
className="w-[200px] justify-between">
{value ? voices.find(v => Number.parseInt(v.value) == value)?.label : "Select voice..."}
{defaultVoice ? voices.find(v => v == defaultVoice) : "Select voice..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
@ -89,20 +88,20 @@ const TTSVoiceFiltersPage = () => {
<CommandGroup>
{voices.map((voice) => (
<CommandItem
key={voice.value + "-" + voice.label}
value={voice.value}
onSelect={(currentValue) => {
setValue(Number.parseInt(currentValue))
onDefaultChange(voice.label)
key={voice}
value={voice}
onSelect={(currentVoice) => {
setDefaultVoice(voice)
onDefaultChange(voice)
setOpen(false)
}}>
<Check
className={cn(
"mr-2 h-4 w-4",
value === Number.parseInt(voice.value) ? "opacity-100" : "opacity-0"
defaultVoice === voice ? "opacity-100" : "opacity-0"
)}
/>
{voice.label}
{voice}
</CommandItem>
))}
</CommandGroup>
@ -116,14 +115,14 @@ const TTSVoiceFiltersPage = () => {
<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.label + "-enabled"} className="h-[30px] row-span-1 col-span-1 align-middle flex items-center justify-center">
<div key={v + "-enabled"} className="h-[30px] row-span-1 col-span-1 align-middle flex items-center justify-center">
<Checkbox onClick={() => {
const newVal = enabled ^ (1 << (Number.parseInt(v.value) - 1))
setEnabled(newVal)
onEnabledChanged(newVal)
dispatchEnabledVoices({ type: enabledVoices[v] ? "disable" : "enable", value: v })
onEnabledChanged(v, !enabledVoices[v])
}}
checked={(enabled & (1 << (Number.parseInt(v.value) - 1))) > 0} />
<div className="pl-[5px]">{v.label}</div>
disabled={loading}
checked={enabledVoices[v]} />
<div className="pl-[5px]">{v}</div>
</div>
))}
</div>

58
app/socket/page.tsx Normal file
View File

@ -0,0 +1,58 @@
"use client"
import { Button } from '@/components/ui/button';
import { v4 } from "uuid"
import { useEffect, useState } from 'react';
import useWebSocket from 'react-use-websocket';
const socketUrl = 'wss://echo.websocket.org';
const SocketPage = () => {
const [mounted, setMounted] = useState(false)
const {
sendMessage,
sendJsonMessage,
lastMessage,
lastJsonMessage,
readyState,
getWebSocket,
} = useWebSocket(socketUrl, {
onOpen: () => console.log('opened'),
onMessage: (e) => console.log("MESSAGE", e),
onError: (e) => console.error(e),
shouldReconnect: (closeEvent) => { console.log("Reconnect"); return true; },
});
const [messageHistory, setMessageHistory] = useState<MessageEvent[]>([]);
useEffect(() => {
if (lastMessage !== null) {
console.log("LAST", lastMessage)
setMessageHistory((prev) => prev.concat(lastMessage));
}
}, [lastMessage, setMessageHistory]);
useEffect(() => {
if (!mounted) {
setMounted(true)
}
}, [])
return (<div className="w-full bg-blue-300">
<p>Hello</p>
<p>{readyState}</p>
<Button onClick={() => sendMessage("uisdhnishdadasdfsd " + v4())}>
Click on me
</Button>
<div>
{lastMessage ? <span>Last message: {lastMessage.data}</span> : null}
<ul>
{messageHistory.map((message, idx) => (
<p className='block' key={idx}>{message ? message.data : null}</p>
))}
</ul>
</div>
</div>)
}
export default SocketPage

View File

@ -0,0 +1,316 @@
import axios from "axios";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Label } from "../ui/label";
import { Maximize2, Minimize2, Trash2Icon } from "lucide-react";
import { ActionType } from "@prisma/client";
const actionTypes = [
{
"name": "Overwrite local file content",
"value": ActionType.WRITE_TO_FILE
},
{
"name": "Append to local file",
"value": ActionType.APPEND_TO_FILE
},
{
"name": "Cause a transformation on OBS scene item",
"value": ActionType.OBS_TRANSFORM
},
{
"name": "Play an audio file locally",
"value": ActionType.AUDIO_FILE
},
]
interface RedeemableAction {
name: string
type: string | undefined
data: { [key: string]: string }
edit?: boolean
showEdit?: boolean
isNew: boolean
obsTransformations: { label: string, placeholder: string, description: string }[]
adder: (name: string, type: ActionType, data: { [key: string]: string }) => void
remover: (action: { name: string, type: string, data: any }) => void
}
const RedemptionAction = ({
name,
type,
data,
edit,
showEdit = true,
isNew = false,
obsTransformations = [],
adder,
remover
}: RedeemableAction) => {
const [open, setOpen] = useState(false)
const [actionName, setActionName] = useState(name)
const [actionType, setActionType] = useState<{ name: string, value: ActionType } | undefined>(actionTypes.find(a => a.value == type?.toUpperCase()))
const [actionData, setActionData] = useState<{ [key: string]: string }>(data)
const [isEditable, setIsEditable] = useState(edit)
const [isMinimized, setIsMinimized] = useState(!isNew)
const [oldData, setOldData] = useState<{ n: string, t: ActionType | undefined, d: { [k: string]: string } } | undefined>(undefined)
function Save(name: string, type: ActionType | undefined, data: { [key: string]: string }, isNew: boolean) {
// TODO: validation
if (!name) {
return
}
if (!type) {
return
}
if (!data) {
return
}
let info: any = {
name,
type
}
info = { ...info, ...data }
if (isNew) {
axios.post("/api/settings/redemptions/actions", info)
.then(d => {
adder(name, type, data)
setActionName("")
setActionType(undefined)
setActionData({})
})
} else {
axios.put("/api/settings/redemptions/actions", info)
.then(d => {
setIsEditable(false)
})
}
}
function Cancel(data: { n: string, t: ActionType | undefined, d: { [k: string]: string } } | undefined) {
if (!data)
return
setActionName(data.n)
setActionType(actionTypes.find(a => a.value == data.t))
setActionData(data.d)
setIsEditable(false)
setOldData(undefined)
}
function Delete() {
axios.delete("/api/settings/redemptions/actions?action_name=" + name)
.then(d => {
remover(d.data)
})
}
return (
<div
className="bg-orange-300 p-3 border-2 border-orange-400 rounded-lg w-[830px]">
{isMinimized &&
<div
className="flex">
<Label
className="mr-2 grow text-lg align-middle"
htmlFor="name">
{actionName}
</Label>
<Button
className="flex inline-block self-end"
onClick={e => setIsMinimized(!isMinimized)}>
{isMinimized ? <Maximize2 /> : <Minimize2 />}
</Button>
</div>
|| !isMinimized &&
<div>
<div
className="pb-3">
<Label
className="mr-2"
htmlFor="name">
Action name
</Label>
<Input
className="inline-block w-[300px]"
id="name"
placeholder="Enter a name for this action"
onChange={e => setActionName(e.target.value)}
value={actionName}
readOnly={!isNew} />
<Label
className="ml-10 mr-2"
htmlFor="type">
Action type
</Label>
{!isEditable &&
<Input
className="inline-block w-[300px] justify-between"
name="type"
value={actionType?.name}
readOnly />
|| isEditable &&
<Popover
open={open}
onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[300px] justify-between"
>{!actionType ? "Select one..." : actionType.name}</Button>
</PopoverTrigger>
<PopoverContent>
<Command>
<CommandInput
placeholder="Filter actions..."
autoFocus={true} />
<CommandList>
<CommandEmpty>No action found.</CommandEmpty>
<CommandGroup>
{actionTypes.map((action) => (
<CommandItem
value={action.name}
key={action.value}
onSelect={(value) => {
setActionType(actionTypes.find(v => v.name.toLowerCase() == value.toLowerCase()))
setOpen(false)
}}>
{action.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
}
</div>
<div>
{actionType && (actionType.value == ActionType.WRITE_TO_FILE || actionType.value == ActionType.APPEND_TO_FILE) &&
<div>
<Label
className="mr-2"
htmlFor="file_path">
File path
</Label>
<Input
className="w-[300px] justify-between inline-block"
name="file_path"
placeholder={actionType.value == ActionType.WRITE_TO_FILE ? "Enter the local file path to the file to overwrite" : "Enter the local file path to the file to append to"}
value={actionData["file_path"]}
onChange={e => setActionData({ ...actionData, "file_path": e.target.value })}
readOnly={!isEditable} />
<Label
className="ml-10 mr-2"
htmlFor="file_content">
File content
</Label>
<Input
className="w-[300px] justify-between inline-block"
name="file_content"
placeholder="Enter the content that should be written"
value={actionData["file_content"]}
onChange={e => setActionData({ ...actionData, "file_content": e.target.value })}
readOnly={!isEditable} />
</div>
}
{actionType && actionType.value == ActionType.OBS_TRANSFORM &&
<div>
{obsTransformations.map(t =>
<div
className="mt-3">
<Label
className="mr-2"
htmlFor={t.label.toLowerCase()}>
{t.label.split("_").map(w => w.substring(0, 1).toUpperCase() + w.substring(1).toLowerCase()).join(" ")}
</Label>
<Input
className="w-[300px] justify-between inline-block"
name={t.label.toLowerCase()}
placeholder={t.placeholder}
value={actionData[t.label]}
onChange={e => {
let c = { ...actionData }
c[t.label] = e.target.value
setActionData(c)
}}
readOnly={!isEditable} />
</div>
)}
</div>
}
{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 &&
<Button
className="m-3"
onClick={() => Save(actionName, actionType?.value, actionData, isNew)}>
{isNew ? "Add" : "Save"}
</Button>
}
{isEditable && !isNew &&
<Button
className="m-3"
onClick={() => Cancel(oldData)}>
Cancel
</Button>
}
{showEdit && !isEditable &&
<Button
className="m-3"
onClick={() => {
setOldData({ n: actionName, t: actionType?.value, d: actionData })
setIsEditable(true)
}}>
Edit
</Button>
}
{!isEditable &&
<Button
className="m-3 bg-red-500 hover:bg-red-600 align-bottom"
onClick={() => Delete()}>
<Trash2Icon />
</Button>
}
{!isNew &&
<Button
className="m-3 align-middle"
onClick={e => setIsMinimized(!isMinimized)}>
{isMinimized ? <Maximize2 /> : <Minimize2 />}
</Button>
}
</div>
</div>
}
</div>
);
}
export default RedemptionAction;

View File

@ -0,0 +1,274 @@
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"
interface Redemption {
id: string | undefined
redemptionId: string | undefined
actionName: string
edit: boolean
showEdit: boolean
isNew: boolean
actions: string[]
twitchRedemptions: { id: string, title: string }[]
adder: (id: string, actionName: string, redemptionId: string, order: number) => void
remover: (redemption: { id: string, redemptionId: string, actionName: string, order: number }) => void
}
const OBSRedemption = ({
id,
redemptionId,
actionName,
edit,
showEdit,
isNew,
actions,
twitchRedemptions,
adder,
remover
}: Redemption) => {
const [actionOpen, setActionOpen] = useState(false)
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 [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))
}, [])
function Save() {
// TODO: validation
if (!isNew && !id)
return
if (!action || !twitchRedemption)
return
if (isNew) {
axios.post("/api/settings/redemptions", {
actionName: action,
redemptionId: twitchRedemption?.id,
order: order,
state: true
}).then(d => {
adder(d.data.id, action, twitchRedemption.id, 0)
setAction(undefined)
setTwitchRedemption(undefined)
setOrder(0)
})
} else {
axios.put("/api/settings/redemptions", {
id: id,
actionName: action,
redemptionId: twitchRedemption?.id,
order: order,
state: true
}).then(d => {
setIsEditable(false)
})
}
}
function Cancel() {
if (!oldData)
return
setAction(oldData.a)
setTwitchRedemption(oldData.r)
setOrder(oldData.o)
setIsEditable(false)
setOldData(undefined)
}
function Delete() {
axios.delete("/api/settings/redemptions?id=" + id)
.then(d => {
remover(d.data)
})
}
return (
<div
className="bg-orange-300 p-5 border-2 border-orange-400 rounded-lg w-[830px]">
<div
className="pb-4">
<Label
className="mr-2"
htmlFor="redemption">
Twitch Redemption
</Label>
{!isEditable &&
<Input
className="inline-block w-[290px] justify-between"
name="redemption"
value={twitchRedemption?.title}
readOnly />
|| isEditable &&
<Popover
open={redemptionOpen}
onOpenChange={setRedemptionOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={actionOpen}
className="w-[290px] justify-between"
>{!twitchRedemption ? "Select one..." : twitchRedemption.title}</Button>
</PopoverTrigger>
<PopoverContent>
<Command>
<CommandInput
placeholder="Filter redemptions..."
autoFocus={true} />
<CommandList>
<CommandEmpty>No redemption found.</CommandEmpty>
<CommandGroup>
{twitchRedemptions.map((redemption) => (
<CommandItem
value={redemption.title}
key={redemption.id}
onSelect={(value) => {
setTwitchRedemption(twitchRedemptions.find(v => v.title.toLowerCase() == value.toLowerCase()))
setRedemptionOpen(false)
}}>
{redemption.title}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
}
<Label
className="ml-10 mr-2"
htmlFor="action">
Action
</Label>
{!isEditable &&
<Input
className="inline-block w-[290px] justify-between"
name="action"
value={action}
readOnly />
|| isEditable &&
<Popover
open={actionOpen}
onOpenChange={setActionOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={actionOpen}
className="w-[290px] justify-between">
{!action ? "Select one..." : action}
</Button>
</PopoverTrigger>
<PopoverContent>
<Command>
<CommandInput
placeholder="Filter actions..."
autoFocus={true} />
<CommandList>
<CommandEmpty>No action found.</CommandEmpty>
<CommandGroup>
{actions.map((action) => (
<CommandItem
value={action}
key={action}
onSelect={(value) => {
let a = actions.find(v => v == action)
if (a)
setAction(a)
setActionOpen(false)
}}>
{action}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
}
</div>
<div
className="pb-4">
<Label
className="mr-2"
htmlFor="order">
Order
</Label>
<Input
className="inline-block w-[300px]"
id="name"
placeholder="Enter an order number for this action"
onChange={e => setOrder(e.target.value.length == 0 ? 0 : parseInt(e.target.value))}
value={order}
readOnly={!isEditable} />
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircleIcon
className="inline-block ml-3"/>
</TooltipTrigger>
<TooltipContent>
<p>This decides when this action will be done relative to other actions for this Twitch redemption.<br/>
Action start from lowest to highest order number.<br/>
Equal order numbers cannot be guaranteed proper order.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</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({ a: actionName, r: twitchRedemption, o: order })
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 OBSRedemption;

View File

@ -13,110 +13,104 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "
const AdminProfile = () => {
const session = useSession();
const [impersonation, setImpersonation] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const session = useSession();
const [impersonation, setImpersonation] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [users, setUsers] = useState<User[]>([])
const [users, setUsers] = useState<User[]>([])
useEffect(() => {
const fetch = async (userId: string | undefined) => {
if (!userId) return
useEffect(() => {
const fetch = async (userId: string | undefined) => {
if (!userId) return
await axios.get("/api/users?id=" + userId)
.then(u => setImpersonation(u.data?.name))
}
await axios.get<User>("/api/users?id=" + userId)
.then(u => {
setImpersonation(u.data?.name)
})
const fetchUsers = async () => {
await axios.get<User[]>("/api/users")
.then((u) => {
setUsers(u.data.filter(x => x.id != session.data?.user.id))
})
}
fetch(session?.data?.user?.impersonation?.id)
fetchUsers()
}, [])
const onImpersonationChange = async (userId: string, name: string) => {
console.log("IMPERSONATION", impersonation)
if (impersonation) {
if (impersonation == name) {
await axios.delete("/api/account/impersonate")
.then(() => {
setImpersonation(null)
window.location.reload()
})
} else {
await axios.put("/api/account/impersonate", { targetId: userId })
.then(() => {
setImpersonation(name)
window.location.reload()
})
}
} else {
await axios.post("/api/account/impersonate", { targetId: userId })
.then(() => {
setImpersonation(name)
window.location.reload()
})
}
}
console.log(session)
fetch(session?.data?.user?.impersonation?.id)
}, [])
useEffect(() => {
const fetchUsers = async () => {
await axios.get<User[]>("/api/users")
.then((u) => {
setUsers(u.data.filter(x => x.id != session.data?.user.id))
})
}
fetchUsers()
}, [])
const onImpersonationChange = async (userId: string, name: string) => {
if (impersonation) {
if (impersonation == session.data?.user.impersonation?.name) {
await axios.delete("/api/account/impersonate")
.then(() => {
setImpersonation(null)
window.location.reload()
})
} else {
await axios.put("/api/account/impersonate", { targetId: userId })
.then(() => {
setImpersonation(name)
window.location.reload()
})
}
} else {
await axios.post("/api/account/impersonate", { targetId: userId })
.then(() => {
setImpersonation(name)
window.location.reload()
})
}
}
return (
<div className={"px-5 py-3 rounded-md bg-red-300 overflow-hidden wrap my-[10px] flex flex-grow flex-col gap-y-3"}>
<div>
<p className="text-xs text-gray-200">Role:</p>
<p>{session?.data?.user?.role}</p>
</div>
<div>
<p className="text-xs text-gray-200">Impersonation:</p>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="flex flex-grow justify-between text-xs">
{impersonation ? impersonation : "Select a user"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search users by name" />
<CommandEmpty>No voices found.</CommandEmpty>
<CommandGroup>
{users.map((user) => (
<CommandItem
key={user.id}
value={user.name ?? undefined}
onSelect={(currentValue) => {
onImpersonationChange(user.id, user.name ?? "")
setOpen(false)
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
user.name == impersonation ? "opacity-100" : "opacity-0"
)}
/>
{user.name}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
);
return (
<div className={"px-5 py-3 rounded-md bg-red-300 overflow-hidden wrap my-[10px] flex flex-grow flex-col gap-y-3"}>
<div>
<p className="text-xs text-gray-200">Role:</p>
<p>{session?.data?.user?.role}</p>
</div>
<div>
<p className="text-xs text-gray-200">Impersonation:</p>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="flex flex-grow justify-between text-xs">
{impersonation ? impersonation : "Select a user"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search users by name" />
<CommandEmpty>No voices found.</CommandEmpty>
<CommandGroup>
{users.map((user) => (
<CommandItem
key={user.id}
value={user.name ?? undefined}
onSelect={(currentValue) => {
onImpersonationChange(user.id, user.name ?? "")
setOpen(false)
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
user.name == impersonation ? "opacity-100" : "opacity-0"
)}
/>
{user.name}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
);
}
export default AdminProfile;

View File

@ -47,6 +47,17 @@ const SettingsNavigation = async () => {
</Link>
</li>
<li className="text-xs text-gray-200">
Twitch
</li>
<li className="">
<Link href={"/settings/redemptions"}>
<Button variant="ghost" className="w-full text-lg">
Channel Redemptions
</Button>
</Link>
</li>
<li className="text-xs text-gray-200">
API
</li>

View File

@ -22,7 +22,7 @@ const UserProfile = () => {
const fetchData = async () => {
if (user) return
var userData = (await axios.get("/api/account")).data
let userData = (await axios.get("/api/account")).data
setUser(userData)
}

30
components/ui/tooltip.tsx Normal file
View File

@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -1,126 +1,4 @@
let voices_data = [
{
value: "1",
label: "Brian",
gender: "Male",
language: "en"
},
{
value: "2",
label: "Amy",
gender: "Female",
language: "en"
},
{
value: "3",
label: "Emma",
gender: "Female",
language: "en"
},
{
value: "4",
label: "Geraint",
gender: "Male",
language: "en"
},
{
value: "5",
label: "Russel",
gender: "Male",
language: "en"
},
{
value: "6",
label: "Nicole",
gender: "Female",
language: "en"
},
{
value: "7",
label: "Joey",
gender: "Male",
language: "en"
},
{
value: "8",
label: "Justin",
gender: "Male",
language: "en"
},
{
value: "9",
label: "Matthew",
gender: "Male",
language: "en"
},
{
value: "10",
label: "Ivy",
gender: "Female",
language: "en"
},
{
value: "11",
label: "Joanna",
gender: "Female",
language: "en"
},
{
value: "12",
label: "Kendra",
gender: "Female",
language: "en"
},
{
value: "13",
label: "Kimberly",
gender: "Female",
language: "en"
},
{
value: "14",
label: "Salli",
gender: "Female",
language: "en"
},
{
value: "15",
label: "Raveena",
gender: "Female",
language: "en"
},
{
value: "16",
label: "Carter",
gender: "Male",
language: "en"
},
{
value: "17",
label: "Paul",
gender: "Male",
language: "en"
},
{
value: "18",
label: "Evelyn",
gender: "Female",
language: "en"
},
{
value: "19",
label: "Liam",
gender: "Male",
language: "en"
},
{
value: "20",
label: "Jasmine",
gender: "Female",
language: "en"
},
]
const voices = voices_data.sort((a, b) => a.label < b.label ? -1 : a.label > b.label ? 1 : 0)
const voices_data = ["Filiz", "Astrid", "Tatyana", "Maxim", "Carmen", "Ines", "Cristiano", "Vitoria", "Ricardo", "Maja", "Jan", "Jacek", "Ewa", "Ruben", "Lotte", "Liv", "Seoyeon", "Takumi", "Mizuki", "Giorgio", "Carla", "Bianca", "Karl", "Dora", "Mathieu", "Celine", "Chantal", "Penelope", "Miguel", "Mia", "Enrique", "Conchita", "Geraint", "Salli", "Matthew", "Kimberly", "Kendra", "Justin", "Joey", "Joanna", "Ivy", "Raveena", "Aditi", "Emma", "Brian", "Amy", "Russell", "Nicole", "Vicki", "Marlene", "Hans", "Naja", "Mads", "Gwyneth", "Zhiyu", "es-ES-Standard-A", "it-IT-Standard-A", "it-IT-Wavenet-A", "ja-JP-Standard-A", "ja-JP-Wavenet-A", "ko-KR-Standard-A", "ko-KR-Wavenet-A", "pt-BR-Standard-A", "tr-TR-Standard-A", "sv-SE-Standard-A", "nl-NL-Standard-A", "nl-NL-Wavenet-A", "en-US-Wavenet-A", "en-US-Wavenet-B", "en-US-Wavenet-C", "en-US-Wavenet-D", "en-US-Wavenet-E", "en-US-Wavenet-F", "en-GB-Standard-A", "en-GB-Standard-B", "en-GB-Standard-C", "en-GB-Standard-D", "en-GB-Wavenet-A", "en-GB-Wavenet-B", "en-GB-Wavenet-C", "en-GB-Wavenet-D", "en-US-Standard-B", "en-US-Standard-C", "en-US-Standard-D", "en-US-Standard-E", "de-DE-Standard-A", "de-DE-Standard-B", "de-DE-Wavenet-A", "de-DE-Wavenet-B", "de-DE-Wavenet-C", "de-DE-Wavenet-D", "en-AU-Standard-A", "en-AU-Standard-B", "en-AU-Wavenet-A", "en-AU-Wavenet-B", "en-AU-Wavenet-C", "en-AU-Wavenet-D", "en-AU-Standard-C", "en-AU-Standard-D", "fr-CA-Standard-A", "fr-CA-Standard-B", "fr-CA-Standard-C", "fr-CA-Standard-D", "fr-FR-Standard-C", "fr-FR-Standard-D", "fr-FR-Wavenet-A", "fr-FR-Wavenet-B", "fr-FR-Wavenet-C", "fr-FR-Wavenet-D", "da-DK-Wavenet-A", "pl-PL-Wavenet-A", "pl-PL-Wavenet-B", "pl-PL-Wavenet-C", "pl-PL-Wavenet-D", "pt-PT-Wavenet-A", "pt-PT-Wavenet-B", "pt-PT-Wavenet-C", "pt-PT-Wavenet-D", "ru-RU-Wavenet-A", "ru-RU-Wavenet-B", "ru-RU-Wavenet-C", "ru-RU-Wavenet-D", "sk-SK-Wavenet-A", "tr-TR-Wavenet-A", "tr-TR-Wavenet-B", "tr-TR-Wavenet-C", "tr-TR-Wavenet-D", "tr-TR-Wavenet-E", "uk-UA-Wavenet-A", "ar-XA-Wavenet-A", "ar-XA-Wavenet-B", "ar-XA-Wavenet-C", "cs-CZ-Wavenet-A", "nl-NL-Wavenet-B", "nl-NL-Wavenet-C", "nl-NL-Wavenet-D", "nl-NL-Wavenet-E", "en-IN-Wavenet-A", "en-IN-Wavenet-B", "en-IN-Wavenet-C", "fil-PH-Wavenet-A", "fi-FI-Wavenet-A", "el-GR-Wavenet-A", "hi-IN-Wavenet-A", "hi-IN-Wavenet-B", "hi-IN-Wavenet-C", "hu-HU-Wavenet-A", "id-ID-Wavenet-A", "id-ID-Wavenet-B", "id-ID-Wavenet-C", "it-IT-Wavenet-B", "it-IT-Wavenet-C", "it-IT-Wavenet-D", "ja-JP-Wavenet-B", "ja-JP-Wavenet-C", "ja-JP-Wavenet-D", "cmn-CN-Wavenet-A", "cmn-CN-Wavenet-B", "cmn-CN-Wavenet-C", "cmn-CN-Wavenet-D", "nb-no-Wavenet-E", "nb-no-Wavenet-A", "nb-no-Wavenet-B", "nb-no-Wavenet-C", "nb-no-Wavenet-D", "vi-VN-Wavenet-A", "vi-VN-Wavenet-B", "vi-VN-Wavenet-C", "vi-VN-Wavenet-D", "sr-rs-Standard-A", "lv-lv-Standard-A", "is-is-Standard-A", "bg-bg-Standard-A", "af-ZA-Standard-A", "Tracy", "Danny", "Huihui", "Yaoyao", "Kangkang", "HanHan", "Zhiwei", "Asaf", "An", "Stefanos", "Filip", "Ivan", "Heidi", "Herena", "Kalpana", "Hemant", "Matej", "Andika", "Rizwan", "Lado", "Valluvar", "Linda", "Heather", "Sean", "Michael", "Karsten", "Guillaume", "Pattara", "Jakub", "Szabolcs", "Hoda", "Naayf"]
const voices = voices_data.filter(v => !v.includes("-")).sort()
export default voices

View File

@ -0,0 +1,72 @@
import axios from 'axios'
import { db } from "@/lib/db"
export async function updateTwitchToken(userId: string) {
const connection = await db.twitchConnection.findFirst({
where: {
userId: 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) {
let data = await db.twitchConnection.findFirst({
where: {
userId: userId
}
})
let dataFormatted = {
user_id: userId,
access_token: data?.accessToken,
refresh_token: data?.refreshToken,
broadcaster_id: connection.broadcasterId
}
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: userId
},
data: {
accessToken: access_token,
refreshToken: refresh_token
}
})
const data = {
user_id: userId,
access_token,
refresh_token,
broadcaster_id: connection.broadcasterId
}
return data
}

0
hooks/ApiKeyHooks.tsx Normal file
View File

View File

@ -12,7 +12,7 @@ export default async function fetchUserWithImpersonation(req: Request) {
}
const token = req.headers?.get('x-api-key')
if (token === null || token === undefined)
if (!token)
return null
const key = await db.apiKey.findFirst({
@ -21,7 +21,8 @@ export default async function fetchUserWithImpersonation(req: Request) {
}
})
if (!key) return null
if (!key)
return null
return fetch(key.userId)
}
@ -35,6 +36,7 @@ const fetch = async (userId: string) => {
if (!user) return null
// Only admins can impersonate others.
if (user.role == "ADMIN") {
const impersonation = await db.impersonation.findFirst({
where: {

View File

@ -12,7 +12,7 @@ export default async function fetchUser(req: Request) {
}
const token = req.headers?.get('x-api-key')
if (token === null || token === undefined)
if (!token)
return null
const key = await db.apiKey.findFirst({
@ -38,6 +38,6 @@ const fetch = async (userId: string) => {
return {
id: user.id,
username: user.name,
role: user.role
role: user.role,
}
}

View File

@ -1,4 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
const nextConfig = {
reactStrictMode: false,
output: 'standalone',
}
module.exports = nextConfig

View File

@ -1,113 +1,222 @@
generator client {
provider = "prisma-client-js"
provider = "prisma-client-js"
}
// datasource db {
// provider = "mysql"
// url = env("DATABASE_URL")
// relationMode = "prisma"
// }
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
relationMode = "prisma"
provider = "postgresql"
url = env("DATABASE_URL")
}
enum UserRole {
USER
ADMIN
USER
ADMIN
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
role UserRole @default(USER)
image String?
ttsDefaultVoice Int @default(1)
ttsEnabledVoice Int @default(1048575)
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
role UserRole @default(USER)
image String?
ttsDefaultVoice String @default("Brian")
impersonationSources Impersonation[] @relation(name: "impersonationSources")
impersonationTargets Impersonation[] @relation(name: "impersonationTargets")
impersonationSources Impersonation[] @relation(name: "impersonationSources")
impersonationTargets Impersonation[] @relation(name: "impersonationTargets")
apiKeys ApiKey[]
accounts Account[]
twitchConnections TwitchConnection[]
ttsUsernameFilter TtsUsernameFilter[]
ttsWordFilter TtsWordFilter[]
apiKeys ApiKey[]
accounts Account[]
twitchConnections TwitchConnection[]
ttsUsernameFilter TtsUsernameFilter[]
ttsWordFilter TtsWordFilter[]
ttsChatVoices TtsChatVoice[]
ttsVoiceStates TtsVoiceState[]
actions Action[]
redemptions Redemption[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@unique([provider, providerAccountId])
@@index([userId])
@@unique([provider, providerAccountId])
}
model Impersonation {
sourceId String
targetId String
sourceId String
targetId String
source User @relation(name: "impersonationSources", fields: [sourceId], references: [id], onDelete: Cascade)
target User @relation(name: "impersonationTargets", fields: [targetId], references: [id], onDelete: Cascade)
source User @relation(name: "impersonationSources", fields: [sourceId], references: [id], onDelete: Cascade, onUpdate: Cascade)
target User @relation(name: "impersonationTargets", fields: [targetId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@id([sourceId])
@@index([sourceId])
@@index([targetId])
@@id([sourceId])
@@index([sourceId])
@@index([targetId])
}
model ApiKey {
id String @id @default(uuid())
label String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
id String @id @default(uuid())
label String
@@index([userId])
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
}
model TwitchConnection {
broadcasterId String @unique
accessToken String
refreshToken String
broadcasterId String @unique
accessToken String
refreshToken String
userId String @id
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
userId String @id
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
}
model TtsUsernameFilter {
username String
tag String
username String
tag String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@index([userId])
@@id([userId, username])
@@id([userId, username])
}
model TtsWordFilter {
id String @id @default(cuid())
search String
replace String
id String @id @default(cuid())
search String
replace String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@index([userId])
@@unique([userId, search])
}
@@unique([userId, search])
}
model TtsVoice {
id String @id @default(cuid())
name String
ttsChatVoices TtsChatVoice[]
ttsVoiceStates TtsVoiceState[]
}
model TtsChatVoice {
userId String
chatterId BigInt
ttsVoiceId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
voice TtsVoice @relation(fields: [ttsVoiceId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@id([userId, chatterId])
@@index([userId])
}
model TtsVoiceState {
userId String
ttsVoiceId String
state Boolean @default(true)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
voice TtsVoice @relation(fields: [ttsVoiceId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@id([userId, ttsVoiceId])
}
model Chatter {
id BigInt
name String
ban DateTime @default(dbgenerated("'1970-01-01 00:00:00.000'"))
//history EmoteUsageHistory[]
@@id([id])
}
model Emote {
id String
name String
//history EmoteUsageHistory[]
@@id([id])
}
model EmoteUsageHistory {
timestamp DateTime
broadcasterId BigInt
emoteId String
chatterId BigInt
//emote Emote @relation(fields: [emoteId], references: [id], onDelete: Cascade, onUpdate: Cascade)
//chatter Chatter @relation(fields: [chatterId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@id([timestamp, emoteId, chatterId])
}
enum ActionType {
WRITE_TO_FILE
APPEND_TO_FILE
AUDIO_FILE
OBS_TRANSFORM
}
model Action {
userId String
name String @unique
type ActionType
data Json
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([userId, name])
}
model Redemption {
id String @id @default(uuid()) @db.Uuid
userId String
redemptionId String
actionName String
order Int
state Boolean
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Quest {
id Int @id @default(autoincrement())
type Int
target Int
start DateTime
end DateTime
@@unique([type, start])
}
model QuestProgression {
chatterId BigInt
questId Int
counter Int
@@id([chatterId, questId])
}